From f26efd248ac9e76d2895624ab09be6650c4ec82c Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 29 Jan 2026 17:32:48 +0100 Subject: [PATCH 1/3] feat: add haptic feedback handling --- .../Message Handling/PaywallMessage.swift | 8 ++ .../PaywallMessageHandler.swift | 33 ++++++++ .../PaywallMessageHandlerTests.swift | 77 +++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index 63d1777e8..59e952e34 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -58,6 +58,7 @@ enum PaywallMessage: Decodable, Equatable { case initiateWebCheckout(contextId: String) case requestStoreReview(ReviewType) case requestPermission(permissionType: PermissionType, requestId: String) + case hapticFeedback(hapticType: String) // All cases below here are sent from device to paywall case paywallClose @@ -98,6 +99,7 @@ enum PaywallMessage: Decodable, Equatable { case requestStoreReview = "request_store_review" case scheduleNotification = "schedule_notification" case requestPermission = "request_permission" + case hapticFeedback = "haptic_feedback" } // Everyone write to eventName, other may use the remaining keys @@ -123,6 +125,7 @@ enum PaywallMessage: Decodable, Equatable { case delay case permissionType case requestId + case hapticType } enum PaywallMessageError: Error { @@ -221,6 +224,11 @@ enum PaywallMessage: Decodable, Equatable { self = .requestPermission(permissionType: permissionType, requestId: requestId) return } + case .hapticFeedback: + if let hapticType = try? values.decode(String.self, forKey: .hapticType) { + self = .hapticFeedback(hapticType: hapticType) + return + } } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 88f81e1d4..259b05b10 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -201,6 +201,8 @@ final class PaywallMessageHandler: WebEventDelegate { requestId: requestId, paywall: paywall ) + case let .hapticFeedback(hapticType): + triggerHapticFeedback(hapticType) } } @@ -508,6 +510,37 @@ final class PaywallMessageHandler: WebEventDelegate { #endif } + /// Triggers haptic feedback based on the type specified from the paywall editor. + private func triggerHapticFeedback(_ hapticType: String) { + #if !os(visionOS) + switch hapticType { + case "light": + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + case "medium": + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + case "heavy": + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.impactOccurred() + case "success": + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + case "warning": + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.warning) + case "error": + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + case "selection": + let generator = UISelectionFeedbackGenerator() + generator.selectionChanged() + default: + break + } + #endif + } + // MARK: - Permission Handling private func handleRequestPermission( diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index e72853f3d..db8cdb258 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -468,4 +468,81 @@ struct PaywallMessageHandlerTests { shouldDismiss: true )) } + + // MARK: - Haptic Feedback Message Decoding Tests + + @Test + func decodeHapticFeedback_medium() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "haptic_feedback", + "haptic_type": "medium" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .hapticFeedback(hapticType: "medium")) + } + + @Test + func decodeHapticFeedback_allTypes() throws { + let hapticTypes = ["light", "medium", "heavy", "success", "warning", "error", "selection"] + + for hapticType in hapticTypes { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "haptic_feedback", + "haptic_type": "\(hapticType)" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .hapticFeedback(hapticType: hapticType)) + } + } + + @Test + func handleHapticFeedback() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler() + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + // This should not crash and should not trigger any delegate events + messageHandler.handle(.hapticFeedback(hapticType: "medium")) + + // Haptic feedback doesn't trigger delegate events, so we just verify it doesn't crash + #expect(delegate.eventDidOccur == nil) + } } From 8fd1289a81502b6b927556a2f89854aedffd3c77 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 29 Jan 2026 18:06:30 +0100 Subject: [PATCH 2/3] fix: add changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b55abbb7..6fe4c97e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.12.10 + +### Enhancements + +- Adds native haptic feedback support for paywall buttons. Haptic types can be configured in the paywall editor and include light, medium, heavy, success, warning, error, and selection. + ## 4.12.9 ### Fixes From ffdfe7a48efdff25a70815a3c91202421a1f4c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:26:19 +0100 Subject: [PATCH 3/3] Add preparation for feedback generator --- .../Web View/Message Handling/PaywallMessageHandler.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 259b05b10..b02f9b813 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -516,24 +516,31 @@ final class PaywallMessageHandler: WebEventDelegate { switch hapticType { case "light": let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() generator.impactOccurred() case "medium": let generator = UIImpactFeedbackGenerator(style: .medium) + generator.prepare() generator.impactOccurred() case "heavy": let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.prepare() generator.impactOccurred() case "success": let generator = UINotificationFeedbackGenerator() + generator.prepare() generator.notificationOccurred(.success) case "warning": let generator = UINotificationFeedbackGenerator() + generator.prepare() generator.notificationOccurred(.warning) case "error": let generator = UINotificationFeedbackGenerator() + generator.prepare() generator.notificationOccurred(.error) case "selection": let generator = UISelectionFeedbackGenerator() + generator.prepare() generator.selectionChanged() default: break