diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 44647e03962..bb82a0e5d03 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -106,20 +106,13 @@ public struct ThreadDetailView: View { } message: { Text("Your draft is still here. Check your connection and try again.") } - .simultaneousGesture(edgeBackGesture) - } - - private var edgeBackGesture: some Gesture { - DragGesture(minimumDistance: 18, coordinateSpace: .local) - .onEnded { value in - guard horizontalSizeClass == .compact, - value.startLocation.x <= 24, - value.translation.width >= 72, - abs(value.translation.height) <= abs(value.translation.width) * 0.7 else { - return - } - onNavigateBack() - } + .background { + ThreadBackSwipeGestureView( + isEnabled: horizontalSizeClass == .compact, + onNavigateBack: onNavigateBack + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } private var detail: FeatureThreadDetail? { @@ -1282,6 +1275,244 @@ struct TranscriptViewportGeometry: Equatable { } } +/// The detail surface uses a native pan recognizer instead of a SwiftUI +/// `DragGesture`. SwiftUI's broad drag recognizer can begin before it knows +/// whether a gesture is vertical, which competes with the transcript's native +/// collection-view scrolling. This recognizer fails for vertical motion at +/// gesture-begin time and remains simultaneous with the collection view for +/// horizontal motion. +enum ThreadBackSwipeGesture { + static let minimumTranslation: CGFloat = 72 + static let horizontalToVerticalRatio: CGFloat = 1.4 + private static let scrollExtentEpsilon: CGFloat = 1 + + static func shouldBegin(with velocity: CGPoint) -> Bool { + shouldBegin(with: velocity, translation: .zero) + } + + static func shouldBegin(with velocity: CGPoint, translation: CGPoint) -> Bool { + let direction = hypot(translation.x, translation.y) >= 8 ? translation : velocity + return direction.x > 0 + && direction.x >= abs(direction.y) * horizontalToVerticalRatio + } + + static func shouldNavigateBack(with translation: CGPoint) -> Bool { + translation.x >= minimumTranslation + && translation.x >= abs(translation.y) * horizontalToVerticalRatio + } + + @MainActor + static func shouldAllowSimultaneousRecognition(with scrollView: UIScrollView) -> Bool { + let hasHorizontalContent = scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon + guard hasHorizontalContent else { + return scrollView.alwaysBounceVertical + || scrollView.contentSize.height + > scrollView.bounds.height + scrollExtentEpsilon + } + return isAtLeadingEdge(scrollView) + } + + @MainActor + static func shouldReceiveTouch(in view: UIView?, host: UIView) -> Bool { + var currentView = view + while let current = currentView { + // Editable text and an active transcript selection need to own + // horizontal drags for caret and selection-handle movement. Plain + // rendered message text still participates in the full-surface pan. + if current is UITextField { + return false + } + if let textView = current as? UITextView, + textView.isEditable || textView.isFirstResponder { + return false + } + if let scrollView = current as? UIScrollView, + scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon { + guard isAtLeadingEdge(scrollView) else { return false } + } + if current === host { return true } + currentView = current.superview + } + return false + } + + @MainActor + private static func isAtLeadingEdge(_ scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.x + <= -scrollView.adjustedContentInset.left + scrollExtentEpsilon + } + + @MainActor + static func shouldReceiveTouch( + _ touch: UITouch, + surface: UIView, + host: UIView + ) -> Bool { + guard surface.window === host.window, + surface.bounds.contains(touch.location(in: surface)), + shouldReceiveTouch(in: touch.view, host: host), + surface.window?.rootViewController?.presentedViewController == nil else { + return false + } + return true + } +} + +private struct ThreadBackSwipeGestureView: UIViewRepresentable { + let isEnabled: Bool + let onNavigateBack: () -> Void + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + return view + } + + func updateUIView(_ view: InstallerView, context: Context) { + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + } + + static func dismantleUIView(_ view: InstallerView, coordinator: ()) { + view.uninstallGesture() + } + + final class InstallerView: UIView { + private var isEnabled = false + private var onNavigateBack: (() -> Void)? + private weak var gestureHost: UIView? + private var panGesture: UIPanGestureRecognizer? + private var gestureDelegate: GestureDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + uninstallGesture() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + uninstallGesture() + } else { + installGestureIfPossible() + } + } + + func update(isEnabled: Bool, onNavigateBack: @escaping () -> Void) { + self.isEnabled = isEnabled + self.onNavigateBack = onNavigateBack + installGestureIfPossible() + } + + func uninstallGesture() { + if let panGesture, let gestureHost { + gestureHost.removeGestureRecognizer(panGesture) + } + panGesture = nil + gestureDelegate = nil + gestureHost = nil + } + + private func installGestureIfPossible() { + // SwiftUI hosts a background UIViewRepresentable beside, rather than + // above, the transcript and composer. Install on their shared root + // view and use the representable's frame to scope received touches. + guard isEnabled, let window, let host = window.rootViewController?.view else { + if !isEnabled { uninstallGesture() } + return + } + guard gestureHost !== host else { return } + + uninstallGesture() + let panGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handlePan(_:)) + ) + let gestureDelegate = GestureDelegate(owner: self) + panGesture.delegate = gestureDelegate + panGesture.cancelsTouchesInView = false + panGesture.delaysTouchesBegan = false + panGesture.maximumNumberOfTouches = 1 + host.addGestureRecognizer(panGesture) + gestureHost = host + self.panGesture = panGesture + self.gestureDelegate = gestureDelegate + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard isEnabled, + gesture.state == .ended, + ThreadBackSwipeGesture.shouldNavigateBack( + with: gesture.translation(in: gesture.view) + ) else { + return + } + onNavigateBack?() + } + + private final class GestureDelegate: NSObject, UIGestureRecognizerDelegate { + weak var owner: InstallerView? + + init(owner: InstallerView) { + self.owner = owner + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let owner, + owner.isEnabled, + let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + return ThreadBackSwipeGesture.shouldBegin( + with: panGesture.velocity(in: panGesture.view), + translation: panGesture.translation(in: panGesture.view) + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard let owner, + let gestureHost = owner.gestureHost, + ThreadBackSwipeGesture.shouldReceiveTouch( + touch, + surface: owner, + host: gestureHost + ) + else { return false } + return true + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + if otherGestureRecognizer is UIScreenEdgePanGestureRecognizer { + return true + } + guard let scrollView = otherGestureRecognizer.view as? UIScrollView else { + return false + } + return ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition( + with: scrollView + ) + } + } + } +} + /// Self-sizing hosted Markdown can change the transcript height after a snapshot finishes, /// while presenting the keyboard changes the viewport without changing the content at all. /// Preserve the visual bottom only while the reader is already following the latest turn. diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index 09ab6120a92..f34e528286f 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -1,4 +1,6 @@ +import CoreGraphics import Testing +import UIKit @testable import T3Code @Suite("Transcript viewport anchoring") @@ -98,4 +100,128 @@ struct TranscriptViewportGeometryTests { ) == nil ) } + + @Test + func verticalPanFailsBeforeItCanCompeteWithTranscriptScrolling() { + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 40, y: 120))) + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: -120, y: 0))) + } + + @Test + func horizontalPanCanLeaveTheThreadFromAnywhereOnTheSurface() { + #expect(ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 120, y: 20))) + #expect( + ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 16) + ) + ) + } + + @Test + func slowHorizontalPanUsesTranslationWhenVelocityIsUnavailable() { + #expect( + ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 16, y: 2) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 4, y: 16) + ) + ) + } + + @Test + func shortOrDiagonalPanDoesNotLeaveTheThread() { + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 71, y: 0) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 80) + ) + ) + } + + @Test + @MainActor + func horizontalScrollContentSharesOnlyAtItsLeadingEdge() { + let transcript = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + transcript.contentSize = CGSize(width: 120, height: 480) + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: transcript)) + + let codeBlock = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + codeBlock.contentSize = CGSize(width: 480, height: 120) + codeBlock.alwaysBounceVertical = true + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + } + + @Test + @MainActor + func horizontalScrollAncestorsCanReceiveBackPanAtLeadingEdge() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let codeBlock = UIScrollView(frame: host.bounds) + codeBlock.contentSize = CGSize(width: 480, height: 240) + let label = UILabel(frame: .zero) + codeBlock.addSubview(label) + host.addSubview(codeBlock) + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + + let detachedHost = UIView(frame: host.bounds) + let detachedCodeBlock = UIScrollView(frame: detachedHost.bounds) + detachedCodeBlock.contentSize = CGSize(width: 480, height: 240) + let detachedLabel = UILabel(frame: .zero) + detachedCodeBlock.addSubview(detachedLabel) + detachedHost.addSubview(detachedCodeBlock) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: detachedLabel, host: host)) + } + + @Test + @MainActor + func activeTextInteractionsKeepHorizontalDrags() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let textField = UITextField(frame: .zero) + let textView = UITextView(frame: .zero) + textView.text = "Selectable transcript text" + let textViewContent = UIView(frame: .zero) + textView.addSubview(textViewContent) + host.addSubview(textField) + host.addSubview(textView) + + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textField, host: host)) + textView.isEditable = false + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 1) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 0) + textView.isEditable = true + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + + let window = UIWindow(frame: host.bounds) + let rootViewController = UIViewController() + window.rootViewController = rootViewController + rootViewController.view.addSubview(host) + window.makeKeyAndVisible() + textView.isEditable = false + #expect(textView.becomeFirstResponder()) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + textView.resignFirstResponder() + window.isHidden = true + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + } }