also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
28 files
+1857
-481
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.
| @@ -1613,6 +1613,43 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 1613 | 1613 | XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled)) |
| 1614 | 1614 | } |
| 1615 | 1615 | |
| 1616 | func testHidingAModalDismissesItsKeyboardWithoutRestoringHiddenInputFocus() throws { | |
| 1617 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 1618 | let window = try hostWindow() | |
| 1619 | window.rootViewController = controller | |
| 1620 | window.makeKeyAndVisible() | |
| 1621 | defer { window.isHidden = true } | |
| 1622 | controller.render(document([ | |
| 1623 | ["type": "TextInput", "props": ["key": "outside"]], | |
| 1624 | ["id": "review-modal", "type": "Modal", "props": ["key": "modal", "visible": true, "animationType": "none"], | |
| 1625 | "children": [["type": "TextInput", "props": ["key": "review", "multiline": true], "events": ["onChangeText": "reviewChanged"]]]], | |
| 1626 | ])) | |
| 1627 | controller.view.layoutIfNeeded() | |
| 1628 | let input = try XCTUnwrap(find(UITextView.self, key: "review", below: controller.view)) | |
| 1629 | XCTAssertTrue(input.becomeFirstResponder()) | |
| 1630 | input.text = "A trail review" | |
| 1631 | controller.textViewDidChange(input) | |
| 1632 | _ = try controller.applyMutation(batch(1, [ | |
| 1633 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]], | |
| 1634 | ])) | |
| 1635 | XCTAssertFalse(input.isFirstResponder, "Saving or cancelling a modal must dismiss its keyboard") | |
| 1636 | let settled = expectation(description: "hidden modal input acknowledgement settled") | |
| 1637 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settled.fulfill() } | |
| 1638 | wait(for: [settled], timeout: 2) | |
| 1639 | XCTAssertFalse(input.isFirstResponder, "A delayed input acknowledgement cannot reopen the keyboard") | |
| 1640 | _ = try controller.applyMutation(batch(2, [ | |
| 1641 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": true]]], | |
| 1642 | ])) | |
| 1643 | XCTAssertFalse(input.isFirstResponder, "Reopening the modal waits for an explicit focus") | |
| 1644 | XCTAssertTrue(input.becomeFirstResponder()) | |
| 1645 | let outside = try XCTUnwrap(find(UITextField.self, key: "outside", below: controller.view)) | |
| 1646 | XCTAssertTrue(outside.becomeFirstResponder()) | |
| 1647 | _ = try controller.applyMutation(batch(3, [ | |
| 1648 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]], | |
| 1649 | ])) | |
| 1650 | XCTAssertTrue(outside.isFirstResponder, "Hiding a modal must not dismiss another field's keyboard") | |
| 1651 | } | |
| 1652 | ||
| 1616 | 1653 | func testAShownModalInAScrollViewCoversTheScreenAndTakesItsTouches() throws { |
| 1617 | 1654 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 1618 | 1655 | let window = try hostWindow() |
| @@ -3373,6 +3373,8 @@ private final class CraftNativeModalView: UIView { | ||
| 3373 | 3373 | let generation = animationGeneration |
| 3374 | 3374 | layer.removeAllAnimations() |
| 3375 | 3375 | guard visible else { |
| 3376 | craftScreenController?.forgetFocusedInput(in: self) | |
| 3377 | endEditing(true) | |
| 3376 | 3378 | if animationType == "slide" && wasVisible { |
| 3377 | 3379 | let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height) |
| 3378 | 3380 | UIView.animate(withDuration: 0.24, animations: { |
| @@ -3412,6 +3414,8 @@ private final class CraftNativeModalView: UIView { | ||
| 3412 | 3414 | } |
| 3413 | 3415 | |
| 3414 | 3416 | func resetForTeardown() { |
| 3417 | craftScreenController?.forgetFocusedInput(in: self) | |
| 3418 | endEditing(true) | |
| 3415 | 3419 | animationGeneration += 1 |
| 3416 | 3420 | layer.removeAllAnimations() |
| 3417 | 3421 | isHidden = true |
| @@ -4329,6 +4333,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 4329 | 4333 | func forgetFocusedInput(_ input: UIView? = nil) { |
| 4330 | 4334 | if input == nil || lastFocusedInput === input { lastFocusedInput = nil } |
| 4331 | 4335 | } |
| 4336 | func forgetFocusedInput(in container: UIView) { | |
| 4337 | if let input = lastFocusedInput, input === container || input.isDescendant(of: container) { | |
| 4338 | lastFocusedInput = nil | |
| 4339 | } | |
| 4340 | } | |
| 4332 | 4341 | private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:] |
| 4333 | 4342 | private var flatListOwners: [String: String] = [:] |
| 4334 | 4343 | /// A drawing's shapes are not views: a change to one redraws its `Svg`. |
| @@ -6051,6 +6060,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6051 | 6060 | private func restoreFocus(_ focused: UIView?) { |
| 6052 | 6061 | guard let focused, focused.window != nil, !focused.isFirstResponder, |
| 6053 | 6062 | lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return } |
| 6063 | var ancestor: UIView? = focused | |
| 6064 | while let view = ancestor { | |
| 6065 | if view.isHidden { return } | |
| 6066 | ancestor = view.superview | |
| 6067 | } | |
| 6054 | 6068 | // A delayed acknowledgement belongs to the input that scheduled it. |
| 6055 | 6069 | // A newer field or an explicit dismissal owns focus now. |
| 6056 | 6070 | _ = focused.becomeFirstResponder() |