Scroll the focused text input above the keyboard - #62
Open
CodyPChristian wants to merge 1 commit into
Open
Conversation
Nothing links focus to scrolling. `NativeUIScrollViewRenderer` scrolls on `keyboardWillShow`, but only for `scroll-anchor="bottom"` and only to the bottom anchor — a chat policy — and `NativeUITextInputCore` has no scroll proxy at all. SwiftUI shrinks the screen by the keyboard height, which is enough when the field is the last thing on the page: shrink the viewport and the composer lands just above the keyboard. That is why this has never come up. It is not enough anywhere else. Shrinking guarantees the field is inside the SCROLLABLE CONTENT, not that it is on screen — so on a login form, tapping the password field mid-page leaves it exactly where it was, now underneath the keyboard, and the user types blind into a masked field. The vertical scroll view already builds a `ScrollViewReader` for bottom anchoring, so this needs no new machinery: publish that proxy into the environment, and let the focused input scroll itself to `.center`. Deliberate choices, each of which is the reason this is safe: - The proxy is WITHHELD from a bottom-anchored scroll view. That mode already owns a keyboard policy and it is a different one — a chat log wants the latest message above the keyboard, not the composer centered in what is left. Two policies driving one proxy would race and the later would win by accident. Withholding it also means the environment value is nil in every case where scrolling isn't this descendant's business: sheets, modals, fixed screens, chat logs. - The scroll runs on FOCUS, not on `keyboardWillShow`. Moving from the email field to the password field never re-shows the keyboard, and that is precisely when a form needs this. - It is deferred ~0.35s, past the keyboard's own presentation, so it centers against the already-shrunk viewport. Centering against the full height first would place the field in the middle of a screen about to lose its bottom half — back under the keyboard. - `.id(node.id)` on the field is what makes it addressable. That id is already the ForEach identity of every node in the tree, so it is stable across republishes, and it is applied to the view `body` returns rather than to the struct, so it cannot reset the value/sync `@State`. Android is NOT covered, and I believe it does not need to be: Compose's `BasicTextField` already requests bring-into-view on focus, and the IME resizes the window, so a field inside a scrollable column is brought up without help. If that turns out to be wrong on the M3 text fields this repo uses, it should be its own change rather than a guess bolted onto this one. Note for whoever merges this alongside NativePHP#27: that PR touches the same `verticalScroll` body and adds an `auto-scroll-to` index the author drives explicitly. The two mechanisms are independent — this one publishes the proxy, that one consumes it locally — but an author who sets both is asking two things to scroll one view, and the same argument used above for `scroll-anchor="bottom"` says the explicit index should win. The one-line guard is `stickBottom || hasAutoScroll`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Nothing links focus to scrolling.
NativeUIScrollViewRendererscrolls onkeyboardWillShow(and, since #50, onkeyboardWillHide), but only forscroll-anchor="bottom"and only to thebottom anchor. That is a chat policy, and a good one.
NativeUITextInputCorehas no scroll proxy at all.
SwiftUI shrinks the screen by the keyboard height, which is enough when the
field is the last thing on the page: shrink the viewport and the composer
lands just above the keyboard. That is why this has never come up.
It is not enough anywhere else. Shrinking guarantees the field is inside the
scrollable content, not that it is on screen.
Why it matters
On a login form the password field sits mid-page. Tap it and the keyboard
comes up; the viewport shrinks; the field stays exactly where it was, now
underneath the keyboard. The user is typing blind into a field that is masked
anyway, so there is no feedback at all that anything is being entered.
It is the single most common form layout there is, and every native form
handles it.
The approach
The vertical scroll view already builds a
ScrollViewReaderfor bottomanchoring, so this needs no new machinery. Publish that proxy into the
environment; let the focused input scroll itself to
.center.Four deliberate choices, each of which is why this is safe:
already owns a keyboard policy and it is a different one — a chat log
wants the latest message above the keyboard, not the composer centered in
what is left. Two policies driving one proxy would race, and the later would
win by accident. Withholding also means the environment value is nil in
every case where scrolling isn't the descendant's business: sheets, modals,
fixed screens, chat logs. Nil is a real answer, not a missing one.
keyboardWillShow. Moving from the email fieldto the password field never re-shows the keyboard, and that is precisely
when a form needs this.
centers against the already-shrunk viewport. Centering against the full
height first would put the field in the middle of a screen about to lose its
bottom half — back under the keyboard.
.id(node.id)on the field is what makes it addressable. That id isalready the
ForEachidentity of every node in the tree, so it is stableacross republishes; and it is applied to the view
bodyreturns rather thanto the struct, so it cannot reset the value/sync
@State.Relationship to #27
I checked #27 ("Scroll-view: honour
auto_scroll_to, gap, and chromecollapse") first, specifically to avoid inventing a second mechanism.
It does not establish a proxy seam for descendants. It consumes the existing
ScrollViewReaderlocally, driving it from an author-suppliedauto-scroll-toindex —Self.scroll(proxy, to: index, in: node, anchor:).That is a scroll-view-driven mechanism; this is a descendant-driven one.
They are independent and can coexist.
Two notes for whoever merges them:
verticalScrollbody, a few lines apart. Textualconflict is likely and trivial.
auto-scroll-toand puts a text input inside isasking two things to scroll one view. The same argument I used above for
scroll-anchor="bottom"says the explicit index should win, and the guardis one line —
stickBottom || hasAutoScrollin place ofstickBottom. Ihave not written it, because
hasAutoScrolldoes not exist on main yet andI would rather not carry a stub for an unmerged PR. Happy to add it in
whichever order they land.
#27 also notes it is blocked on
LocalChromeScrollControllerfrom core. Thisbranch has no such dependency — it compiles against main as it stands.
Alternatives considered
keyboardWillShowin the input instead. Gives a real animationduration instead of a constant, and misses the field-to-field case entirely,
which is half the problem.
viewport, so the field lands in the middle of a screen that is about to lose
its bottom half. Tried it; it is visibly wrong.
horizontal proxy would scroll the wrong axis, and the innermost environment
value wins, so a field inside a nested horizontal scroll would get the wrong
one.
renderer has no scroll proxy to give; the scroll view is the only thing that
knows how to scroll.
scroll-on-focusopt-out prop. Deliberately not added. Withholdingthe proxy from bottom-anchored views already covers the only case I could
find where this behavior is unwanted, and a prop nobody needs is worse than
no prop. Easy to add if a case turns up.
What I verified / what I could not
Verified:
resources/ios/*.swiftcompiled together with the core
NativeRendersources from a real appinstall:
swiftc -typecheck -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -target arm64-apple-ios18.2-simulator.Clean; no new warnings.
pint --testpasses. No PHPchanges on this branch.
whole change is two iOS files.
Could not verify:
No runtime test, and this is a timing-sensitive change. The 0.35s
constant, the interaction with
scrollDismissesKeyboard(.interactively),and how it feels moving between fields all need a device.
mobile-ui@maindoes not currently register most of its components againstthe released core — the manifest's
bladepaths sayNative\Mobile\Edge\Components\Textwhile core shipsNative\Mobile\Edge\Components\Native\Text, so 25 of 59 entries failclass_exists()and are skipped silently, taking<button>and every textinput with them (52 registered components → 27). I could not build an app
against this branch.
The equivalent behavior is running on a device in an app pinned to an
older mobile-ui — that is where the 0.35s came from, by trying shorter
values and watching them center against the wrong viewport. That is
evidence the approach works, not evidence this diff does.
Untested against
axis="both"and horizontal scroll views, which do notpublish a proxy, so inputs inside them behave exactly as they do today. That
is intentional, but unexercised.
Android is not touched, and I believe it does not need to be: Compose's
BasicTextFieldalready requests bring-into-view on focus and the IMEresizes the window, so a field inside a scrollable column is brought up
without help. If that turns out to be wrong on the M3 text fields this repo
uses, it should be its own change rather than a guess bolted onto this one.