ReviewOS

also looking at this

craft-native/craft

fix(mobile): preserve native editing, navigation, and durable media requests

#369
Merged chris wants to merge codex/native-vertical-scroll-tab-swipes into main
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.

packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+15-0
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -158,6 +158,21 @@ final class NativeNavigationUITests: XCTestCase {
158158 XCTAssertEqual(home.frame.minX, baseline.minX, accuracy: 1)
159159 }
160160
161 func testMultilineInputKeepsKeyboardFocusWhenTapped() {
162 let app = XCUIApplication()
163 app.launch()
164 let root = app.scrollViews["native-root-scroll"]
165 XCTAssertTrue(root.waitForExistence(timeout: 10))
166 let notes = app.textViews["notes-input"]
167 reveal(notes, in: root)
168 notes.tap()
169 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5), "The scroll container dismissed the text area's keyboard")
170 notes.typeText(" edited")
171 XCTAssertEqual(notes.value as? String, "Draft edited")
172 notes.tap()
173 XCTAssertTrue(app.keyboards.firstMatch.exists, "Tapping the text area again must preserve editing")
174 }
175
161176 func testHorizontalCarouselKeepsItsSwipe() {
162177 let app = XCUIApplication()
163178 app.launch()
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+30-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -1749,6 +1749,36 @@ final class NativeRenderUnitTests: XCTestCase {
17491749 XCTAssertEqual(proof.font?.pointSize, 22)
17501750 }
17511751
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
17521782 func testTextInputsPadTheirTextAndAskForAutofill() throws {
17531783 let controller = CraftNativeScreenController(config: CraftConfig())
17541784 controller.loadViewIfNeeded()
packages/ios/templates/CraftNativeFlatList.swiftmodified+2-17
Changes to packages/ios/templates/CraftNativeFlatList.swift
@@ -363,24 +363,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
363363 @objc private func keyboardTap() {
364364 guard keyboardShouldPersistTaps != "always" else { return }
365365 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()
373368 }
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)
384369 }
385370
386371 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
packages/ios/templates/CraftNativeScreen.swiftmodified+20-18
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -3606,6 +3606,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur
36063606 return false
36073607}
36083608
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.
3612func 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
36093626private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
36103627 let contentStack = CraftNativeFlowView()
36113628 private let keyboardTapGesture = UITapGestureRecognizer()
@@ -3863,24 +3880,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
38633880 @objc private func keyboardTap() {
38643881 guard keyboardShouldPersistTaps != "always" else { return }
38653882 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 }
38843886 }
38853887
38863888 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {