diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/BouncesEnable.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/BouncesEnable.kt index e57b5c8132..0e5ae9312a 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/BouncesEnable.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/BouncesEnable.kt @@ -22,32 +22,45 @@ import com.tencent.kuikly.compose.ui.node.requireLayoutNode import com.tencent.kuikly.core.views.ScrollerView fun Modifier.bouncesEnable( - enable: Boolean -): Modifier = this.then(BouncesEnableElement(enable)) + enable: Boolean, + limitHeaderBounces: Boolean = false, + limitFooterBounces: Boolean = false +): Modifier = this.then(BouncesEnableElement(enable, limitHeaderBounces, limitFooterBounces)) private class BouncesEnableElement( - val bouncesEnable: Boolean + val bouncesEnable: Boolean, + val limitHeaderBounces: Boolean, + val limitFooterBounces: Boolean ) : ModifierNodeElement() { - override fun create(): BouncesEnableNode = BouncesEnableNode(bouncesEnable) + override fun create(): BouncesEnableNode = BouncesEnableNode(bouncesEnable, limitHeaderBounces, limitFooterBounces) override fun hashCode(): Int { - return bouncesEnable.hashCode() + var result = bouncesEnable.hashCode() + result = 31 * result + limitHeaderBounces.hashCode() + result = 31 * result + limitFooterBounces.hashCode() + return result } override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is BouncesEnableElement) return false - return bouncesEnable == other.bouncesEnable + return bouncesEnable == other.bouncesEnable && + limitHeaderBounces == other.limitHeaderBounces && + limitFooterBounces == other.limitFooterBounces } override fun update(node: BouncesEnableNode) { node.bouncesEnable = bouncesEnable + node.limitHeaderBounces = limitHeaderBounces + node.limitFooterBounces = limitFooterBounces node.update() } } private class BouncesEnableNode( - var bouncesEnable: Boolean + var bouncesEnable: Boolean, + var limitHeaderBounces: Boolean, + var limitFooterBounces: Boolean ) : Modifier.Node() { override fun onAttach() { @@ -60,7 +73,7 @@ private class BouncesEnableNode( val kNode = layoutNode as? KNode<*> ?: return val scrollerView = kNode.view as? ScrollerView<*, *> ?: return scrollerView.getViewAttr().run { - bouncesEnable(bouncesEnable) + bouncesEnable(bouncesEnable, limitHeaderBounces, limitFooterBounces) } } } \ No newline at end of file diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt index ce3699f23f..ad9bff378c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt @@ -116,6 +116,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi */ private var bouncesEnable = true internal var limitHeaderBounces = false + internal var limitFooterBounces = false /** * List上一次的滚动状态 @@ -252,6 +253,30 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private var springAnimationConsumedX = 0f private var springAnimationConsumedY = 0f + /** + * 用于补偿 RecyclerView 位置变化产生的触摸偏移 + * + * 问题背景: + * 当 RecyclerView 在父容器中的位置发生变化时(例如父容器滚动、布局变化等), + * 会导致触摸事件的坐标产生额外的偏移。如果不进行补偿,嵌套滚动时会出现抖动问题。 + * + * 解决方案: + * 1. 在 onLayout 中记录位置变化,累加到 accumulatedPositionOffsetX/Y + * 2. 在嵌套滚动时,优先消耗这些累积的偏移量,避免传递给父容器 + * 3. 在滚动结束时重置,避免累积错误 + */ + /** X 方向累积的位置偏移量(像素),正值表示向右偏移,负值表示向左偏移 */ + internal var accumulatedPositionOffsetX = 0 + + /** Y 方向累积的位置偏移量(像素),正值表示向下偏移,负值表示向上偏移 */ + internal var accumulatedPositionOffsetY = 0 + + /** 上一次布局时的 left 坐标,使用 -1 作为初始值以区分首次布局 */ + private var lastLayoutLeft = -1 + + /** 上一次布局时的 top 坐标,使用 -1 作为初始值以区分首次布局 */ + private var lastLayoutTop = -1 + private fun cancelSpringAnimations() { springAnimationX?.cancel() springAnimationY?.cancel() @@ -342,6 +367,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi SCROLL_ENABLED, KRCssConst.TOUCH_ENABLE -> setScrollEnabled(propValue) VERTICAL_BOUNCES, BOUNCES_ENABLE, HORIZONTAL_BOUNCES -> setBouncesEnable(propValue) LIMIT_HEADER_BOUNCES -> limitHeaderBounces(propValue) + LIMIT_FOOTER_BOUNCES -> limitFooterBounces(propValue) FLING_ENABLE -> setFlingEnable(propValue) SCROLL_WITH_PARENT -> setScrollWithParent(propValue) KRCssConst.FRAME -> { @@ -361,6 +387,11 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi return true } + private fun limitFooterBounces(propValue: Any): Boolean { + limitFooterBounces = (propValue as Int) == 1 + return true + } + @Suppress("UNCHECKED_CAST") private fun observeDragBegin(propValue: Any): Boolean { dragBeginEventCallback = propValue as KuiklyRenderCallback @@ -484,6 +515,31 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) + + // 记录 RecyclerView 位置变化,用于补偿触摸事件 + // 当 RecyclerView 在父容器中的位置发生变化时,需要记录这个变化量 + // 以便在嵌套滚动时进行补偿,避免出现抖动 + + // 处理 X 方向(left)的位置变化 + if (lastLayoutLeft != -1) { // -1 表示首次布局,跳过记录 + val deltaX = l - lastLayoutLeft + if (deltaX != 0) { + // 累加位置变化量,正值表示向右移动,负值表示向左移动 + accumulatedPositionOffsetX += deltaX + } + } + lastLayoutLeft = l + + // 处理 Y 方向(top)的位置变化 + if (lastLayoutTop != -1) { // -1 表示首次布局,跳过记录 + val deltaY = t - lastLayoutTop + if (deltaY != 0) { + // 累加位置变化量,正值表示向下移动,负值表示向上移动 + accumulatedPositionOffsetY += deltaY + } + } + lastLayoutTop = t + tryApplyPendingSetContentOffset() tryApplyPendingFireOnScroll() } @@ -497,6 +553,13 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi nestedVerticalChildInterceptor?.also { interceptor -> closestVerticalRecyclerViewParent?.removeNestedChildInterceptEventListener(interceptor) } + + // 清理状态,避免内存泄漏 + // 在 View 销毁时重置所有位置偏移相关的状态 + accumulatedPositionOffsetX = 0 + accumulatedPositionOffsetY = 0 + lastLayoutLeft = -1 + lastLayoutTop = -1 } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { @@ -510,8 +573,8 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi } override fun onInterceptTouchEvent(e: MotionEvent): Boolean { - if (touchConsumeByKuikly) { - return true; + if (touchConsumeByKuikly) { + return true } if (!scrollEnabled || mNestedScrollAxesTouch != SCROLL_AXIS_NONE) { return false @@ -546,12 +609,13 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi override fun onTouchEvent(e: MotionEvent): Boolean { if (touchConsumeByKuikly) { - return true; + return true } if (!scrollEnabled) { return false } + return if (overScrollHandler?.onTouchEvent(e) == true) { true } else { @@ -1366,6 +1430,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private const val HORIZONTAL_BOUNCES = "horizontalbounces" private const val BOUNCES_ENABLE = "bouncesEnable" private const val LIMIT_HEADER_BOUNCES = "limitHeaderBounces" + private const val LIMIT_FOOTER_BOUNCES = "limitFooterBounces" private const val FLING_ENABLE = "flingEnable" private const val SCROLL_WITH_PARENT = "scrollWithParent" @@ -1487,10 +1552,25 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi lastScrollParentY = 0 } } + + // 嵌套滚动结束时,无论是否处于 OverScroll 状态都需要重置累积偏移量 + // 原因: + // 1. 滚动结束后,位置变化已经通过滚动补偿处理完毕 + // 2. 如果不重置,下次滚动时会重复计算,导致累积错误 + // 3. 即使处于 OverScroll 状态,当 OverScroll 回弹结束后也需要重置 + // 将重置逻辑移到这里可以确保所有情况都被正确处理 + accumulatedPositionOffsetX = 0 + accumulatedPositionOffsetY = 0 + + // 同时重置子 RecyclerView 的累积偏移量 + if (target is KRRecyclerView) { + target.accumulatedPositionOffsetX = 0 + target.accumulatedPositionOffsetY = 0 + } overScrollHandler?.let { handler -> - if ((target as KRRecyclerView).skipFlingIfNestOverScroll) { - target.skipFlingIfNestOverScroll = false + if ((target as? KRRecyclerView)?.skipFlingIfNestOverScroll == true) { + (target as KRRecyclerView).skipFlingIfNestOverScroll = false } if (handler.overScrolling) { handler.processBounceBack() @@ -1566,18 +1646,25 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi type: Int ) { // Dispatch to the parent for processing first - val parentDx = dx - val parentDy = dy - if (parentDx != 0 || parentDy != 0) { + if (dx != 0 || dy != 0) { // Temporarily store `consumed` to reuse the Array val consumedX = consumed[0] val consumedY = consumed[1] consumed[0] = 0 consumed[1] = 0 if (target is KRRecyclerView) { - scrollParentIfNeeded(target ,parentDx, parentDy, consumed, type) + scrollParentIfNeeded(target, dx, dy, consumed, type) + } + // 传递给父容器时,需要减去已经被补偿消耗的值 + // 这样父容器收到的是实际需要处理的滚动距离 + val remainingDx = dx - consumed[0] + val remainingDy = dy - consumed[1] + if (remainingDx != 0 || remainingDy != 0) { + val parentConsumed = intArrayOf(0, 0) + dispatchNestedPreScroll(remainingDx, remainingDy, parentConsumed, null, type) + consumed[0] += parentConsumed[0] + consumed[1] += parentConsumed[1] } - dispatchNestedPreScroll(parentDx, parentDy, consumed, null, type) consumed[0] += consumedX consumed[1] += consumedY } @@ -1593,14 +1680,141 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi */ private fun scrollParentIfNeeded(target: KRRecyclerView, parentDx: Int, - parentDy: Int, + parentDyInput: Int, consumed: IntArray, touchType: Int) { + + // This solve the shake when target change position in parent + // + // 补偿逻辑说明: + // 当子 RecyclerView 在父容器中的位置发生变化时,会产生额外的触摸偏移。 + // 在嵌套滚动时,需要优先消耗这些累积的偏移量,而不是直接传递给父容器, + // 这样可以避免因为位置变化导致的滚动抖动问题。 + + // ========== 处理 X 方向的偏移补偿 ========== + var parentDx = parentDx // 使用局部变量,以便在补偿逻辑中修改 + var compensationConsumedX = 0 // 记录补偿消耗的值,避免被后续滚动逻辑覆盖 + if (parentDx != 0 && target.accumulatedPositionOffsetX != 0) { + val offsetX = target.accumulatedPositionOffsetX // 子列表累积的 X 方向偏移 + val dxInput = parentDx // 父容器请求的 X 方向滚动距离 + + // 根据符号关系决定补偿策略: + // - 符号相同:偏移方向一致,优先消耗累积偏移 + // - 符号相反:偏移方向相反,先抵消累积偏移 + + when { + // 情况1:符号相同(同向偏移) + // 例如:offsetX = +10(向右偏移10px),dxInput = +5(向右滚动5px) + // 策略:优先消耗 accumulatedPositionOffsetX,减少传递给父容器的滚动量 + (offsetX > 0 && dxInput > 0) || (offsetX < 0 && dxInput < 0) -> { + val absOffset = kotlin.math.abs(offsetX) + val absDx = kotlin.math.abs(dxInput) + if (absOffset >= absDx) { + // 累积偏移足够大,完全消耗父容器的滚动请求 + compensationConsumedX = dxInput + target.accumulatedPositionOffsetX = offsetX - dxInput // 剩余偏移量 + parentDx = 0 // 不再需要传递给父容器 + } else { + // 累积偏移不够大,部分消耗,剩余部分继续传递给父容器 + compensationConsumedX = if (offsetX > 0) absOffset else -absOffset + target.accumulatedPositionOffsetX = 0 // 偏移已完全消耗 + parentDx = if (dxInput > 0) (absDx - absOffset) else -(absDx - absOffset) + } + } + + // 情况2:符号相反(反向偏移) + // 例如:offsetX = +10(向右偏移10px),dxInput = -5(向左滚动5px) + // 策略:累积偏移和滚动方向相反,它们会相互抵消 + // 消耗的值应该与滚动方向一致(即 dxInput 的符号) + (offsetX > 0 && dxInput < 0) || (offsetX < 0 && dxInput > 0) -> { + val absOffset = kotlin.math.abs(offsetX) + val absDx = kotlin.math.abs(dxInput) + if (absOffset >= absDx) { + // 累积偏移足够大,完全抵消父容器的滚动请求 + compensationConsumedX = dxInput + target.accumulatedPositionOffsetX = offsetX + dxInput // 符号相反时相加(实际是减少偏移量) + parentDx = 0 // 不再需要传递给父容器 + } else { + // 累积偏移不够大,完全消耗偏移,剩余的滚动量继续传递给父容器 + // 消耗的值与偏移量符号相反(因为是抵消) + compensationConsumedX = if (offsetX > 0) -absOffset else absOffset + target.accumulatedPositionOffsetX = 0 // 偏移已完全抵消 + // 剩余滚动量 = 原始滚动量 + 偏移量(因为符号相反,所以相加) + parentDx = dxInput + offsetX + } + } + + // 情况3:offsetX == 0(理论上不应该进入,但为了安全起见保留) + else -> { + // 无累积偏移,parentDx 保持不变,正常传递给父容器 + } + } + } + + // ========== 处理 Y 方向的偏移补偿 ========== + // 逻辑与 X 方向相同,详见上方 X 方向的注释说明 + var parentDy = parentDyInput + var compensationConsumedY = 0 // 记录补偿消耗的值,避免被后续滚动逻辑覆盖 + if (parentDyInput != 0 && target.accumulatedPositionOffsetY != 0) { + val offsetY = target.accumulatedPositionOffsetY // 子列表累积的 Y 方向偏移 + val dyInput = parentDyInput // 父容器请求的 Y 方向滚动距离 + + when { + // 符号相同:优先消耗累积偏移 + (offsetY > 0 && dyInput > 0) || (offsetY < 0 && dyInput < 0) -> { + val absOffset = kotlin.math.abs(offsetY) + val absDy = kotlin.math.abs(dyInput) + if (absOffset >= absDy) { + // 累积偏移足够大,完全消耗父容器的滚动请求 + compensationConsumedY = dyInput + target.accumulatedPositionOffsetY = offsetY - dyInput + parentDy = 0 + } else { + // 累积偏移不够大,部分消耗 + compensationConsumedY = if (offsetY > 0) absOffset else -absOffset + target.accumulatedPositionOffsetY = 0 + parentDy = if (dyInput > 0) (absDy - absOffset) else -(absDy - absOffset) + } + } + // 符号相反:累积偏移和滚动方向相反,它们会相互抵消 + (offsetY > 0 && dyInput < 0) || (offsetY < 0 && dyInput > 0) -> { + val absOffset = kotlin.math.abs(offsetY) + val absDy = kotlin.math.abs(dyInput) + if (absOffset >= absDy) { + // 累积偏移足够大,完全抵消父容器的滚动请求 + compensationConsumedY = dyInput + target.accumulatedPositionOffsetY = offsetY + dyInput // 符号相反时相加(实际是减少偏移量) + parentDy = 0 + } else { + // 累积偏移不够大,完全消耗偏移,剩余的滚动量继续传递给父容器 + // 消耗的值与偏移量符号相反(因为是抵消) + compensationConsumedY = if (offsetY > 0) -absOffset else absOffset + target.accumulatedPositionOffsetY = 0 + // 剩余滚动量 = 原始滚动量 + 偏移量(因为符号相反,所以相加) + parentDy = dyInput + offsetY + } + } + // offsetY == 0 的情况不应该进入这个分支,但为了安全起见保留 + else -> { + parentDy = parentDyInput + } + } + } + + // 两种情况可以滚动父亲 // 1、父亲支持fling情况下,无论是子列表传递过来fling和touch都可以消费 // 2、父亲不支持fling的情况,需要时touch拖拽非fling才能消费 val canScrollParent = (supportFling && !pageEnable) || touchType == ViewCompat.TYPE_TOUCH if (!canScrollParent) { + // 即使不能滚动父容器,也需要记录补偿消耗的值 + // 否则这些补偿值会丢失,导致下次滚动时出现抖动 + if (compensationConsumedX != 0) { + consumed[0] = compensationConsumedX + } + if (compensationConsumedY != 0) { + consumed[1] = compensationConsumedY + } return } @@ -1612,6 +1826,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi else -> false } + var didConsumeY = false // 标记是否已经设置了 consumed[1] if (shouldScrollParentY) { if (canScrollVertically(parentDy)) { // 记录滚动前的偏移量 @@ -1619,8 +1834,10 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi scrollBy(0, parentDy) // 计算实际滚动的距离 val actualScrollY = computeVerticalScrollOffset() - beforeScrollY - consumed[1] = actualScrollY + // 累加补偿消耗的值,避免覆盖 + consumed[1] = compensationConsumedY + actualScrollY lastScrollParentY = parentDy + didConsumeY = true } else { if (touchType == ViewCompat.TYPE_TOUCH) { // 走Overscroll时,如果是ParentFirst模式,容易出现父亲有Overscroll可处理,导致子列表没法下拉查看数据的情况 @@ -1630,13 +1847,20 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi overScrollHandler?.let{ it.setTranslationByNestScrollTouch(parentDy.toFloat()) target.skipFlingIfNestOverScroll = true - consumed[1] = parentDy + // 累加补偿消耗的值,避免覆盖 + consumed[1] = compensationConsumedY + parentDy lastScrollParentY = parentDy + didConsumeY = true } } } } } + + // 如果补偿消耗了值但没有实际滚动,也需要记录补偿值 + if (compensationConsumedY != 0 && !didConsumeY) { + consumed[1] = compensationConsumedY + } val shouldScrollParentX = when { parentDx > 0 && target.scrollForwardMode == KRNestedScrollMode.PARENT_FIRST -> true @@ -1646,14 +1870,22 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi else -> false } + var didConsumeX = false // 标记是否已经设置了 consumed[0] if (shouldScrollParentX && canScrollHorizontally(parentDx)) { // 记录滚动前的偏移量 val beforeScrollX = computeHorizontalScrollOffset() scrollBy(parentDx, 0) // 计算实际滚动的距离 val actualScrollX = computeHorizontalScrollOffset() - beforeScrollX - consumed[0] = actualScrollX + // 累加补偿消耗的值,避免覆盖 + consumed[0] = compensationConsumedX + actualScrollX lastScrollParentX = parentDx + didConsumeX = true + } + + // 如果补偿消耗了值但没有实际滚动,也需要记录补偿值 + if (compensationConsumedX != 0 && !didConsumeX) { + consumed[0] = compensationConsumedX } } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt index 9987908111..4d29aa23b6 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt @@ -193,7 +193,7 @@ internal class OverScrollHandler( !recyclerView.limitHeaderBounces && isInStart() && (offset > 0 || currentTranslation > 0) private fun needInEndTranslate(offset: Float, currentTranslation: Float): Boolean = - isInEnd() && (offset < 0 || currentTranslation < 0) + !recyclerView.limitFooterBounces && isInEnd() && (offset < 0 || currentTranslation < 0) private fun processPointerUpEVent(activeIndex: Int, event: MotionEvent): Boolean { pointerDataMap.remove(event.getPointerId(activeIndex)) diff --git a/core-render-ios/Extension/Components/KRScrollView.m b/core-render-ios/Extension/Components/KRScrollView.m index bf78ea8673..ca31f4daf5 100644 --- a/core-render-ios/Extension/Components/KRScrollView.m +++ b/core-render-ios/Extension/Components/KRScrollView.m @@ -45,6 +45,8 @@ @interface KRScrollView()toBool(); + RegisterEvent(NODE_SCROLL_EVENT_ON_SCROLL_EDGE); + return true; +} + bool KRScrollerView::SetShowScrollerIndicator(const KRAnyValue &value) { auto enable = value->toBool(); kuikly::util::SetArkUIShowScrollerIndicator(GetNode(), enable); @@ -513,6 +522,7 @@ void KRScrollerView::OnScrollStop(ArkUI_NodeEvent *event) { void KRScrollerView::OnWillScroll(ArkUI_NodeEvent *event) { AdjustHeaderBouncesEnableWhenWillScroll(event); + AdjustFooterBouncesEnableWhenWillScroll(event); auto new_scroll_state = kuikly::util::GetArkUIScrollerState(event, 2); if (new_scroll_state == current_scroll_state_) { @@ -610,10 +620,50 @@ void KRScrollerView::AdjustHeaderBouncesEnableWhenWillScroll(ArkUI_NodeEvent *ev return; } auto content_offset = kuikly::util::GetArkUIScrollContentOffset(GetNode()); + // 只处理顶部的情况,底部的情况由 AdjustFooterBouncesEnableWhenWillScroll 处理 if (content_offset.y <= 0) { InnerSetBouncesEnable(false); + } else if (!limit_footer_bounces_) { + // 如果底部没有限制,且不在顶部,则恢复回弹 + InnerSetBouncesEnable(bounces_enabled_); + } + // 如果底部有限制,让 AdjustFooterBouncesEnableWhenWillScroll 来决定 +} + +void KRScrollerView::AdjustFooterBouncesEnableWhenWillScroll(ArkUI_NodeEvent *event) { + if (!limit_footer_bounces_) { + return; + } + if (!content_view_) { + return; + } + auto content_offset = kuikly::util::GetArkUIScrollContentOffset(GetNode()); + auto frame = GetFrame(); + auto content_view_frame = content_view_->GetFrame(); + + // 计算是否到达底部 + bool isReachEnd = false; + if (content_view_frame.height > frame.height) { + // 垂直滚动 + float maxOffsetY = content_view_frame.height - frame.height; + isReachEnd = content_offset.y >= maxOffsetY; + } else if (content_view_frame.width > frame.width) { + // 横向滚动 + float maxOffsetX = content_view_frame.width - frame.width; + isReachEnd = content_offset.x >= maxOffsetX; + } + + if (isReachEnd) { + InnerSetBouncesEnable(false); } else { - InnerSetBouncesEnable(true); + // 如果不在底部,检查是否在顶部(顶部限制优先) + if (limit_header_bounces_ && content_offset.y <= 0) { + // 顶部限制生效,保持禁用 + InnerSetBouncesEnable(false); + } else { + // 既不在顶部也不在底部,恢复回弹 + InnerSetBouncesEnable(bounces_enabled_); + } } } diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h index f14a1a47a7..ec3a7910b0 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h @@ -98,6 +98,7 @@ class KRScrollerView : public IKRRenderViewExport { bool SetPagingEnabled(const KRAnyValue &value); bool SetBouncesEnable(const KRAnyValue &value); bool SetLimitHeaderBounces(const KRAnyValue &value); + bool SetLimitFooterBounces(const KRAnyValue &value); bool SetShowScrollerIndicator(const KRAnyValue &value); bool RegisterOnScrollEvent(const KRRenderCallback event_call_back); bool RegisterOnDragBeginEvent(const KRRenderCallback event_callback); @@ -127,6 +128,7 @@ class KRScrollerView : public IKRRenderViewExport { void ApplyContentInsetWhenDragEnd(); void InnerSetBouncesEnable(bool enable); void AdjustHeaderBouncesEnableWhenWillScroll(ArkUI_NodeEvent *event); + void AdjustFooterBouncesEnableWhenWillScroll(ArkUI_NodeEvent *event); void DispatchDidScrollToObservers(KRPoint point); bool SetFlingEnable(bool enable); @@ -139,6 +141,7 @@ class KRScrollerView : public IKRRenderViewExport { std::shared_ptr content_view_; bool bounces_enabled_ = true; bool limit_header_bounces_ = false; + bool limit_footer_bounces_ = false; bool current_bounces_enabled_ = false; bool is_dragging_ = false; bool is_set_frame_ = false; diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt index f4dd85bc2c..d14fee2262 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt @@ -369,15 +369,38 @@ open class ScrollerAttr : ContainerAttr() { fun scrollEnable(value: Boolean) { SCROLL_ENABLED with value.toInt() } - /* - * 是否允许边界回弹效果 - * @param bouncesEnable 是否允许边界回弹 - * @param limitHeaderBounces 是否禁止顶部回弹(如bouncesEnable为false,该值就无效) + + /** + * 设置是否允许边界回弹效果,以及是否限制顶部或底部的回弹。 + * + * 当 [bouncesEnable] 为 `false` 时,整个滚动视图将禁用回弹效果,此时 [limitHeaderBounces] 和 [limitFooterBounces] 参数将无效。 + * 当 [bouncesEnable] 为 `true` 时,可以通过 [limitHeaderBounces] 和 [limitFooterBounces] 参数分别控制顶部和底部的回弹行为。 + * + * @param bouncesEnable 是否允许边界回弹效果。默认为 `true`。 + * @param limitHeaderBounces 是否禁止顶部回弹。当滚动到顶部时,如果此参数为 `true`,则禁止向上拖拽时的回弹效果。 + * 注意:如果 [bouncesEnable] 为 `false`,该参数将无效。默认为 `false`。 + * @param limitFooterBounces 是否禁止底部回弹。当滚动到底部时,如果此参数为 `true`,则禁止向下拖拽时的回弹效果。 + * 注意:如果 [bouncesEnable] 为 `false`,该参数将无效。默认为 `false`。 + * + * @sample 示例:启用回弹但禁止顶部和底部回弹 + * ``` + * scrollerView.attr { + * bouncesEnable(true, limitHeaderBounces = true, limitFooterBounces = true) + * } + * ``` + * + * @sample 示例:完全禁用回弹效果 + * ``` + * scrollerView.attr { + * bouncesEnable(false) + * } + * ``` */ - fun bouncesEnable(bouncesEnable: Boolean, limitHeaderBounces: Boolean = false) { + fun bouncesEnable(bouncesEnable: Boolean, limitHeaderBounces: Boolean = false, limitFooterBounces: Boolean = false) { this.bouncesEnable = bouncesEnable BOUNCES_ENABLE with bouncesEnable.toInt() LIMIT_BOUNCES_ENABLE with limitHeaderBounces.toInt() + LIMIT_FOOTER_BOUNCES with limitFooterBounces.toInt() } // 是否显示滚动指示进度条(默认显示) fun showScrollerIndicator(value: Boolean) { @@ -449,6 +472,7 @@ open class ScrollerAttr : ContainerAttr() { const val SCROLL_ENABLED = "scrollEnabled" const val BOUNCES_ENABLE = "bouncesEnable" const val LIMIT_BOUNCES_ENABLE = "limitHeaderBounces" + const val LIMIT_FOOTER_BOUNCES = "limitFooterBounces" const val SHOW_SCROLLER_INDICATOR = "showScrollerIndicator" const val PAGING_ENABLED = "pagingEnabled" const val DIRECTION_ROW = "directionRow" diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/NestStageCardView.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/NestStageCardView.kt new file mode 100644 index 0000000000..ff41e5be1a --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/NestStageCardView.kt @@ -0,0 +1,2060 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 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.demo.pages + +import com.tencent.kuikly.core.base.Anchor +import com.tencent.kuikly.core.base.Color +import com.tencent.kuikly.core.base.ColorStop +import com.tencent.kuikly.core.base.ComposeAttr +import com.tencent.kuikly.core.base.ComposeEvent +import com.tencent.kuikly.core.base.ComposeView +import com.tencent.kuikly.core.base.Direction +import com.tencent.kuikly.core.base.Rotate +import com.tencent.kuikly.core.base.Scale +import com.tencent.kuikly.core.base.Translate +import com.tencent.kuikly.core.base.ViewBuilder +import com.tencent.kuikly.core.base.ViewContainer +import com.tencent.kuikly.core.base.ViewRef +import com.tencent.kuikly.core.base.event.EventHandlerFn +import com.tencent.kuikly.core.base.event.layoutFrameDidChange +import com.tencent.kuikly.core.datetime.DateTime +import com.tencent.kuikly.core.directives.scrollToPosition +import com.tencent.kuikly.core.reactive.handler.observable +import com.tencent.kuikly.core.directives.vif +import com.tencent.kuikly.core.layout.FlexDirection +import com.tencent.kuikly.core.layout.undefined +import com.tencent.kuikly.core.layout.valueEquals +import com.tencent.kuikly.core.views.KRNestedScrollMode +import com.tencent.kuikly.core.views.Scroller +import com.tencent.kuikly.core.views.ScrollerView +import com.tencent.kuikly.core.views.View +import com.tencent.kuikly.core.views.FooterRefresh +import com.tencent.kuikly.core.views.FooterRefreshView +import com.tencent.kuikly.core.views.FooterRefreshState +import com.tencent.kuikly.core.views.Hover +import com.tencent.kuikly.core.views.Refresh +import com.tencent.kuikly.core.views.RefreshView +import com.tencent.kuikly.core.views.RefreshViewState +import com.tencent.kuikly.core.views.ScrollParams +import com.tencent.kuikly.core.views.WaterfallList +import com.tencent.kuikly.core.views.WaterfallListView +import kotlin.math.abs +import kotlin.math.roundToInt + +// import com.tencent.map.kuiklyapplication.utils.width + + +public class TMNestStageCardAttr : ComposeAttr() { + /*** + * 多段页卡高度集合 + * 递增模式 + */ + var levelHeights = listOf(200f, 400f, 600f) + + /** + * 初始化level + * 默认小页卡 + */ + var initLevel = 0 + + // 顶部通知栏内容 + var topContent: ViewBuilder? = null + + // 空白区域布局里面的内容 + var functionContent: ViewBuilder? = null + + // 吸顶内容 + var headerContent: ViewBuilder? = null + + // 可滚动内容 + // 默认是ListView的样式,瀑布流的也支持 + var scrollContent: ViewBuilder? = null + + // 底部固定的content(跟随滚动有动画) bottomContent是覆盖三段式页卡上面的控件 + var bottomContent: ViewBuilder? = null + + // 底部刷新内容 + var footerContent: ViewBuilder? = null + + // 底部刷新区域宽度 + var footerWidth: Float by observable(0f) + + // 内部悬浮内容的顶部位置 + var innerHoverTop: Float by observable(300f) + + // 手柄距离底部距离 + var handlerMarginBottom: Float by observable(0f) + var animCompensationHeight: Float by observable(0f) + + // 内部悬浮内容 + var innerHoverContent: ViewBuilder? = null + + // 是否启用底部刷新能力 + var enableFooterRefresh: Boolean = false + + // 顶部下拉刷新内容 + var headerRefreshContent: ViewBuilder? = null + + // 是否启用顶部下拉刷新能力 + var enableHeaderRefresh by observable(false) + + var columnCount: Int = 1 + + var itemSpacing: Float = 0f + + var lineSpacing: Float = 0f + + var contentPaddingLeft = 0f + var contentPaddingRight = 0f + var contentPaddingBottom = 0f + var contentPaddingTop = 0f + + /** + * 列表宽度 + */ + var listWidth: Float = 0f + + // 是否禁用内容滚动 + var disableContentScroll: Boolean = false + + // 组件外层 padding 配置 + var customPaddingLeft: Float = 0f + var customPaddingRight: Float = 0f + var customPaddingTop: Float = 0f + var customPaddingBottom: Float = 0f + + // 是否自定义头部的内容,因为有些场景可能不需要手柄...... + var isCustomHeaderContent by observable(false) + + // 是否启用外部 scroll 滚动 + var enableOuterScroll by observable(true) + + /** + * 是否启用嵌套滚动 + * 当为true时,启用嵌套滚动机制;当为false时,禁用嵌套滚动 + */ + var nestedScrollEnable by observable(true) + + /** + * 是否启用内部滚动视图的弹性效果 + * 当为true时,内部滚动视图可以产生弹性效果(bounces);当为false时,禁用弹性效果 + */ + var enableChildScrollBounces by observable(false) + + /** + * 自定义边距值 + * 如果设置了此值,将使用该边距值;如果为null,则使用默认值 DEFAULT_MARGIN_VALUE + */ + var customMarginValue: Float? = null + + /** + * 是否启用动态边距计算 + * 当为true时,边距会根据卡片高度动态变化(无论是自定义值还是默认值) + * 当为false时,使用固定边距值(自定义值或默认值) + */ + var enableDynamicMargin: Boolean = true + + /** + * 层级 + */ + var bringIndex: Int = 0 + + /** + * 内部悬浮内容的 zIndex 值 + * 用于控制内部悬浮内容的 z 轴层级,值越大层级越高 + * 默认值为 200 + */ + var hoverZIndex: Int = 0 + + /** + * 级别吸附阈值数组 + * 数组长度应该等于 levelHeights.size - 1 + * 每个值的范围是 0.0 到 1.0,表示在两个相邻level之间的阈值比例 + * 例如:[0.3, 0.7] 表示: + * - 在level0和level1之间,如果滚动位置超过30%则吸附到level1,否则吸附到level0 + * - 在level1和level2之间,如果滚动位置超过70%则吸附到level2,否则吸附到level1 + * 如果不设置或设置为空数组,则使用默认的距离最近原则 + */ + var levelAttachThresholds: List = listOf() + + /** + * 速率吸附阈值(像素/毫秒) + * 当用户手指离开时的滚动速率超过此阈值时,将跳过距离/阈值判断,直接向滚动方向吸附到下一个级别 + * 默认值为0.2f,表示速率超过0.2像素/毫秒时触发速率吸附 + */ + var velocityAttachThreshold: Float = 0.2f + + /** + * 背景色(浅色模式) + * 设置整个组件的背景色(包含头部和内容区域) + */ + var lightBackgroundColor by observable(Color.WHITE) + + /** + * 背景色(深色模式) + * 设置整个组件在深色模式下的背景色,如果为null则使用lightBackgroundColor + */ + var darkBackgroundColor by observable(null) + + /** + * 线性渐变背景方向 + * 设置渐变的方向,如果为null则不使用渐变背景 + */ + var gradientDirection: Direction? by observable(null) + + /** + * 线性渐变背景颜色停止点 + * 定义渐变的颜色和位置,配合gradientDirection使用 + */ + var gradientColorStops: List by observable(listOf()) + + // 内容设置方法 - 保持单行以提高可读性 + fun topContent(content: ViewBuilder) = apply { this.topContent = content } + // 页卡上方的区域 + fun functionContent(content: ViewBuilder) = apply { this.functionContent = content } + + fun headerContent(content: ViewBuilder) = apply { this.headerContent = content } + fun scrollContent(content: ViewBuilder) = apply { this.scrollContent = content } + fun footerContent(content: ViewBuilder) = apply { this.footerContent = content } + fun innerHoverContent(content: ViewBuilder) = apply { this.innerHoverContent = content } + fun headerRefreshContent(content: ViewBuilder) = apply { this.headerRefreshContent = content } + + // 数值设置方法 + fun footerWidth(width: Float) = apply { this.footerWidth = width } + fun innerHoverTop(top: Float) = apply { this.innerHoverTop = top } + + // 布尔设置方法 + fun enableFooterRefresh(enable: Boolean) = apply { this.enableFooterRefresh = enable } + fun enableHeaderRefresh(enable: Boolean) = apply { this.enableHeaderRefresh = enable } + fun bringIndex(enable: Int) = apply { this.bringIndex = enable } + fun hoverZIndex(zIndex: Int) = apply { this.hoverZIndex = zIndex } + // 其他配置方法 + fun disableContentScroll(disable: Boolean) = apply { this.disableContentScroll = disable } + fun levelHeights(content: List) = apply { this.levelHeights = content } + fun initLevel(content: Int) = apply { this.initLevel = content } + fun columnCount(content: Int) = apply { this.columnCount = content } + fun listWidth(content: Float) = apply { this.listWidth = content } + fun itemSpacing(content: Float) = apply { this.itemSpacing = content } + fun lineSpacing(content: Float) = apply { this.lineSpacing = content } + fun bottomContent(content: ViewBuilder) = apply { this.bottomContent = content } + fun handlerMarginBottom(content: Float) = apply { this.handlerMarginBottom = content } + fun animCompensationHeight(content: Float) = apply { this.animCompensationHeight = content } + + /** + * 设置组件外层内边距(统一设置) + * @param padding 内边距值 + */ + fun customPadding(padding: Float) = apply { + this.customPaddingLeft = padding + this.customPaddingRight = padding + this.customPaddingTop = padding + this.customPaddingBottom = padding + } + + /** + * 设置组件外层内边距(水平和垂直) + * @param horizontal 水平内边距 + * @param vertical 垂直内边距 + */ + fun customPadding(horizontal: Float, vertical: Float) = apply { + this.customPaddingLeft = horizontal + this.customPaddingRight = horizontal + this.customPaddingTop = vertical + this.customPaddingBottom = vertical + } + + /** + * 设置组件外层内边距(分别设置) + * @param left 左侧内边距 + * @param top 顶部内边距 + * @param right 右侧内边距 + * @param bottom 底部内边距 + */ + fun customPadding(left: Float, top: Float, right: Float, bottom: Float) = apply { + this.customPaddingLeft = left + this.customPaddingTop = top + this.customPaddingRight = right + this.customPaddingBottom = bottom + } + + /** + * 设置左侧内边距 + */ + fun customPaddingLeft(padding: Float) = apply { this.customPaddingLeft = padding } + + /** + * 设置右侧内边距 + */ + fun customPaddingRight(padding: Float) = apply { this.customPaddingRight = padding } + + /** + * 设置顶部内边距 + */ + fun customPaddingTop(padding: Float) = apply { this.customPaddingTop = padding } + + /** + * 设置底部内边距 + */ + fun customPaddingBottom(padding: Float) = apply { this.customPaddingBottom = padding } + + /** + * 设置内边距。 + * @param top 顶部内边距。 + * @param left 左侧内边距。 + * @param bottom 底部内边距。 + * @param right 右侧内边距。 + */ + fun contentPadding(top: Float, left: Float = 0f, bottom: Float = 0f, right: Float = 0f) { + + if (!top.valueEquals(Float.undefined)) { + contentPaddingTop = top + } + if (!left.valueEquals(Float.undefined)) { + contentPaddingLeft = left + } + if (!bottom.valueEquals(Float.undefined)) { + contentPaddingBottom = bottom + } + if (!right.valueEquals(Float.undefined)) { + contentPaddingRight = right + } + } + + /** + * 设置自定义边距值 + * @param marginValue 边距值 + */ + fun customMarginValue(marginValue: Float) = apply { + this.customMarginValue = marginValue + } + + /** + * 启用或禁用动态边距计算 + * @param enable 是否启用动态边距,默认为true + */ + fun enableDynamicMargin(enable: Boolean) = apply { + this.enableDynamicMargin = enable + } + + /** + * 设置背景色(单色模式) + * @param color 组件的背景色,同时用于浅色和深色模式 + */ + fun dmBackgroundColor(color: Color) = apply { + this.lightBackgroundColor = color + this.darkBackgroundColor = null + } + + /** + * 设置背景色(双色模式) + * @param lightColor 浅色模式下的背景色 + * @param darkColor 深色模式下的背景色 + */ + fun dmBackgroundColor(lightColor: Color, darkColor: Color) = apply { + this.lightBackgroundColor = lightColor + this.darkBackgroundColor = darkColor + } + + /** + * 设置线性渐变背景 + * @param direction 渐变方向 + * @param colorStops 颜色停止点,可变参数 + * + * 使用示例: + * backgroundLinearGradient( + * Direction.TO_RIGHT, + * ColorStop(Color.RED, 0f), + * ColorStop(Color.GREEN, 0.3f), + * ColorStop(Color.BLACK, 1f) + * ) + */ + fun cardBackgroundLinearGradient(direction: Direction, vararg colorStops: ColorStop) = apply { + this.gradientDirection = direction + this.gradientColorStops = colorStops.toList() + // 清除纯色背景设置,避免冲突 + this.lightBackgroundColor = Color.TRANSPARENT + this.darkBackgroundColor = null + } + + /** + * 清除渐变背景设置 + */ + fun clearGradientBackground() = apply { + this.gradientDirection = null + this.gradientColorStops = listOf() + } + + /** + * 设置是否启用嵌套滚动 + * @param enable 是否启用嵌套滚动,默认为true + */ + fun nestedScrollEnable(enable: Boolean) = apply { + this.nestedScrollEnable = enable + } + + /** + * 设置是否启用内部滚动视图的弹性效果 + * @param enable 是否启用弹性效果,默认为true + */ + fun enableChildScrollBounces(enable: Boolean) = apply { + this.enableChildScrollBounces = enable + } + + /** + * 设置级别吸附阈值数组 + * @param thresholds 阈值数组,每个值范围0.0-1.0,数组长度应该等于levelHeights.size-1 + * 例如:levelAttachThresholds(listOf(0.3f, 0.7f)) + * 表示在level0-1之间30%阈值,level1-2之间70%阈值 + */ + fun levelAttachThresholds(thresholds: List) = apply { + // 验证阈值范围 + val validThresholds = thresholds.filter { it in 0.0f..1.0f } + this.levelAttachThresholds = validThresholds + } + + /** + * 设置速率吸附阈值 + * @param threshold 速率阈值(像素/毫秒),当滚动速率超过此值时触发速率吸附 + * 例如:velocityAttachThreshold(0.8f) 表示速率超过0.8像素/毫秒时触发速率吸附 + */ + fun velocityAttachThreshold(threshold: Float) = apply { + this.velocityAttachThreshold = threshold + } + + /** + * 滚动事件限流时间(毫秒) + * 控制滚动事件的发送频率,避免过于频繁的事件触发 + * 默认值为10ms + */ + var scrollEventThrottleMs: Long = 10L + + /** + * 设置滚动事件限流时间 + * @param throttleMs 限流时间(毫秒),默认为10ms + */ + fun scrollEventThrottleMs(throttleMs: Long) = apply { + this.scrollEventThrottleMs = throttleMs + } + +} + + +/** + * 分段式页卡组件 + */ +public class TMNestStageCardView : ComposeView() { + + var outerScrollView: ViewRef>? = null + var innerScrollView: ViewRef? = null + var footerRefreshView: ViewRef? = null + var headerRefreshRef: ViewRef? = null + + // 优化:只保留真正需要触发 UI 重新计算的 observable 变量 + var headerRefreshState: RefreshViewState by observable(RefreshViewState.IDLE) + var currentLevel: Int by observable(0) + var footerRefreshState: FooterRefreshState by observable(FooterRefreshState.IDLE) + var screenHeight: Float by observable(0f) + var isInitialized: Boolean by observable(false) + var isOutDragging: Boolean by observable(false) + + /** + * 基础配置更新 + */ + private var baseConfigChangeFlag: Boolean by observable(false) + + // 优化:改为 private var - 不触发 UI 重新计算(高频更新的变量) + private var curOuterScrollOffset: Float = 0f + + // 存储最后的滚动速率(用于速率吸附判断) + private var lastScrollVelocityY: Float = 0f + + // 用于手动计算速率的变量 + private var lastScrollOffset: Float = 0f + private var lastScrollTime: Long = 0L + + // 拖拽期间速率计算变量 + private var dragBeginOffset: Float = 0f + private var dragBeginTime: Long = 0L + private var dragEndOffset: Float = 0f + private var dragEndTime: Long = 0L + private var finalDragVelocity: Float = 0f + + + // 优化:改为 private var - 不触发 UI 重新计算(高频更新的变量) + private var scrollContentHeight: Float by observable(0f) + private var cardHeight: Float = 0f + + // 实时高度 + private var stickContentHeight = 0f + + // 实时高度 + private var headerContentHeight = 0f + + // 手柄之上的高度 + private var topContentHeight = 0f + + // bottom的高度 + private var bottomContentHeight: Float = 0.001f + + // 内部scroll的placeHolder高度 + private var innerScrollPlaceHolderHeight: Float by observable(0f) + + // 渲染时用的高度 + private var stickContentHeightUse: Float by observable(0f) + var marginValue by observable(0f) + private var childOffset: Float = 0f + private var canScroll: Boolean = true + private var bottomCornerRadius: Float = 20f + private var previousScrollOffset: Float = 0f + private var isChildFlingEnable: Boolean by observable(false) + + // 优化:缓存计算结果 - 避免重复计算 + private var cachedHeightRatio: Float = 0f + private var cachedMarginValue: Float = DEFAULT_MARGIN_VALUE + private var lastEmitTime: Long = 0L + private var lastEmittedOffset: Float = 0f + private var lastEmittedCardHeight: Float = 0f + private var isFirstLoad: Boolean = true + + // 优化:borderRadius 缓存 + private var cachedTopBorderRadius: Float = 32f + private var cachedBottomBorderRadius by observable(44f) + private var lastHeightForRadius: Float = 0f + // 卡片的mask背景色 + private var cardMaskColor by observable(Color.TRANSPARENT) + + + + // 优化:内层滚动节流 + private var lastChildScrollTime: Long = 0L + + companion object { + private const val TAG = "TMNestStageCardView" + + /** + * 默认边距值常量 + */ + const val DEFAULT_MARGIN_VALUE = 8f + + /** + * 滚动偏移变化阈值(像素) + * 只有当偏移变化超过此值时才发送事件,避免微小抖动 + */ + private const val OFFSET_CHANGE_THRESHOLD = 0.5f + + /** + * 添加容差 + */ + val OFFSET_TOLERANCE = 1.0f + } + + override fun createAttr(): TMNestStageCardAttr { + return TMNestStageCardAttr() + } + + override fun createEvent(): TMNestStageCardViewEvent { + return TMNestStageCardViewEvent() + } + + override fun didInit() { + super.didInit() + // 初始化marginValue + initializeMarginValue() + scrollContentHeight = getScrollContentHeightInner() + } + + /** + * 初始化边距值 + */ + private fun initializeMarginValue() { + marginValue = attr.customMarginValue ?: DEFAULT_MARGIN_VALUE + } + + private fun getInitOffset(initLevel: Int): Float { + val initOffset = attr.levelHeights[initLevel] - attr.levelHeights[0] + return initOffset + } + + /** + * 优化:计算并缓存 borderRadius + * 只有高度变化超过阈值时才重新计算,避免频繁计算三角函数 + */ + private fun calculateBorderRadius(): Float { + val heightDelta = kotlin.math.abs(cardHeight - lastHeightForRadius) + + // 高度变化小于 5px 时使用缓存值 + if (heightDelta < 5f) { + return cachedTopBorderRadius + } + + val levelHeights = attr.levelHeights + if (levelHeights.isEmpty() || levelHeights.size < 2) { + cachedTopBorderRadius = 32f + lastHeightForRadius = cardHeight + return cachedTopBorderRadius + } + + val minHeight = levelHeights[0] + val maxHeight = levelHeights[levelHeights.size - 1] + val denominator = maxHeight - minHeight + val heightRatio = if (denominator != 0f) (cardHeight - minHeight) / denominator else 0f + + cachedTopBorderRadius = 20f * (1f - heightRatio * heightRatio).coerceIn(0f, 1f) + lastHeightForRadius = cardHeight + + return cachedTopBorderRadius + } + + /** + * 更新大卡时的属性 + */ + private fun updateMaxCardAttr() { + val ctx = this + val levelHeights = attr.levelHeights + if (levelHeights.isEmpty()) { + return + } + val maxLevel = levelHeights.size - 1 + val maxOffSet = levelHeights.last() - levelHeights.first() +// TMLog.i(TAG,"[updateMaxCardAttr] curoffset:${curOuterScrollOffset} int:${curOuterScrollOffset.roundToInt()} maxOffset:${maxOffSet}") + if (currentLevel >= maxLevel && curOuterScrollOffset.roundToInt() == maxOffSet.roundToInt()) { + ctx.cachedBottomBorderRadius = 0f + ctx.cardMaskColor = Color(0x99000000) + } else { + ctx.cachedBottomBorderRadius = 44f + ctx.cardMaskColor = Color.TRANSPARENT + } + } + + /** + * 优化方案 2: 更新外层滚动状态 - 集中处理所有计算,避免重复 + * + * 性能优化策略: + * 1. 时间节流:scroll 回调入口已做时间过滤(16ms间隔) + * 2. 数值节流:偏移变化超过阈值才发送事件 + * 3. 智能缓存:缓存计算结果避免重复计算 + * + * 注意:此函数调用前已通过时间节流检查 + */ + private fun updateOuterScrollState(offsetY: Float) { + val levelHeights = attr.levelHeights + if (levelHeights.isNotEmpty() && levelHeights.size > 1) { + val minHeight = levelHeights[0] + val maxHeight = levelHeights[levelHeights.size - 1] + val denominator = maxHeight - minHeight + cachedHeightRatio = if (denominator != 0f) (cardHeight - minHeight) / denominator else 0f + } + + // 数值节流:只有偏移变化超过阈值才发送 SCROLL_OFFSET 事件 + val offsetDelta = kotlin.math.abs(curOuterScrollOffset - lastEmittedOffset) + // 使用默认level为0,并且为通过手势滚动就直接调用snapToLevel不需要attachLevel + if (this@TMNestStageCardView.isFirstLoad && cardHeight >= attr.levelHeights.last()) { + this@TMNestStageCardView.isFirstLoad = false + attachToStage() + } else { + this@TMNestStageCardView.isFirstLoad = false + } + if (offsetDelta > OFFSET_CHANGE_THRESHOLD) { + emit( + TMNestStageCardViewEvent.SCROLL_OFFSET, + mutableMapOf( + "parentOffset" to curOuterScrollOffset, + "childOffset" to childOffset, + "scrollParams" to null + ) + ) + lastEmittedOffset = curOuterScrollOffset + } + + // 仅当卡片高度有明显变化时才发送 CARD_HEIGHT_CHANGE 事件 + val cardHeightDelta = kotlin.math.abs(cardHeight - lastEmittedCardHeight) + if (cardHeightDelta > OFFSET_CHANGE_THRESHOLD) { + fireCardHeightEvent() + lastEmittedCardHeight = cardHeight + } + } + + /** + * 优化方案 2: 动态计算和缓存边距值 + */ + private fun updateMarginValue() { + if (!attr.enableDynamicMargin) { + marginValue = attr.customMarginValue ?: DEFAULT_MARGIN_VALUE + return + } + + val levelHeights = attr.levelHeights + if (levelHeights.isEmpty() || levelHeights.size < 2) { + marginValue = attr.customMarginValue ?: DEFAULT_MARGIN_VALUE + return + } + + val baseMarginValue = attr.customMarginValue ?: DEFAULT_MARGIN_VALUE + val minHeight = levelHeights[0] + val level1Height = levelHeights[1] + val maxHeight = levelHeights.last() + + marginValue = if (cardHeight <= level1Height && levelHeights.size >= 3) { + // cardHeight 在 levelHeights[0] 到 levelHeights[1] 之间,且 levelHeights 大于等于 3 时,使用固定值 + baseMarginValue + } else { + // 其他情况:使用 cardHeight 与最大高度的占比计算 + val denominator = maxHeight - minHeight + val heightRatio = if (denominator != 0f) (cardHeight - minHeight) / denominator else 0f + baseMarginValue * (1f - heightRatio).coerceIn(0f, 1f) + } +// TMLog.i( +// TAG, +// "ScollerView updateMarginValue marginValue${marginValue} cardHeight:${cardHeight} baseMargin:${baseMarginValue}" +// ) + cachedMarginValue = marginValue + } + + override fun body(): ViewBuilder { + val ctx = this + return { + attr { + absolutePosition(0f, 0f, 0f, 0f) + justifyContentFlexEnd() + } + View { + // 整体动画属性放外面 + attr { + justifyContentFlexEnd() + width(pagerData.pageViewWidth) + transform( + Rotate.DEFAULT, + Scale( + ctx.calcScrollWidth(ctx.marginValue), + ctx.calcScrollHeight(ctx.marginValue) + ), + Translate.DEFAULT, + Anchor.DEFAULT + ) + ctx.updateMaxCardAttr() + backgroundColor(ctx.cardMaskColor) + // 优化:使用缓存的 borderRadius,避免每次 render 都计算 + borderRadius(0f, 0f, ctx.cachedBottomBorderRadius, ctx.cachedBottomBorderRadius) + } + Scroller { + ref { + ctx.outerScrollView = it + } + attr { + bouncesEnable(false) + flingEnable(false) + height(pagerData.pageViewHeight) + scrollEnable(ctx.attr.enableOuterScroll) + showScrollerIndicator(false) + } + View{ + attr { + height(ctx.attr.levelHeights.last() + ctx.getContentPaddingTop()) + } + // 上方空白区域 + View { + attr { + width(pagerData.pageViewWidth) + height(if (ctx.baseConfigChangeFlag) ctx.getContentPaddingTop() else ctx.getContentPaddingTop()) + } + + vif({ ctx.attr.functionContent != null }) { + ctx.attr.functionContent?.invoke(this) + } + } + // 手柄上面的部分 + View { + // 顶部通知栏内容 + vif({ ctx.attr.topContent != null }) { + ctx.attr.topContent?.invoke(this) + } + event { + layoutFrameDidChange { frame -> + if (frame.height != ctx.topContentHeight) { + ctx.topContentHeight = frame.height + ctx.stickContentHeight = frame.height + ctx.headerContentHeight + ctx.stickContentHeightUse = + frame.height + ctx.headerContentHeight + ctx.calcInnerScrollPlaceHolderHeight() + } + } + } + } + View { + attr { + borderRadius( + ctx.cachedTopBorderRadius, + ctx.cachedTopBorderRadius, + 0f, + 0f + ) + backgroundColor(Color.YELLOW) + + // 根据配置决定使用渐变背景还是纯色背景 + if (ctx.attr.gradientDirection != null && ctx.attr.gradientColorStops.isNotEmpty()) { + backgroundLinearGradient( + ctx.attr.gradientDirection!!, + *ctx.attr.gradientColorStops.toTypedArray() + ) + } else { + } + } + ctx.renderStickContent()() + // 滚动容器内容 + ctx.renderChildScrollContent()() + // 补偿高度 +// ctx.renderComposseContent()() + } + } + event { + scrollToTop { + TMLog.i( + TAG, + "ScollerView parentScroller scrollToTop" + ) + } + + scroll { it -> + ctx.outerOnScroll(it) + } + + dragBegin { params -> + // 暴露 outerScrollView 的 dragBegin 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.OUTER_SCROLL_DRAG_BEGIN, + params + ) + ctx.isOutDragging = false + // 记录拖拽开始时的时间戳和滚动位置 + this@TMNestStageCardView.dragBeginTime = DateTime.currentTimestamp() + this@TMNestStageCardView.dragBeginOffset = params.offsetY + + TMLog.i( + TAG, + "ScollerView parentScroller dragBegin - offset: ${params.offsetY}, time: ${this@TMNestStageCardView.dragBeginTime}" + ) + } + + willDragEndBySync { + TMLog.i( + TAG, + "ScollerView parentScroller willDragEndBySync it: ${it}, velocity: ${this@TMNestStageCardView.lastScrollVelocityY}" + ) + // Android系统滚动内部View时kuikly不回调dragEnd + if (ctx.pagerData.isAndroid) { + ctx.calcOutScrollYV("willDragEndBySync") + } + } + + dragEnd { params -> + // 暴露 outerScrollView 的 dragEnd 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.OUTER_SCROLL_DRAG_END, + params + ) + ctx.isOutDragging = false + + TMLog.i(TAG, "ScollerView parentScroller drag parent out so pf dragEnd") + ctx.calcOutScrollYV("dragEnd") + // 使用计算出的最终速率进行吸附判断 + this@TMNestStageCardView.attachToStageWithVelocity() + } + + scrollEnd { + TMLog.i( + TAG, + "ScollerView parentScroller scrollEnd offsetY:${it.offsetY}" + ) + // 根据当前offset计算对应的level并更新状态 + this@TMNestStageCardView.updateLevelByOffset(it.offsetY) + } + + // contentSizeDidChanged { + // if (ctx.stopScroll) { + // ctx.outerScrollView?.view?.abortContentOffsetAnimate() + // KLog.i(TAG, "ScollerView parentScroller contentSizeDidChanged") + // } + // } + } + } + ctx.renderBottomContent()() + } + } + } + + private fun renderComposseContent(): ViewBuilder { + val ctx = this + return { + View { + attr{ + width(pagerData.pageViewWidth) + backgroundColor(Color.GREEN) + height(ctx.getAnimHeight()) + } + } + } + } + + private fun getAnimHeight(): Float { + val ctx = this + TMLog.i( + TAG, + "[getAnimHeight] height${ctx.attr.animCompensationHeight} " + ) + return ctx.attr.animCompensationHeight + } + + private fun renderChildScrollContent(): ViewBuilder { + val ctx = this + return { + WaterfallList { + Hover { + attr { + absolutePosition( + top = ctx.attr.innerHoverTop, + left = 0f, + right = 0f + ) + bringIndex(ctx.attr.bringIndex) + zIndex(ctx.attr.hoverZIndex) + // 修复多列情况下第二列第3列的区域的hover无法点击的bug + extProps["waterfall_static_width"] = true + } + vif({ ctx.attr.innerHoverContent != null }) { + ctx.attr.innerHoverContent?.invoke(this) + } + } + // 顶部下拉刷新组件 + vif({ ctx.attr.enableHeaderRefresh }) { + this@WaterfallList.Refresh { + ref { + ctx.headerRefreshRef = it + } + attr { + allCenter() + } + event { + refreshStateDidChange { + ctx.headerRefreshState = it + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.HEADER_REFRESH, + it + ) + } + } + ctx.attr.headerRefreshContent?.invoke(this) + } + } + + + ref { + ctx.innerScrollView = it + } + attr { + paddingLeft(ctx.attr.customPaddingLeft) + paddingRight(ctx.attr.customPaddingRight) + paddingTop(ctx.attr.customPaddingTop) + paddingBottom(ctx.attr.customPaddingBottom) + // backgroundColor(Color.RED) + // overflow(true) + //TODO iOS设备上fling设不设置都无效?待kuikly讨论 + flingEnable(ctx.isChildFlingEnable) + // borderRadius(20f) + // borderRadius(0f, 0f, ctx.bottomCornerRadius, ctx.bottomCornerRadius) + showScrollerIndicator(false) + height(if (ctx.baseConfigChangeFlag) ctx.scrollContentHeight else ctx.scrollContentHeight) + bouncesEnable(ctx.attr.enableHeaderRefresh || ctx.attr.enableChildScrollBounces) + flexDirection(FlexDirection.COLUMN) + nestedScroll( + this@TMNestStageCardView.getUpScrollMode(), + this@TMNestStageCardView.getDownScrollMode() + ) + // 瀑布流特殊属性 + listWidth(ctx.attr.listWidth) + itemSpacing(ctx.attr.itemSpacing) + columnCount(ctx.attr.columnCount) + lineSpacing(ctx.attr.lineSpacing) + contentPadding( + ctx.attr.contentPaddingTop, + ctx.attr.contentPaddingLeft, + ctx.attr.contentPaddingBottom, + ctx.attr.contentPaddingRight + ) + } + event { + scrollToTop { + TMLog.i( + TAG, + "ScollerView child-inner scrollToTop" + ) + } + + layoutFrameDidChange { frame -> +// TMLog.i( +// TAG, +// "inner scroll frame y:${frame.y} height:${frame.height}" +// ) + // KLog.i(TAG, "[layoutFrameDidChange]stick height:${frame.height}") + } + scroll { it -> + // 暴露 innerScrollView 的 scroll 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.INNER_SCROLL, + it + ) + TMLog.i(TAG, "ScollerView-childScroller scroll it: ${it.offsetY}") + // 优化:内层滚动也做节流控制,避免高频触发 + val currentTime = DateTime.currentTimestamp() + val timeDelta = + currentTime - this@TMNestStageCardView.lastChildScrollTime + + // 如果时间间隔不足,直接返回 + if (timeDelta < ctx.attr.scrollEventThrottleMs) { + // 但仍需要更新 childOffset 和 flingEnable 状态 + ctx.childOffset = it.offsetY + ctx.isChildFlingEnable = it.offsetY > 0 + return@scroll + } + + // 更新时间戳 + this@TMNestStageCardView.lastChildScrollTime = currentTime + + val lastChildOffset = ctx.childOffset + ctx.childOffset = it.offsetY + if (!lastChildOffset.valueEquals(it.offsetY)) { + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.SCROLL_OFFSET, + mutableMapOf( + "parentOffset" to ctx.curOuterScrollOffset, + "childOffset" to ctx.childOffset, + "scrollParams" to it + ) + ) + } + ctx.isChildFlingEnable = it.offsetY > 0 +// TMLog.i( +// TAG, +// "ScollerView child-inner scroll lastChildOffset:${lastChildOffset} ,curoffset:${ctx.childOffset} childFlingEnable:${ctx.isChildFlingEnable}" +// ) + } + scrollEnd { + // 暴露 innerScrollView 的 scrollEnd 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.INNER_SCROLL_END, + it + ) + + val lastFlingEnable = ctx.isChildFlingEnable + ctx.isChildFlingEnable = it.offsetY > 0 + TMLog.i( + TAG, + "ScollerView child-inner scrollEnd lastFlingEnable${lastFlingEnable} ctx.isChildFlingEnable:${ctx.isChildFlingEnable}" + ) + } + dragBegin { + // 暴露 innerScrollView 的 dragBegin 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.INNER_SCROLL_DRAG_BEGIN, + it + ) + // 记录拖拽开始时的时间戳和滚动位置,Android 端在滚动触摸的是子Scroll时,只会回调子的begin和end,不会回调父scroller的begin和End + this@TMNestStageCardView.dragBeginTime = DateTime.currentTimestamp() + this@TMNestStageCardView.dragBeginOffset = ctx.curOuterScrollOffset + + TMLog.i(TAG, "ScollerView drag child-inner so pf dragBegin") + } + + willDragEndBySync { + TMLog.i(TAG, "ScollerView child willDragEndBySync it: ${it}") + } + + dragEnd { params -> + // 解决iOS滚动到大页卡还会继续滚动的bug + if (!ctx.isChildFlingEnable && ctx.pagerData.isIOS) { + ctx.innerScrollView?.view?.setContentOffset(0f, 0f, true) + } + // 暴露 innerScrollView 的 dragEnd 事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.INNER_SCROLL_DRAG_END, + params + ) + + if (!ctx.attr.enableHeaderRefresh && ctx.attr.nestedScrollEnable) { + TMLog.i( + TAG, + "[dragEnd]ScollerView child attachToStage with calculated velocity" + ) + this@TMNestStageCardView.attachToStageWithVelocity() + } + } + } + vif({ ctx.attr.scrollContent != null }) { + ctx.attr.scrollContent?.invoke(this) + } + + vif({ ctx.attr.enableFooterRefresh }) { + FooterRefresh { + ref { + ctx.footerRefreshView = it + } + attr { + width(if (ctx.attr.footerWidth > 0) ctx.attr.footerWidth else pagerData.pageViewWidth) + allCenter() + } + event { + refreshStateDidChange { + ctx.footerRefreshState = it + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.FOOTER_REFRESH, + it + ) + } + } + vif({ ctx.attr.footerContent != null }) { + ctx.attr.footerContent?.invoke(this) + } + } + } + View { + attr { + height(ctx.innerScrollPlaceHolderHeight) + } + } + } + } + } + + private fun renderBottomContent(): ViewBuilder { + val ctx = this + return { + View { + attr { + flex(1f) + absolutePositionAllZero() + justifyContentFlexEnd() + } + View { + attr { + } + vif({ ctx.attr.bottomContent != null }) { + ctx.attr.bottomContent?.invoke(this) + } + event { + layoutFrameDidChange { + if (ctx.bottomContentHeight != it.height) { + ctx.bottomContentHeight = it.height + ctx.innerScrollPlaceHolderHeight = + ctx.calcInnerScrollPlaceHolderHeight() + } + TMLog.i(TAG, "bottomContentHeight:${it.height}") + } + } + } + + } + } + } + + private fun renderStickContent(): ViewBuilder { + val ctx = this + return { + View { + vif({ ctx.attr.isCustomHeaderContent }) { + ctx.attr.headerContent?.invoke(this) + } + + vif({ !ctx.attr.isCustomHeaderContent }) { + View { + // 手柄 + View { + attr { + width(pagerData.pageViewWidth) + height(12f) + marginBottom(ctx.attr.handlerMarginBottom) + alignSelfCenter() + allCenter() + } + + event { + click { + // 触发手柄点击事件,传递当前级别、级别高度和卡片高度等参数 + val handleClickParams = mutableMapOf( + "currentLevel" to ctx.currentLevel, + "levelHeights" to ctx.attr.levelHeights, + "cardHeight" to ctx.cardHeight + ) + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.HANDLE_CLICK, + handleClickParams + ) + } + } + + // 拖拽手柄视觉元素 + View { + attr { + width(36f) + height(4f) + borderRadius(4f / 2) + } + } + } + // 顶部内容 + View { + vif({ ctx.attr.headerContent != null }) { + ctx.attr.headerContent?.invoke(this) + } + } + + // View { + // attr { + // absolutePosition(bottom = -100f) + // width(pagerData.pageViewWidth) + // height(42f) + // alignSelfCenter() + // allCenter() + // backgroundColor(Color.RED) + // } + // } + + } + } + + event { + layoutFrameDidChange { frame -> + if (frame.height != ctx.headerContentHeight) { + ctx.headerContentHeight = frame.height + ctx.stickContentHeight = frame.height + ctx.topContentHeight + ctx.stickContentHeightUse = frame.height + ctx.topContentHeight + ctx.calcInnerScrollPlaceHolderHeight() + // 刷新一下卡片的高度 + ctx.notifyCardHeightChange() + } +// TMLog.i(TAG, "update listHeight stickContentHeight[layoutFrameDidChange]stick height:${frame.height}") + } + } + } + } + } + + fun notifyCardHeightChange() { + emit(TMNestStageCardViewEvent.CARD_HEIGHT_CHANGE, getTouchHeight()) + lastEmittedCardHeight = cardHeight + } + + + private fun calcInnerScrollPlaceHolderHeight(): Float { + if (isOutDragging){ + return 0f + } + scrollContentHeight = getScrollContentHeightInner() + val placeholderViewHeight = + scrollContentHeight - (attr.levelHeights[currentLevel] - stickContentHeight - bottomContentHeight) +// TMLog.i( +// TAG, +// "calcInnerScrollPlaceHolderHeight:${placeholderViewHeight} curlevel[${attr.levelHeights[currentLevel]}] stickContentHeight:${stickContentHeight}" +// ) + return placeholderViewHeight + } + + // 如果设置了外部不滚动 + private fun getTouchHeight(): Float { + val ctx = this@TMNestStageCardView + // 返回正常高度 + if (ctx.attr.enableOuterScroll) { + return ctx.cardHeight + } else { // 设置外部不可滚动 + return (ctx.cardHeight - (if (pagerData.isIOS) 0f else ctx.stickContentHeight)) + } + + } + + + private fun getContentPaddingTop(): Float { + val paddingTop = + pagerData.pageViewHeight - this@TMNestStageCardView.attr.levelHeights[0] + TMLog.i( + TAG, + "[getContentPaddingTop] paddingTop:${paddingTop} pagerData.pageViewHeight:${pagerData.pageViewHeight}" + ) + return paddingTop + } + + + fun getCurrentCardHeight(): Float { + val ctx = this@TMNestStageCardView + var cardHeight = ctx.curOuterScrollOffset + ctx.attr.levelHeights[0] + val maxHeight = ctx.attr.levelHeights[ctx.attr.levelHeights.size - 1] + cardHeight = minOf(cardHeight, maxHeight) +// cardHeight = 500f +// TMLog.i(TAG, "[getCurrentCardHeight] cardHeight:${cardHeight}") + return cardHeight + } + + private fun getScrollContentHeightInner(): Float { + if (isOutDragging) { + return attr.levelHeights.last() + } + val height = + if (curOuterScrollOffset >= 0) attr.levelHeights.last() - attr.animCompensationHeight else attr.levelHeights.last() + TMLog.i( + TAG, + "scroll-inner-Height:${height} curOuterScrollOffset:${curOuterScrollOffset} stickHeight:${this@TMNestStageCardView.stickContentHeight} stickHeightUse:${this@TMNestStageCardView.stickContentHeightUse} " + ) + return height + } + + + /** + * 外层滚动回调处理方法 + * 处理外层滚动事件,包括速率计算、节流控制和状态更新 + */ + private fun outerOnScroll(it: ScrollParams) { + curOuterScrollOffset = it.offsetY + cardHeight = getCurrentCardHeight() + // 动态计算和缓存边距值 + updateMarginValue() + // 由于限流导致的高度回调不准确的,这里补充一下,非限流版本 + fireCardHeightEvent(true) + // 点击状态栏会自动滚动到底部,这个回调出去,iOS会自动,Android部分机型会 + if (it.offsetY.valueEquals(0f)) { + currentLevel = 0 + emit( + TMNestStageCardViewEvent.STATE_CHANGE, + currentLevel + ) + } + // 通过时间检查后,再进行实际处理 + TMLog.i(TAG, "ScollerView-parentScroller scroll it: ${it.offsetY} ${isFirstLoad}") + val maxOffset = + attr.levelHeights.last() - attr.levelHeights.first() + if (it.offsetY > maxOffset) { + // 超过最大滚动距立即更新inner的高度,防止滚动超过卡片距离 +// TMLog.i(TAG,"update listHeight stickContentHeight:${stickContentHeight} first:${isFirstLoad}") + stickContentHeightUse = stickContentHeight + scrollContentHeight = getScrollContentHeightInner() + if (!isFirstLoad) { + attachToStage() + } + TMLog.i( + TAG, + " out overscroll ${stickContentHeightUse} maxOffset:${maxOffset} it.offsetY:${it.offsetY}" + ) + // 首次加载不拦截计算 + if (isFirstLoad) { + // nothing + } else { + return + } + } + // 优化:在回调入口处立即判断是否需要处理,避免无效计算 + val currentTime = DateTime.currentTimestamp() + val timeDelta = currentTime - lastEmitTime + val currentOffset = it.offsetY + + if (lastScrollTime > 0) { + val timeDeltaV = + currentTime - lastScrollTime + val offsetDelta = + currentOffset - lastScrollOffset + + if (timeDeltaV > 0 && timeDelta > 0) { + // 计算速率(像素/毫秒) + lastScrollVelocityY = + offsetDelta / timeDelta + } + } + + lastScrollOffset = currentOffset + lastScrollTime = currentTime + + // 如果时间间隔不足,直接返回,不做任何处理 + if (timeDelta < attr.scrollEventThrottleMs) { + return + } + + // 更新时间戳,确保节流生效 + lastEmitTime = currentTime + + updateOuterScrollState(it.offsetY) + } + + private fun fireCardHeightEvent(realtime: Boolean = false) { + if (realtime) { + emit(TMNestStageCardViewEvent.REALTIME_CARD_HEIGHT_CHANGE, getCurrentCardHeight()) + } else { + emit(TMNestStageCardViewEvent.CARD_HEIGHT_CHANGE, getTouchHeight()) + } + } + + /** + * 根据当前 offset 计算并更新 level 状态 + * levelHeights 相邻的差值表示 offset,其中 levelHeights[0] 的 offset 为 0 + * 如果 level 变化了则发送 STATE_CHANGE 事件 + * @param offsetY 当前滚动偏移量 + */ + private fun updateLevelByOffset(offsetY: Float) { + val levelHeights = attr.levelHeights + var matchedLevel = -1 + var accumulatedOffset = 0f + val maxOffset = levelHeights.last() - levelHeights.first() + if (offsetY >= maxOffset) { + matchedLevel = levelHeights.lastIndex + } else { + for (i in levelHeights.indices) { + if (i == 0) { + // level 0 的 offset 为 0 + if (offsetY.roundToInt() == 0) { + matchedLevel = 0 + break + } + } else { + // 相邻差值表示 offset + accumulatedOffset += levelHeights[i] - levelHeights[i - 1] + if (abs(offsetY - accumulatedOffset) <= OFFSET_TOLERANCE) { + matchedLevel = i + break + } + } + } + } + if (matchedLevel > 0 ) { + currentLevel = matchedLevel + emit(TMNestStageCardViewEvent.STATE_CHANGE, currentLevel) + } + } + + /** + * 基于速率的吸附到阶段方法 + * 考虑滚动速率来决定是否跳过阈值判断直接吸附到下一级别 + */ + private fun attachToStageWithVelocity() { + val velocityThreshold = attr.velocityAttachThreshold + TMLog.i( + TAG, + "attachToStageWithVelocity: finalVelocity=$finalDragVelocity, threshold=$velocityThreshold, lastV=$lastScrollVelocityY" + ) + + // 如果速率超过阈值,使用速率吸附逻辑 + if (abs(lastScrollVelocityY) > velocityThreshold) { + attachToStageByVelocity(lastScrollVelocityY) + } else if (abs(finalDragVelocity) > velocityThreshold) { + // 否则使用原来的吸附逻辑 + attachToStageByVelocity(finalDragVelocity) + } else { + // 否则使用原来的吸附逻辑 + attachToStage() + } + } + + + /** + * 基于速率方向的吸附逻辑 + * @param velocity 滚动速率,正值表示向下滚动,负值表示向上滚动 + */ + private fun attachToStageByVelocity(velocity: Float) { + var stageHeight: Float = 0f + var stageLevel: Int = 0 + + val levelHeights = this@TMNestStageCardView.attr.levelHeights + var outOffset = this@TMNestStageCardView.curOuterScrollOffset + var curLevelHeight = outOffset + levelHeights[0] + val previousLevel = currentLevel + + if (levelHeights.isEmpty()) { + stageHeight = 0f + stageLevel = 0 + } else if (levelHeights.size == 1) { + stageHeight = levelHeights[0] + stageLevel = 0 + } else { + // 二分查找找到当前位置在哪两个level之间 + if (curLevelHeight <= levelHeights[0]) { + stageHeight = levelHeights[0] + stageLevel = 0 + } else if (curLevelHeight >= levelHeights.last()) { + stageHeight = levelHeights.last() + stageLevel = levelHeights.size - 1 + } else { + var left = 0 + var right = levelHeights.size - 1 + + while (left < right - 1) { + val mid = (left + right) / 2 + if (curLevelHeight <= levelHeights[mid]) { + right = mid + } else { + left = mid + } + } + + // 基于速率方向决定吸附目标 + if (velocity > 0) { + // 向下滚动(正方向),吸附到较高的level(right) + stageHeight = levelHeights[right] + stageLevel = right + TMLog.i( + TAG, + "速率吸附-向下: velocity=$velocity, 从level $left 吸附到level$right" + ) + } else { + // 向上滚动(负方向),吸附到较低的level(left) + stageHeight = levelHeights[left] + stageLevel = left + TMLog.i( + TAG, + "速率吸附-向上: velocity=$velocity, 从level$right 吸附到level$left" + ) + } + } + } + + currentLevel = stageLevel + // attach 之后刷新 + stickContentHeightUse = stickContentHeight + innerScrollPlaceHolderHeight = calcInnerScrollPlaceHolderHeight() + val needAnim = + !outOffset.valueEquals(stageHeight - levelHeights[0]) && curLevelHeight < levelHeights.last() + outerScrollView?.view?.abortContentOffsetAnimate() + outerScrollView?.view?.setContentOffset(0f, stageHeight - levelHeights[0], needAnim) + if (!needAnim) { + this@TMNestStageCardView.emit(TMNestStageCardViewEvent.STATE_CHANGE, stageLevel) + } + + TMLog.i( + TAG, + "attachToStageByVelocity: stageLevel=$stageLevel, stageHeight=$stageHeight, previousLevel=$previousLevel, velocity=$velocity, needAnim=$needAnim" + ) + } + + private fun attachToStage() { + var stageHeight: Float = 0f + var stageLevel: Int = 0 + + val levelHeights = this@TMNestStageCardView.attr.levelHeights + var outOffset = this@TMNestStageCardView.curOuterScrollOffset + var curLevelHeight = outOffset + levelHeights[0] + val previousLevel = currentLevel + + // 优化方案 3: 使用二分查找替代线性查找,提高算法效率 O(log n) vs O(n) + if (levelHeights.isEmpty()) { + stageHeight = 0f + stageLevel = 0 + } else if (levelHeights.size == 1) { + stageHeight = levelHeights[0] + stageLevel = 0 + } else { + // 优化: 二分查找替代线性搜索 + if (curLevelHeight <= levelHeights[0]) { + stageHeight = levelHeights[0] + stageLevel = 0 + } else if (curLevelHeight >= levelHeights.last()) { + stageHeight = levelHeights.last() + stageLevel = levelHeights.size - 1 + } else { + // 二分查找找到当前位置在哪两个level之间 + var left = 0 + var right = levelHeights.size - 1 + + while (left < right - 1) { + val mid = (left + right) / 2 + if (curLevelHeight <= levelHeights[mid]) { + right = mid + } else { + left = mid + } + } + + // 使用阈值判断或距离判断 + val shouldUseThreshold = attr.levelAttachThresholds.isNotEmpty() && + left < attr.levelAttachThresholds.size && + attr.levelAttachThresholds[left] > 0.0f && + attr.levelAttachThresholds[left] < 1.0f + + if (shouldUseThreshold) { + // 使用阈值判断 + val threshold = attr.levelAttachThresholds[left] + val levelDistance = levelHeights[right] - levelHeights[left] + val thresholdPosition = levelHeights[left] + levelDistance * threshold + + if (curLevelHeight >= thresholdPosition) { + stageHeight = levelHeights[right] + stageLevel = right + } else { + stageHeight = levelHeights[left] + stageLevel = left + } + + TMLog.i( + TAG, "使用阈值判断: left=$left, right=$right, threshold=$threshold, " + + "thresholdPosition=$thresholdPosition, curLevelHeight=$curLevelHeight, " + + "选择level=$stageLevel" + ) + } else { + // 使用原来的距离判断 + val distanceToLeft = curLevelHeight - levelHeights[left] + val distanceToRight = levelHeights[right] - curLevelHeight + + if (distanceToLeft <= distanceToRight) { + stageHeight = levelHeights[left] + stageLevel = left + } else { + stageHeight = levelHeights[right] + stageLevel = right + } + + TMLog.i( + TAG, "使用距离判断: left=$left, right=$right, " + + "distanceToLeft=$distanceToLeft, distanceToRight=$distanceToRight, " + + "选择level=$stageLevel" + ) + } + } + } + + currentLevel = stageLevel + // attach 之后刷新 + stickContentHeightUse = stickContentHeight + innerScrollPlaceHolderHeight = calcInnerScrollPlaceHolderHeight() + val needAnim = + !outOffset.valueEquals(stageHeight - levelHeights[0]) && curLevelHeight < levelHeights.last() + outerScrollView?.view?.abortContentOffsetAnimate() + outerScrollView?.view?.setContentOffset(0f, stageHeight - levelHeights[0], needAnim) + if (!needAnim) { + this@TMNestStageCardView.emit(TMNestStageCardViewEvent.STATE_CHANGE, stageLevel) + } + + TMLog.i( + TAG, + "attachToStage stageLevel:$stageLevel, stageHeight:$stageHeight, previousLevel:$previousLevel needAnim:$needAnim ,level0:${levelHeights[0]} anim to${stageHeight - levelHeights[0]} out offset:${this@TMNestStageCardView.curOuterScrollOffset} stickContentHeightUse:${stickContentHeightUse}" + ) + } + + /** + * 滚动到底部 + */ + fun childScrollToBottom( + animated: Boolean = true + ) { + TMLog.i( + TAG, + "childScrollToBottom childOffset: $childOffset, animated: $animated" + ) + val contentHeight = innerScrollView?.view?.contentView?.frame?.height ?: 0f + val offset = contentHeight - getScrollContentHeightInner() + if (offset > 0) { + innerScrollView?.view?.abortContentOffsetAnimate() + innerScrollView?.view?.setContentOffset(0f, offset, animated) + } + TMLog.i( + TAG, + "设置内部子滚动容器到底部: $animated contentHeight:$contentHeight offset:$offset " + ) + } + + /** + * 滚动到指定位置 + * @param parentOffset 外部父容器的滚动偏移量,为null时不设置父容器滚动位置 + * @param childOffset 内部子滚动容器的滚动偏移量,为null时不设置子容器滚动位置 + * @param animated 是否使用动画,默认为true + */ + fun scrollToOffset(parentOffset: Float? = null, + childOffset: Float? = null, + animated: Boolean = true + ) { + TMLog.i( + TAG, + "scrollToOffset parentOffset: $parentOffset, childOffset: $childOffset, animated: $animated" + ) + + // 设置外部父容器滚动位置 + parentOffset?.let { offset -> + outerScrollView?.view?.setContentOffset(0f, offset, animated) + TMLog.i(TAG, "设置外部父容器滚动位置: $offset") + } + + // 设置内部子滚动容器位置 + childOffset?.let { offset -> + innerScrollView?.view?.setContentOffset(0f, offset, animated) + TMLog.i(TAG, "设置内部子滚动容器位置: $offset") + } + } + + /** + * 获取当前滚动位置 + * @return Pair 返回 (父容器偏移量, 子容器偏移量) + */ + fun getCurrentScrollOffset(): Pair { + return Pair(curOuterScrollOffset, childOffset) + } + + /** + * 吸附到指定级别的卡片高度 + * @param level 目标级别,对应 levelHeights 数组的下标 + * @param animated 是否使用动画,默认为true + * @return Boolean 返回是否成功执行吸附操作 + */ + fun snapToLevel(level: Int, animated: Boolean = true): Boolean { + TMLog.i(TAG, "snapToLevel level: $level, animated: $animated") + + // 检查level是否在有效范围内 + if (level < 0 || level >= attr.levelHeights.size) { + TMLog.e( + TAG, + "snapToLevel 无效的level: $level, levelHeights.size: ${attr.levelHeights.size}" + ) + return false + } + + // 获取目标高度 + val targetHeight = attr.levelHeights[level] + + // 计算需要设置的偏移量(相对于第一个级别的偏移) + val targetOffset = targetHeight - attr.levelHeights[0] + + // 更新当前级别 + val previousLevel = currentLevel + currentLevel = level + // 修复多次调用出现的bug + outerScrollView?.view?.abortContentOffsetAnimate() + // 执行滚动动画 + outerScrollView?.view?.setContentOffset(0f, targetOffset, animated) + + if (!animated) { + // 触发高度变化事件 + this@TMNestStageCardView.cardHeight = targetHeight + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.CARD_HEIGHT_CHANGE, + targetHeight + ) + // 触发级别稳定事件 + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.STATE_CHANGE, + currentLevel + ) + } + + + TMLog.i( + TAG, + "snapToLevel 成功执行吸附: level=$level, targetHeight=$targetHeight, targetOffset=$targetOffset, previousLevel=$previousLevel" + ) + + return true + } + + /** + * 滚动到指定 item 位置 + * @param position item 在列表中的索引位置(从0开始) + * @param animated 是否使用动画,默认为true + * @return Boolean 返回是否成功执行滚动操作 + */ + fun scrollToItemPosition(position: Int, animated: Boolean = true): Boolean { + TMLog.i(TAG, "scrollToItemPosition position: $position, animated: $animated") + + // 检查 position 是否有效 + if (position < 0) { + TMLog.e(TAG, "scrollToItemPosition 无效的position: $position,position不能小于0") + return false + } + + // 检查 innerScrollView 是否可用 + val waterfallListView = innerScrollView?.view + if (waterfallListView == null) { + TMLog.e(TAG, "scrollToItemPosition 失败: innerScrollView 未初始化") + return false + } + + try { + // 调用 WaterfallListView 的滚动到指定位置方法 + waterfallListView.scrollToPosition(position, 0f, animated) + + TMLog.i( + TAG, + "scrollToItemPosition 成功执行滚动到位置: position=$position, animated=$animated" + ) + return true + } catch (e: Exception) { + TMLog.e(TAG, "scrollToItemPosition 执行失败: ${e.message}") + return false + } + } + + /** + * 获取向上滚动的嵌套滚动模式 + * @return KRNestedScrollMode 向上滚动的嵌套滚动模式 + */ + private fun getUpScrollMode(): KRNestedScrollMode { + val mode = + if (attr.enableHeaderRefresh || !attr.nestedScrollEnable || !attr.enableOuterScroll) KRNestedScrollMode.SELF_ONLY else KRNestedScrollMode.PARENT_FIRST +// TMLog.i(TAG, "getUpScrollMode: enableHeaderRefresh=${attr.enableHeaderRefresh}, mode=$mode") + return mode + } + + /** + * 获取向下滚动的嵌套滚动模式 + * @return KRNestedScrollMode 向下滚动的嵌套滚动模式 + */ + private fun getDownScrollMode(): KRNestedScrollMode { + val mode = + if (attr.enableHeaderRefresh || !attr.nestedScrollEnable || !attr.enableOuterScroll) KRNestedScrollMode.SELF_ONLY else KRNestedScrollMode.SELF_FIRST +// TMLog.i( +// TAG, +// "getDownScrollMode: enableHeaderRefresh=${attr.enableHeaderRefresh}, mode=$mode" +// ) + return mode + } + + /** + * 控制外部 scroll 是否可以滚动 + * @param enable true 表示启用外部滚动,false 表示禁用外部滚动 + */ + fun setOuterScrollEnabled(enable: Boolean) { + TMLog.i(TAG, "setOuterScrollEnabled enable: $enable") + attr.enableOuterScroll = enable + this@TMNestStageCardView.emit( + TMNestStageCardViewEvent.CARD_HEIGHT_CHANGE, + this@TMNestStageCardView.getTouchHeight() + ) + } + + fun calcOutScrollYV(tag: String) { + // 记录拖拽结束时的时间戳和滚动位置 + this@TMNestStageCardView.dragEndTime = DateTime.currentTimestamp() + this@TMNestStageCardView.dragEndOffset = curOuterScrollOffset + + // 计算整个拖拽期间的平均速率 + val totalTimeDelta = + this@TMNestStageCardView.dragEndTime - this@TMNestStageCardView.dragBeginTime + val totalOffsetDelta = + this@TMNestStageCardView.dragEndOffset - this@TMNestStageCardView.dragBeginOffset + + if (totalTimeDelta > 0) { + // 计算最终拖拽速率(像素/毫秒) + this@TMNestStageCardView.finalDragVelocity = totalOffsetDelta / totalTimeDelta + } + + TMLog.i( + TAG, + "[$tag]ScollerView parentScroller dragEnd - " + + "beginOffset: ${this@TMNestStageCardView.dragBeginOffset}, " + + "endOffset: ${this@TMNestStageCardView.dragEndOffset}, " + + "timeDelta: ${totalTimeDelta}ms, " + + "offsetDelta: ${totalOffsetDelta}px, " + + "finalVelocity: ${this@TMNestStageCardView.finalDragVelocity}px/ms" + ) + } + + /** + * 动态更新页卡高度级别 + * @param newLevelHeights 新的高度级别列表,必须是递增序列 + * @param maintainCurrentLevel 是否保持当前级别,如果为true且当前级别在新列表范围内,则保持当前级别;否则重置到级别0 + */ + fun updateLevelHeights(newLevelHeights: List, maintainCurrentLevel: Boolean = false) { + if (newLevelHeights.isEmpty()) { + TMLog.i(TAG, "updateLevelHeights: newLevelHeights is empty, ignoring update") + return + } + + // 验证高度列表是否为递增序列 + for (i in 1 until newLevelHeights.size) { + if (newLevelHeights[i] <= newLevelHeights[i - 1]) { + TMLog.i( + TAG, + "updateLevelHeights: heights must be in ascending order, ignoring update" + ) + return + } + } + + val oldLevelHeights = attr.levelHeights + val oldCurrentLevel = currentLevel + + TMLog.i( + TAG, + "updateLevelHeights: old=$oldLevelHeights, new=$newLevelHeights, currentLevel=$oldCurrentLevel" + ) + + // 更新高度列表 + attr.levelHeights = newLevelHeights + + // 处理当前级别 + val newCurrentLevel = if (maintainCurrentLevel && oldCurrentLevel < newLevelHeights.size) { + oldCurrentLevel + } else { + 0 // 重置到级别0 + } + + // 如果当前级别发生变化,需要调整到新的级别 + if (newCurrentLevel != oldCurrentLevel || newLevelHeights != oldLevelHeights) { + currentLevel = newCurrentLevel + + // 重新计算并应用新的高度 + val newHeight = newLevelHeights[newCurrentLevel] + baseConfigChangeFlag = !baseConfigChangeFlag + TMLog.i( + TAG, + "updateLevelHeights: adjusting to level $newCurrentLevel with height $newHeight" + ) + snapToLevel(currentLevel, false) + } + } + + private fun calcScrollWidth(marginValue: Float): Float { + val pageWidth = pagerData.pageViewWidth + val xScale = if (pageWidth != 0f) (pageWidth - marginValue * 2) / pageWidth else 1f +// TMLog.i(TAG, "[calcScrollWidth] xScale:${xScale} marginValue:${marginValue} pageViewWidth:${pagerData.pageViewWidth} enable:${attr.enableDynamicMargin}") + return if (attr.enableDynamicMargin) xScale else 1f + } + + private fun calcScrollHeight(marginValue: Float): Float { + val pageHeight = pagerData.pageViewHeight + val yScale = if (pageHeight != 0f) (pageHeight - marginValue * 2) / pageHeight else 1f +// TMLog.i(TAG, "[calcScrollHeight] yScale:${yScale} enable:${attr.enableDynamicMargin}") + return if (attr.enableDynamicMargin) yScale else 1f + } + /** + * 结束顶部刷新 + */ + fun endHeaderRefresh() { + headerRefreshRef?.view?.endRefresh() + } + + override fun viewDidLayout() { + super.viewDidLayout() + screenHeight = pagerData.pageViewHeight + val initOffset = getInitOffset(attr.initLevel) + this@TMNestStageCardView.outerScrollView?.view?.setContentOffset( + 0f, + initOffset + ) + initCardHeight(attr.initLevel) + TMLog.i( + TAG, + "【viewDidLayout】 level:${attr.initLevel} view:${this@TMNestStageCardView.outerScrollView?.view} offset:${ + initOffset + }" + ) + } + + private fun initCardHeight(initLevel: Int) { + this.cardHeight = attr.levelHeights[initLevel] + } + + override fun created() { + super.created() + if (!isInitialized) { + isInitialized = true + currentLevel = attr.initLevel + // 触发初始状态的 onLevelStable 回调 + this@TMNestStageCardView.emit(TMNestStageCardViewEvent.STATE_CHANGE, currentLevel) + } + } + +} + +/** + * 分段式页卡事件 + */ +public class TMNestStageCardViewEvent : ComposeEvent() { + fun onLevelStable(handler: EventHandlerFn) { + registerEvent(STATE_CHANGE, handler) + } + + fun onCardHeightChange(handler: EventHandlerFn) { + registerEvent(CARD_HEIGHT_CHANGE, handler) + } + + fun onRealtimeCardHeightChange(handler: EventHandlerFn) { + registerEvent(REALTIME_CARD_HEIGHT_CHANGE, handler) + } + + fun onFooterRefresh(handler: EventHandlerFn) { + registerEvent(FOOTER_REFRESH, handler) + } + + fun onHeaderRefresh(handler: EventHandlerFn) { + registerEvent(HEADER_REFRESH, handler) + } + + fun scrollOffset(handler: EventHandlerFn) { + registerEvent(SCROLL_OFFSET, handler) + } + + // innerScrollView 滚动事件 + fun onInnerScrollDragBegin(handler: EventHandlerFn) { + registerEvent(INNER_SCROLL_DRAG_BEGIN, handler) + } + + fun onInnerScrollDragEnd(handler: EventHandlerFn) { + registerEvent(INNER_SCROLL_DRAG_END, handler) + } + + fun onInnerScrollEnd(handler: EventHandlerFn) { + registerEvent(INNER_SCROLL_END, handler) + } + + fun onInnerScroll(handler: EventHandlerFn) { + registerEvent(INNER_SCROLL, handler) + } + + fun onHandleClick(handler: EventHandlerFn) { + registerEvent(HANDLE_CLICK, handler) + } + + // outerScrollView 滚动事件 + fun onOuterScrollDragBegin(handler: EventHandlerFn) { + registerEvent(OUTER_SCROLL_DRAG_BEGIN, handler) + } + + fun onOuterScrollDragEnd(handler: EventHandlerFn) { + registerEvent(OUTER_SCROLL_DRAG_END, handler) + } + + companion object { + const val STATE_CHANGE = "stateChange" + const val CARD_HEIGHT_CHANGE = "cardHeightChange" + const val FOOTER_REFRESH = "footerRefresh" + const val HEADER_REFRESH = "headerRefresh" + + // 子父scroller的滚动距离 + const val SCROLL_OFFSET = "scrollOffset" + + // innerScrollView 滚动事件常量 + const val INNER_SCROLL_DRAG_BEGIN = "innerScrollDragBegin" + const val INNER_SCROLL_DRAG_END = "innerScrollDragEnd" + const val INNER_SCROLL_END = "innerScrollEnd" + const val INNER_SCROLL = "innerScroll" + + + // outerScrollView 滚动事件常量 + const val OUTER_SCROLL_DRAG_BEGIN = "outerScrollDragBegin" + const val OUTER_SCROLL_DRAG_END = "outerScrollDragEnd" + + // 手柄点击事件常量 + const val HANDLE_CLICK = "handleClick" + + // 实时高度变化事件常量 + const val REALTIME_CARD_HEIGHT_CHANGE = "realtimeCardHeightChange" + } +} + +/** + * 分段式页卡组件扩展函数 + */ +public fun ViewContainer<*, *>.TMNestStageCardView(init: TMNestStageCardView.() -> Unit) { + addChild(TMNestStageCardView(), init) +} \ No newline at end of file diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMLog.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMLog.kt new file mode 100644 index 0000000000..92d95d5ad4 --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMLog.kt @@ -0,0 +1,127 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 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.demo.pages + +import com.tencent.kuikly.core.datetime.DateTime +import com.tencent.kuikly.core.log.KLog + +/** + * TMLog - 统一日志管理类 + * + * 对KLog进行封装,提供统一的日志接口,方便后续日志管理和扩展。 + * 所有方法签名与KLog保持一致,确保无缝替换。 + */ +object TMLog { + // 如果是js的代码 + val isJSCode = true + + /** + * 信息级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun i(tag: String, message: String) { + KLog.i(tag, message) + if (isJSCode){ + println("[${formatCurrentTime()}][${tag}]: ${message}") + } + } + + /** + * 调试级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun d(tag: String, message: String) { + KLog.d(tag, message) + if (isJSCode) { + println("[${formatCurrentTime()}][${tag}]: ${message}") + } + } + + /** + * 错误级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun e(tag: String, message: String) { + KLog.e(tag, message) + if (isJSCode) { + println("[${formatCurrentTime()}][${tag}]: ${message}") + } + } + + /** + * 格式化当前时间戳为可读格式 + * @return 格式化的时间字符串,格式为 "HH:mm:ss.SSS" + */ + fun formatCurrentTime(): String { + return formatTimestamp(DateTime.currentTimestamp()) + } + + /** + * 格式化时间戳为可读格式 + * @param timestamp 时间戳(毫秒) + * @return 格式化的时间字符串,格式为 "HH:mm:ss.SSS" + */ + fun formatTimestamp(timestamp: Long): String { + // 获取小时、分钟、秒和毫秒 + val totalSeconds = timestamp / 1000 + val milliseconds = timestamp % 1000 + + val hours = (totalSeconds / 3600) % 24 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return buildString { + append(hours.toString().padStart(2, '0')) + append(':') + append(minutes.toString().padStart(2, '0')) + append(':') + append(seconds.toString().padStart(2, '0')) + append('.') + append(milliseconds.toString().padStart(3, '0')) + } + } + + /** + * 带时间戳的信息级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun iWithTime(tag: String, message: String) { + KLog.i(tag, "[${formatCurrentTime()}] $message") + } + + /** + * 带时间戳的调试级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun dWithTime(tag: String, message: String) { + KLog.d(tag, "[${formatCurrentTime()}] $message") + } + + /** + * 带时间戳的错误级别日志 + * @param tag 日志标签 + * @param message 日志消息 + */ + fun eWithTime(tag: String, message: String) { + KLog.e(tag, "[${formatCurrentTime()}] $message") + } + +} \ No newline at end of file diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMNestStageCardViewDemo.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMNestStageCardViewDemo.kt new file mode 100644 index 0000000000..79c2f1e1e9 --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/TMNestStageCardViewDemo.kt @@ -0,0 +1,833 @@ +package com.tencent.kuikly.demo.pages + +import com.tencent.kuikly.core.annotations.Page +import com.tencent.kuikly.core.base.BaseObject +import com.tencent.kuikly.core.base.Color +import com.tencent.kuikly.core.base.ViewBuilder +import com.tencent.kuikly.core.views.Image +import com.tencent.kuikly.core.views.View +import com.tencent.kuikly.core.views.Text +import com.tencent.kuikly.core.base.ComposeEvent +import com.tencent.kuikly.core.base.ViewRef +import com.tencent.kuikly.core.base.event.appearPercentage +import com.tencent.kuikly.core.base.event.layoutFrameDidChange +import com.tencent.kuikly.core.directives.vfor +import com.tencent.kuikly.core.directives.vif +import com.tencent.kuikly.core.reactive.collection.ObservableList +import com.tencent.kuikly.core.reactive.handler.observable +import com.tencent.kuikly.core.reactive.handler.observableList +import com.tencent.kuikly.core.timer.setTimeout +import com.tencent.kuikly.core.views.Blur +import com.tencent.kuikly.core.views.FooterRefreshState +import com.tencent.kuikly.core.views.FooterRefreshEndState +import com.tencent.kuikly.core.views.Modal +import com.tencent.kuikly.core.views.Scroller +import com.tencent.kuikly.core.views.compose.Button +import com.tencent.kuikly.demo.pages.base.BasePager + + +@Page("w1") +internal class TMNestStageCardViewDemo : BasePager() { + + companion object { + private const val TAG = "TMNestStageCardViewDemo" + } + + internal class WaterFallItem : BaseObject() { + var title: String by observable("") + var bgColor: Color by observable(Color.WHITE) + var height: Float by observable(0f) + } + + var dataList by observableList() + var levelHeight by observableList() + var nestStageCardView: ViewRef? = null + var textViewBgColor: Boolean by observable(false) + var enableHeaderFresh: Boolean by observable(false) + var enableOutScrollEnable: Boolean by observable(true) + var nestedScrollEnable: Boolean by observable(true) + var enableChildScrollBounces: Boolean by observable(false) + var firstCardHeight: Float by observable(150f) + var cardHeight: Float by observable(150f) + var menuHeight: Float by observable(50f) + var menuWidth: Float by observable(120f) + var headerHeight: Float by observable(120f) + var footerRefreshState: FooterRefreshState by observable(FooterRefreshState.IDLE) + var showModal: Boolean by observable(false) + override fun createEvent(): ComposeEvent { + return ComposeEvent() + } + + + override fun body(): ViewBuilder { + val ctx = this@TMNestStageCardViewDemo + for (index in 0..5) { + dataList.add(WaterFallItem().apply { + title = "我是第${this@TMNestStageCardViewDemo.dataList.size + 1}个卡片" + height = (200..500).random().toFloat() + bgColor = Color((0..255).random(), (0..255).random(), (0..255).random(), 1.0f) + }) + } + levelHeight = ObservableList() + levelHeight.add(200f) + levelHeight.add(400f) + levelHeight.add(600f) + return { + // 主要内容区域 + View { + attr { + flex(1f) + } + View { + attr { absolutePositionAllZero() } + ctx.renderBtns()() + } + + + TMNestStageCardView { + ref { + ctx.nestStageCardView = it + } + attr { + levelHeights(listOf(ctx.firstCardHeight, 450f, 600f)) +// levelAttachThresholds(listOf(0.5f,0.1f)) + initLevel(2) + animCompensationHeight(ctx.headerHeight) + enableFooterRefresh(false) + enableHeaderRefresh(ctx.enableHeaderFresh) + contentPadding(16f, 16f, 16f, 16f) + listWidth(pagerData.pageViewWidth) +// cardBackgroundLinearGradient( +// Direction.TO_BOTTOM, +// ColorStop(Color.RED, 0f), +// ColorStop(Color.GREEN, 0.3f), +// ColorStop(Color.BLACK, 1f) +// ) +// handlerMarginBottom(80f) +// dmBackgroundColor(Color.RED, Color.YELLOW) + // 设置组件外层内边距 + customPadding(16f, 8f) // 水平16f,垂直8f +// customMarginValue(0f) + enableDynamicMargin(true) + // 新增的嵌套滚动和弹性效果控制 + nestedScrollEnable(ctx.nestedScrollEnable) + enableChildScrollBounces(ctx.enableChildScrollBounces) + // dmBackgroundColor(Color.RED, Color.BLUE) + // 或者可以分别设置: + // customPaddingLeft(16f) + // customPaddingRight(16f) + // customPaddingTop(8f) + // customPaddingBottom(8f) + // 瀑布流属性,非瀑布流可以不传 start + // columnCount(3) + // lineSpacing(10f) + // itemSpacing(10f) + // 瀑布流属性,非瀑布流可以不传 end + functionContent = { + View { + attr { + height(100f) + width(100f) + backgroundColor(Color.RED) + absolutePosition(left = 0f, right = 0f, bottom = 0f) + } + + event { + click { + println("tipsContent,12345678") + } + } + } + } +// topContent { +// View { +// attr { +// allCenter() +// backgroundColor(Color.BLUE) +// marginBottom(10f) +// } +// Text { +// attr { +// text("我是顶部通知栏内容") +// } +// } +// } +// } + headerContent { + View { + attr { + height(ctx.headerHeight) + allCenter() + } + View { + attr { + flexDirectionRow() + alignItemsCenter() + } + Text { + attr { + text("主题切换1:") + fontSize(16f) + } + } + + } + } + + } + scrollContent { +// View { +// attr { +// height(100f) +// allCenter() +// backgroundColor(Color.GREEN) +// marginBottom(10f) +// } +// Text { +// attr { +// text("是子滚动控件下非vfor下的控件,点击我显示modal") +// } +// } +// event { +// click { +// ctx.showModal = true +// TMLog.i(TAG, "点击显示Modal弹窗") +// } +// } +// } +// View { +// attr { +// height(100f) +// allCenter() +// backgroundColor(Color.GREEN) +// marginBottom(10f) +// } +// Text { +// attr { +// text("是子滚动控件下非vfor下的控件11,点击我显示modal") +// } +// } +// event { +// click { +// ctx.showModal = true +// TMLog.i(TAG, "点击显示Modal弹窗") +// } +// } +// } + vfor({ ctx.dataList }) { item -> + View { + attr { + zIndex(100) + allCenter() + height(100f) + width(pagerData.pageViewWidth) + backgroundColor(item.bgColor) + borderRadius(8f) + } + + Text { + attr { + text(item.title) + color(Color.WHITE) + } + } + + event { + click { + this@View.attr { + height((150..300).random().toFloat()) + } + } + + + layoutFrameDidChange { + + if (item.title.contains("1个")) { + println("layoutFrameDidChange ${it}") + } + } + + appearPercentage { + + } + + } + } + } + } + headerRefreshContent { + View { + attr { + height(50f) + backgroundColor(Color.RED) + width(pagerData.pageViewWidth) + allCenter() + flexDirectionRow() + } + Text { + attr { + text("下拉刷新") + fontSize(14f) + } + } + } + } + footerContent { + View { + attr { + height(40f) + allCenter() + backgroundColor(Color.YELLOW) + } + Text { + attr { + fontSize(14f) + text( + when (ctx.footerRefreshState) { + FooterRefreshState.IDLE -> "" + FooterRefreshState.REFRESHING -> "正在加载" + FooterRefreshState.NONE_MORE_DATA -> "没有更多了" + FooterRefreshState.FAILURE -> "加载失败" + } + ) + } + } + } + } +// bottomContent { +// View { +// attr{ +// allCenter() +// size(pagerData.pageViewWidth, 60f) +// dmBackgroundColor(Color.TRANSPARENT) +// border(Border(2f,BorderStyle.SOLID,Color.RED)) +// } +// Text { +// attr{ +// text("我是底部固定区域") +// textAlignCenter() +// } +// } +// } +// } +// footerWidth(300f) + // 新增:内部悬浮内容演示 + innerHoverTop(150f) // 设置悬浮内容距离顶部150f + innerHoverContent { + View { + attr { + height(80f) + backgroundColor(Color(0xFF007AFF)) // 蓝色背景 + borderRadius(12f) + marginLeft(20f) + marginRight(20f) + justifyContentCenter() + alignItemsCenter() + } + + View { + attr { + flexDirectionRow() + alignItemsCenter() + justifyContentSpaceBetween() + paddingLeft(16f) + paddingRight(16f) + } + + Text { + attr { + text("🎯 悬浮提示") + color(Color.WHITE) + fontSize(16f) + fontWeightMedium() + } + } + + View { + attr { + width(60f) + height(30f) + backgroundColor(Color.WHITE) + borderRadius(15f) + justifyContentCenter() + alignItemsCenter() + } + + Text { + attr { + text("点击") + color(Color(0xFF007AFF)) + fontSize(12f) + fontWeightMedium() + } + } + + event { + click { + TMLog.i(TAG, "点击了悬浮内容按钮") + // 可以在这里添加点击后的逻辑,比如显示更多信息 + ctx.showModal = true + } + } + } + } + } + } + } + event { + onCardHeightChange { height -> + if (height is Float) { + ctx.cardHeight = height + ctx.getScrollerRatio() + TMLog.i(TAG, "onCardHeightChange: $height") + } + } + onFooterRefresh { state -> + if (state is FooterRefreshState) { + ctx.footerRefreshState = state + if (state == FooterRefreshState.REFRESHING) { + setTimeout(2000) { + for (index in 0..10) { + ctx.dataList.add(WaterFallItem().apply { + title = "新增第${ctx.dataList.count()}个卡片" + height = (200..500).random().toFloat() + bgColor = Color( + (0..255).random(), + (0..255).random(), + (0..255).random(), + 1.0f + ) + }) + } + ctx.nestStageCardView?.view?.footerRefreshView?.view?.endRefresh( + FooterRefreshEndState.SUCCESS + ) + ctx.footerRefreshState = FooterRefreshState.IDLE + } + } + TMLog.i(TAG, "onFooterRefresh: $state") + } + } + onHeaderRefresh { state -> + if (state is com.tencent.kuikly.core.views.RefreshViewState) { + when (state) { + com.tencent.kuikly.core.views.RefreshViewState.REFRESHING -> { + TMLog.i(TAG, "顶部刷新中...") + setTimeout(2000) { + // 模拟刷新数据 - 清空现有数据并添加新数据 + ctx.dataList.clear() + for (index in 0..20) { + ctx.dataList.add(WaterFallItem().apply { + title = "刷新后第${index + 1}个卡片" + height = (200..500).random().toFloat() + bgColor = Color( + (0..255).random(), + (0..255).random(), + (0..255).random(), + 1.0f + ) + }) + } + // 结束刷新 + TMLog.i( + TAG, + "endHeaderRefresh ${ctx.nestStageCardView?.view?.headerRefreshRef?.view}" + ) + ctx.nestStageCardView?.view?.headerRefreshRef?.view?.endRefresh() + } + } + + com.tencent.kuikly.core.views.RefreshViewState.IDLE -> { + TMLog.i("TMNestStageCardView", "顶部刷新空闲") + } + + com.tencent.kuikly.core.views.RefreshViewState.PULLING -> { + TMLog.i("TMNestStageCardView", "正在下拉...") + } + } + TMLog.i("TMNestStageCardView onHeaderRefresh", "$state") + } + } + onLevelStable { levelStable -> + TMLog.i(TAG, "levelStable: $levelStable") + } + scrollOffset { param -> +// TMLog.i(TAG, "scroll offset: $param") + } + + // 手柄点击事件 + onHandleClick { handleClickParams -> + TMLog.i(TAG, "手柄被点击,参数: $handleClickParams") + // 从参数中获取当前级别,如果获取失败则使用默认值 + val currentLevel = try { + (handleClickParams as? Map)?.get("currentLevel") as? Int + ?: 0 + } catch (e: Exception) { + TMLog.e(TAG, "获取当前级别失败: ${e.message}") + 0 + } + + // 根据当前level,跳转到下一个等级 (level + 1) % 3 + val nextLevel = (currentLevel + 1) % 3 + TMLog.i(TAG, "点击手柄,从级别 $currentLevel 跳转到级别 $nextLevel") + ctx.nestStageCardView?.view?.snapToLevel(nextLevel, true) + } + onOuterScrollDragBegin { + TMLog.i(TAG, "外部滚动onOuterScrollDragBegin") + } + onOuterScrollDragEnd { + TMLog.i(TAG, "外部滚动onOuterScrollDragEnd") + } + } + } + + } + + // Modal 弹窗 + vif({ ctx.showModal }) { + Modal { + // 遮罩层 + View { + attr { + absolutePosition(0f, 0f, 0f, 0f) + justifyContentCenter() + alignItemsCenter() + backgroundColor(Color(0, 0, 0, 0.5f)) // 半透明黑色遮罩 + } + + // 弹窗内容 + View { + attr { + width(300f) + height(200f) + backgroundColor(Color.WHITE) + borderRadius(12f) + justifyContentCenter() + alignItemsCenter() + paddingLeft(20f) + paddingRight(20f) + paddingTop(20f) + paddingBottom(20f) + } + + Text { + attr { + text("这是一个 Kuikly DSL Modal 弹窗") + fontSize(16f) + color(Color.BLACK) + textAlignCenter() + marginBottom(20f) + } + } + + Button { + attr { + titleAttr { + text("关闭") + color(Color.WHITE) + } + backgroundColor(Color.RED) + width(100f) + height(40f) + borderRadius(8f) + } + event { + click { + ctx.showModal = false + TMLog.i(TAG, "关闭Modal弹窗") + } + } + } + } + + // 点击遮罩层关闭弹窗 + event { + click { + ctx.showModal = false + TMLog.i(TAG, "点击遮罩层关闭Modal弹窗") + } + } + } + } + } + } + } + + private fun renderBtns(): ViewBuilder { + val ctx = this + return { + View { + attr { + marginTop(40f) + allCenter() + height(ctx.menuHeight) + flexDirectionRow() + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("我是底图按钮1") + backgroundColor(if (ctx.textViewBgColor) Color.RED else Color.GREEN) + textAlignCenter() + } + event { + click { + ctx.textViewBgColor = !ctx.textViewBgColor + TMLog.i(TAG, "我是底图按钮click1") + if (ctx.headerHeight == 120f) { + ctx.headerHeight = 60f + } else { + ctx.headerHeight = 120f + } + + } + } + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("子滚动页卡下拉刷新:${ctx.enableHeaderFresh}") + textAlignCenter() + } + event { + click { + ctx.enableHeaderFresh = !ctx.enableHeaderFresh + } + } + } + Blur { + attr { + height(100f) + width(80f) + blurRadius(10f) + } + } + + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("点击设置卡片吸附到level=2") + backgroundColor(Color.YELLOW) + textAlignCenter() + } + event { + click { + ctx.nestStageCardView?.view?.snapToLevel(2, true) + ctx.nestStageCardView?.view?.snapToLevel(2, true) + ctx.nestStageCardView?.view?.snapToLevel(2, true) +// setTimeout(20){ +// ctx.nestStageCardView?.view?.snapToLevel(2, true) +// } +// setTimeout(10){ +// ctx.nestStageCardView?.view?.snapToLevel(2, true) +// } + + } + } + } + } + + View { + attr { + allCenter() + height(50f) + flexDirectionRow() + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("设置特定的滚动距离,子控件滚动100f") + backgroundColor(Color.YELLOW) + lines(3) + textAlignCenter() + } + event { + click { + ctx.nestStageCardView?.view?.scrollToOffset(null, 100f) + } + } + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + lines(3) + text("设置内部的scroll滚动到第4个position") + backgroundColor(Color.YELLOW) + textAlignCenter() + } + event { + click { + ctx.nestStageCardView?.view?.scrollToItemPosition(4, true) + } + } + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("外部scroll:${if (ctx.enableOutScrollEnable) "启用" else "禁用"}") + backgroundColor(if (ctx.enableOutScrollEnable) Color.GREEN else Color.RED) + textAlignCenter() + } + event { + click { + ctx.enableOutScrollEnable = !ctx.enableOutScrollEnable + ctx.nestStageCardView?.view?.setOuterScrollEnabled(ctx.enableOutScrollEnable) + TMLog.i(TAG, "切换外部scroll状态: ${ctx.enableOutScrollEnable}") + } + } + } + } + + // 新增的嵌套滚动和弹性效果控制按钮 + View { + attr { + allCenter() + height(50f) + flexDirectionRow() + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("嵌套滚动:${if (ctx.nestedScrollEnable) "启用" else "禁用"}") + backgroundColor(if (ctx.nestedScrollEnable) Color.GREEN else Color.RED) + textAlignCenter() + lines(2) + } + event { + click { + ctx.nestedScrollEnable = !ctx.nestedScrollEnable + ctx.nestStageCardView?.view?.childScrollToBottom(true) + // 重新设置属性以应用更改 + TMLog.i(TAG, "切换嵌套滚动状态: ${ctx.nestedScrollEnable}") + } + } + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("子滚动弹性:${if (ctx.enableChildScrollBounces) "启用" else "禁用"}") + backgroundColor(if (ctx.enableChildScrollBounces) Color.GREEN else Color.RED) + textAlignCenter() + lines(2) + } + event { + click { + ctx.enableChildScrollBounces = !ctx.enableChildScrollBounces + // 重新设置属性以应用更改 + TMLog.i( + TAG, + "切换子滚动弹性效果状态: ${ctx.enableChildScrollBounces}" + ) + } + } + } + Text { + attr { + height(ctx.menuHeight) + width(ctx.menuWidth) + text("设置第一个卡片高度变化${ctx.firstCardHeight}") + backgroundColor(Color.YELLOW) + textAlignCenter() + lines(2) + } + event { + click { + if (ctx.firstCardHeight > 150f) { + ctx.firstCardHeight = 150f + } else { + ctx.firstCardHeight = 250f + } + ctx.nestStageCardView?.view?.updateLevelHeights( + listOf( + ctx.firstCardHeight, + 400f, + 600f + ) + ) + // 触发组件重新渲染以应用新的属性设置 + TMLog.i(TAG, "设置第一个卡片高度变化") + } + } + } + } + + View { + attr { + size(100f, 100f) + } + Scroller { + attr { + absolutePosition(0f, 0f, 0f, 0f) + size(100f, 100f) + } + Text { + attr { + size(100f, 100f) + backgroundColor(Color.RED) + } + } + Image { + attr { + size(100f, 100f) + + src("https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/lQ8TO29r.gif") + } + } + } + + View { + attr { + size(100f, 100f) + } + Blur { + attr { + width(100f) + height(100f) + blurRadius(1f) + } + } + } + + } + } + } + + + /** + * 计算滚动比例 + * + * 根据当前卡片高度计算滚动进度比例,用于控制卡片展开/收起的动画过渡。 + * + * 计算逻辑: + * 1. 计算当前高度与中等高度的差值 + * 2. 除以最大高度与中等高度的差值,得到比例 + * 3. 将比例限制在 [0, 1] 范围内 + * + * @return 滚动比例值,范围 [0, 1] + * - 0: 卡片处于中等高度状态 + * - 1: 卡片处于完全展开(大卡片)状态 + * - 0-1: 卡片处于过渡动画中 + */ + private fun getScrollerRatio(): Float { +// return 1f; + + val diff = this.cardHeight - 450f + var ratio = diff / (600f - 450f) + ratio = maxOf(ratio, 0f) + ratio = minOf(ratio, 1f) + this@TMNestStageCardViewDemo.headerHeight = 120f * (if (ratio < 0.5) 1f else 0.5f) + TMLog.i( + TAG, + "getScrollerRatio->ratio${ratio} height:${this@TMNestStageCardViewDemo.headerHeight}" + ) + return ratio + } +} \ No newline at end of file diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/list/ListLimitBouncesTest.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/list/ListLimitBouncesTest.kt new file mode 100644 index 0000000000..c4c62c93cc --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/list/ListLimitBouncesTest.kt @@ -0,0 +1,219 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 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.demo.pages.demo.list + +import com.tencent.kuikly.core.annotations.Page +import com.tencent.kuikly.core.base.Color +import com.tencent.kuikly.core.base.ViewBuilder +import com.tencent.kuikly.core.reactive.handler.observable +import com.tencent.kuikly.core.views.* +import com.tencent.kuikly.core.views.compose.Button +import com.tencent.kuikly.demo.pages.base.BasePager +import com.tencent.kuikly.demo.pages.demo.base.NavBar + +@Page("ListLimitBouncesTest") +internal class ListLimitBouncesTest : BasePager() { + + private var limitHeaderBounces by observable(false) + private var limitFooterBounces by observable(false) + private lateinit var scrollerView: ScrollerView<*, *> + + override fun body(): ViewBuilder { + val ctx = this + return { + attr { backgroundColor(Color.WHITE) } + NavBar { attr { title = "Limit Bounces Test" } } + + // 控制按钮区域 + View { + attr { + padding(16f) + backgroundColor(Color(0xFFF5F5F5)) + } + + Text { + attr { + text("回弹限制设置") + fontSize(18f) + fontWeight500() + marginBottom(12f) + } + } + + // 顶部回弹限制开关 + View { + attr { + flexDirectionRow() + alignItemsCenter() + justifyContentSpaceBetween() + padding(12f) + backgroundColor(Color.WHITE) + marginBottom(8f) + borderRadius(8f) + } + Text { + attr { + text("限制顶部回弹 (limitHeaderBounces)") + fontSize(16f) + } + } + Button { + attr { + titleAttr { + text(if (ctx.limitHeaderBounces) "已开启" else "已关闭") + } + backgroundColor(if (ctx.limitHeaderBounces) Color(0xFF4CAF50) else Color(0xFF9E9E9E)) + } + event { + click { + ctx.limitHeaderBounces = !ctx.limitHeaderBounces + ctx.updateBouncesSettings() + } + } + } + } + + // 底部回弹限制开关 + View { + attr { + flexDirectionRow() + alignItemsCenter() + justifyContentSpaceBetween() + padding(12f) + backgroundColor(Color.WHITE) + marginBottom(8f) + borderRadius(8f) + } + Text { + attr { + text("限制底部回弹 (limitFooterBounces)") + fontSize(16f) + } + } + Button { + attr { + titleAttr { + text(if (ctx.limitFooterBounces) "已开启" else "已关闭") + } + backgroundColor(if (ctx.limitFooterBounces) Color(0xFF4CAF50) else Color(0xFF9E9E9E)) + } + event { + click { + ctx.limitFooterBounces = !ctx.limitFooterBounces + ctx.updateBouncesSettings() + } + } + } + } + + // 状态显示 + View { + attr { + padding(12f) + backgroundColor(Color(0xFFE3F2FD)) + borderRadius(8f) + marginTop(8f) + } + Text { + attr { + text("当前状态:\n" + + "• 顶部回弹限制: ${if (ctx.limitHeaderBounces) "开启" else "关闭"}\n" + + "• 底部回弹限制: ${if (ctx.limitFooterBounces) "开启" else "关闭"}\n\n" + + "测试说明:\n" + + "1. 向上滚动到顶部,测试顶部回弹限制\n" + + "2. 向下滚动到底部,测试底部回弹限制\n" + + "3. 切换开关查看效果变化") + fontSize(14f) + color(Color(0xFF1976D2)) + lineHeight(20f) + } + } + } + } + + // 滚动区域 + Scroller { + ctx.scrollerView = this + attr { + flex(1f) + bouncesEnable(true, ctx.limitHeaderBounces, ctx.limitFooterBounces) + } + + // 顶部标识 + View { + attr { + height(100f) + backgroundColor(Color(0xFFFFEB3B)) + allCenter() + } + Text { + attr { + text("顶部区域 - 向上滚动测试顶部回弹限制") + fontSize(16f) + fontWeight500() + color(Color.BLACK) + } + } + } + + // 中间内容区域 + for (i in 1..30) { + View { + attr { + height(80f) + backgroundColor(if (i % 2 == 0) Color(0xFFE0E0E0) else Color(0xFFF5F5F5)) + padding(16f) + alignItemsCenter() + } + Text { + attr { + text("内容项 $i - 滚动测试") + fontSize(16f) + color(Color.BLACK) + } + } + } + } + + // 底部标识 + View { + attr { + height(100f) + backgroundColor(Color(0xFF4CAF50)) + allCenter() + } + Text { + attr { + text("底部区域 - 向下滚动测试底部回弹限制") + fontSize(16f) + fontWeight500() + color(Color.WHITE) + } + } + } + } + } + } + + private fun updateBouncesSettings() { + scrollerView.getViewAttr().bouncesEnable( + bouncesEnable = true, + limitHeaderBounces = limitHeaderBounces, + limitFooterBounces = limitFooterBounces + ) + } +} + diff --git a/docs/API/components/list.md b/docs/API/components/list.md index 118e494013..dab1d6fba9 100644 --- a/docs/API/components/list.md +++ b/docs/API/components/list.md @@ -19,11 +19,17 @@ ### bouncesEnable -是否开启回弹效果,默认 `true` +设置是否允许边界回弹效果,以及是否限制顶部或底部的回弹。 + +当 `bouncesEnable` 为 `false` 时,整个滚动视图将禁用回弹效果,此时 `limitHeaderBounces` 和 `limitFooterBounces` 参数将无效。 +当 `bouncesEnable` 为 `true` 时,可以通过 `limitHeaderBounces` 和 `limitFooterBounces` 参数分别控制顶部和底部的回弹行为。 + +| 参数 | 描述 | 类型 | 默认值 | +|--------------| ------------------------------------------------------------ | ------------- | ------ | +| bouncesEnable | 是否允许边界回弹效果 | Boolean | `true` | +| limitHeaderBounces | 是否禁止顶部回弹。当滚动到顶部时,如果此参数为 `true`,则禁止向上拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | +| limitFooterBounces | 是否禁止底部回弹。当滚动到底部时,如果此参数为 `true`,则禁止向下拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | -| 参数 | 描述 | 类型 | -|--------------| ------------------------------------------------------------ | ------------- | -| value | 是否开启回弹效果,默认`true` | Boolean | ### showScrollerIndicator diff --git a/docs/API/components/scroller.md b/docs/API/components/scroller.md index 6ad8bc6b3d..5860a09fd5 100644 --- a/docs/API/components/scroller.md +++ b/docs/API/components/scroller.md @@ -18,11 +18,16 @@ ### bouncesEnable -是否开启回弹效果 +设置是否允许边界回弹效果,以及是否限制顶部或底部的回弹。 -| 参数 | 描述 | 类型 | -| --------------------- | ------------------------------------------------------------ | ------------- | -| bouncesEnable | 是否开启回弹效果,默认 `true` | Boolean | +当 `bouncesEnable` 为 `false` 时,整个滚动视图将禁用回弹效果,此时 `limitHeaderBounces` 和 `limitFooterBounces` 参数将无效。 +当 `bouncesEnable` 为 `true` 时,可以通过 `limitHeaderBounces` 和 `limitFooterBounces` 参数分别控制顶部和底部的回弹行为。 + +| 参数 | 描述 | 类型 | 默认值 | +| --------------------- | ------------------------------------------------------------ | ------------- | ------ | +| bouncesEnable | 是否允许边界回弹效果 | Boolean | `true` | +| limitHeaderBounces | 是否禁止顶部回弹。当滚动到顶部时,如果此参数为 `true`,则禁止向上拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | +| limitFooterBounces | 是否禁止底部回弹。当滚动到底部时,如果此参数为 `true`,则禁止向下拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | ### showScrollerIndicator diff --git a/docs/API/components/waterfall-list.md b/docs/API/components/waterfall-list.md index a2cbfbe72e..91139645c3 100644 --- a/docs/API/components/waterfall-list.md +++ b/docs/API/components/waterfall-list.md @@ -58,11 +58,17 @@ ### bouncesEnable -是否开启回弹效果,默认 `true` +设置是否允许边界回弹效果,以及是否限制顶部或底部的回弹。 + +当 `bouncesEnable` 为 `false` 时,整个滚动视图将禁用回弹效果,此时 `limitHeaderBounces` 和 `limitFooterBounces` 参数将无效。 +当 `bouncesEnable` 为 `true` 时,可以通过 `limitHeaderBounces` 和 `limitFooterBounces` 参数分别控制顶部和底部的回弹行为。 + +| 参数 | 描述 | 类型 | 默认值 | +|--------------| ------------------------------------------------------------ | ------------- | ------ | +| bouncesEnable | 是否允许边界回弹效果 | Boolean | `true` | +| limitHeaderBounces | 是否禁止顶部回弹。当滚动到顶部时,如果此参数为 `true`,则禁止向上拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | +| limitFooterBounces | 是否禁止底部回弹。当滚动到底部时,如果此参数为 `true`,则禁止向下拖拽时的回弹效果。注意:如果 `bouncesEnable` 为 `false`,该参数将无效 | Boolean | `false` | -| 参数 | 描述 | 类型 | -|--------------| ------------------------------------------------------------ | ------------- | -| value | 是否开启回弹效果,默认`true` | Boolean | ### showScrollerIndicator