Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- A `SubscriptionRevenueCatUI` product, the only one that links RevenueCatUI. It carries
`RevenueCatPaywallView`, which shows a paywall designed with RevenueCat Paywalls for an
offering (the current one for `nil`), and `PaywallContainer`, which shows either that or the
app's own paywall from one place. After a purchase or a restore the RevenueCat paywall asks the
`SubscriptionUseCase` whether the customer is entitled, rather than reading the `CustomerInfo`
RevenueCatUI hands back, so the configured entitlement decides and the `EntitlementCache` is
refreshed; a restore that finds nothing does not count as entitled.
- `PaywallMode` in `SubscriptionUI`: `.custom`, `.revenueCat(offering:)` and
`.automatic(offering:)`, with `.revenueCat` and `.automatic` as shorthands for the current
offering. A struct rather than an enum, because an enum case with an associated value and a
static property of the same name make `PaywallMode.automatic` ambiguous. `PaywallMode.choice(for:)` is the pure rule that turns the mode and a
`PaywallOfferingLookup` into a `PaywallChoice`; everything that stops a RevenueCat paywall from
being shown falls back to the app's own. Held in `SubscriptionUI` so that an app can keep the
setting without linking RevenueCatUI.
- `PaywallHandlers`: the `onEntitled` / `onError` / `onDismiss` that `PaywallContainer` passes
to the closure building the app's own paywall, so both paywalls report in the same three
callbacks.

### Changed
- The minimum RevenueCat SDK is now 5.19.0, the first with `Offering.hasPaywall`.

## [2.0.0] - 2026-09-08

### Added
Expand Down
27 changes: 26 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ let package = Package(
.library(
name: "SubscriptionUI",
targets: ["SubscriptionUI"]
),
// Paywalls built in the RevenueCat dashboard, and the container that chooses between one
// of those and the app's own. The only product that links RevenueCatUI, so an app that
// draws its own paywall does not carry it.
.library(
name: "SubscriptionRevenueCatUI",
targets: ["SubscriptionRevenueCatUI"]
)
],
dependencies: [
// RevenueCat SDK
.package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.14.0"),
.package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.19.0"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0")
],
targets: [
Expand All @@ -42,6 +49,16 @@ let package = Package(
dependencies: ["Subscription"],
path: "Sources/SubscriptionUI"
),
.target(
name: "SubscriptionRevenueCatUI",
dependencies: [
"Subscription",
"SubscriptionUI",
.product(name: "RevenueCat", package: "purchases-ios"),
.product(name: "RevenueCatUI", package: "purchases-ios")
],
path: "Sources/SubscriptionRevenueCatUI"
),
.testTarget(
name: "SubscriptionTests",
dependencies: ["Subscription"],
Expand All @@ -51,6 +68,14 @@ let package = Package(
name: "SubscriptionUITests",
dependencies: ["SubscriptionUI"],
path: "Tests/SubscriptionUITests"
),
.testTarget(
name: "SubscriptionRevenueCatUITests",
dependencies: [
"SubscriptionRevenueCatUI",
.product(name: "RevenueCat", package: "purchases-ios")
],
path: "Tests/SubscriptionRevenueCatUITests"
)
]
)
53 changes: 52 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ RevenueCat を使ったサブスクリプションとアプリ内課金を、小
どちらの答えかは値そのものが持つ(規約ではなく型で分かる)
- 購入・復元・ユーザー同期
- 権利の変化を流す `AsyncStream`(アプリが起こしていない変化も届く)
- RevenueCat Paywalls で作ったペイウォールは別プロダクト `SubscriptionRevenueCatUI`。
自前のペイウォールとの切り替えを一か所で行える
- ペイウォールの骨格は別プロダクト `SubscriptionUI`。請求される実額が常に主表示
- 全体が Sendable。状態は actor が持つ

Expand Down Expand Up @@ -76,6 +78,55 @@ PaywallView(
復元ボタンと 2 本の法的リンクを持つ。**請求される実額が各行で最も大きい文字**で、
それを変える引数は無い —— 審査 3.1.2(c) を規約ではなく構造で守る。

### RevenueCat のペイウォールに切り替える

RevenueCatUI に依存するのは `SubscriptionRevenueCatUI` だけ。RevenueCat のダッシュボードで
作ったペイウォールを出し、`PaywallContainer` が `PaywallMode` に従って自前のペイウォールと
どちらを出すかを決める。

| `PaywallMode` | 出るもの |
|---|---|
| `.custom` | 常に自前。RevenueCat には何も問い合わせない |
| `.revenueCat(offering:)` | その offering の RevenueCat ペイウォール(`nil` なら current) |
| `.automatic(offering:)` | offering にダッシュボードでペイウォールが付いていれば RevenueCat、無ければ自前 |

RevenueCat のペイウォールを出せない理由があるとき —— 環境に `SubscriptionUseCase` が無い、
SDK が設定されていない、offering の読み込みに失敗した、指定した offering が無い —— は
自前のペイウォールを出す。

```swift
import SubscriptionRevenueCatUI
import SubscriptionUI

PaywallContainer(
mode: .automatic,
onEntitled: { analytics.track(.purchaseCompleted) }
) { handlers in
NavigationStack {
PaywallView(
pages: pages,
links: links,
onEntitled: handlers.onEntitled,
onError: handlers.onError
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("閉じる", action: handlers.onDismiss)
}
}
}
}
```

コンテナはどちらのペイウォールにも枠(ナビゲーションバーや閉じるボタン)を付けない。
RevenueCat のペイウォールは閉じるボタンを自分で描くので、ナビゲーションバーと閉じるボタンは
`custom` のクロージャの中に入れ、コンテナ自体はそのままシートに載せる。
`onEntitled` / `onError` / `onDismiss` はどちらのペイウォールでも呼ばれる。自前のほうには
クロージャに渡る `PaywallHandlers` として届く。

`PaywallMode` と、それをどちらを出すかに変える規則(`PaywallMode.choice(for:)`)は
`SubscriptionUI` にある。RevenueCatUI をリンクしないアプリでも設定値として持てる。

## ドキュメント

**[API ドキュメントと Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)**
Expand All @@ -88,7 +139,7 @@ Getting Started に、前提となる App Store Connect / RevenueCat ダッシ

- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.14.0+
- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.19.0+

## インストール

Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ answer current as renewals and expiries arrive.
- An `AsyncStream` of entitlement changes, including ones the app did not cause
- A separate `SubscriptionUI` product with a paywall skeleton that keeps the billed amount the
most prominent price on screen
- A separate `SubscriptionRevenueCatUI` product that shows a paywall built with RevenueCat
Paywalls, and switches between it and the app's own from one place
- Sendable throughout, with an actor holding the state

## Usage
Expand Down Expand Up @@ -82,6 +84,54 @@ that will be billed is the largest text in every plan row and there is no parame
change that, which is App Store Review 3.1.2(c) enforced by construction rather than by
documentation.

### Switching to a RevenueCat paywall

`SubscriptionRevenueCatUI` is the only product that links RevenueCatUI. It shows a paywall
designed in the RevenueCat dashboard, and `PaywallContainer` chooses between that and the app's
own paywall according to a `PaywallMode`:

| `PaywallMode` | What is shown |
|---|---|
| `.custom` | Always the app's own paywall. RevenueCat is not asked anything |
| `.revenueCat(offering:)` | The RevenueCat paywall for the offering (`nil` for the current one) |
| `.automatic(offering:)` | The RevenueCat paywall if the offering has one attached in the dashboard, the app's own otherwise |

Whatever stops a RevenueCat paywall from being shown — no `SubscriptionUseCase` in the
environment, the SDK not configured, the offerings failing to load, an offering that is not
there — ends at the app's own paywall.

```swift
import SubscriptionRevenueCatUI
import SubscriptionUI

PaywallContainer(
mode: .automatic,
onEntitled: { analytics.track(.purchaseCompleted) }
) { handlers in
NavigationStack {
PaywallView(
pages: pages,
links: links,
onEntitled: handlers.onEntitled,
onError: handlers.onError
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", action: handlers.onDismiss)
}
}
}
}
```

The container adds no chrome to either paywall. The RevenueCat paywall draws its own close
button, so the navigation bar and the close button go inside the `custom` closure, and the
container is presented bare. `onEntitled`, `onError` and `onDismiss` are called for both
paywalls; the app's own gets them as the `PaywallHandlers` passed to the closure.

`PaywallMode` and the rule that turns it into a choice (`PaywallMode.choice(for:)`) live in
`SubscriptionUI`, so an app can hold the setting without linking RevenueCatUI.

## Documentation

**[API documentation and Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)**
Expand All @@ -94,7 +144,7 @@ cannot be tested on a simulator.

- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.14.0+
- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.19.0+

## Installation

Expand Down
148 changes: 148 additions & 0 deletions Sources/SubscriptionRevenueCatUI/PaywallContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import RevenueCat
import Subscription
import SubscriptionUI
import SwiftUI

/// The one place an app shows its paywall from, whichever paywall that turns out to be.
///
/// ```swift
/// PaywallContainer(
/// mode: .automatic,
/// onEntitled: { analytics.track(.purchaseCompleted) }
/// ) { handlers in
/// NavigationStack {
/// PaywallView(
/// pages: pages,
/// links: links,
/// onEntitled: handlers.onEntitled,
/// onError: handlers.onError
/// )
/// .toolbar {
/// ToolbarItem(placement: .cancellationAction) {
/// Button("Close", action: handlers.onDismiss)
/// }
/// }
/// }
/// }
/// ```
///
/// ## Chrome belongs to the custom paywall
///
/// The two paywalls do not want the same frame around them. The app's paywall usually sits in a
/// `NavigationStack` with a title and a close button; a RevenueCat paywall draws its own close
/// button and runs edge to edge, and wrapping it in a navigation bar puts two close buttons on
/// screen. So the container adds no chrome to either, and the `custom` closure returns the app's
/// paywall with its chrome already on. Present the container itself bare, in a sheet.
///
/// ## One set of callbacks
///
/// `onEntitled`, `onError` and `onDismiss` are called for both paywalls. The RevenueCat one is
/// wired to them here; the app's own gets them as the ``PaywallHandlers`` passed to `custom`,
/// to hand on to `PaywallView` and to its close button.
///
/// ## Falling back
///
/// Which paywall is shown is ``PaywallMode/choice(for:)`` applied to what looking up the
/// offering found. Anything that stops the RevenueCat paywall from being shown — no
/// `SubscriptionUseCase` in the environment, the SDK not configured, the offerings failing to
/// load, an offering that is not there — ends at the app's own paywall. For ``PaywallMode/custom``
/// nothing is looked up and the app's paywall appears without a loading state.
public struct PaywallContainer<Custom: View>: View {
private enum Phase {
case deciding
case custom
case revenueCat(Offering)
}

@Environment(\.subscriptionUseCase) private var subscriptionUseCase
@Environment(\.dismiss) private var dismiss

private let mode: PaywallMode
private let onEntitled: (@MainActor () -> Void)?
private let onError: (@MainActor (any Error) -> Void)?
private let onDismiss: (@MainActor () -> Void)?
private let custom: (PaywallHandlers) -> Custom

@State private var phase: Phase

/// Creates a container.
///
/// - Parameters:
/// - mode: Which paywall the app wants. Changing it decides again.
/// - onEntitled: Run when a purchase or a restore leaves the customer entitled, on either
/// paywall.
/// - onError: Run when a purchase or a restore fails, on either paywall. A cancelled
/// purchase does not arrive here, and neither does a failed lookup: that one falls back
/// to the app's paywall instead.
/// - onDismiss: Run when the customer asks to close either paywall. `nil` dismisses the
/// presentation the container is in.
/// - custom: Builds the app's own paywall, chrome included, from the handlers to call.
public init(
mode: PaywallMode,
onEntitled: (@MainActor () -> Void)? = nil,
onError: (@MainActor (any Error) -> Void)? = nil,
onDismiss: (@MainActor () -> Void)? = nil,
@ViewBuilder custom: @escaping (PaywallHandlers) -> Custom
) {
self.mode = mode
self.onEntitled = onEntitled
self.onError = onError
self.onDismiss = onDismiss
self.custom = custom
self._phase = State(initialValue: mode.needsLookup ? .deciding : .custom)
}

public var body: some View {
content
.task(id: mode) { await decide() }
}

@ViewBuilder
private var content: some View {
switch phase {
case .deciding:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .custom:
custom(handlers)
case .revenueCat(let offering):
RevenueCatPaywallView(loaded: offering, displayCloseButton: true, handlers: handlers)
.id(offering.identifier)
}
}

private var handlers: PaywallHandlers {
PaywallHandlers(
onEntitled: { onEntitled?() },
onError: { onError?($0) },
onDismiss: { if let onDismiss { onDismiss() } else { dismiss() } }
)
}

private func decide() async {
guard mode.needsLookup else {
phase = .custom
return
}

var offering: Offering?
let lookup: PaywallOfferingLookup
if subscriptionUseCase == nil {
lookup = .unavailable
} else {
do {
offering = try await RevenueCatOfferingLookup.load(mode.offeringIdentifier)
lookup = RevenueCatOfferingLookup.lookup(for: offering)
} catch {
lookup = .unavailable
}
}

switch (mode.choice(for: lookup), offering) {
case (.revenueCat, let offering?):
phase = .revenueCat(offering)
case (.revenueCat, nil), (.custom, _):
phase = .custom
}
}
}
Loading
Loading