Skip to content

feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard - #1577

Open
RoeKun wants to merge 13 commits into
Tencent-TDS:mainfrom
RoeKun:feature/valo_compose_keyboard_bringtoVisualView_build
Open

feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard#1577
RoeKun wants to merge 13 commits into
Tencent-TDS:mainfrom
RoeKun:feature/valo_compose_keyboard_bringtoVisualView_build

Conversation

@RoeKun

@RoeKun RoeKun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 KuiklyAI Code Review

基本信息

  • PR:#1577 · feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard
  • 作者:RoeKun main ← feature/valo_compose_keyboard_bringtoVisualView_build

变更概述

  • 干了什么:为 Compose DSL 新增「输入框获焦被键盘遮挡时自动滚入可视区」能力,配套补齐 WindowInsets.ime / Modifier.imePadding() 公共 API,并将页面级 IME 高度从三端 render(Android/iOS/OHOS)统一通过 pager event 通道回传到 Configuration
  • 解决什么:对齐官方 Compose 的 bring-into-view 与 IME inset 语义,替代原先耦合在输入框实例上的键盘高度上报,键盘弹起时聚焦输入框能自动上滚避开键盘。
  • 方案 & 合理性:分层清晰——render 层只负责采集键盘高度并发 imeInsetsDidChanged 事件,Configuration/WindowInsets.ime 作页面级状态源,BringIntoViewResponder(responder)+ BringIntoViewRequester(requester)双路径(path A 焦点追踪 + path B 显式请求)对齐官方 ContentInViewNode,LazyList 通过 animateScrollBy 落地滚动。纯新增 public API(BringIntoViewRequester 为普通 class + @Stable,无 data class/enum 兼容隐患),无二进制兼容破坏。整体在正确的层级,副作用面可控。

主要问题

  • WindowInsets.kt 新增了 3 个未使用的 import(SideEffect / animateFloatAsState / tween),见 inline。
  • iOS KuiklyRenderView.mcurrentKeyboardHeight 属性只赋值不读取,属死代码,见 inline。
  • 两处轻微建议(bring-into-view 几何坐标基准、viewport-shrink 判定与官方的细微差异),见 inline。

文档同步

  • 本 PR 已自带 openspec/ 设计文档与 spec,覆盖设计意图。但新增了对外公共 API WindowInsets.ime / Modifier.imePadding() / BringIntoViewRequesterdocs/ 下对外官网文档目前无任何相关说明,建议补充组件用法文档,避免业务侧无从知晓这些能力。

Comment thread core-render-ios/View/KuiklyRenderView.m Outdated
if (targetRect == Rect.Zero || containerRect == Rect.Zero) {
return null
}
val windowBottom = containerCoordinates.findRootCoordinates().boundsInRoot().bottom

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里 windowBottom 取的是 findRootCoordinates().boundsInRoot().bottom,也就是 root 自身的高度。calculateBringIntoViewDelta 里用它和 imeBottomPx 算可视底边 windowBottom - imeBottomPx,隐含了「root 坐标系原点 == window 原点」的假设。如果 root 顶部相对 window 有偏移(例如外层有状态栏/Scaffold AppBar 把 root 顶下去了),windowBottom 会偏小,算出的可视区会比实际窄、滚动量偏大。Kuikly 里 root 一般就是整个 pager 内容区,多数情况成立,但值得在注释里点明这个假设,或在 root 非 window 原点时改用 boundsInWindow()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:本笔提交在 viewportRect / effectiveVisibleHeight / calculateScrollDelta 三处补了 root 坐标系的假设说明,并把 windowBottom = findRootCoordinates().boundsInRoot().bottom 改为 rootBottom = findRootCoordinates().size.height。在 root 自身上 boundsInRoot() == Rect(0,0,w,h),二者等价,行为不变但语义更清晰;注释也点明了「window 坐标链路未实现」的前置条件和未来变更需重新评估的口径。

* @param newViewport The viewport rect after the resize.
* @return `true` if the focused child was fully visible before but is now partially clipped.
*/
internal fun wasFocusedChildClippedByViewportShrink(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议留意一下与官方 isMaxVisible 判定的细微差异:官方是用「旧的 focusedChild bounds 对比旧 viewport」+「新的 focusedChild bounds 对比新 viewport」,而 checkViewportShrinkAndSchedule 里两次都用同一个当前 focusedRect 去对比 old/new viewport。键盘弹起瞬间 child 一般还没动,影响不大;但如果 viewport shrink 和 child 重排发生在同一帧,可能会漏判或误判。当前 MVP 可接受,留个注释说明这个近似即可。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:在 wasFocusedChildClippedByViewportShrink 的 KDoc 里补了 Note,明确说明本 MVP 近似用「当前 bounds 同时对比 old/new viewport」,与官方「旧 bounds 对旧 viewport + 新 bounds 对新 viewport」的差异,以及「viewport shrink 与 child 重排同帧可能误判、键盘弹起瞬间 child 通常未动故可接受」的前提,与你的建议一致。

// 动态计算页面内容区底边到窗口底边的距离,而不是固定减去导航栏高度:
// - 沉浸式页面:contentView 底边 == 窗口底边,gap 为 0,上报完整 imeHeight
// - 非沉浸式页面:contentView 停在导航栏上方,gap == 导航栏高度,上报 imeHeight - gap
val windowBottom = activity.windowManager.currentWindowMetrics.bounds.bottom

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentWindowMetrics.bounds 在分屏/多窗口模式下返回的是当前窗口的 bounds,而 rootView.getLocationOnScreen(contentLoc) 给的是相对整个屏幕的坐标,两者基准在分屏时会错位,pageBottomGap 可能算偏。全屏常态没问题,如果后续要支持分屏场景,这里需要统一基准(比如都用 currentWindowMetrics.bounds 内的相对坐标)。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:在 Android11PlusKeyboardWatcher 里补了「已知限制」注释,点明 currentWindowMetrics.bounds 基于当前窗口、getLocationOnScreen 基于整屏,分屏/多窗口下基准错位会导致 pageBottomGap 算偏,全屏场景正确,后续支持分屏需统一为窗口内相对坐标。当前全屏为主,先记下来。

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 KuiklyAI Code Review

基本信息

  • PR:#1577 · feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard
  • 作者:RoeKun main ← feature/valo_compose_keyboard_bringtoVisualView_build

变更概述

  • 干了什么:为 Compose DSL 新增 bring-into-view 能力(BringIntoViewRequester/bringIntoViewResponder,Focusable 获焦自动滚入可视区)+ 页面级 IME insets(WindowInsets.ime/imePadding()/Scaffold 默认避让),三端渲染层统一通过 imeInsetsDidChanged pager event 上报键盘高度,core/Compose 模块编译通过。
  • 解决什么:输入框获焦被键盘遮挡时自动滚入可视区,并提供统一的页面级 IME 高度消费 API,替代旧的、耦合到输入框实例的 keyboardHeightChange 回调。
  • 方案 & 合理性:两条路径分工清晰——Path B(Focusable 获焦请求整节点入视)+ Path A(viewport 因 IME 收缩时补偿滚动);坐标计算统一在 root 坐标系,visibleBottom = min(containerBottom, windowBottom - imeHeight) 对 overlay IME 模式做了显式扣减,思路正确。事件链路从渲染层 → Pager event → ComposeContainer → Configuration(mutableState) → WindowInsets.ime 触发 recomposition,再驱动 responder node.update 触发 viewport-shrink 检查,闭环完整。

提醒

  • 文档已随 PR 同步(openspec compose-bring-into-view / compose-ime-insets 两份 spec + archive,三端场景齐全),无需额外补文档。
  • 几处预留但未被消费的 API/状态(见 inline),建议确认是否本期保留。

整体评价

核心逻辑(坐标转换、双路径优先级、IME 收缩检测)实现正确,跨端 key 常量一致,demo 引用同步更新。主要问题集中在少量死代码/未使用 import 上,不影响功能,清理后更干净。

// Aligned with official CoreTextField.kt:312. The focusable modifier already requests the
// entire node on focus gain; this requester is reserved for cursor rect requests
// (bringSelectionEndIntoView) once TextLayoutResult is available in Kuikly's CoreTextField.
val bringIntoViewRequester = remember { BringIntoViewRequester() }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个 bringIntoViewRequesterremember 创建并挂到了 modifier 链上,但整个代码库里没有任何地方调用它的 bringIntoView(rect)——也就是说每个 TextField 都会创建一个 requester 和对应的 node,却从不触发。注释说它是预留给 cursor rect 请求(bringSelectionEndIntoView)的,可以理解,但既然本期没有调用方,是否可以先不挂这个 modifier,等真正接上 cursor 请求再加?现在挂着会让人以为 TextField 已经具备 cursor 级滚入能力。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:本笔提交直接删掉了 CoreTextField 里未被调用的 bringIntoViewRequester 及其 Modifier.bringIntoViewRequester(...) 挂载和两个 import。获焦级滚入仍由 Focusable 自带的 bringIntoViewRequester(Focusable.kt 里获焦时调 bringIntoView())承担,未受影响;cursor 级请求等真正接上 TextLayoutResult 再加。

val imeBottomDp: Float by _imeBottomDp
// 当前页面的软件键盘动画时长(毫秒,phase1 仅内部预留)
val imeAnimationDuration: Float by _imeAnimationDuration
// 当前页面的软件键盘动画曲线(内部标准化值,phase1 仅内部预留)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imeAnimationDuration / imeAnimationCurve 这两个 public valmutableStateOf 支撑,onImeInsetsChanged 每次都会写入它们,但全代码库没有任何消费者读取。spec 里把它们描述为「internal reserved metadata」,可这里却暴露成了 Configuration 的 public API。作为预留状态没问题,但建议要么降到 internal/private(与 spec 的 internal 措辞一致),要么至少在某处消费,避免对外暴露一个永远不会被用到、却会随每次键盘事件被写入的 public 状态。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:imeAnimationDuration / imeAnimationCurve 两个 val 已从 public 降为 internal,与 spec 里「internal reserved metadata」的措辞一致;全代码库无外部读取点,降级不破坏任何调用方。

Comment thread core-render-ohos/src/main/ets/KRNativeRenderController.ets Outdated
- Remove unused imports in WindowInsets.kt (SideEffect, animateFloatAsState, tween)
- Fix Scaffold.kt comment: '动画化后的投影值' -> '页面级键盘当前高度'
- Remove dead onKeyboardHeightChanged method in OHOS KRNativeRenderController.ets
- Fix misleading '复用现有窗口级 keyboardHeightChange 来源' comment in OHOS
- Remove write-only currentKeyboardHeight property in iOS KuiklyRenderView.m
- Add WindowInsets.ime / Modifier.imePadding() docs to core-components.md
- Update Scaffold demo references to new file names

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 KuiklyAI Code Review(增量)

上轮提的 4 条死代码 / 未用 import / 误导注释类问题,本笔 b99ccff 都处理干净了:

  • WindowInsets.kt 删掉了未使用的 SideEffect / animateFloatAsState / tween
  • KuiklyRenderView.m 删掉了只写不读的 currentKeyboardHeight,去重继续由 lastImeInsetsEventData 承担;
  • KRNativeRenderController.ets 删掉了无调用方的 onKeyboardHeightChanged,页面级 IME 上报仍走 avoidAreaChange 的 TYPE_KEYBOARD 路径和 onPageShow,删除入口未造成断链;
  • 同步修正了 Scaffold.kt 和 OHOS 那两处误导性注释,措辞与实际逻辑对齐。

文档侧也补齐了 WindowInsets.ime / Modifier.imePadding() / Scaffold 默认避让的说明,引用的 demo 链接(ImeInsetDemo.ktScaffoldImeInsetDemo.kt)都指向真实存在的文件。

上轮还有 5 条偏设计层面 / 跨端一致性的建议(BringIntoViewResponder 里 root 坐标系假设、wasFocusedChildClippedByViewportShrink 与官方 isMaxVisible 的近似差异、KRKeyboardModule 分屏基准、CoreTextFieldbringIntoViewRequester 暂无调用方、ConfigurationimeAnimationDuration/imeAnimationCurve 暴露为 public),本笔未触及,留待后续 phase 处理即可。

本次改动未引入新问题,整体看 OK。

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 KuiklyAI Code Review

这笔提交(fd13baf8)是纯文档补充,给 core-components.mdlist-and-scroll.md 加上了 bring-into-view 能力的说明,没有动任何代码。

核对了一遍文档描述和实际代码,对得上:BringIntoViewRequester / bringIntoView() / bringIntoView(rect: Rect?) / Modifier.bringIntoViewRequester 的签名和用法都和 compose/foundation/relocation/BringIntoViewRequester.kt 一致;「自动滚入、已可见不滚动、仅 LazyColumn 支持、caret 级暂未实现、三端一致、与 imePadding 协同」这些行为说明也和实现相符,限制项标注得也挺诚实(ScrollState/verticalScroll 未覆盖、caret 未实现)。示例代码省略 import 的写法和这两个文件里其它示例风格一致,没问题。

上轮(b99ccffb)针对代码的几条已解决项(未使用 import、死代码 currentKeyboardHeight / onKeyboardHeightChanged、误导注释)都在那笔提交里清理了,这笔文档提交没有回退它们。之前未解决的几条(root 坐标系假设、与官方 isMaxVisible 判定差异、windowBottom 分屏基准、imeAnimationDuration/Curve 暴露为 public、TextField 预留 requester 未接入)这次也没动到对应代码,状态保持不变。

这笔文档本身没有需要改的地方。

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 KuiklyAI Code Review(增量 · 最新提交 a020c43

本笔提交主要在处理上轮 review 遗留项,逐条对照如下:

  • BringIntoViewResponder root 坐标假设viewportRect / effectiveVisibleHeight / calculateScrollDelta 三处补了「root 坐标系、window 坐标链路未实现」的前提说明,并把 windowBottom = findRootCoordinates().boundsInRoot().bottom 改为 rootBottom = findRootCoordinates().size.height。在 root 自身上 boundsInRoot() == Rect(0,0,w,h),两者等价、行为不变,语义更清晰。✅
  • ContentInViewNode isMaxVisible 近似:在 wasFocusedChildClippedByViewportShrink KDoc 补了 Note,说明用「当前 bounds 同时对比 old/new viewport」与官方的差异及适用前提。✅
  • KRKeyboardModule 分屏基准错位:补了「已知限制」注释,点明分屏/多窗口下 currentWindowMetrics.boundsgetLocationOnScreen 基准错位、全屏正确。✅
  • CoreTextField 未用的 bringIntoViewRequester:连同 import 和 modifier 挂载一并删除;获焦级滚入仍由 Focusable 自带的 requester 承担,未受影响。✅
  • LocalConfiguration public imeAnimationDuration/Curve:降为 internal,与 spec 措辞一致,全库无外部读取点,降级安全。✅

上轮剩余 5 条均已在各自线程回复「已解决」。本次改动未引入新的功能或兼容性问题,无需新增 inline。

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.

2 participants