Skip to content

Add presentationMode option to control paywall modal presentation style#1726

Merged
facumenzella merged 6 commits into
mainfrom
facundo/fix-ipad-paywall-presentation-style
Jul 22, 2026
Merged

Add presentationMode option to control paywall modal presentation style#1726
facumenzella merged 6 commits into
mainfrom
facundo/fix-ipad-paywall-presentation-style

Conversation

@facumenzella

@facumenzella facumenzella commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

Adds a presentationMode option to PaywallProxy so hybrid SDKs can choose the UIModalPresentationStyle used when presenting a paywall (pageSheet, formSheet, fullScreen, automatic).

It's tricky because it's a iOS thing only.

on iPad the presentation style is hardcoded to .pageSheet, which renders a large sheet covering most of the screen (reported in purchases-unity#976). Customers expect the smaller, centered modal (.formSheet), which is what the native SDK's exit offer already shows. This PR exposes that choice without changing the default.

Precedence (backwards compatible):

  1. presentationMode if present and recognized.
  2. else legacy useFullScreenPresentation boolean (true.fullScreen).
  3. else .pageSheet (unchanged default).

Any caller that does not send presentationMode gets the same behavior as today on every device, including RN, Flutter, Capacitor, and Unity. Nothing changes unless a hybrid explicitly opts in.

Why .formSheet and not just flipping the default

Verified on an iPad Pro 11" / iOS 26 simulator (see videos), presenting a PaywallViewController with each style (mirrors this file's rootController.present(controller) path):

Style iPad result
.pageSheet (current default) Large, near full-screen — the #976 complaint
.formSheet Small centered modal — the expected behavior
.automatic Small centered modal, but only on iOS 18+; resolves to .pageSheet on earlier iPadOS

So .formSheet is the version-stable value for the small modal; .automatic explains why the native exit offer (which sets no explicit style) already renders small on iOS 18+.

Videos

Recorded on iPad Pro 11" / iOS 26. Each clip shows the full flow: open paywall → dismiss → exit offer. Drop each .mp4 into the slot directly below its heading (GitHub only accepts media via web upload).

Before — bug (.pageSheet): main paywall opens large, but the exit offer is the correct small modal — the inconsistency reported in #976.

bug_exit_flow.mp4

After — fix (.formSheet): main paywall opens as a small modal, and the exit offer matches — consistent.

fix_exit_flow_clip.mp4

Note: the debug copy / "SAVE 85%" in the exit offer is that test paywall's own content, not part of the fix. The fix controls the main paywall's style; the exit offer is presented by the SDK as .automatic (already small on iOS 18+).

Scope / follow-up

This is the purchases-hybrid-common half only. It does not by itself fix #976 for Unity users. The Unity follow-up wires the new option through:

  • Add IOSPaywallPresentationStyle.FormSheet (and optionally .Automatic) in Unity's PaywallPresentationConfiguration.
  • Thread the style string into the options dict under presentationMode via RevenueCatUI.m / IOSPaywallPresenter.cs.
  • Unity's existing Sheet token ("sheet") already maps to .pageSheet here, so wiring it is safe.
AI session context

Visual companion: https://claude.ai/code/artifact/8feedede-b300-406e-83fb-1d0ffa8f0b57

AI Context

Metadata

  • PR: this PR (purchases-hybrid-common, draft)
  • Branch: facundo/fix-ipad-paywall-presentation-style
  • Author / human owner: @facumenzella
  • Agent(s): Claude Code (Opus 4.8, 1M context); Codex (GPT-5.x) consulted for a second opinion
  • Session source: current conversation
  • Generated: 2026-07-07
  • Context document version: 1

Goal

Fix purchases-unity#976: on iPad, PaywallsPresenter.Present() opens the paywall as a large sheet instead of the small modal customers expect (the native exit offer renders correctly small). Provide a retrocompatible way to control iPad presentation.

Initial Prompt

"Check RevenueCat/purchases-unity#976" — investigate the iPad large-paywall report and determine root cause and fix. Later refined to: make presentation configurable per platform without changing the default; reproduce the bug on iOS without Unity; then open a draft PR in purchases-hybrid-common with before/after videos.

Important Follow-up Prompts

  • "What we need is this to be configurable per platform ... We can't change the default now because we might break this for other folks." (retrocompatibility is a hard constraint)
  • "where does formSheet come from" / "but pageSheet is not formSheet" (forced precise grounding in UIKit docs, corrected an initial hand-wave)
  • "run this through codex, maybe there's another point of view"
  • "verify the bug first ... use paywalls tester on the ios repository"
  • "how can we first reproduce this bug? it would be great to do it on iOS, without having to setup unity"
  • "record a video with the bug, and one with the fix. then open a PR as draft and include the videos"

Agent Contribution

  • Traced root cause: PaywallProxy.swift hardcodes controller.modalPresentationStyle = useFullScreenPresentation ? .fullScreen : .pageSheet. .pageSheet is large on iPad; the native exit offer (purchases-ios PaywallViewController.swift) sets no style (.automatic).
  • Grounded the .pageSheet vs .formSheet vs .automatic distinction in Apple docs; confirmed .automatic resolves to .formSheet on iOS 18+ (SDK header), .pageSheet earlier.
  • Built a temporary launch-argument harness in the purchases-ios PaywallsTester to present a PaywallViewController with each style and captured screenshots + videos on iPad Pro 11" / iOS 26 (harness reverted afterward; purchases-ios left clean).
  • Implemented the presentationMode option with a backwards-compatible precedence chain; built PurchasesHybridCommonUI successfully.

Human Decisions

  • Rejected flipping the default (both the .formSheet default and a colleague's "iPad default → fullScreen" proposal) because either changes behavior for existing hybrid users; chose opt-in.
  • Directed empirical reproduction on iOS without Unity, and a draft PR with videos.

Key Implementation Decisions

  • Decision: Add opt-in presentationMode; keep .pageSheet default.
    • Rationale: zero behavior change for existing callers on any device / any hybrid; fixes [AUTOMATIC] Android 8.10.2 => 8.10.3 #976 for those who opt in.
    • Rejected: change PHC default to .automatic/.formSheet (breaks existing iPad behavior for all hybrids); colleague's iPad-default-.fullScreen (still a breaking iPad change and wrong direction for the report).
  • Decision: recommend .formSheet for the small modal, not .automatic.
    • Rationale: .automatic is small only on iOS 18+ and regresses to .pageSheet on older iPadOS.
  • Decision: accept "sheet" as a .pageSheet alias.
    • Rationale: matches Unity's existing IOSPaywallPresentationStyle.Sheet token so the follow-up can't silently no-op.

Files / Symbols Touched

  • ios/PurchasesHybridCommon/PurchasesHybridCommonUI/Paywalls/PaywallProxy.swift
    • Symbols: PaywallOptionsKeys.presentationMode, PaywallPresentationParams.modalPresentationStyle, PaywallPresentationParams.resolveModalPresentationStyle(from:), privatePresentPaywall.
    • Review relevance: the precedence chain and the string→style mapping.

Dependencies / Config / Migrations

  • None. No dependency, Podfile.lock, or Package.resolved changes.

Validation

  • Commands run:
    • xcodebuild -workspace PurchasesHybridCommon.xcworkspace -scheme PurchasesHybridCommonUI -configuration Debug -destination "id=<iPad sim>" build: BUILD SUCCEEDED.
  • Manual verification:
    • iPad Pro 11" / iOS 26: .pageSheet = large (repro), .formSheet = small modal (fix), .automatic = small (identical to formSheet). Videos attached.
  • CI:
    • Not run yet (draft).

Validation Gaps

  • The videos verify the rendering outcome of each modalPresentationStyle; they were produced via a purchases-ios harness and do not exercise this PR's presentationModeresolveModalPresentationStyle parsing path.
  • No automated test for the string→style resolution (no PurchasesHybridCommonUI test target exists; the pure resolveModalPresentationStyle could be extracted and tested if desired).

Review Focus

  • Is the precedence (presentationMode > useFullScreenPresentation > .pageSheet) correct and truly non-breaking for callers that send neither key, or send only the legacy boolean?
  • Are the accepted string tokens the right canonical contract for the Unity/RN/Flutter/Capacitor follow-ups?

Risks / Reviewer Notes

  • Risk: string contract drift across hybrids.
    • Evidence: "sheet" alias added specifically to match Unity's existing token.
    • Mitigation: accepted tokens documented in this PR; unrecognized values fall through to the legacy default.

Non-goals / Out of Scope

  • Unity SDK wiring and the public FormSheet API (separate follow-up PR).
  • Changing the default presentation style.

Omitted Context

  • Raw transcript, unrelated exploration, and chain-of-thought were omitted.

Note

Medium Risk
Changes paywall presentation behavior only when new options are passed; default remains .pageSheet, but incorrect hybrid wiring could surprise iPad layouts.

Overview
Adds an opt-in presentationMode paywall option so hybrid SDKs can set UIModalPresentationStyle (pageSheet, formSheet, fullScreen, automatic, plus sheet as a pageSheet alias) instead of only toggling full screen via useFullScreenPresentation.

PaywallProxy now resolves style through resolveModalPresentationStyle: recognized presentationMode wins, then the legacy boolean, then .pageSheet when neither is set—so existing callers keep today’s default unless they opt in.

Unit coverage lands in PaywallPresentationStyleTests; the test target links PurchasesHybridCommonUI and pulls RevenueCatUI in the Podfile so @testable import works.

Reviewed by Cursor Bugbot for commit 3e500af. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds an opt-in `presentationMode` key to `PaywallProxy.PaywallOptionsKeys`
(pageSheet, formSheet, fullScreen, automatic) so hybrid SDKs can control the
`UIModalPresentationStyle` used when presenting a paywall.

Motivation: on iPad the hardcoded `.pageSheet` presents a large sheet that covers
most of the screen (purchases-unity#976). `.formSheet` presents the smaller,
centered modal customers expect. This exposes that choice without changing the
default: when `presentationMode` is absent it falls back to the existing
`useFullScreenPresentation` boolean and defaults to `.pageSheet`, so existing
integrations (RN, Flutter, Capacitor, Unity) are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iVCuCLTGx1MWbF2p24LdT
@facumenzella facumenzella added the pr:feat A new feature label Jul 7, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_016iVCuCLTGx1MWbF2p24LdT
@facumenzella facumenzella added pr:other pr:fix A bug fix and removed pr:feat A new feature pr:other labels Jul 7, 2026
@facumenzella
facumenzella marked this pull request as ready for review July 8, 2026 10:55
@facumenzella
facumenzella requested a review from a team as a code owner July 8, 2026 10:55
facumenzella and others added 2 commits July 20, 2026 13:00
Adds unit coverage for the string->UIModalPresentationStyle resolution
introduced by the presentationMode option: token mapping, case
insensitivity, the "sheet" alias, presentationMode-over-legacy
precedence, unrecognized/non-String fallthrough, and the neither-key
backwards-compat default (.pageSheet).

To reach the resolver via @testable import, PaywallPresentationParams,
Content, and resolveModalPresentationStyle are relaxed from private to
internal (nothing becomes public, so no API change). The existing
PurchasesHybridCommonTests target now links PurchasesHybridCommonUI
(and RevenueCatUI, which it transitively needs), so the tests run in
the existing fastlane scan CI job with no new target or CI wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01894Y6cNoGsxxfmRHz8bu6i
@facumenzella

Copy link
Copy Markdown
Member Author

Unity follow-up (draft) that consumes this: RevenueCat/purchases-unity#995. It adds IOSPaywallPresentationStyle.FormSheet and threads the new presentationMode option through the Unity bridge. It's gated on this PR shipping (the PHC version pins get bumped once released).

@facumenzella
facumenzella merged commit b9d7bc1 into main Jul 22, 2026
21 checks passed
@facumenzella
facumenzella deleted the facundo/fix-ipad-paywall-presentation-style branch July 22, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants