fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
This commit is contained in:
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
@@ -40,9 +40,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var gestureStart = CGPoint.zero
|
||||
private var gestureAllowed = false
|
||||
private var gestureRevision = 0
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -216,17 +213,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||
// owns each gesture and traverses the same history that Inertia restores.
|
||||
webview.allowsBackForwardNavigationGestures = false
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
webview.scrollView.bounces = false
|
||||
for edge: UIRectEdge in [.left, .right] {
|
||||
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||
gesture.edges = edge
|
||||
gesture.delegate = self
|
||||
webview.addGestureRecognizer(gesture)
|
||||
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||
}
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -285,45 +273,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
workspace?.isHidden = false
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||
gestureStart = touch.location(in: webview)
|
||||
gestureAllowed = false
|
||||
gestureRevision += 1
|
||||
let revision = gestureRevision
|
||||
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||
self.gestureAllowed = result as? Bool == true
|
||||
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||
let velocity = edge.velocity(in: webview)
|
||||
return abs(velocity.x) > abs(velocity.y)
|
||||
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||
}
|
||||
|
||||
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||
guard gesture.state == .ended, let webview = workspace else { return }
|
||||
let direction = gesture.edges == .left ? -1 : 1
|
||||
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||
navigateHistory(direction, from: gestureStart)
|
||||
}
|
||||
|
||||
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||
guard let webview = workspace,
|
||||
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
guard server?.allows(url) == true else {
|
||||
|
||||
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||
let name = "KAN71-history-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
|
||||
""")
|
||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
XCTAssertNil(webview.goBack())
|
||||
XCTAssertNil(webview.goForward())
|
||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||
for index in 1...3 {
|
||||
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
||||
}
|
||||
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
||||
let edge = TestEdgeGesture()
|
||||
edge.edges = .left
|
||||
edge.testVelocity = CGPoint(x: 800, y: 0)
|
||||
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
||||
var allowed = false
|
||||
for _ in 0..<50 {
|
||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertTrue(allowed)
|
||||
edge.testState = .cancelled
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testState = .ended
|
||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
@@ -158,10 +130,9 @@ final class WorkspaceTests: XCTestCase {
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -272,19 +243,3 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class TestTouch: UITouch {
|
||||
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
|
||||
}
|
||||
|
||||
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
|
||||
var testState: UIGestureRecognizer.State = .possible
|
||||
var testVelocity = CGPoint.zero
|
||||
var testTranslation = CGPoint.zero
|
||||
override var state: UIGestureRecognizer.State {
|
||||
get { testState }
|
||||
set { testState = newValue }
|
||||
}
|
||||
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
|
||||
override func translation(in view: UIView?) -> CGPoint { testTranslation }
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -457,7 +457,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -618,7 +618,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -634,7 +634,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.4"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "0.2.5"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
|
||||
Reference in New Issue
Block a user