Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 44 additions & 21 deletions e2e/tests/issues/issue-1154-page-change-focus.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,102 @@ 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<void> {
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}`)
);
}

/** Opens the repro and focuses the input on page `page`. */
async function focusInputOnPage(
app: App,
screen: Screen,
platform: string,
page: number
): Promise<void> {
await openExample(app, screen, 'Issue #1154 Page Change Focus Repro');
if (page !== 0) {
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');
}
);
8 changes: 4 additions & 4 deletions example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
97 changes: 96 additions & 1 deletion ios/PagerViewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import UIKit
}
}

@MainActor
@objc public protocol PagerViewProviderDelegate {
func onPageScroll(data: OnPageScrollEventData)
func onPageScrollStateChanged(state: PageScrollState)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Loading