fix(KAN-71): correct Apple navigation gestures and scroll bounds
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
CI/CD / test_and_check_app (pull_request) Successful in 6m44s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-29 07:22:45 +02:00
parent 0662a6970e
commit 4afaa79834
12 changed files with 784 additions and 44 deletions
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
}
}
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
private let defaults: UserDefaults
private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer?
@@ -40,8 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar()
private var historyObservation: NSKeyValueObservation?
private var backButton: UIBarButtonItem!
private var gestureStart = CGPoint.zero
private var gestureAllowed = false
private var gestureRevision = 0
private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) {
@@ -94,7 +95,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true
launcher.bounces = false
view.addSubview(launcher)
launcher.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
@@ -164,11 +165,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar)
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
backButton.accessibilityIdentifier = "workspaceBack"
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
NSLayoutConstraint.activate([
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
@@ -203,7 +202,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey)
timeout?.cancel()
historyObservation = nil
workspace?.stopLoading()
workspace?.navigationDelegate = nil
workspace?.removeFromSuperview()
@@ -211,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
server = selected
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// Remote content receives no native message handlers or Tauri bridge.
let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self
webview.uiDelegate = self
webview.allowsBackForwardNavigationGestures = true
// 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.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
@@ -227,9 +238,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
workspace = webview
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
self?.backButton.isEnabled = view.canGoBack
}
view.endEditing(true)
address.text = url.absoluteString
launcher.isHidden = true
@@ -275,10 +283,46 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
launcher.isHidden = true
toolbar.isHidden = false
workspace?.isHidden = false
backButton.isEnabled = workspace?.canGoBack == true
}
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
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 }
@@ -320,7 +364,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
connectButton.setTitle(text("openWorkspace"), for: .normal)
status.text = text("connected")
resumeButton.isHidden = false
backButton.isEnabled = webView.canGoBack
if firstLoad { showWorkspace() }
}