Skip to content

Scroll the focused text input above the keyboard - #62

Open
CodyPChristian wants to merge 1 commit into
NativePHP:mainfrom
CodyPChristian:feat/scroll-focused-input-into-view
Open

Scroll the focused text input above the keyboard#62
CodyPChristian wants to merge 1 commit into
NativePHP:mainfrom
CodyPChristian:feat/scroll-focused-input-into-view

Conversation

@CodyPChristian

Copy link
Copy Markdown
Contributor

The problem

Nothing links focus to scrolling.

NativeUIScrollViewRenderer scrolls on keyboardWillShow (and, since #50, on
keyboardWillHide), but only for scroll-anchor="bottom" and only to the
bottom anchor. That is a chat policy, and a good one. 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.

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 ScrollViewReader for bottom
anchoring, so this needs no new machinery. Publish that proxy into the
environment; let the focused input scroll itself to .center.

.environment(\.nativeUIScrollProxy, stickBottom ? nil : proxy)

Four deliberate choices, each of which is why 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 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.
  • It 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 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 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.

Relationship to #27

I checked #27 ("Scroll-view: honour auto_scroll_to, gap, and chrome
collapse") first, specifically to avoid inventing a second mechanism.

It does not establish a proxy seam for descendants. It consumes the existing
ScrollViewReader locally, driving it from an author-supplied
auto-scroll-to index — 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:

  • They touch the same verticalScroll body, a few lines apart. Textual
    conflict is likely and trivial.
  • An author who sets both auto-scroll-to and puts a text input inside is
    asking two things to scroll one view. The same argument I used above for
    scroll-anchor="bottom" says the explicit index should win, and the guard
    is one line — stickBottom || hasAutoScroll in place of stickBottom. I
    have not written it, because hasAutoScroll does not exist on main yet and
    I 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 LocalChromeScrollController from core. This
branch has no such dependency — it compiles against main as it stands.

Alternatives considered

  • Observe keyboardWillShow in the input instead. Gives a real animation
    duration instead of a constant, and misses the field-to-field case entirely,
    which is half the problem.
  • Scroll immediately on focus, no deferral. Centers against the full-height
    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.
  • Publish the proxy from the horizontal and 2D paths too. Rejected: a
    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.
  • Do it in core's root renderer instead of per-scroll-view. The root
    renderer has no scroll proxy to give; the scroll view is the only thing that
    knows how to scroll.
  • A scroll-on-focus opt-out prop. Deliberately not added. Withholding
    the 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:

  • iOS type-checks against the real SDK. All of resources/ios/*.swift
    compiled together with the core NativeRender sources from a real app
    install:
    swiftc -typecheck -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -target arm64-apple-ios18.2-simulator.
    Clean; no new warnings.
  • Pest suite passes unchanged (217 tests); pint --test passes. No PHP
    changes on this branch.
  • No new element props, no wire-format change, no PHP surface at all — the
    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@main does not currently register most of its components against
    the released core — the manifest's blade paths say
    Native\Mobile\Edge\Components\Text while core ships
    Native\Mobile\Edge\Components\Native\Text, so 25 of 59 entries fail
    class_exists() and are skipped silently, taking <button> and every text
    input 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 not
    publish 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
    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.

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`.
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.

1 participant