diff --git a/resources/ios/NativeUIScrollViewRenderer.swift b/resources/ios/NativeUIScrollViewRenderer.swift index b1d01de..19d6f85 100644 --- a/resources/ios/NativeUIScrollViewRenderer.swift +++ b/resources/ios/NativeUIScrollViewRenderer.swift @@ -170,6 +170,19 @@ struct NativeUIScrollViewRenderer: View { .frame(maxWidth: .infinity) } .scrollDismissesKeyboard(.interactively) + // Hand this scroll view's proxy to its subtree, so a text input + // anywhere inside can bring ITSELF above the keyboard when it + // takes focus (`NativeUITextInputCore`). `ScrollViewReader` + // proposes its content the size it was proposed, so this is + // identity plumbing and nothing else — no layout of its own. + // + // Withheld from a bottom-anchored view on purpose. That mode + // already owns a keyboard policy (the show / hide re-pins below) + // and it is deliberately a different one: a chat log wants the + // LATEST MESSAGE above the keyboard, not the composer centered in + // what's left. Two policies driving one proxy would race, and the + // later one would win by accident. + .environment(\.nativeUIScrollProxy, stickBottom ? nil : proxy) .onAppear { guard stickBottom else { return } // Defer past first layout — lazy content isn't measured yet @@ -260,3 +273,23 @@ private struct MinViewportHeightModifier: ViewModifier { } } } + +// MARK: - Focus-driven scrolling + +/// The enclosing vertical `ScrollView`'s proxy, for descendants that need to +/// scroll themselves into view. +/// +/// `nil` by default, and nil is a real answer rather than a missing one: a +/// sheet, a modal, a fixed screen or a bottom-anchored chat log all have no +/// vertical scroll view whose scrolling is this descendant's business, and a +/// descendant that finds no proxy correctly does nothing. +struct NativeUIScrollProxyKey: EnvironmentKey { + static let defaultValue: ScrollViewProxy? = nil +} + +extension EnvironmentValues { + var nativeUIScrollProxy: ScrollViewProxy? { + get { self[NativeUIScrollProxyKey.self] } + set { self[NativeUIScrollProxyKey.self] = newValue } + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 018d9a4..92532be 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -27,6 +27,12 @@ struct NativeUITextInputCore: View { @State private var debounceTask: Task? = nil @FocusState private var isFocused: Bool + /// The enclosing vertical ``'s proxy, published by + /// `NativeUIScrollViewRenderer`. Nil everywhere there isn't one — sheets, + /// modals, non-scrolling screens, and bottom-anchored chat logs, which run + /// their own keyboard policy — and nil means this field does not scroll. + @Environment(\.nativeUIScrollProxy) private var scrollProxy + // ─── Selection / caret reporting (opt-in via `on_selection_change`) ────── // // Independent of the value/`sync_mode` machinery above: it never touches @@ -153,6 +159,11 @@ struct NativeUITextInputCore: View { .autocorrectionDisabled(!autocorrect) .disabled(disabled || readOnly) .submitLabel(onSubmitCb != 0 ? .done : .return) + // Scroll target for `scrollIntoView()` below. `node.id` is already the + // ForEach identity of every node in the tree, so it is stable across + // republishes; and because it is applied to the view `body` returns + // rather than to this struct, it cannot reset the `@State` above. + .id(node.id) .onAppear { if !initialized { text = serverValue @@ -224,6 +235,8 @@ struct NativeUITextInputCore: View { if selectionEnabled { flushSelection(cb: onSelectionCb) } + } else { + scrollIntoView() } } .onSubmit { @@ -249,6 +262,41 @@ struct NativeUITextInputCore: View { } } + // ─── Keyboard avoidance ────────────────────────────────────────────────── + + /// Ask the enclosing `` to bring this field into view. + /// + /// SwiftUI already shrinks the screen by the keyboard height, but that + /// only guarantees the field is somewhere in the SCROLLABLE CONTENT — not + /// that it is on screen. On a chat composer pinned to the bottom the two + /// amount to the same thing, which is why nothing needed this before; on a + /// login form the password field sits mid-page and a shrunk viewport + /// leaves it exactly where it was, under the keyboard. + /// + /// Deferred past the keyboard's own presentation so the scroll runs + /// against the already-shrunk viewport. Centering against the full height + /// first would put the field in the middle of a screen that is about to + /// lose its bottom half — i.e. back under the keyboard. The delay is a + /// little longer than the ~0.25s the system animates the keyboard in. + /// + /// Runs on focus rather than on `keyboardWillShow`, because moving from + /// one field to the next never re-shows the keyboard and is exactly when + /// this is needed. Where there is no proxy — sheets, modals, fixed + /// screens, bottom-anchored scroll views — this is a no-op. + private func scrollIntoView() { + guard let scrollProxy else { return } + + let id = node.id + DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusScrollDelay) { + withAnimation(.easeOut(duration: 0.2)) { + scrollProxy.scrollTo(id, anchor: .center) + } + } + } + + /// How long to wait after focus before scrolling, in seconds. + private static let focusScrollDelay: TimeInterval = 0.35 + // ─── Dispatch policy ───────────────────────────────────────────────────── private func handleLocalChange(_ value: String, mode: String, debounceMs: Int, onChangeCb: Int) {