Skip to content

Feature request: first-class support for native iOS UI hosts (SwiftUI/UIKit) — official paywall & Customer Center bridge #897

Description

@thomasflad

Context

A very common KMP architecture is shared Kotlin for logic, fully native SwiftUI for iOS UI — no Compose Multiplatform on iOS. Before 3.0, this worked naturally: the SPM PurchasesHybridCommon(UI) copy was the single SDK instance, and Swift code could use PaywallView/CustomerCenterView directly.

With 3.x bundling purchases-ios inside the Kotlin framework, that door closed: import RevenueCat no longer compiles, and re-adding the SPM package creates a second SDK copy that crashes at startup (#882, where it was confirmed the Kotlin API is the only supported way). The officially supported UI module (purchases-kmp-ui) is Compose-only — so SwiftUI hosts currently have no supported way to show paywalls or the Customer Center.

We completed this migration (purchases-kmp 3.0.4, static framework, Xcode 26.6). It works, but here is everything a SwiftUI-host team has to do today:

What a native-UI host must do today

  1. Discover the constraint — the 3.0.0 migration guide says to remove the SPM packages but never mentions that native import RevenueCat/import RevenueCatUI code stops working or what to do instead; the answer lives in an issue comment.
  2. Depend on internal-looking binding artifactscom.revenuecat.purchases:purchases-kmp-kn-ui and the com.revenuecat.purchases.kn.ui package, which aren't documented as public API.
  3. Hand-write a Kotlin iosMain bridge, dealing with several footguns:
    • RCPaywallViewController's identifier-based inits aren't exposed (non-@objc/@_spi), so you construct with offering = null and later call the deprecated updateWithOfferingIdentifier:/updateWithOffering: after resolving via awaitOfferings().
    • Implementing RCPaywallViewControllerDelegateProtocol requires casts between duplicate cinterop types for the same Obj-C classes (kn-core's RCPackage vs kn-ui's), with @Suppress("CAST_NEVER_SUCCEEDS").
    • The VC delegates are weak; the bridge must retain them for the controller's lifetime or callbacks silently stop.
  4. Expose the UIViewControllers through the framework and host them in SwiftUI via UIViewControllerRepresentable.
  5. Manually embed RevenueCatUI's resources — the bundled RevenueCatUI is compiled with COMPOSE_RESOURCES and preconditionFailures at first render unless compose-resources/composeResources/com.revenuecat.purchases.kn.ui.resources/files/… exists in the app bundle. The files ship in the kotlin_resources Maven artifact that only the Compose Gradle plugin consumes, so a non-Compose host needs a custom Gradle unzip task plus an Xcode script-phase rsync.
  6. Accept reduced Customer Center fidelity — the @objc RCCustomerCenterViewController hardcodes default CustomerCenterNavigationOptions, so options the SwiftUI CustomerCenterView exposes (usesExistingNavigation, usesNavigationStack, shouldShowCloseButton) aren't reachable; pushed presentations get a nested navigation bar.

Proposal

An official, documented native-UI bridge — e.g. a purchases-kmp-ui-uikit module (or a public surface in kn-ui) that exports from the Kotlin framework:

// Exported to Swift via the shared framework
class PaywallViewController(
    offeringIdentifier: String? = null,
    placementIdentifier: String? = null,
    listener: PaywallListener? = null,        // strongly retained internally
) { val viewController: UIViewController }

class CustomerCenterViewController(
    navigationOptions: CustomerCenterNavigationOptions = CustomerCenterNavigationOptions.default,
    listener: CustomerCenterListener? = null,
) { val viewController: UIViewController }

With:

  • offering/placement resolution built in (no deprecated update calls in app code),
  • internal delegate retention (no weak-delegate footgun),
  • callbacks carrying KMP model types (Package, CustomerInfo) or primitives, reusing the existing PaywallListener,
  • resource embedding handled by embedAndSignAppleFrameworkForXcode (it already runs inside Xcode with $TARGET_BUILD_DIR available) — or, failing that, a graceful English fallback instead of preconditionFailure,
  • Customer Center navigation options exposed, and
  • a docs page with the ~10-line UIViewControllerRepresentable Swift snippet.

That would reduce the entire list above to "add one dependency, call one factory, host the view controller".

Alternatives we considered

Happy to share our working bridge implementation (paywall + customer center + resource-embedding Gradle/Xcode wiring) as a starting point if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions