also looking at this
fix(mobile): preserve native editing, navigation, and durable media requests
#369
36 files
+864
-185
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1749,6 +1749,36 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 1749 | 1749 | XCTAssertEqual(proof.font?.pointSize, 22) |
| 1750 | 1750 | } |
| 1751 | 1751 | |
| 1752 | func testScrollKeyboardTapPreservesTextAreaFocusAndSelectionButDismissesBackground() throws { | |
| 1753 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 1754 | let window = try hostWindow() | |
| 1755 | window.rootViewController = controller | |
| 1756 | window.makeKeyAndVisible() | |
| 1757 | defer { window.isHidden = true } | |
| 1758 | controller.render(document([ | |
| 1759 | ["type": "ScrollView", "props": ["key": "form"], "style": ["height": 300], "children": [ | |
| 1760 | ["type": "TextInput", "props": ["key": "review", "multiline": true, "value": "A beautiful ridge"], "style": ["height": 120]], | |
| 1761 | ]], | |
| 1762 | ])) | |
| 1763 | let form = try XCTUnwrap(find(UIScrollView.self, key: "form", below: controller.view)) | |
| 1764 | let review = try XCTUnwrap(find(UITextView.self, key: "review", below: controller.view)) | |
| 1765 | XCTAssertTrue(review.becomeFirstResponder()) | |
| 1766 | review.selectedRange = NSRange(location: 2, length: 9) | |
| 1767 | let selectionSurface = UIView() | |
| 1768 | selectionSurface.addGestureRecognizer(UITapGestureRecognizer()) | |
| 1769 | review.addSubview(selectionSurface) | |
| 1770 | var forgotFocus = false | |
| 1771 | for policy in ["never", "handled", "always"] { | |
| 1772 | craftHandleKeyboardTap(in: form, hit: selectionSurface, persistTaps: policy) { forgotFocus = true } | |
| 1773 | XCTAssertTrue(review.isFirstResponder, policy) | |
| 1774 | XCTAssertEqual(review.selectedRange, NSRange(location: 2, length: 9), policy) | |
| 1775 | XCTAssertFalse(forgotFocus, policy) | |
| 1776 | } | |
| 1777 | craftHandleKeyboardTap(in: form, hit: form, persistTaps: "never") { forgotFocus = true } | |
| 1778 | XCTAssertFalse(review.isFirstResponder) | |
| 1779 | XCTAssertTrue(forgotFocus) | |
| 1780 | } | |
| 1781 | ||
| 1752 | 1782 | func testTextInputsPadTheirTextAndAskForAutofill() throws { |
| 1753 | 1783 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 1754 | 1784 | controller.loadViewIfNeeded() |
| @@ -363,24 +363,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL | ||
| 363 | 363 | @objc private func keyboardTap() { |
| 364 | 364 | guard keyboardShouldPersistTaps != "always" else { return } |
| 365 | 365 | let point = keyboardTapGesture.location(in: self) |
| 366 | var hit = hitTest(point, with: nil) | |
| 367 | var onControl = false | |
| 368 | var onButton = false | |
| 369 | while let view = hit, view !== self { | |
| 370 | if view is UIButton { onButton = true } | |
| 371 | if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break } | |
| 372 | hit = view.superview | |
| 366 | craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) { | |
| 367 | self.craftScreenController?.forgetFocusedInput() | |
| 373 | 368 | } |
| 374 | if keyboardShouldPersistTaps == "handled", onControl { return } | |
| 375 | // A button keeps the keyboard: its press gives the focus straight | |
| 376 | // back. Ending editing first only let the keyboard's inset go while | |
| 377 | // the finger was down, the button moved out from under it, and the | |
| 378 | // press was lost. | |
| 379 | if onButton { return } | |
| 380 | // Otherwise the keyboard goes; after a tap on nothing, for good (the | |
| 381 | // next render must not bring the last input back). | |
| 382 | if !onControl { craftScreenController?.forgetFocusedInput() } | |
| 383 | window?.endEditing(true) | |
| 384 | 369 | } |
| 385 | 370 | |
| 386 | 371 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { |
| @@ -3606,6 +3606,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur | ||
| 3606 | 3606 | return false |
| 3607 | 3607 | } |
| 3608 | 3608 | |
| 3609 | /// Scroll containers observe taps without cancelling native input focus or | |
| 3610 | /// selection. Text views are not UIControls, and their selection subviews may | |
| 3611 | /// have recognizers of their own, so inspect the whole ancestor chain. | |
| 3612 | func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: String, forgetFocus: () -> Void) { | |
| 3613 | guard persistTaps != "always" else { return } | |
| 3614 | var candidate = hit | |
| 3615 | var onControl = false | |
| 3616 | while let view = candidate, view !== container { | |
| 3617 | if view is UITextField || view is UITextView || view is UIButton { return } | |
| 3618 | if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true } | |
| 3619 | candidate = view.superview | |
| 3620 | } | |
| 3621 | if persistTaps == "handled", onControl { return } | |
| 3622 | if !onControl { forgetFocus() } | |
| 3623 | container.window?.endEditing(true) | |
| 3624 | } | |
| 3625 | ||
| 3609 | 3626 | private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate { |
| 3610 | 3627 | let contentStack = CraftNativeFlowView() |
| 3611 | 3628 | private let keyboardTapGesture = UITapGestureRecognizer() |
| @@ -3863,24 +3880,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele | ||
| 3863 | 3880 | @objc private func keyboardTap() { |
| 3864 | 3881 | guard keyboardShouldPersistTaps != "always" else { return } |
| 3865 | 3882 | let point = keyboardTapGesture.location(in: self) |
| 3866 | var hit = hitTest(point, with: nil) | |
| 3867 | var onControl = false | |
| 3868 | var onButton = false | |
| 3869 | while let view = hit, view !== self { | |
| 3870 | if view is UIButton { onButton = true } | |
| 3871 | if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break } | |
| 3872 | hit = view.superview | |
| 3873 | } | |
| 3874 | if keyboardShouldPersistTaps == "handled", onControl { return } | |
| 3875 | // A button keeps the keyboard: its press gives the focus straight | |
| 3876 | // back. Ending editing first only let the keyboard's inset go while | |
| 3877 | // the finger was down, the button moved out from under it, and the | |
| 3878 | // press was lost. | |
| 3879 | if onButton { return } | |
| 3880 | // Otherwise the keyboard goes; after a tap on nothing, for good (the | |
| 3881 | // next render must not bring the last input back). | |
| 3882 | if !onControl { craftScreenController?.forgetFocusedInput() } | |
| 3883 | window?.endEditing(true) | |
| 3883 | craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) { | |
| 3884 | self.craftScreenController?.forgetFocusedInput() | |
| 3885 | } | |
| 3884 | 3886 | } |
| 3885 | 3887 | |
| 3886 | 3888 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { |