feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard - #1577
Conversation
…events Signed-off-by: valoxbwang <valoxbwang@tencent.com>
Signed-off-by: valoxbwang <valoxbwang@tencent.com>
…keyboard_IME_support
Signed-off-by: valoxbwang <valoxbwang@tencent.com>
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 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.m的currentKeyboardHeight属性只赋值不读取,属死代码,见 inline。 - 两处轻微建议(bring-into-view 几何坐标基准、viewport-shrink 判定与官方的细微差异),见 inline。
文档同步
- 本 PR 已自带
openspec/设计文档与 spec,覆盖设计意图。但新增了对外公共 APIWindowInsets.ime/Modifier.imePadding()/BringIntoViewRequester,docs/下对外官网文档目前无任何相关说明,建议补充组件用法文档,避免业务侧无从知晓这些能力。
| if (targetRect == Rect.Zero || containerRect == Rect.Zero) { | ||
| return null | ||
| } | ||
| val windowBottom = containerCoordinates.findRootCoordinates().boundsInRoot().bottom |
There was a problem hiding this comment.
这里 windowBottom 取的是 findRootCoordinates().boundsInRoot().bottom,也就是 root 自身的高度。calculateBringIntoViewDelta 里用它和 imeBottomPx 算可视底边 windowBottom - imeBottomPx,隐含了「root 坐标系原点 == window 原点」的假设。如果 root 顶部相对 window 有偏移(例如外层有状态栏/Scaffold AppBar 把 root 顶下去了),windowBottom 会偏小,算出的可视区会比实际窄、滚动量偏大。Kuikly 里 root 一般就是整个 pager 内容区,多数情况成立,但值得在注释里点明这个假设,或在 root 非 window 原点时改用 boundsInWindow()。
There was a problem hiding this comment.
已解决:本笔提交在 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( |
There was a problem hiding this comment.
建议留意一下与官方 isMaxVisible 判定的细微差异:官方是用「旧的 focusedChild bounds 对比旧 viewport」+「新的 focusedChild bounds 对比新 viewport」,而 checkViewportShrinkAndSchedule 里两次都用同一个当前 focusedRect 去对比 old/new viewport。键盘弹起瞬间 child 一般还没动,影响不大;但如果 viewport shrink 和 child 重排发生在同一帧,可能会漏判或误判。当前 MVP 可接受,留个注释说明这个近似即可。
There was a problem hiding this comment.
已解决:在 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 |
There was a problem hiding this comment.
currentWindowMetrics.bounds 在分屏/多窗口模式下返回的是当前窗口的 bounds,而 rootView.getLocationOnScreen(contentLoc) 给的是相对整个屏幕的坐标,两者基准在分屏时会错位,pageBottomGap 可能算偏。全屏常态没问题,如果后续要支持分屏场景,这里需要统一基准(比如都用 currentWindowMetrics.bounds 内的相对坐标)。
There was a problem hiding this comment.
已解决:在 Android11PlusKeyboardWatcher 里补了「已知限制」注释,点明 currentWindowMetrics.bounds 基于当前窗口、getLocationOnScreen 基于整屏,分屏/多窗口下基准错位会导致 pageBottomGap 算偏,全屏场景正确,后续支持分屏需统一为窗口内相对坐标。当前全屏为主,先记下来。
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 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 默认避让),三端渲染层统一通过imeInsetsDidChangedpager 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() } |
There was a problem hiding this comment.
这个 bringIntoViewRequester 被 remember 创建并挂到了 modifier 链上,但整个代码库里没有任何地方调用它的 bringIntoView(rect)——也就是说每个 TextField 都会创建一个 requester 和对应的 node,却从不触发。注释说它是预留给 cursor rect 请求(bringSelectionEndIntoView)的,可以理解,但既然本期没有调用方,是否可以先不挂这个 modifier,等真正接上 cursor 请求再加?现在挂着会让人以为 TextField 已经具备 cursor 级滚入能力。
There was a problem hiding this comment.
已解决:本笔提交直接删掉了 CoreTextField 里未被调用的 bringIntoViewRequester 及其 Modifier.bringIntoViewRequester(...) 挂载和两个 import。获焦级滚入仍由 Focusable 自带的 bringIntoViewRequester(Focusable.kt 里获焦时调 bringIntoView())承担,未受影响;cursor 级请求等真正接上 TextLayoutResult 再加。
| val imeBottomDp: Float by _imeBottomDp | ||
| // 当前页面的软件键盘动画时长(毫秒,phase1 仅内部预留) | ||
| val imeAnimationDuration: Float by _imeAnimationDuration | ||
| // 当前页面的软件键盘动画曲线(内部标准化值,phase1 仅内部预留) |
There was a problem hiding this comment.
imeAnimationDuration / imeAnimationCurve 这两个 public val 由 mutableStateOf 支撑,onImeInsetsChanged 每次都会写入它们,但全代码库没有任何消费者读取。spec 里把它们描述为「internal reserved metadata」,可这里却暴露成了 Configuration 的 public API。作为预留状态没问题,但建议要么降到 internal/private(与 spec 的 internal 措辞一致),要么至少在某处消费,避免对外暴露一个永远不会被用到、却会随每次键盘事件被写入的 public 状态。
There was a problem hiding this comment.
已解决:imeAnimationDuration / imeAnimationCurve 两个 val 已从 public 降为 internal,与 spec 里「internal reserved metadata」的措辞一致;全代码库无外部读取点,降级不破坏任何调用方。
- 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
left a comment
There was a problem hiding this comment.
🤖 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.kt、ScaffoldImeInsetDemo.kt)都指向真实存在的文件。
上轮还有 5 条偏设计层面 / 跨端一致性的建议(BringIntoViewResponder 里 root 坐标系假设、wasFocusedChildClippedByViewportShrink 与官方 isMaxVisible 的近似差异、KRKeyboardModule 分屏基准、CoreTextField 的 bringIntoViewRequester 暂无调用方、Configuration 的 imeAnimationDuration/imeAnimationCurve 暴露为 public),本笔未触及,留待后续 phase 处理即可。
本次改动未引入新问题,整体看 OK。
…oViewRequester 用法)
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 KuiklyAI Code Review
这笔提交(fd13baf8)是纯文档补充,给 core-components.md 和 list-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 未接入)这次也没动到对应代码,状态保持不变。
这笔文档本身没有需要改的地方。
…h window-chain risk notes
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 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 近似:在
wasFocusedChildClippedByViewportShrinkKDoc 补了 Note,说明用「当前 bounds 同时对比 old/new viewport」与官方的差异及适用前提。✅ - KRKeyboardModule 分屏基准错位:补了「已知限制」注释,点明分屏/多窗口下
currentWindowMetrics.bounds与getLocationOnScreen基准错位、全屏正确。✅ - CoreTextField 未用的 bringIntoViewRequester:连同 import 和 modifier 挂载一并删除;获焦级滚入仍由 Focusable 自带的 requester 承担,未受影响。✅
- LocalConfiguration public imeAnimationDuration/Curve:降为
internal,与 spec 措辞一致,全库无外部读取点,降级安全。✅
上轮剩余 5 条均已在各自线程回复「已解决」。本次改动未引入新的功能或兼容性问题,无需新增 inline。
feat(compose): add bring-into-view support to auto-scroll focused input fields above the keyboard