Skip to content
Draft
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
9 changes: 9 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
2C8EC6DB2CCC23B700D6CCF8 /* MultiTierPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC6DA2CCC23B700D6CCF8 /* MultiTierPreview.swift */; };
2C8EC6DD2CCC7C5B00D6CCF8 /* PackageValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC6DC2CCC7C5500D6CCF8 /* PackageValidator.swift */; };
2C8EC6DF2CCD27A500D6CCF8 /* PartialComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC6DE2CCD27A100D6CCF8 /* PartialComponentTests.swift */; };
D0D83AE85FF3400EA30B0BDE /* TabsComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11B9143D0AA4C0D9A694592 /* TabsComponentTests.swift */; };
2C8EC6E12CCD7BA700D6CCF8 /* ComponentOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC6E02CCD7BA300D6CCF8 /* ComponentOverrides.swift */; };
2C8EC71B2CCDD43900D6CCF8 /* ComponentViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC71A2CCDD43500D6CCF8 /* ComponentViewState.swift */; };
2C8EC7202CCF276100D6CCF8 /* PresentedPartials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8EC71F2CCF275E00D6CCF8 /* PresentedPartials.swift */; };
Expand Down Expand Up @@ -1638,6 +1639,7 @@
164933232E5808BE00CE43A9 /* PaywallTransitionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallTransitionTest.swift; sourceTree = "<group>"; };
166BA7992F84022400BB46C9 /* PaywallEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventTracker.swift; sourceTree = "<group>"; };
166BA7A02F84028600BB46C9 /* ControlInteractionLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlInteractionLoggerTests.swift; sourceTree = "<group>"; };
1F73FB16F7644A5CBD7FB08A /* PackageSelectionHapticFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageSelectionHapticFeedbackTests.swift; sourceTree = "<group>"; };
166BA7A12F84028600BB46C9 /* PaywallEventTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventTrackerTests.swift; sourceTree = "<group>"; };
167D67082EA151C400B4503F /* SynchronizedLargeItemCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedLargeItemCache.swift; sourceTree = "<group>"; };
1696D72B3007FC4800E6E8C0 /* SizeConstraintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SizeConstraintTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1762,6 +1764,7 @@
2C8EC6DA2CCC23B700D6CCF8 /* MultiTierPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiTierPreview.swift; sourceTree = "<group>"; };
2C8EC6DC2CCC7C5500D6CCF8 /* PackageValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageValidator.swift; sourceTree = "<group>"; };
2C8EC6DE2CCD27A100D6CCF8 /* PartialComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartialComponentTests.swift; sourceTree = "<group>"; };
D11B9143D0AA4C0D9A694592 /* TabsComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsComponentTests.swift; sourceTree = "<group>"; };
2C8EC6E02CCD7BA300D6CCF8 /* ComponentOverrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentOverrides.swift; sourceTree = "<group>"; };
2C8EC71A2CCDD43500D6CCF8 /* ComponentViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentViewState.swift; sourceTree = "<group>"; };
2C8EC71F2CCF275E00D6CCF8 /* PresentedPartials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentedPartials.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2428,6 +2431,8 @@
57BF87582967880C00424254 /* MockCachingTrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockCachingTrialOrIntroPriceEligibilityChecker.swift; sourceTree = "<group>"; };
57BFCD0A2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsPackageInheritanceTests.swift; sourceTree = "<group>"; };
57BFCD0F2EEB1234009E5844 /* TabsPackageSelectionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsPackageSelectionResolverTests.swift; sourceTree = "<group>"; };
E7D46162A95A4706925EC868 /* TabControlHapticFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabControlHapticFeedbackTests.swift; sourceTree = "<group>"; };
0AA5FEF26985445B9576D005 /* TabsComponentDuplicateInstanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsComponentDuplicateInstanceTests.swift; sourceTree = "<group>"; };
57C0BB6829F840BD00827807 /* FrameworkDisambiguation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameworkDisambiguation.swift; sourceTree = "<group>"; };
57C2931428BFEF4F0054EDFC /* PurchasesError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesError.swift; sourceTree = "<group>"; };
57C381B62791E593009E3940 /* StoreKit2TransactionListenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2TransactionListenerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3258,6 +3263,7 @@
57BFCD0F2EEB1234009E5844 /* TabsPackageSelectionResolverTests.swift */,
57BFCD0A2EEB0E56009E5844 /* TabsPackageInheritanceTests.swift */,
0AA5FEF26985445B9576D005 /* TabsComponentDuplicateInstanceTests.swift */,
E7D46162A95A4706925EC868 /* TabControlHapticFeedbackTests.swift */,
E7EAAD1DC166ABD68E484EAA /* RootViewSheetDismissalTests.swift */,
83EE33252E1E20360011CF1C /* PaywallPreviewResourcesLoader.swift */,
038EC8222DE7A8AC00786C42 /* TakeScreenshot.swift */,
Expand Down Expand Up @@ -3567,6 +3573,7 @@
038213DA2DD4979A003C02C3 /* PurchaseButtonComponentTests.swift */,
0C5FC914F4E87D00963A8F1D /* PackageComponentTests.swift */,
2C8EC6DE2CCD27A100D6CCF8 /* PartialComponentTests.swift */,
D11B9143D0AA4C0D9A694592 /* TabsComponentTests.swift */,
16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */,
16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */,
164933232E5808BE00CE43A9 /* PaywallTransitionTest.swift */,
Expand Down Expand Up @@ -5894,6 +5901,7 @@
isa = PBXGroup;
children = (
166BA7A02F84028600BB46C9 /* ControlInteractionLoggerTests.swift */,
1F73FB16F7644A5CBD7FB08A /* PackageSelectionHapticFeedbackTests.swift */,
166BA7A12F84028600BB46C9 /* PaywallEventTrackerTests.swift */,
57F4B2C12F8E10A600BB46C9 /* ExitOfferHelperTests.swift */,
16E146B42E99FF640089B609 /* MockStoreTransaction.swift */,
Expand Down Expand Up @@ -8006,6 +8014,7 @@
1622D3FB2E900DE000C20E3C /* ChecksumTests.swift in Sources */,
5748008C29BFC6660032F001 /* SignatureVerificationHTTPClientTests.swift in Sources */,
2C8EC6DF2CCD27A500D6CCF8 /* PartialComponentTests.swift in Sources */,
D0D83AE85FF3400EA30B0BDE /* TabsComponentTests.swift in Sources */,
351B51A326D450BC00BD2BD7 /* DictionaryExtensionsTests.swift in Sources */,
1D73D6622EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift in Sources */,
FEDA000000000000000000E2 /* MockRemoteConfigSourceProvider.swift in Sources */,
Expand Down
72 changes: 72 additions & 0 deletions RevenueCatUI/Purchasing/PaywallEventTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import Foundation
@_spi(Internal) import RevenueCat
import SwiftUI

#if canImport(UIKit) && os(iOS)
import UIKit
#endif

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
final class PaywallEventTracker: @unchecked Sendable {
typealias EventDispatcher = @Sendable (@Sendable @escaping () async -> Void) -> Void
Expand Down Expand Up @@ -257,3 +261,71 @@ extension EnvironmentValues {
set { self[ComponentInteractionLoggerKey.self] = newValue }
}
}

/// Fires a native selection-changed haptic when the user changes the selected package or tab
/// on a Paywalls V2 screen. Wraps a closure (like `ComponentInteractionLogger`) so tests can
/// inject a spy without touching real UIKit APIs.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct PackageSelectionHapticFeedback {

private let action: () -> Void
private let prepareAction: () -> Void

init(
action: @escaping () -> Void = Self.defaultAction,
prepare: @escaping () -> Void = Self.defaultPrepare
) {
self.action = action
self.prepareAction = prepare
}

func callAsFunction() {
self.action()
}

/// Warms up the haptics engine ahead of the first selection. The first haptic in a process
/// loads the underlying engine on the main thread, a one-time cost that does not decay.
/// Calling this on paywall appear pays it off the selection's critical path, so the first
/// tap's highlight render isn't stalled behind the engine load.
func prepare() {
self.prepareAction()
}

#if canImport(UIKit) && os(iOS)
// One shared generator kept warm across selections, instead of allocating and re-warming
// a fresh one on every tap.
private static let generator = UISelectionFeedbackGenerator()
#endif

private static func defaultAction() {
#if canImport(UIKit) && os(iOS)
Self.generator.selectionChanged()
// Keep the engine warm for the next selection.
Self.generator.prepare()
#endif
}

private static func defaultPrepare() {
#if canImport(UIKit) && os(iOS)
Self.generator.prepare()
#endif
}

// Once the deployment target reaches iOS 17, this can be replaced by the SwiftUI-native
// `.sensoryFeedback(.selection, trigger:)` modifier attached directly at each call site,
// dropping this imperative UIKit path.
}

/// `EnvironmentKey` for storing the paywall package/tab selection haptic feedback.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
struct PackageSelectionHapticFeedbackKey: EnvironmentKey {
static let defaultValue: PackageSelectionHapticFeedback = .init()
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension EnvironmentValues {
var packageSelectionHapticFeedback: PackageSelectionHapticFeedback {
get { self[PackageSelectionHapticFeedbackKey.self] }
set { self[PackageSelectionHapticFeedbackKey.self] = newValue }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ struct PackageComponentView: View {
packageContext: self.packageContext,
package: package,
componentName: self.viewModel.componentName,
hasPurchaseButton: self.viewModel.hasPurchaseButton
hasPurchaseButton: self.viewModel.hasPurchaseButton,
hapticFeedbackEnabled: self.viewModel.hapticFeedbackEnabled
)
}
}
Expand All @@ -96,30 +97,35 @@ private extension View {
packageContext: PackageContext,
package: Package,
componentName: String?,
hasPurchaseButton: Bool
hasPurchaseButton: Bool,
hapticFeedbackEnabled: Bool
) -> some View {
modifier(PackageSelectorIfNeeded(
packageContext: packageContext,
package: package,
componentName: componentName,
hasPurchaseButton: hasPurchaseButton
hasPurchaseButton: hasPurchaseButton,
hapticFeedbackEnabled: hapticFeedbackEnabled
))
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct PackageSelectorIfNeeded: ViewModifier {
struct PackageSelectorIfNeeded: ViewModifier {

@Environment(\.componentInteractionLogger)
private var componentInteractionLogger
@Environment(\.planSelectionDefaultPackage)
private var planSelectionDefaultPackage
@Environment(\.packageSelectionHapticFeedback)
private var hapticFeedback

let packageContext: PackageContext
let package: Package
let componentName: String?
let hasPurchaseButton: Bool
let hapticFeedbackEnabled: Bool

func body(content: Content) -> some View {
if hasPurchaseButton {
Expand All @@ -140,15 +146,37 @@ private struct PackageSelectorIfNeeded: ViewModifier {
)
)
}
if Self.shouldTriggerHapticFeedback(
origin: origin,
destination: self.package,
hapticFeedbackEnabled: self.hapticFeedbackEnabled
) {
self.hapticFeedback()
}
self.packageContext.update(
package: self.package,
variableContext: self.packageContext.variableContext
)
} label: {
content
}
// Warm the haptics engine as the selectable package appears, so the first
// selection's highlight isn't stalled behind the one-time engine load.
.onAppear {
if hapticFeedbackEnabled {
self.hapticFeedback.prepare()
}
}
}
}

static func shouldTriggerHapticFeedback(
origin: Package?,
destination: Package,
hapticFeedbackEnabled: Bool
) -> Bool {
return hapticFeedbackEnabled && origin?.identifier != destination.identifier
}
}

#if DEBUG
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class PackageComponentViewModel {
let package: Package?
let stackViewModel: StackComponentViewModel
let hasPurchaseButton: Bool
let hapticFeedbackEnabled: Bool

private let componentVisible: Bool?
private let uiConfigProvider: UIConfigProvider
Expand All @@ -45,6 +46,7 @@ class PackageComponentViewModel {
self.isSelectedByDefault = component.isSelectedByDefault
self.promotionalOfferProductCode = component.applePromoOfferProductCode
self.componentName = component.name
self.hapticFeedbackEnabled = component.hapticFeedbackEnabled ?? true

self.package = offering.package(identifier: component.packageID)
if package == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ struct TabControlButtonComponentView: View {
@Environment(\.componentInteractionLogger)
private var componentInteractionLogger

@Environment(\.packageSelectionHapticFeedback)
private var hapticFeedback

private let viewModel: TabControlButtonComponentViewModel
private let onDismiss: () -> Void

Expand All @@ -57,14 +60,28 @@ struct TabControlButtonComponentView: View {

self.tabControlContext.selectedTabId = destinationTabId
self.trackTabcomponentInteraction(originTabId: originTabId, destinationTabId: destinationTabId)

if Self.shouldTriggerHapticFeedback(
originTabId: originTabId,
destinationTabId: destinationTabId,
hapticFeedbackEnabled: self.viewModel.component.hapticFeedbackEnabled ?? true
) {
self.hapticFeedback()
}
} label: {
StackComponentView(
viewModel: self.viewModel.stackViewModel,
onDismiss: self.onDismiss
)
.environment(\.componentViewState, self.selectedState)
}

// Warm the haptics engine as the tab control appears, so the first tab
// selection's render isn't stalled behind the one-time engine load.
.onAppear {
if self.viewModel.component.hapticFeedbackEnabled ?? true {
self.hapticFeedback.prepare()
}
}
}

private func trackTabcomponentInteraction(originTabId: String, destinationTabId: String) {
Expand All @@ -83,6 +100,14 @@ struct TabControlButtonComponentView: View {
))
}

static func shouldTriggerHapticFeedback(
originTabId: String,
destinationTabId: String,
hapticFeedbackEnabled: Bool
) -> Bool {
return hapticFeedbackEnabled && originTabId != destinationTabId
}

}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ struct TabControlToggleComponentView: View {
@Environment(\.componentInteractionLogger)
private var componentInteractionLogger

@Environment(\.packageSelectionHapticFeedback)
private var hapticFeedback

private let viewModel: TabControlToggleComponentViewModel
private let onDismiss: () -> Void

Expand All @@ -60,6 +63,9 @@ struct TabControlToggleComponentView: View {
componentName: self.tabControlContext.name,
isOn: newValue
))
if self.viewModel.component.hapticFeedbackEnabled ?? true {
self.hapticFeedback()
}
}
)
}
Expand All @@ -80,6 +86,13 @@ struct TabControlToggleComponentView: View {
)
)
.labelsHidden()
// Warm the haptics engine as the toggle appears, so the first toggle's
// render isn't stalled behind the one-time engine load.
.onAppear {
if self.viewModel.component.hapticFeedbackEnabled ?? true {
self.hapticFeedback.prepare()
}
}
}

/// Computes the toggle's ON state based on the selected tab.
Expand Down
6 changes: 6 additions & 0 deletions Sources/Paywalls/Components/PaywallPackageComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import Foundation
public let stack: PaywallComponent.StackComponent
public let name: String?
public let id: String?
public let hapticFeedbackEnabled: Bool?

public let overrides: ComponentOverrides<PartialPackageComponent>?

Expand All @@ -38,6 +39,7 @@ import Foundation
stack: PaywallComponent.StackComponent,
name: String? = nil,
id: String? = nil,
hapticFeedbackEnabled: Bool? = nil,
overrides: ComponentOverrides<PartialPackageComponent>? = nil
) {
self.type = .package
Expand All @@ -48,6 +50,7 @@ import Foundation
self.stack = stack
self.name = name
self.id = id
self.hapticFeedbackEnabled = hapticFeedbackEnabled
self.overrides = overrides
}

Expand All @@ -60,6 +63,7 @@ import Foundation
hasher.combine(stack)
hasher.combine(name)
hasher.combine(id)
hasher.combine(hapticFeedbackEnabled)
hasher.combine(overrides)
}

Expand All @@ -72,6 +76,7 @@ import Foundation
lhs.stack == rhs.stack &&
lhs.name == rhs.name &&
lhs.id == rhs.id &&
lhs.hapticFeedbackEnabled == rhs.hapticFeedbackEnabled &&
lhs.overrides == rhs.overrides
}
}
Expand Down Expand Up @@ -106,6 +111,7 @@ extension PaywallComponent.PackageComponent {
case stack
case name
case id
case hapticFeedbackEnabled
case overrides
}

Expand Down
Loading