From 134a60902be1f89aaa8dc00a0b2f1a55edde7748 Mon Sep 17 00:00:00 2001 From: porcupine072 <220051930+porcupine072@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:57:36 +1100 Subject: [PATCH 1/3] feat(InAppPurchaseView): allow inserting custom view content --- .../Activities/InAppPurchaseView.swift | 70 +++++++++++++++++++ .../CustomContentInsertionPoint.swift | 13 ++++ 2 files changed, 83 insertions(+) create mode 100644 Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift diff --git a/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift b/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift index 4fe63c6..50ec8e7 100644 --- a/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift +++ b/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift @@ -28,6 +28,12 @@ public struct InAppPurchaseView: View { /// will be performed. If an action is set, you will need to also dismiss the view. This /// is handled automatically when no action is set. private let onPurchaseAction: (@Sendable () -> Void)? + + /// Optional custom content to insert into the view. + private let customContent: AnyView? + + /// The insertion point for custom content, if provided. + private let customContentInsertionPoint: CustomContentInsertionPoint /// The current in-app purchase tier that has been selected in the list. @State private var selectedTier: PurchaseTier? @@ -54,6 +60,35 @@ public struct InAppPurchaseView: View { self.includeNavigationStack = includeNavigationStack self.includeDismissButton = includeDismissButton self.onPurchaseAction = onPurchaseAction + self.customContent = nil + self.customContentInsertionPoint = .afterHeader + } + + /// Creates a new `InAppPurchaseView` with custom content inserted at a specific point. + /// - Parameters: + /// - includeNavigationStack: A `Bool` indicating whether the purchase view should be contained in + /// its own `NavigationStack`. Defaults to `true`. + /// - includeDismissButton: A `Bool` indicating whether the purchase view should be dismissed from + /// the top toolbar. Defaults to `true`. + /// - onPurchaseAction: An optional action to perform when a transaction is completed. This is separate + /// to the action set in `InAppPurchaseKitConfiguration` but both + /// will be performed. If an action is set, you will need to also dismiss the view. This + /// is handled automatically when no action is set. Defaults to `nil`. + /// - insertionPoint: The position to insert the custom content. + /// Only one insertion point is active at a time. + /// - insertContent: A `ViewBuilder` that creates custom content. + public init( + includeNavigationStack: Bool = true, + includeDismissButton: Bool = true, + onPurchase onPurchaseAction: (@Sendable () -> Void)? = nil, + insertContentAt insertionPoint: CustomContentInsertionPoint, + @ViewBuilder insertContent: () -> some View + ) { + self.includeNavigationStack = includeNavigationStack + self.includeDismissButton = includeDismissButton + self.onPurchaseAction = onPurchaseAction + self.customContent = AnyView(insertContent()) + self.customContentInsertionPoint = insertionPoint } public var body: some View { @@ -130,15 +165,20 @@ public struct InAppPurchaseView: View { ) .frame(maxWidth: .infinity) + customContentIfInsertionPointMatches(insertionPoint: .afterHeader) + TiersView( selectedTier: $selectedTier, ignorePurchaseState: $ignorePurchaseState ) + customContentIfInsertionPointMatches(insertionPoint: .afterTiers) + VStack(spacing: SizingConstants.mainSpacing / 2) { Group { Divider() FeaturesView(inAppPurchase.configuration.features) + customContentIfInsertionPointMatches(insertionPoint: .afterFeatures) Divider() } .frame(maxWidth: SizingConstants.mainContentWidth) @@ -146,6 +186,8 @@ public struct InAppPurchaseView: View { AdditionalOptionsView( ignorePurchaseState: $ignorePurchaseState ) + + customContentIfInsertionPointMatches(insertionPoint: .afterAdditionalOptions) } } .frame(maxWidth: .infinity) @@ -160,6 +202,15 @@ public struct InAppPurchaseView: View { } } + @ViewBuilder + private func customContentIfInsertionPointMatches( + insertionPoint: CustomContentInsertionPoint + ) -> some View { + if customContentInsertionPoint == insertionPoint, let customContent { + customContent + } + } + // MARK: - Update @@ -218,3 +269,22 @@ public struct InAppPurchaseView: View { InAppPurchaseView() .environment(inAppPurchase) } + +#Preview("InAppPurchaseView+CustomContent") { + let inAppPurchase = InAppPurchaseKit.configure(with: .example) + + InAppPurchaseView(insertContentAt: .afterTiers) { + VStack(spacing: 8) { + Text("Limited-time discount") + .font(.headline) + Text("Save 20% with App Store Code XYZ") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .environment(inAppPurchase) +} diff --git a/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift b/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift new file mode 100644 index 0000000..a091d5c --- /dev/null +++ b/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift @@ -0,0 +1,13 @@ +// +// CustomContentInsertionPoint.swift +// InAppPurchaseKit +// +// +// + +public enum CustomContentInsertionPoint: Equatable { + case afterHeader + case afterTiers + case afterFeatures + case afterAdditionalOptions +} From aac46aee1038620139ba4ffee59ba6e8bcf13904 Mon Sep 17 00:00:00 2001 From: porcupine072 <220051930+porcupine072@users.noreply.github.com> Date: Fri, 8 May 2026 19:04:32 +1000 Subject: [PATCH 2/3] refactor(InAppPurchaseView): allow custom ordering of purchase view content via contentOrder composition and custom view rendering --- README.md | 20 ++- .../Activities/InAppPurchaseView.swift | 138 ++++++++---------- .../InAppPurchaseViewContent.swift | 25 ++++ 3 files changed, 103 insertions(+), 80 deletions(-) create mode 100644 Sources/InAppPurchaseKit/Models/Configuration/InAppPurchaseViewContent.swift diff --git a/README.md b/README.md index c107fda..4a02138 100644 --- a/README.md +++ b/README.md @@ -273,12 +273,30 @@ This is a view containing all of the in-app purchase options. It can be created InAppPurchaseView( includeNavigationStack: true, includeDismissButton: true, - onPurchase onPurchaseAction: nil + onPurchase onPurchaseAction: nil, + contentOrder: InAppPurchaseViewContent.defaultOrder ) ``` It has options to include its own navigation stack and dismiss button which is useful for when displaying in a sheet instead of in an existing navigation stack. A separate purchase action can be added too. +The order of content in the purchase view can also be customised. Supports reordering the order and adding custom SwiftUI content: + +```swift +InAppPurchaseView( + contentOrder: [ + .header, + .custom(AnyView( + Text("Limited-time discount") + .font(.headline) + )), + .tiers, + .features, + .additionalOptions + ] +) +``` + ### LockedInAppPurchaseFeatureButton This is a view containing a button that performs an action when a user is subscribed or is locked when not. It can be created like so: diff --git a/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift b/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift index 50ec8e7..bee504a 100644 --- a/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift +++ b/Sources/InAppPurchaseKit/Activities/InAppPurchaseView.swift @@ -29,11 +29,8 @@ public struct InAppPurchaseView: View { /// is handled automatically when no action is set. private let onPurchaseAction: (@Sendable () -> Void)? - /// Optional custom content to insert into the view. - private let customContent: AnyView? - - /// The insertion point for custom content, if provided. - private let customContentInsertionPoint: CustomContentInsertionPoint + /// The order that content should be displayed in the purchase view. + private let contentOrder: [InAppPurchaseViewContent] /// The current in-app purchase tier that has been selected in the list. @State private var selectedTier: PurchaseTier? @@ -52,43 +49,18 @@ public struct InAppPurchaseView: View { /// to the action set in `InAppPurchaseKitConfiguration` but both /// will be performed. If an action is set, you will need to also dismiss the view. This /// is handled automatically when no action is set. Defaults to `nil`. - public init( - includeNavigationStack: Bool = true, - includeDismissButton: Bool = true, - onPurchase onPurchaseAction: (@Sendable () -> Void)? = nil - ) { - self.includeNavigationStack = includeNavigationStack - self.includeDismissButton = includeDismissButton - self.onPurchaseAction = onPurchaseAction - self.customContent = nil - self.customContentInsertionPoint = .afterHeader - } - - /// Creates a new `InAppPurchaseView` with custom content inserted at a specific point. - /// - Parameters: - /// - includeNavigationStack: A `Bool` indicating whether the purchase view should be contained in - /// its own `NavigationStack`. Defaults to `true`. - /// - includeDismissButton: A `Bool` indicating whether the purchase view should be dismissed from - /// the top toolbar. Defaults to `true`. - /// - onPurchaseAction: An optional action to perform when a transaction is completed. This is separate - /// to the action set in `InAppPurchaseKitConfiguration` but both - /// will be performed. If an action is set, you will need to also dismiss the view. This - /// is handled automatically when no action is set. Defaults to `nil`. - /// - insertionPoint: The position to insert the custom content. - /// Only one insertion point is active at a time. - /// - insertContent: A `ViewBuilder` that creates custom content. + /// - contentOrder: The order that content should be displayed in the purchase view. + /// Defaults to `InAppPurchaseViewContent.defaultOrder`. public init( includeNavigationStack: Bool = true, includeDismissButton: Bool = true, onPurchase onPurchaseAction: (@Sendable () -> Void)? = nil, - insertContentAt insertionPoint: CustomContentInsertionPoint, - @ViewBuilder insertContent: () -> some View + contentOrder: [InAppPurchaseViewContent] = InAppPurchaseViewContent.defaultOrder ) { self.includeNavigationStack = includeNavigationStack self.includeDismissButton = includeDismissButton self.onPurchaseAction = onPurchaseAction - self.customContent = AnyView(insertContent()) - self.customContentInsertionPoint = insertionPoint + self.contentOrder = contentOrder } public var body: some View { @@ -160,34 +132,8 @@ public struct InAppPurchaseView: View { private var subscriptionViewContents: some View { ScrollView { VStack(spacing: SizingConstants.mainSpacing) { - InAppPurchaseHeaderView( - configuration: inAppPurchase.configuration - ) - .frame(maxWidth: .infinity) - - customContentIfInsertionPointMatches(insertionPoint: .afterHeader) - - TiersView( - selectedTier: $selectedTier, - ignorePurchaseState: $ignorePurchaseState - ) - - customContentIfInsertionPointMatches(insertionPoint: .afterTiers) - - VStack(spacing: SizingConstants.mainSpacing / 2) { - Group { - Divider() - FeaturesView(inAppPurchase.configuration.features) - customContentIfInsertionPointMatches(insertionPoint: .afterFeatures) - Divider() - } - .frame(maxWidth: SizingConstants.mainContentWidth) - - AdditionalOptionsView( - ignorePurchaseState: $ignorePurchaseState - ) - - customContentIfInsertionPointMatches(insertionPoint: .afterAdditionalOptions) + ForEach(contentOrder.indices, id: \.self) { index in + renderView(for: contentOrder[index]) } } .frame(maxWidth: .infinity) @@ -203,10 +149,36 @@ public struct InAppPurchaseView: View { } @ViewBuilder - private func customContentIfInsertionPointMatches( - insertionPoint: CustomContentInsertionPoint - ) -> some View { - if customContentInsertionPoint == insertionPoint, let customContent { + private func renderView(for content: InAppPurchaseViewContent) -> some View { + switch content { + case .header: + InAppPurchaseHeaderView( + configuration: inAppPurchase.configuration + ) + .frame(maxWidth: .infinity) + + case .tiers: + TiersView( + selectedTier: $selectedTier, + ignorePurchaseState: $ignorePurchaseState + ) + + case .features: + VStack(spacing: SizingConstants.mainSpacing / 2) { + Group { + Divider() + FeaturesView(inAppPurchase.configuration.features) + Divider() + } + } + .frame(maxWidth: SizingConstants.mainContentWidth) + + case .additionalOptions: + AdditionalOptionsView( + ignorePurchaseState: $ignorePurchaseState + ) + + case .custom(let customContent): customContent } } @@ -273,18 +245,26 @@ public struct InAppPurchaseView: View { #Preview("InAppPurchaseView+CustomContent") { let inAppPurchase = InAppPurchaseKit.configure(with: .example) - InAppPurchaseView(insertContentAt: .afterTiers) { - VStack(spacing: 8) { - Text("Limited-time discount") - .font(.headline) - Text("Save 20% with App Store Code XYZ") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding() - .background(.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } + InAppPurchaseView( + contentOrder: [ + .header, + .custom(AnyView( + VStack(spacing: 8) { + Text("Limited-time discount") + .font(.headline) + Text("Save 20% with App Store Code XYZ") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + )), + .tiers, + .features, + .additionalOptions + ] + ) .environment(inAppPurchase) } diff --git a/Sources/InAppPurchaseKit/Models/Configuration/InAppPurchaseViewContent.swift b/Sources/InAppPurchaseKit/Models/Configuration/InAppPurchaseViewContent.swift new file mode 100644 index 0000000..181f592 --- /dev/null +++ b/Sources/InAppPurchaseKit/Models/Configuration/InAppPurchaseViewContent.swift @@ -0,0 +1,25 @@ +// +// InAppPurchaseViewContent.swift +// InAppPurchaseKit +// +// Created by Adam Foot on 08/05/2026. +// + +import SwiftUI + +public enum InAppPurchaseViewContent { + case header + case tiers + case features + case additionalOptions + case custom(AnyView) + + public static var defaultOrder: [InAppPurchaseViewContent] { + [ + .header, + .tiers, + .features, + .additionalOptions + ] + } +} From 6b3b24aca212c61d49c21c0bc7abd7f917eb7399 Mon Sep 17 00:00:00 2001 From: porcupine072 <220051930+porcupine072@users.noreply.github.com> Date: Fri, 8 May 2026 19:06:34 +1000 Subject: [PATCH 3/3] refactor(CustomContentInsertionPoint): remove file, no longer used --- .../Configuration/CustomContentInsertionPoint.swift | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift diff --git a/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift b/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift deleted file mode 100644 index a091d5c..0000000 --- a/Sources/InAppPurchaseKit/Models/Configuration/CustomContentInsertionPoint.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CustomContentInsertionPoint.swift -// InAppPurchaseKit -// -// -// - -public enum CustomContentInsertionPoint: Equatable { - case afterHeader - case afterTiers - case afterFeatures - case afterAdditionalOptions -}