diff --git a/README.md b/README.md index 6edcd9f9..01641b72 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ For more examples, see the [example app](example/src): nested pagers, keyboard h `PagerView` also accepts all standard `View` props. +Leaving a page blurs an input focused on it, whatever `keyboardDismissMode` is set to. On iOS the blur follows `onPageSelected`, including the one sent on mount, and is held off while any scroll view in the app is being dragged or is still decelerating, so a swipe never dismisses the keyboard under the user's finger. Because the blur follows the event, it never takes the focus from an input an `onPageSelected` handler focused, though the keyboard can close and reopen on the way. On iOS that event arrives when the pager starts moving rather than when it settles, so a focus call made from the handler during an animated `setPage` can land on a page that is not on screen yet and do nothing; focus from `onPageScrollStateChanged` once the state is `idle` where it has to hold. + ### Methods | Method | Description | Platform | diff --git a/e2e/README.md b/e2e/README.md index 464af1f6..491ce267 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -40,6 +40,14 @@ bun run e2e:test:android From this directory, `bun run test:ios -- --tag smoke` runs only the smoke test, and `bun run list` prints every test without running it. +### Software keyboard on iOS + +The keyboard tests (`issue-1096-keyboard-shrink` and `issue-1154-page-change-focus`) need the simulator to show its own keyboard. A device that has seen a Mac keyboard once shows none again, across boots, and `Simulator.app`'s `ConnectHardwareKeyboard` preference does not reach a device the engine booted itself, so clear the device's own flag: + +```bash +xcrun simctl spawn booted defaults write com.apple.keyboard.preferences HardwareKeyboardLastSeen -bool false +``` + ### Several simulators at once With nothing pinned, the engine uses every booted simulator as the pool, so diff --git a/e2e/tests/issues/issue-1154-page-change-focus.e2e.ts b/e2e/tests/issues/issue-1154-page-change-focus.e2e.ts index ffcff23b..da3f44b1 100644 --- a/e2e/tests/issues/issue-1154-page-change-focus.e2e.ts +++ b/e2e/tests/issues/issue-1154-page-change-focus.e2e.ts @@ -4,21 +4,29 @@ import { openExample } from '../support/app.ts'; freshAppBeforeEach(); -// Android-only: on iOS the pager never kept focus on the page being left. -const options = { tags: ['regression', 'issue-1154'], platforms: ['android'] }; +const options = { tags: ['regression', 'issue-1154'] }; /** - * Checks the selected page and the focused input. The keyboard part of the - * status line is not checked: agent-device swaps in its own headless IME for - * the run, so React Native never reports the keyboard as shown. + * Checks the selected page, the focused input, and the keyboard, which has to + * follow the focus. The keyboard is checked on iOS only: Android runs a + * headless IME that never reports it. An iOS check that fails with + * `Keyboard: hidden` means the simulator is showing no software keyboard + * because it has seen a Mac keyboard once, which it remembers across boots. + * `xcrun simctl spawn booted defaults write com.apple.keyboard.preferences + * HardwareKeyboardLastSeen -bool false` clears that. */ async function expectStatus( screen: Screen, + platform: string, page: number, focused: string ): Promise { + const keyboard = + platform === 'ios' + ? ` Keyboard: ${focused === 'none' ? 'hidden' : 'visible'}` + : ''; await expect(screen.getByTestId('issue-1154-status')).toHaveText( - new RegExp(`^Selected: ${page} \\| Focused: ${focused} \\|`) + new RegExp(`^Selected: ${page} \\| Focused: ${focused} \\|${keyboard}`) ); } @@ -26,6 +34,7 @@ async function expectStatus( async function focusInputOnPage( app: App, screen: Screen, + platform: string, page: number ): Promise { await openExample(app, screen, 'Issue #1154 Page Change Focus Repro'); @@ -33,50 +42,64 @@ async function focusInputOnPage( await screen.getByTestId(`issue-1154-go-${page}`).tap(); } await screen.getByTestId(`issue-1154-input-${page}`).tap(); - await expectStatus(screen, page, `input ${page}`); + await expectStatus(screen, platform, page, `input ${page}`); } test( 'leaving a page with setPage blurs its input', options, - async ({ app, screen }) => { - await focusInputOnPage(app, screen, 1); + async ({ app, screen, platform }) => { + await focusInputOnPage(app, screen, platform, 1); // Before the fix the off-screen input kept focus. await screen.getByTestId('issue-1154-go-0').tap(); - await expectStatus(screen, 0, 'none'); + await expectStatus(screen, platform, 0, 'none'); } ); test( 'leaving a page with a swipe blurs its input', options, - async ({ app, screen }) => { - await focusInputOnPage(app, screen, 0); + async ({ app, screen, platform }) => { + await focusInputOnPage(app, screen, platform, 0); // Swipe on the page title: a swipe that starts on the input is taken by // the input instead of the pager. await screen .getByTestId('issue-1154-label-0') - .swipe({ direction: 'right' }); - await expectStatus(screen, 1, 'none'); + .swipe({ direction: 'right', momentum: 'fast' }); + await expectStatus(screen, platform, 1, 'none'); } ); test( 'an input focused on the new page keeps focus', options, - async ({ app, screen }) => { - await focusInputOnPage(app, screen, 1); + async ({ app, screen, platform }) => { + await focusInputOnPage(app, screen, platform, 1); - // Focused right after setPage, while the pager is still animating. + // Focused right after setPage, before the pager has settled. await screen.getByTestId('issue-1154-mode-onPress').tap(); await screen.getByTestId('issue-1154-go-0').tap(); - await expectStatus(screen, 0, 'input 0'); + await expectStatus(screen, platform, 0, 'input 0'); + } +); + +// A focus call made from an `onPageSelected` handler is covered for a swipe +// only. iOS reports the selection when SwiftUI applies it, at the start of an +// animated `setPage`, and a focus call made then can land on a page that is +// not in the window yet, where it silently does nothing. + +test( + 'a swipe keeps the focus the new page takes from onPageSelected', + options, + async ({ app, screen, platform }) => { + await focusInputOnPage(app, screen, platform, 0); - // Focused from onPageSelected, once the pager has settled. await screen.getByTestId('issue-1154-mode-onSelected').tap(); - await screen.getByTestId('issue-1154-go-1').tap(); - await expectStatus(screen, 1, 'input 1'); + await screen + .getByTestId('issue-1154-label-0') + .swipe({ direction: 'right', momentum: 'fast' }); + await expectStatus(screen, platform, 1, 'input 1'); } ); diff --git a/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx b/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx index edad1eaa..317a4a7d 100644 --- a/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx +++ b/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx @@ -1,8 +1,8 @@ /** - * Repro for #1154: on Android, a focused TextInput must lose focus and hide - * the keyboard when its page is left, via setPage or a swipe. The mode buttons - * focus the new page's input from onPageSelected or right after setPage, which - * must keep working. + * Repro for #1154: a focused TextInput must lose focus and hide the keyboard + * when its page is left, via setPage or a swipe. The mode buttons focus the + * new page's input from onPageSelected or right after setPage, which must + * keep working. */ import React, { useEffect, useRef, useState } from 'react'; import { diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift index 82cf6f1e..39d1a4de 100644 --- a/ios/PagerViewProvider.swift +++ b/ios/PagerViewProvider.swift @@ -18,6 +18,7 @@ import UIKit } } +@MainActor @objc public protocol PagerViewProviderDelegate { func onPageScroll(data: OnPageScrollEventData) func onPageScrollStateChanged(state: PageScrollState) @@ -63,11 +64,80 @@ import UIKit } } + private lazy var selectionObserver = PagerSelectionObserver(provider: self) + @objc public convenience init(delegate: PagerViewProviderDelegate) { self.init() self.delegate = delegate } + fileprivate func forward(pageScroll data: OnPageScrollEventData) { + delegate?.onPageScroll(data: data) + } + + fileprivate func forward(scrollState state: PageScrollState) { + delegate?.onPageScrollStateChanged(state: state) + } + + fileprivate func handle(pageSelected position: Int) { + delegate?.onPageSelected(position: position) + scheduleBlur() + } + + /// Resigning inside a selection change forces a synchronous layout pass that + /// applies the change outside its animation. Default mode also holds the + /// blur for the whole of a swipe: a gesture and the deceleration that + /// follows it keep the run loop in tracking mode, so the keyboard cannot + /// close under the finger. The timer this schedules wakes the run loop, + /// which a queued block would not. + private func scheduleBlur() { + cancelScheduledBlur() + perform(#selector(blurOutsideCurrentPage), with: nil, afterDelay: 0, inModes: [.default]) + } + + private func cancelScheduledBlur() { + NSObject.cancelPreviousPerformRequests( + withTarget: self, + selector: #selector(blurOutsideCurrentPage), + object: nil + ) + } + + /// The selected page is read here rather than captured when the blur was + /// scheduled, so a second page change that arrives first decides which page + /// keeps its input. A responder inside the pager but outside its pages is + /// SwiftUI's own, and one that takes no text, such as a key-command view, is + /// not an input. + @objc private func blurOutsideCurrentPage() { + let page = props.currentPage + guard props.children[safe: page] != nil else { + return + } + // A window has one first responder, so the first page that holds it is the + // only one that can. + for (index, child) in props.children.enumerated() where index != page { + guard let responder = Self.firstResponder(in: child.view) else { + continue + } + if responder is UIKeyInput || responder.inputView != nil { + responder.resignFirstResponder() + } + return + } + } + + private static func firstResponder(in view: UIView) -> UIView? { + if view.isFirstResponder { + return view + } + for subview in view.subviews { + if let responder = firstResponder(in: subview) { + return responder + } + } + return nil + } + @objc(insertChild:atIndex:) public func insertChild(_ child: UIView, at index: Int) { guard index >= 0 && index <= props.children.count else { @@ -95,6 +165,9 @@ import UIKit super.willMove(toWindow: newWindow) if newWindow != nil { syncParentViewController() + } else { + // A scheduled blur retains the pager until it fires. + cancelScheduledBlur() } } @@ -237,7 +310,7 @@ import UIKit // insets (the RN screen's, including a native search bar) and re-injects // them; the two are independent. let hostingController = UIHostingController( - rootView: PagerView(props: props, delegate: delegate), + rootView: PagerView(props: props, delegate: selectionObserver), ignoreSafeArea: true ) self.hostingController = hostingController @@ -268,3 +341,25 @@ import UIKit hostingController.didMove(toParent: parentViewController) } } + +/// A separate type keeps the delegate methods off `PagerViewProvider`, where +/// conforming to an `@objc` protocol would make them public. +private final class PagerSelectionObserver: NSObject, PagerViewProviderDelegate { + private weak var provider: PagerViewProvider? + + init(provider: PagerViewProvider) { + self.provider = provider + } + + func onPageScroll(data: OnPageScrollEventData) { + provider?.forward(pageScroll: data) + } + + func onPageScrollStateChanged(state: PageScrollState) { + provider?.forward(scrollState: state) + } + + func onPageSelected(position: Int) { + provider?.handle(pageSelected: position) + } +}