Skip to content
Open
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
6 changes: 6 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; };
0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */; };
D969194D68D6967E81788B1F /* WebViewComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A2779D02050D28BF369658 /* WebViewComponentView.swift */; };
B3728970B7484A89838D0504 /* WebViewScrollGestureArbitration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047CB41107F93D74B35CED61 /* WebViewScrollGestureArbitration.swift */; };
B53032B7D38B14FE9850F314 /* WebViewComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */; };
16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; };
16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; };
Expand Down Expand Up @@ -1666,8 +1667,10 @@
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = "<group>"; };
72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSession.swift; sourceTree = "<group>"; };
D0A2779D02050D28BF369658 /* WebViewComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentView.swift; sourceTree = "<group>"; };
047CB41107F93D74B35CED61 /* WebViewScrollGestureArbitration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewScrollGestureArbitration.swift; sourceTree = "<group>"; };
6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewComponentViewModel.swift; sourceTree = "<group>"; };
F75819E258F47D1DB058500D /* WebViewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSessionTests.swift; sourceTree = "<group>"; };
F1A2B3C4D5E6A7B8C9D0E1F2 /* WebViewScrollGestureArbitrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewScrollGestureArbitrationTests.swift; sourceTree = "<group>"; };
16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = "<group>"; };
16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = "<group>"; };
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3209,6 +3212,7 @@
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */,
C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */,
F75819E258F47D1DB058500D /* WebViewSessionTests.swift */,
F1A2B3C4D5E6A7B8C9D0E1F2 /* WebViewScrollGestureArbitrationTests.swift */,
802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */,
16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */,
DBD2A16E300E833F0019DB7F /* VideoAudioSessionHandlerTests.swift */,
Expand Down Expand Up @@ -3353,6 +3357,7 @@
89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */,
72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */,
D0A2779D02050D28BF369658 /* WebViewComponentView.swift */,
047CB41107F93D74B35CED61 /* WebViewScrollGestureArbitration.swift */,
6F6E5F9ABE99E8F2BC1DE237 /* WebViewComponentViewModel.swift */,
);
path = WebView;
Expand Down Expand Up @@ -8315,6 +8320,7 @@
11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */,
0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */,
D969194D68D6967E81788B1F /* WebViewComponentView.swift in Sources */,
B3728970B7484A89838D0504 /* WebViewScrollGestureArbitration.swift in Sources */,
B53032B7D38B14FE9850F314 /* WebViewComponentViewModel.swift in Sources */,
DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */,
164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ struct WebViewRepresentable: PlatformViewRepresentable {
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = true
webView.scrollView.bounces = false
webView.scrollView.bounces = true
webView.scrollView.minimumZoomScale = 1
webView.scrollView.maximumZoomScale = 1
#endif
Expand All @@ -285,11 +285,31 @@ struct WebViewRepresentable: PlatformViewRepresentable {
}
#endif

#if os(iOS)
// Nested-scroll arbitration: let JS-panned content claim a drag from the enclosing paywall
// scroll. Installed before `load` so the document-start probe is present for the first
// navigation.
self.installScrollGestureArbitration(on: webView)
#endif

self.configureSession(for: webView)
self.load(webView)
return webView
}

#if os(iOS)
@MainActor
private func installScrollGestureArbitration(on webView: PlatformWebView) {
let recognizer = WebViewScrollOwnershipRecognizer(webView: webView)
webView.addGestureRecognizer(recognizer)
webView.configuration.userContentController.add(
WeakScriptMessageHandler(recognizer),
name: WebViewGestureProbe.messageHandlerName
)
webView.configuration.userContentController.addUserScript(WebViewGestureProbe.userScript)
}
#endif

@MainActor
private func update(_ webView: PlatformWebView) {
self.configureSession(for: webView)
Expand Down Expand Up @@ -324,6 +344,11 @@ struct WebViewRepresentable: PlatformViewRepresentable {
webView.configuration.userContentController.removeScriptMessageHandler(
forName: WebViewEnvelope.messageHandlerName
)
#if os(iOS)
webView.configuration.userContentController.removeScriptMessageHandler(
forName: WebViewGestureProbe.messageHandlerName
)
#endif
}

// `WKNavigationDelegate` is `@MainActor`-annotated in the SDK (its `WK_SWIFT_UI_ACTOR` attribute
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// WebViewScrollGestureArbitration.swift
//
// Created by Antonio Pallares.
//

// A web_view component embedded in a scrollable paywall competes for drag gestures with the paywall
// scroll — on whichever axis the paywall scrolls (vertical for a typical paywall, horizontal for a
// paged/carousel container). iOS resolves nested *native* scroll views on its own (the inner web
// scroll wins when it can scroll), but content that pans via JavaScript — an SVG map with
// `touch-action: none`, an inner `overflow: auto` list — is invisible to that arbitration, so a drag
// both pans the content *and* scrolls the paywall ("double scroll").

#if os(iOS) && canImport(WebKit)

import Foundation
@_spi(Internal) import RevenueCat
import UIKit.UIGestureRecognizerSubclass
import WebKit

// MARK: - Probe

@available(iOS 15.0, *)
enum WebViewGestureProbe {

/// Message handler name the probe posts verdicts to. Distinct from the bridge's
/// ``WebViewEnvelope/messageHandlerName`` so gesture traffic never touches the app channel.
static let messageHandlerName = "rcPaywallGestureProbe"

static let verdictOwn = "own"
static let verdictRelease = "release"

/// Runs at document start in the main frame. On every `touchstart` it walks from the touched
/// element up its ancestors and posts `own` when one declares a non-default `touch-action`
/// (JS panning) or is an overflowing `auto`/`scroll` scroller — the per-element signals the
/// native scroll-offset checks can't see — otherwise `release`. Passive, so it never blocks
/// the page's own handling.
static var userScript: WKUserScript {
let source = """
(function () {
function consumesGesture(el) {
var ELEMENT_NODE = Node.ELEMENT_NODE;
var node = el && el.nodeType === ELEMENT_NODE ? el : (el ? el.parentElement : null);
for (var n = node; n && n.nodeType === ELEMENT_NODE; n = n.parentElement) {
var s = getComputedStyle(n);
if (s.touchAction && s.touchAction !== 'auto' && s.touchAction !== 'manipulation') return true;
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && n.scrollHeight > n.clientHeight) return true;
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && n.scrollWidth > n.clientWidth) return true;
}
return false;
}
function post(verdict) {
try {
window.webkit.messageHandlers.\(messageHandlerName).postMessage(verdict);
} catch (e) {}
}
document.addEventListener('touchstart', function (event) {
post(consumesGesture(event.target) ? '\(verdictOwn)' : '\(verdictRelease)');
}, { passive: true, capture: true });
})();
"""
return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}

}

// MARK: - Decision

/// Whether the web view should claim the drag (else the paywall scroll keeps it). A content `own`
/// verdict — an inner scroller or `touch-action` map native can't see — wins immediately; otherwise
/// the web view's own scroll offsets in the dominant drag direction decide. `direction > 0` means
/// toward the end of the content (scrolling down / trailing), as used by the `canScroll*` closures below.
@available(iOS 15.0, *)
// swiftlint:disable:next function_parameter_count
func shouldWebViewOwnGesture(
totalDx: CGFloat,
totalDy: CGFloat,
touchSlop: CGFloat,
webContentWantsGesture: Bool?,
canScrollHorizontally: (_ direction: Int) -> Bool,
canScrollVertically: (_ direction: Int) -> Bool
) -> Bool {
if webContentWantsGesture == true { return true }
if abs(totalDx) < touchSlop && abs(totalDy) < touchSlop { return false }
if abs(totalDy) >= abs(totalDx) {
return canScrollVertically(totalDy < 0 ? 1 : -1)
} else {
return canScrollHorizontally(totalDx < 0 ? 1 : -1)
}
}

// MARK: - Recognizer

/// Installed on the web view; makes any *ancestor* paywall scroll view wait for it to fail (via
/// ``gestureRecognizer(_:shouldBeRequiredToFailBy:)``). It recognizes — and so blocks that scroll —
/// only when the drag belongs to the web content, per ``shouldWebViewOwnGesture(...)`` fed by the
/// probe verdict and the web view's own scroll offsets. It never cancels touches, so the page still
/// receives them for panning, taps and links.
@available(iOS 15.0, *)
final class WebViewScrollOwnershipRecognizer: UIGestureRecognizer,
UIGestureRecognizerDelegate,
WKScriptMessageHandler {

// Points a finger may drift before we commit to a verdict. Roughly UIKit's own pan slop.
private static let touchSlop: CGFloat = 10

private weak var webView: WKWebView?

private var startLocation: CGPoint = .zero
/// `nil` until the probe reports; `true` == content owns, `false` == release to the paywall.
private var contentWantsGesture: Bool?
/// Once we pick `.began` or `.failed` for a gesture we don't revisit it (UIKit can't un-fail).
private var decided = false

init(webView: WKWebView) {
self.webView = webView
super.init(target: nil, action: nil)
self.delegate = self
// Arbitrate only; let the web view keep every touch so JS panning, taps and links still work.
self.cancelsTouchesInView = false
self.delaysTouchesBegan = false
self.delaysTouchesEnded = false
}

// MARK: Touch tracking

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
self.startLocation = touches.first?.location(in: self.view) ?? .zero
self.contentWantsGesture = nil
self.decided = false
// Stay `.possible`: the ancestor scroll (required to fail by us) waits until we resolve.
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
guard !self.decided, let location = touches.first?.location(in: self.view) else { return }
self.evaluate(
totalDx: location.x - self.startLocation.x,
totalDy: location.y - self.startLocation.y
)
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.finish(with: self.state == .began || self.state == .changed ? .ended : .failed)
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.finish(with: self.state == .began || self.state == .changed ? .cancelled : .failed)
}

override func reset() {
super.reset()
self.contentWantsGesture = nil
self.decided = false
}

// MARK: Verdict

/// Receives the probe verdict. May arrive a frame or two after `touchstart`; only useful while the
/// gesture is unresolved. A late `own` after we've already failed is dropped (that gesture is lost
/// to the paywall — the same async caveat as Android).
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.frameInfo.isMainFrame else { return }
guard !self.decided else { return }
let wantsGesture = (message.body as? String) == WebViewGestureProbe.verdictOwn
self.contentWantsGesture = wantsGesture
// An `own` verdict claims immediately, even within slop. `release` waits for movement so the
// dominant-axis native-scroll check below can still decide.
if wantsGesture {
self.decided = true
self.state = .began
}
}

// MARK: Arbitration

private func evaluate(totalDx: CGFloat, totalDy: CGFloat) {
let owns = shouldWebViewOwnGesture(
totalDx: totalDx,
totalDy: totalDy,
touchSlop: Self.touchSlop,
webContentWantsGesture: self.contentWantsGesture,
canScrollHorizontally: { [weak self] in self?.canScroll(horizontally: $0) ?? false },
canScrollVertically: { [weak self] in self?.canScroll(vertically: $0) ?? false }
)

if owns {
self.decided = true
self.state = .began
} else if abs(totalDx) >= Self.touchSlop || abs(totalDy) >= Self.touchSlop {
// Past slop and not owned: hand the drag to the paywall.
self.decided = true
self.state = .failed
}
// Within slop and not owned yet: stay `.possible`, awaiting a verdict or more movement.
}

private func canScroll(vertically direction: Int) -> Bool {
guard let scrollView = self.webView?.scrollView else { return false }
if direction > 0 {
return scrollView.contentOffset.y + scrollView.bounds.height < scrollView.contentSize.height - 0.5
} else {
return scrollView.contentOffset.y > 0.5
}
}

private func canScroll(horizontally direction: Int) -> Bool {
guard let scrollView = self.webView?.scrollView else { return false }
if direction > 0 {
return scrollView.contentOffset.x + scrollView.bounds.width < scrollView.contentSize.width - 0.5
} else {
return scrollView.contentOffset.x > 0.5
}
}

private func finish(with endState: UIGestureRecognizer.State) {
self.decided = true
self.state = endState
}

// MARK: UIGestureRecognizerDelegate

/// Make an ancestor paywall scroll view's pan wait for us: it only begins if we fail (release), so
/// recognizing (own) blocks it. Scoped to scroll views that actually contain the web view, and not
/// the web view's own scroll view.
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
guard otherGestureRecognizer is UIPanGestureRecognizer,
let scrollView = otherGestureRecognizer.view as? UIScrollView,
let webView = self.webView,
scrollView !== webView.scrollView,
webView.isDescendant(of: scrollView) else {
return false
}
return true
}

/// Coexist with the web view's own recognizers (its scroll view pan, taps); we only gate the
/// ancestor paywall scroll, via the failure requirement above.
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}

}

#endif
Loading