Skip to content

fix(ios): stop UIKit scrolling the pager to a focused input - #1234

Open
TaduJR wants to merge 8 commits into
callstack:mainfrom
TaduJR:fix/ios-pager-left-between-pages
Open

TaduJR wants to merge 8 commits into
callstack:mainfrom
TaduJR:fix/ios-pager-left-between-pages

Conversation

@TaduJR

@TaduJR TaduJR commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #1230.

When a text field on another page takes focus, UIKit scrolls the pager's collection view to reveal it: scrollRectToVisible, twice and animated, once while the field becomes first responder and once from the keyboard notification. That scroll replaces the slide TabView is running and stops at the smallest offset that shows the field, where UIKit leaves the pager. The selection and the screen then disagree, onPageSelected reports one page while the pager rests nearest another, and because the pager is not on a page boundary nothing reports a rest, so the input on the page being left keeps focus with the keyboard open.

The pager moves only between pages, so a reveal of its own can never help: a field on the selected page is already in view along that axis, and while a slide is running the selection brings that page in anyway. From iOS 16 the pager therefore declines every reveal, on the runtime subclass PagerGestureObserver.installGestureArbitration already gives the collection view, and the selection stays the only thing that moves it. A field inside a page is still revealed by the scroll views within that page, which this does not touch.

This is a behaviour change, and it is the part to decide. An app that focuses an input on a page it is not showing, without calling setPage, now sees nothing move. What happened before was not that the page changed: UIKit dragged the pager part of the way and left it between pages, which is this bug. Assistive focus moves are the open question, since VoiceOver, Full Keyboard Access and a hardware Tab onto a neighbouring page are declined in the same way and are untested here.

A repair sits behind the override and stays, with a narrower justification than when this branch opened. A slide that ends between pages is followed for a run of still display frames and moved back to the selection if it never arrives, counted in frames rather than time because a stalled main thread holds back the next frame of a slide that is still coming. That frame count is also what releases an idle held back for such a slide, so the watch cannot be removed without JS seeing idle mid-slide again. The snap itself has no trigger left that I can find: 0 snaps in 64 watches with the override in place, where the same recipe stranded about half the time without it. It stays as the backstop for a path that does not come through scrollRectToVisible, which is unproven in either direction.

iOS 15 is untouched. The override is installed under #available(iOS 16, *), because the same observer governs iOS 15's embedded paging scroll views and the collection view that scrolls vertically behind them, and the repair does not reach those delegates either, since no selection is wired to them. README.md and the API page say iOS 16 and later. Wiring iOS 15 needs a 15 runtime to verify and is filed separately.

The documentation gains the behaviour and the advice that goes with it: change the page with setPage and focus the input once onPageScrollStateChanged reports idle, because on iOS onPageSelected arrives as the slide starts, where the page can still be out of the window and focus does nothing. That is also why this repo's own case for focusing from onPageSelected is Android-only.

Test Plan

What's required for testing (prerequisites)?

bun run bootstrap
bun run example:ios:release

This case needs the simulator to show its own keyboard, because the second reveal comes from the keyboard notification, and a device that has seen a Mac keyboard will not show it. Clear both flags on every booted device after building (also in e2e/README.md):

for udid in $(xcrun simctl list devices booted | grep -Eo '[0-9A-F-]{36}'); do
  xcrun simctl spawn "$udid" defaults write com.apple.keyboard.preferences HardwareKeyboardLastSeen -bool false
  xcrun simctl spawn "$udid" defaults write com.apple.keyboard.preferences AutomaticMinimizationEnabled -bool false
done

What are the steps to reproduce (after prerequisites)?

On Issue #1230 Pager Between Pages Repro, which this PR adds, tap Strand. It sends setPage(1), focuses page 1's input in the same handler and redirects to page 2 from onPageSelected. The status should read Selected: 2 | Focused: none. On main the pager stops between pages and the status reads Selected: 2 | Focused: input 1 with the keyboard still up.

cd e2e && bun run test:ios --tag issue-1230
cd e2e && bun run test:ios

On an iPhone 18 Pro simulator, iOS 27, React Native 0.88, release build:

  • the new test passes 6 of 6 with this change and 1 of 6 without it
  • issue-1154 passes 13 of 13
  • the full iOS suite is 41 tests: 41 of 41 on one run, 40 or 39 of 41 otherwise. Every failure is a test that also fails without this change, measured per test: tab-view-inside-scroll-view passes 2 of 6 on origin/main, 2 of 6 with the repair alone and 4 of 6 with the override; issue-1096-keyboard-shrink fails with these changes reverted; two setPage calls that cancel out land on the first page failed once with LOCATOR_NOT_FOUND on an input after 38 s against a usual 9 and passes 4 of 4 alone; setPage called behind a native-stack modal lands on the requested page failed once in a suite and passes 6 of 6 alone
  • 20 of 20 reveals were declined in the new screen, and the recipe on the [Android] Focused TextInput keeps focus and keyboard after switching pages (v9) #1154 screen ended on page 2 in 10 of 10 runs with no snap
  • in a pager inside a ScrollView inside a KeyboardAvoidingView, every reveal went to the outer RCTEnhancedScrollView and the field's own editor and the pager received none, 6 of 6, identically with the override passing through to super

Not covered: vertical paging, right to left, a real device, iOS versions other than 27, VoiceOver and Full Keyboard Access. One case is left alone deliberately: a drag that interrupts a repair lets UIKit decelerate the pager to the nearest page, leaving the selection and the resting page disagreeing, which predates this change.

Compatibility

OS Implemented
iOS ✅
Android ❌

Checklist

  • I have tested this on a device and a simulator - simulator only
  • I added the documentation in README.md - the behaviour and the focus advice, and the same text on the API page
  • I updated the typed files (TS and Flow) - no API change

When a text field on another page takes focus, UIKit scrolls the pager's
collection view to reveal it, twice and animated. That scroll replaces the
slide TabView is running and stops at the smallest offset that shows the
field, where UIKit leaves it: the selection and the screen disagree, no rest
is reported, and the input on the page being left keeps focus.

The pager is moved back to the selection once it has held still for a run of
display frames, counted again whenever it moves and paused while a finger
holds it. Frames rather than time, since a stalled main thread holds back the
next frame of a slide that is still coming. A drag hands the reporting back,
a pager out of the window moves without an animation, and idle is held back
while a watch runs so JS sees one report at the destination.

Closes callstack#1230
@TaduJR

TaduJR commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

@KisaneNeko

Could you help starting the workflows?

Thanks!!!

@troZee

troZee commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

@apex-sentinel-staging review

@apex-sentinel-staging

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

5 finding(s): 5 minor.

🟡 Minor (5)

Where Finding
ios/PagerScrollDelegate.swift:195 Resetting the frame cap after each snap lets a failing snap loop the display link forever
ios/PagerView.swift:86 Comparing clampedCurrentPage reports a rest for a selection that was never in range
ios/PagerScrollDelegate.swift:188 A held finger freezes the watch past its 120-frame cap
ios/PagerScrollDelegate.swift:214 snapToSelection writes contentOffset.x with no regard for vertical paging
ios/PagerScrollDelegate.swift:223 Snap ignores keyboard avoidance: pager can jump back over an open keyboard

5 findings below minor not shown (user_settings.min_severity).

Reviewed with this repository's own Sentinel configuration (d85bc5fd2fbe).

Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apex Sentinel: 5 new findings on f0cabc2. The verdict and the full list are in the summary comment.

Comment thread ios/PagerScrollDelegate.swift Resolved
Comment thread ios/PagerView.swift Resolved
Comment thread ios/PagerScrollDelegate.swift Resolved
Comment thread ios/PagerScrollDelegate.swift Resolved
Comment thread ios/PagerScrollDelegate.swift Resolved
@troZee

troZee commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

@TaduJR
Thank you for checking all of those Sentinel comments. I really appreciate that

I will check this PR with @krozniata, and we will merge it ASAP

@TaduJR

TaduJR commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

I will check this PR with @krozniata, and we will merge it ASAP

Sure thing!

I'm also refactoring one case. I will push when I'm finished

@TaduJR

TaduJR commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

As well as writing a regression test.

@troZee
troZee marked this pull request as draft November 6, 2026 11:20
@troZee

troZee commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Please change it to ready to review once it is finished 🙏

UIKit reveals a first responder by scrolling the nearest scrolling
ancestor, which for a pager is the view that pages. Focusing an input on
a page that is not shown therefore drags the pager part of the way there
and leaves it between pages, and a reveal that arrives while the pager
is moving replaces the page it was asked for, so the pager finishes on
the page UIKit wanted rather than the page the app asked for last.

The pager moves only between pages, so no reveal of its own can help: a
field on the selected page is already in view along that axis, and a
slide brings the selected page in by itself. Every reveal is declined
and the selection stays the only thing that moves the pager. A field
inside a page is still revealed by the scroll views within that page.

The override is installed from iOS 16 only. On iOS 15 the same observer
governs the collection view, which scrolls vertically there, and the
scroll views embedded in it that do the paging.

Snaps are counted from one idle report to the next rather than per
watch, because a snap interrupted between pages reports a scroll end of
its own and starts a fresh watch, which began the count again at zero
and bounded nothing.

The example app gains the screen that found this, and a test asserts the
pager ends on the page asked for last with the input it left behind
blurred.
@TaduJR TaduJR changed the title fix(ios): move a pager left between pages back to the selection fix(ios): stop UIKit scrolling the pager to a focused input Nov 7, 2026
@TaduJR
TaduJR marked this pull request as ready for review November 7, 2026 21:55
@TaduJR

TaduJR commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Please change it to ready to review once it is finished 🙏

Thanks @troZee

It's ready now.

@TaduJR

TaduJR commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Can we start the CI here?

Thanks!

@TaduJR

TaduJR commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

CI passed 🎉

Resolving conflicts

 into fix/ios-pager-left-between-pages

# Conflicts:
#	docs/docs/docs/api/pager-view.md
#	ios/PagerView.swift

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS] UIKit's scroll to a focused field can leave the pager between pages

2 participants