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
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,12 @@ open class ComposeContainer :
val fontWeightScale = eventData.optDouble("fontWeightScale", 1.0)
val fontSizeScale = eventData.optDouble("fontSizeScale", 1.0)
configuration?.onFontConfigChange(fontSizeScale, fontWeightScale)
} else if (pagerEvent == PAGER_EVENT_IME_INSETS_DID_CHANGED) {
configuration?.onImeInsetsChanged(
height = eventData.optDouble(IME_HEIGHT),
duration = eventData.optDouble(IME_DURATION),
curve = eventData.optInt(IME_CURVE, 0)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ package com.tencent.kuikly.compose.foundation
import com.tencent.kuikly.compose.foundation.interaction.FocusInteraction
import com.tencent.kuikly.compose.foundation.interaction.Interaction
import com.tencent.kuikly.compose.foundation.interaction.MutableInteractionSource
//import com.tencent.kuikly.compose.foundation.relocation.scrollIntoView
import com.tencent.kuikly.compose.foundation.relocation.BringIntoViewRequester
import com.tencent.kuikly.compose.foundation.relocation.BringIntoViewRequesterNode
import androidx.compose.runtime.Stable
import androidx.compose.runtime.InternalComposeApi
import com.tencent.kuikly.compose.material3.internal.identityHashCode
Expand Down Expand Up @@ -203,6 +204,15 @@ internal class FocusableNode(
private val focusablePinnableContainer = delegate(FocusablePinnableContainerNode())
private val focusedBoundsNode = delegate(FocusedBoundsNode())

// Path B: BringIntoViewRequester for focus-driven bring-into-view.
// Aligned with official Focusable.kt:225-229: every focusable gets a requester so that
// gaining focus automatically requests the entire node to be brought into view.
// If the node is already fully visible, the responder's calculator returns 0 delta (no-op).
private val bringIntoViewRequester = BringIntoViewRequester()
private val bringIntoViewRequesterNode = delegate(
BringIntoViewRequesterNode(bringIntoViewRequester)
)

init {
delegate(FocusTargetModifierNode())
}
Expand All @@ -225,12 +235,14 @@ internal class FocusableNode(
override fun onFocusEvent(focusState: FocusState) {
if (this.focusState != focusState) { // focus state changed
val isFocused = focusState.isFocused
// todo pel scrollIntoView
/*if (isFocused) {
// Path B: when a focusable gains focus, request the entire node be brought into view.
// Aligned with official Focusable.kt:242-245.
// If the node is already visible, the responder returns 0 delta (no-op).
if (isFocused) {
coroutineScope.launch {
scrollIntoView()
bringIntoViewRequester.bringIntoView()
}
}*/
}
if (isAttached) invalidateSemantics()
focusableInteractionNode.setFocus(isFocused)
focusedBoundsNode.setFocus(isFocused)
Expand All @@ -255,6 +267,8 @@ internal class FocusableNode(
// TODO(levima) Remove this once delegation can propagate this events on its own
override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
focusedBoundsNode.onGloballyPositioned(coordinates)
// Forward to requester node so it has up-to-date coordinates for bringIntoView().
bringIntoViewRequesterNode.onGloballyPositioned(coordinates)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,57 @@

package com.tencent.kuikly.compose.foundation.gestures

import com.tencent.kuikly.compose.ui.geometry.Rect

/**
* Static field to turn on a bunch of verbose logging to debug animations. Since this is a constant,
* any log statements guarded by this value should be removed by the compiler when it's false.
*/
private const val DEBUG = false
private const val TAG = "ContentInViewModifier"
internal const val CONTENT_IN_VIEW_DEBUG = false
internal const val CONTENT_IN_VIEW_TAG = "BringIntoView"

/**
* A minimum amount of delta that it is considered a valid scroll.
*/
private const val MinScrollThreshold = 0.5f
internal const val MinScrollThreshold = 0.5f

/**
* Checks whether [focusedChildRect] was fully visible in [oldViewport] but is at least partially
* clipped by [newViewport]. This is the official Compose condition for triggering path-A
* (FocusedBounds) compensation scrolling when the viewport shrinks (e.g. keyboard appearing
* with ADJUST_RESIZE semantics).
*
* Aligned with official `ContentInViewNode.kt:150-167`:
* ```kotlin
* previousFocusedChildBounds.isMaxVisible(oldSize) && !focusedChild.isMaxVisible(size)
* ```
*
* Note: unlike the official implementation which compares the PREVIOUS focused child bounds
* against the old viewport (and the current bounds against the new viewport), this MVP
* approximation uses the CURRENT bounds for both checks. If the viewport shrink and the child
* relayout happen in the same frame, this may misjudge; acceptable since the focused child
* typically has not moved when the keyboard appears.
*
* @param focusedChildRect The current bounds of the focused child, in container-local coordinates.
* @param oldViewport The viewport rect before the resize.
* @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 通常未动故可接受」的前提,与你的建议一致。

focusedChildRect: Rect,
oldViewport: Rect,
newViewport: Rect,
): Boolean {
return isMaxVisible(focusedChildRect, oldViewport) && !isMaxVisible(focusedChildRect, newViewport)
}

/**
* Returns `true` if [rect] is fully contained within [viewport] (i.e. visible on all sides).
* Aligned with official `Rect.isMaxVisible(...)`.
*/
private fun isMaxVisible(rect: Rect, viewport: Rect): Boolean {
return rect.top >= viewport.top &&
rect.bottom <= viewport.bottom &&
rect.left >= viewport.left &&
rect.right <= viewport.right
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.tencent.kuikly.compose.ui.Modifier
import com.tencent.kuikly.compose.ui.platform.LocalConfiguration
import com.tencent.kuikly.compose.ui.platform.LocalDensity
import com.tencent.kuikly.compose.ui.unit.Density
import com.tencent.kuikly.compose.ui.unit.Dp
Expand Down Expand Up @@ -254,6 +255,16 @@ fun WindowInsets.asPaddingValues(): PaddingValues = InsetsPaddingValues(this, Lo
fun WindowInsets.asPaddingValues(density: Density): PaddingValues =
InsetsPaddingValues(this, density)

/**
* 当前页面软件键盘占用的窗口 inset。
*/
val WindowInsets.Companion.ime: WindowInsets
@Composable
get() {
val configuration = LocalConfiguration.current
return WindowInsets(bottom = configuration.imeBottomDp.dp)
}

/**
* Convert a [PaddingValues] to a [WindowInsets].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ fun Modifier.windowInsetsPadding(insets: WindowInsets): Modifier = composed(
remember(insets) { InsetsPaddingModifier(insets) }
}

/**
* Adds padding to accommodate the [ime][WindowInsets.Companion.ime] insets.
*/
@Stable
fun Modifier.imePadding(): Modifier = composed(
debugInspectorInfo {
name = "imePadding"
}
) {
// imePadding 复用现有 inset 消费语义,避免业务重复处理键盘空间。
windowInsetsPadding(WindowInsets.ime)
}

/**
* Consume insets that haven't been consumed yet by other insets Modifiers similar to
* [windowInsetsPadding] without adding any padding.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,28 @@ import com.tencent.kuikly.compose.foundation.ComposeFoundationFlags
import com.tencent.kuikly.compose.foundation.ExperimentalFoundationApi
import com.tencent.kuikly.compose.foundation.checkScrollableContainerConstraints
import com.tencent.kuikly.compose.foundation.gestures.Orientation
import com.tencent.kuikly.compose.foundation.gestures.animateScrollBy
import com.tencent.kuikly.compose.foundation.layout.Arrangement
import com.tencent.kuikly.compose.foundation.layout.PaddingValues
import com.tencent.kuikly.compose.foundation.layout.WindowInsets
import com.tencent.kuikly.compose.foundation.layout.asPaddingValues
import com.tencent.kuikly.compose.foundation.layout.calculateEndPadding
import com.tencent.kuikly.compose.foundation.layout.calculateStartPadding
import com.tencent.kuikly.compose.foundation.layout.ime
import com.tencent.kuikly.compose.foundation.lazy.layout.LazyLayout
import com.tencent.kuikly.compose.foundation.lazy.layout.LazyLayoutMeasureScope
import com.tencent.kuikly.compose.foundation.lazy.layout.LazyListPrefetchTrace
import com.tencent.kuikly.compose.foundation.lazy.layout.StickyItemsPlacement
import com.tencent.kuikly.compose.foundation.lazy.layout.lazyLayoutSemantics
import com.tencent.kuikly.compose.foundation.lazy.layout.calculateLazyLayoutPinnedIndices
import com.tencent.kuikly.compose.foundation.lazy.layout.lazyLayoutBeyondBoundsModifier
import com.tencent.kuikly.compose.foundation.onFocusedBoundsChanged
import com.tencent.kuikly.compose.foundation.relocation.BringIntoViewResponderCoordinator
import com.tencent.kuikly.compose.foundation.relocation.BringIntoViewResponderModifierElement
import com.tencent.kuikly.compose.foundation.relocation.BringIntoViewResponderNode
import com.tencent.kuikly.compose.scroller.kuiklyInfo
import com.tencent.kuikly.compose.scroller.tryExpandStartSizeNoScroll
import com.tencent.kuikly.compose.ui.platform.LocalDensity
import com.tencent.kuikly.compose.ui.Alignment
import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi
import com.tencent.kuikly.compose.ui.Modifier
Expand Down Expand Up @@ -87,6 +96,30 @@ internal fun LazyList(
val coroutineScope = rememberCoroutineScope()
state.kuiklyInfo.scope = coroutineScope

// Install BringIntoView responder + FocusedBounds observer (aligned with Scroll.kt's Modifier.scroll).
// Focused children (e.g. TextField) will be automatically brought into the visible viewport
// when the keyboard appears, or when the user requests it via BringIntoViewRequester.
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
.let { with(density) { it.toPx() } }
val responderCoordinator = remember { BringIntoViewResponderCoordinator() }
val bringIntoViewModifier = modifier
.then(BringIntoViewResponderModifierElement(
nodeFactory = {
LazyListStateBringIntoViewResponderNode(
responderCoordinator = responderCoordinator,
imeBottomPx = imeBottomPx,
lazyListState = state,
)
},
nodeUpdater = { node ->
node.update(responderCoordinator, imeBottomPx)
}
))
.onFocusedBoundsChanged { focusedBounds ->
responderCoordinator.onFocusedBoundsChanged(focusedBounds)
}

// val graphicsContext = LocalGraphicsContext.current
// val stickyHeadersEnabled = !LocalScrollCaptureInProgress.current

Expand All @@ -111,7 +144,7 @@ internal fun LazyList(
val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
@OptIn(ExperimentalComposeUiApi::class)
LazyLayout(
modifier = modifier
modifier = bringIntoViewModifier
.then(state.remeasurementModifier)
.then(state.awaitLayoutModifier)
.lazyLayoutSemantics(
Expand Down Expand Up @@ -405,3 +438,32 @@ private fun rememberLazyListMeasurePolicy(
measureResult
}
}

/**
* BringIntoViewResponderNode implementation for [LazyListState] / `LazyColumn` / `LazyRow`.
*
* Uses [ScrollableState.animateScrollBy] for smooth scrolling. LazyListState implements
* [ScrollableState] directly, and the scroll mutation mutex auto-cancels previous animations,
* so [stopOngoingScroll] is a no-op.
*
* Note: pixel-level `animateScrollBy` in a lazy list translates to item-based scroll via
* LazyList's internal `scroll` implementation. The current MVP doesn't reverse-map the
* focused child's coordinates back to a specific item index, so accuracy is approximate;
* the bring-into-view will scroll by the calculated pixel delta and let LazyList decide
* the actual item positioning.
*/
internal class LazyListStateBringIntoViewResponderNode(
responderCoordinator: BringIntoViewResponderCoordinator,
imeBottomPx: Float,
private val lazyListState: LazyListState,
) : BringIntoViewResponderNode(responderCoordinator, imeBottomPx) {

override suspend fun stopOngoingScroll() {
// animateScrollBy uses scroll() which auto-cancels previous mutations
// via MutatePriority. No explicit stop needed.
}

override suspend fun performScroll(delta: Float) {
lazyListState.animateScrollBy(delta)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Tencent is pleased to support the open source community by making KuiklyUI
* available.
* Copyright (C) 2026 Tencent. All rights reserved.
* Licensed under the License of KuiklyUI;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.tencent.kuikly.compose.foundation.relocation

import com.tencent.kuikly.compose.ui.geometry.Rect
import kotlin.math.min

internal const val DefaultBringIntoViewThresholdPx = 1f
internal const val DefaultBringIntoViewExtraMarginPx = 12f

internal fun calculateBringIntoViewDelta(
targetRect: Rect,
containerRect: Rect,
windowBottom: Float,
imeBottomPx: Float,
extraMarginPx: Float = DefaultBringIntoViewExtraMarginPx,
): Float {
val visibleTop = containerRect.top
val visibleBottom = min(containerRect.bottom, windowBottom - imeBottomPx.coerceAtLeast(0f))
if (visibleBottom <= visibleTop) {
return 0f
}
return when {
targetRect.bottom > visibleBottom -> {
targetRect.bottom - visibleBottom + extraMarginPx
}
targetRect.top < visibleTop -> {
targetRect.top - visibleTop - extraMarginPx
}
else -> 0f
}
}
Loading