A drop-in view that switches Apple subscribers to direct billing
ZSMigrateTipView is a self-contained view that prompts eligible users to switch from Apple billing to direct billing at a discount. It manages its own visibility, checkout flow, and dismissal state. You just add it to your layout.
ZSMigrateTipView( userId: currentUser.id, stripeCustomerId: "cus_abc123", // optional backgroundColor: .black, // optional titleFont: .system(size: 20, weight: .bold), // optional bodyFont: .system(size: 16), // optional ctaFont: .system(size: 17, weight: .bold), // optional borderColor: .white.opacity(0.2), // optional onEvent: { event in // optional switch event { case .ctaTapped: print("user tapped CTA") case .checkoutCompleted: print("web checkout succeeded") case .appleSubscriptionManagementOpened: print("opened Apple sub management") case .migrationCompleted: print("full migration done") case .dismissed: print("view dismissed") } })
All three use the same native implementation under the hood. Only userId is required — everything else has sensible defaults.
ZSMigrateTipView targets users switching from Apple StoreKit subscriptions to direct billing. On Android, the equivalent switch and save flow from Google Play Billing is not yet supported by this component.
The view decides whether to render itself based on the user’s entitlement state. You never need to write conditional logic around it.
Condition
Behavior
User has a StoreKit subscription that matches the dashboard-configured product
✅ View appears (user is eligible to switch and save)
User already has a web checkout entitlement
🚫 View is hidden (already switched)
User has no active subscription
🚫 View is hidden (not a switch and save candidate)
User previously dismissed the view
🚫 View is hidden (dismissal persisted via UserDefaults)
User has not been subscribed long enough
🚫 View is hidden (e.g., user subscribed less than 90 days ago)
User is not in the rollout cohort
🚫 View is hidden (phased rollout has not reached this user yet)
The minimum subscription age and rollout percentage are configured in the dashboard switch and save campaign. For example, you can set the minimum to 90 days so only users subscribed for at least 90 days see the view, and roll out to 10% of eligible users first before going to 100%. This lets you target loyal, high-retention users and control exposure as you ramp up.On mount, the view checks the minimum subscription age and evaluates the rollout cohort from your dashboard config. If none of the conditions for showing are met, it renders as an EmptyView with no layout impact.
The user identifier passed to the checkout backend.
stripeCustomerId
String?
nil
Optional existing Stripe Customer ID (cus_xxx). When provided, the checkout is attached to this customer instead of creating a new one. Useful for a unified Billing Portal view.
backgroundColor
Color (SwiftUI, Flutter) / string (React Native)
.black / "#000000"
Background color for the card. Pass a Color in SwiftUI and Flutter, or a hex string in React Native.
The onEvent callback receives a MigrationTipView.Event value as the user progresses through the migration flow:
Event
When it fires
.ctaTapped
The user tapped the main CTA button to begin checkout.
.checkoutCompleted
The web checkout completed successfully. The user now has a web entitlement, but their Apple subscription is still active.
.appleSubscriptionManagementOpened
The user opened Apple’s subscription-management sheet to cancel their Apple billing.
.migrationCompleted
The full migration flow is finished — web checkout succeeded and the Apple subscription-management sheet was shown.
.dismissed
The view was dismissed (close button, auto-dismiss after completion, or the user became ineligible).
Events fire in lifecycle order: ctaTapped → checkoutCompleted → appleSubscriptionManagementOpened → migrationCompleted → dismissed. Not every event is guaranteed — for example, a user who dismisses the view before completing checkout will only trigger ctaTapped then dismissed.
onDismiss is deprecated but still works. It maps to onEvent and only fires on .dismissed. Migrate to onEvent for full visibility into the flow.
The discount, messaging, and targeting are all controlled from the dashboard, so you can iterate without shipping an app update.
Setting
What it controls
Discount %
The percentage off the web price shown in the CTA (e.g., 15% off)
Title
The heading displayed in the tip view
Message
The body text explaining the offer
Minimum subscription age
Minimum subscription age in days before the view appears
Rollout %
Percentage of eligible users who see the view
Adjust these at any time to test different offers. For example, start with a 10% discount at 180 days tenure rolled out to 10% of users, then broaden to 15% off at 90 days for 100% of users and compare conversion rates in your dashboard analytics.
The view may be hidden during development because it was previously dismissed. Reset the persisted state:
Copy
ZSMigrationManager.resetDismissedState()
ZSMigrateTipView.resetMigrateTipState() still works but is deprecated. Use ZSMigrationManager.resetDismissedState() instead.
The view logs its visibility decisions to the console with the prefix [ZSMigrateTipView Business Logic], including which condition caused it to show or hide.