Repository navigation
Conversation
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
|
Could you help starting the workflows? Thanks!!! |
|
@apex-sentinel-staging review |
Apex SentinelVerdict: 5 finding(s): 5 minor. 🟡 Minor (5)
5 findings below Reviewed with this repository's own Sentinel configuration ( Reply under an inline comment with |
There was a problem hiding this comment.
Apex Sentinel: 5 new findings on f0cabc2. The verdict and the full list are in the summary comment.
|
@TaduJR 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 |
|
As well as writing a regression test. |
|
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.
Thanks @troZee It's ready now. |
|
Can we start the CI here? Thanks! |
|
CI passed 🎉 Resolving conflicts |
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 slideTabViewis running and stops at the smallest offset that shows the field, where UIKit leaves the pager. The selection and the screen then disagree,onPageSelectedreports 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.installGestureArbitrationalready 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
idleheld 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 throughscrollRectToVisible, 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.mdand 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
setPageand focus the input onceonPageScrollStateChangedreportsidle, because on iOSonPageSelectedarrives 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 fromonPageSelectedis Android-only.Test Plan
What's required for testing (prerequisites)?
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):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 fromonPageSelected. The status should readSelected: 2 | Focused: none. Onmainthe pager stops between pages and the status readsSelected: 2 | Focused: input 1with the keyboard still up.On an iPhone 18 Pro simulator, iOS 27, React Native 0.88, release build:
issue-1154passes 13 of 13tab-view-inside-scroll-viewpasses 2 of 6 onorigin/main, 2 of 6 with the repair alone and 4 of 6 with the override;issue-1096-keyboard-shrinkfails with these changes reverted;two setPage calls that cancel out land on the first pagefailed once withLOCATOR_NOT_FOUNDon 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 pagefailed once in a suite and passes 6 of 6 aloneScrollViewinside aKeyboardAvoidingView, every reveal went to the outerRCTEnhancedScrollViewand the field's own editor and the pager received none, 6 of 6, identically with the override passing through to superNot 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
Checklist
README.md- the behaviour and the focus advice, and the same text on the API page