Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions resources/ios/NativeUIScrollViewRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}
}
48 changes: 48 additions & 0 deletions resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ struct NativeUITextInputCore: View {
@State private var debounceTask: Task<Void, Never>? = nil
@FocusState private var isFocused: Bool

/// The enclosing vertical `<scroll-view>`'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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -224,6 +235,8 @@ struct NativeUITextInputCore: View {
if selectionEnabled {
flushSelection(cb: onSelectionCb)
}
} else {
scrollIntoView()
}
}
.onSubmit {
Expand All @@ -249,6 +262,41 @@ struct NativeUITextInputCore: View {
}
}

// ─── Keyboard avoidance ──────────────────────────────────────────────────

/// Ask the enclosing `<scroll-view>` 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) {
Expand Down