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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,8 @@ proto/**/*.kt
/iosApp/logs/
/.claude-internal
/.cursor/
.mcp.json
.mcp.json

## Local IDE / tool noise
.vscode/
.hvigor/
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import com.tencent.kuikly.compose.ui.node.requireLayoutNode
import com.tencent.kuikly.compose.ui.semantics.SemanticsPropertyReceiver
import com.tencent.kuikly.compose.ui.semantics.text
import com.tencent.kuikly.compose.ui.text.AnnotatedString
import com.tencent.kuikly.compose.ui.text.LineMetrics
import com.tencent.kuikly.compose.ui.text.MultiParagraph
import com.tencent.kuikly.compose.ui.text.TextLayoutInput
import com.tencent.kuikly.compose.ui.text.TextLayoutResult
Expand Down Expand Up @@ -230,7 +231,7 @@ internal class TextStringRichNode(
val placeholderRects = mutableListOf<Rect>()

val textView = (requireLayoutNode() as? KNode<RichTextView>)?.view
val pageDensity = textView!!.getPager().pagerDensity()
val pageDensity = textView?.getPager()?.pagerDensity() ?: requireDensity().density
// 遍历所有文本片段,处理占位符
textView?.getViewAttr()?.getSpans()?.forEachIndexed { index, span ->
if (span !is PlaceholderSpan) return@forEachIndexed
Expand Down Expand Up @@ -259,6 +260,50 @@ internal class TextStringRichNode(
}

val effectiveAnnotated = annotatedText ?: AnnotatedString(plainText ?: "")

// 行度量:惰性拉取——不在 measure 热路径同步桥调用,仅 getLineTop/getLineStart
// 等首次被读取时才向 native 查询一次并缓存(与 getBoundingBoxFn 同思路)。
// 新格式:"N top0 bottom0 start0 end0 top1 bottom1 start1 end1 ..."
// 为兼容旧实现,若 start/end 缺失则退化为 0。
val lineMetricsFn: (() -> LineMetrics)? = textView?.let { tv ->
{
val metricsStr = tv.shadow?.callMethod("lineMetrics", "") ?: ""
val parts = metricsStr.split(" ")
val lineCount = parts.getOrNull(0)?.toIntOrNull() ?: 0
val lineTops = FloatArray(lineCount)
val lineBottoms = FloatArray(lineCount)
val lineStarts = IntArray(lineCount)
val lineEnds = IntArray(lineCount)
val hasLineOffsets = parts.size >= 1 + lineCount * 4
var idx = 1
for (i in 0 until lineCount) {
lineTops[i] = (parts.getOrNull(idx)?.toFloatOrNull() ?: 0f) * pageDensity
lineBottoms[i] = (parts.getOrNull(idx + 1)?.toFloatOrNull() ?: 0f) * pageDensity
if (hasLineOffsets) {
lineStarts[i] = parts.getOrNull(idx + 2)?.toIntOrNull() ?: 0
lineEnds[i] = parts.getOrNull(idx + 3)?.toIntOrNull() ?: lineStarts[i]
idx += 4
} else {
idx += 2
}
}
LineMetrics(lineCount, lineTops, lineBottoms, lineStarts, lineEnds)
}
}
// getBoundingBox:按需向 native 查询 offset 处字符包围盒(dp),× pageDensity → px
val getBoundingBoxFn: ((Int) -> Rect)? = textView?.let { tv ->
{ offset ->
val s = tv.shadow?.callMethod("getBoundingBox", offset.toString()) ?: ""
val c = s.split(" ")
Rect(
(c.getOrNull(0)?.toFloatOrNull() ?: 0f) * pageDensity,
(c.getOrNull(1)?.toFloatOrNull() ?: 0f) * pageDensity,
(c.getOrNull(2)?.toFloatOrNull() ?: 0f) * pageDensity,
(c.getOrNull(3)?.toFloatOrNull() ?: 0f) * pageDensity
)
}
}

return TextLayoutResult(
TextLayoutInput(
effectiveAnnotated,
Expand All @@ -272,7 +317,11 @@ internal class TextStringRichNode(
// fontFamilyResolver,
// finalConstraints
),
MultiParagraph(placeholderRects = placeholderRects),
MultiParagraph(
placeholderRects = placeholderRects,
lineMetricsFn = lineMetricsFn,
getBoundingBoxFn = getBoundingBoxFn
),
size
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.tencent.kuikly.compose.ui.geometry.Rect
import com.tencent.kuikly.compose.ui.geometry.RoundRect
import com.tencent.kuikly.compose.ui.graphics.Canvas
import com.tencent.kuikly.compose.ui.graphics.ClipOp
import com.tencent.kuikly.compose.ui.graphics.DashPathEffect
import com.tencent.kuikly.compose.ui.graphics.ImageBitmap
import com.tencent.kuikly.compose.ui.graphics.LinearGradient
import com.tencent.kuikly.compose.ui.graphics.Matrix
Expand All @@ -43,7 +44,26 @@ import kotlin.math.PI

internal class KuiklyCanvas : Canvas {

/**
* 虚线脏标记:仅当上一笔真向原生下发过 setLineDash(intervals) 时才为 true。
* setLineDash 是真实桥调用(拼 JSON 走 callMethod),未激活场景(从未画过虚线)
* 必须做到零桥调用,避免 Canvas 密集页面每个图形多一次跨端通信。
*/
private var dashActive = false

/**
* 仅在 dashActive 时向原生下发清空虚线指令并复位标记;未激活时零开销。
*/
private fun CanvasContext.clearLineDashIfActive() {
if (dashActive) {
setLineDash(emptyList())
dashActive = false
}
}

private fun CanvasContext.fillOrStroke(paint: Paint) {
// 清空上一笔 drawLine 残留的虚线状态,避免同 lambda 内后续描边图形继承虚线
clearLineDashIfActive()
val linearGradient = paint.toKuiklyLinearGradient(densityValue)
if (paint.style == PaintingStyle.Fill) {
if (linearGradient != null) {
Expand Down Expand Up @@ -74,10 +94,16 @@ internal class KuiklyCanvas : Canvas {
override var view: DeclarativeBaseView<*, *>? = null
set(value) {
if (value is CanvasView) {
context = CanvasContext(value.renderView!!, value.pagerId, value.nativeRef)
densityValue = value.getPager().pagerDensity()
value.renderView?.callMethod("reset", "")
strokeCap = StrokeCap.Butt
val renderView = value.renderView
if (renderView != null) {
context = CanvasContext(renderView, value.pagerId, value.nativeRef)
densityValue = value.getPager().pagerDensity()
renderView.callMethod("reset", "")
strokeCap = StrokeCap.Butt
dashActive = false // 原生画布已 reset,虚线状态同步复位
} else {
context = null
}
} else {
context = null
}
Expand Down Expand Up @@ -156,6 +182,20 @@ internal class KuiklyCanvas : Canvas {

override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
context?.apply {
// dash 参数下沉:px → dp/pt 换算后透传给 CanvasContext.setLineDash
val effect = paint.pathEffect
if (effect is DashPathEffect) {
val intervals = effect.intervals
// 非法输入兜底:intervals 为空或全零时降级画实线,避免透传未定义行为
if (intervals.isEmpty() || intervals.all { it == 0f }) {
clearLineDashIfActive()
} else {
setLineDash(intervals.map { it / densityValue }.toList())
dashActive = true
}
} else {
clearLineDashIfActive() // 仅上一笔真画过虚线时才下发清理,防残留
}
beginPath()
moveTo(p1.x / densityValue, p1.y / densityValue)
lineTo(p2.x / densityValue, p2.y / densityValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ import com.tencent.kuikly.compose.ui.node.OwnerScope
import com.tencent.kuikly.compose.ui.node.invalidateDraw
import com.tencent.kuikly.compose.ui.node.requireLayoutNode
import com.tencent.kuikly.compose.ui.node.requireOwner
import com.tencent.kuikly.compose.ui.KuiklyCanvas
import com.tencent.kuikly.compose.ui.graphics.drawscope.CanvasDrawScope
import com.tencent.kuikly.core.base.DeclarativeBaseView
import com.tencent.kuikly.core.base.ViewContainer
import com.tencent.kuikly.core.log.KLog
import com.tencent.kuikly.core.views.CanvasView

Expand Down Expand Up @@ -119,6 +122,22 @@ internal class DrawBackgroundModifier(
var onDraw: DrawScope.() -> Unit
) : Modifier.Node(), DrawModifierNode, OwnerScope {

/**
* drawBehind 用的背景绘制层。
* 文字等组件要画背景时,挂一个独立的背景视图到父容器最底层来实现。
*
* 注意:这条背景是挂在父容器里的(不是画在文字内部)。
* 正常从上往下、从左往右排布不受影响;
* 以后想完全对齐官方做法,需要把背景画进宿主自己内部。
*/
private var bgCanvasView: CanvasView? = null

/**
* 背景 CanvasView 专用的 DrawScope(canvas 绑到 bgCanvasView)。
* 复用同一个 CanvasDrawScope 以复用 Paint 对象,与 LayoutNodeDrawScope 同思路。
*/
private val bgDrawScope = CanvasDrawScope()

override fun ContentDrawScope.draw(view: DeclarativeBaseView<*, *>?) {
if (view is CanvasView) {
requireOwner().snapshotObserver.observeReads(
Expand All @@ -127,12 +146,111 @@ internal class DrawBackgroundModifier(
) {
onDraw()
}
} else if (view != null) {
// 非 CanvasView 宿主:走背景 CanvasView 通道
ensureBackgroundCanvasView(view)
// 用 DrawScope.size(= 宿主完整布局尺寸,含多行)而非 renderView.currentFrame
//(后者对 RichTextView 只返一行高)
// 与 CanvasView 分支一致,用 observeReads 包裹,使 draw 闭包内读取的
// snapshot state 变化时能触发重绘(否则仅依赖重组会漏掉部分场景)
requireOwner().snapshotObserver.observeReads(
this@DrawBackgroundModifier,
DrawModifierNode::invalidateDraw
) {
drawIntoBackgroundCanvasView(view, size)
}
} else {
KLog.e("Kuikly.Compose", "drawBehind expect CanvasView, but got $view")
}
drawContent()
}

/**
* 惰性创建背景 CanvasView 并加到宿主的父容器(绝对定位,初始位置=宿主位置)。
* 实际尺寸/位置由 [drawIntoBackgroundCanvasView] 每次 draw 手动 setFrame 同步
*(flex 不会给 draw 期间注入的 absolute 子 view 分 frame)。
*/
private fun ensureBackgroundCanvasView(hostView: DeclarativeBaseView<*, *>) {
if (bgCanvasView != null) return
val parent = hostView.parent as? ViewContainer<*, *> ?: run {
KLog.e("Kuikly.Compose", "drawBehind bgCanvas: host has no ViewContainer parent")
return
}
// RichTextView 等自测量组件的 flexNode.layoutFrame 为 0,真位置在 renderView.currentFrame
val frame = hostView.renderView?.currentFrame ?: return
val bg = CanvasView()
var addedToParent = false
try {
parent.addChild(bg, {
// absolutePosition 设 positionType=ABSOLUTE + 初始 top/left;
// 尺寸不在此设(flex 不分 frame)
getViewAttr().absolutePosition(top = frame.y, left = frame.x)
}, 0)
addedToParent = true
parent.insertDomSubView(bg, 0)
bgCanvasView = bg
} catch (e: Throwable) {
KLog.e("Kuikly.Compose", "drawBehind bgCanvas: ensure failed: ${e.message}")
// 半挂状态回滚:addChild 成功但后续步骤失败时,onDetach 无法通过 bgCanvasView
// 触达 bg,会造成 view 泄漏;这里显式清掉已挂的 bg。
if (addedToParent) {
runCatching { parent.removeDomSubView(bg) }
runCatching { parent.removeChild(bg) }
}
}
}

/**
* 把背景 CanvasView 定位到宿主同帧(无额外 padding,与官方语义对齐),并用 KuiklyCanvas +
* CanvasDrawScope 把 onDraw 跑进 bg。绕过 CanvasView.draw() 的
* flexNode.layoutFrame.isDefaultValue() 检查(flex 不给注入子分 frame,该检查恒 true)。
*/
private fun drawIntoBackgroundCanvasView(
hostView: DeclarativeBaseView<*, *>,
scopeSize: Size
) {
val bg = bgCanvasView ?: return
val bgRender = bg.renderView ?: return
// 位置用 renderView.currentFrame.x/y(宿主在父容器中的坐标,dp);
// 尺寸用 scopeSize(= 宿主完整布局尺寸含多行,px),不再用 currentFrame 的 height
//(后者对 RichTextView 只返一行高)。
val posFrame = hostView.renderView?.currentFrame ?: return
if (scopeSize.width <= 0f || scopeSize.height <= 0f) return
try {
// 对齐官方语义:drawBehind 的 DrawScope.size 必须严格等于组件自身布局
// 尺寸,无任何偏移补丁。下划线"不穿字"由调用方在 lambda 内自行决定
// 绘制 y 坐标(如画在 size.height),框架不替宿主加底部 padding。
val density = requireDensity().density
val bgWidthDp = scopeSize.width / density
val bgHeightDp = scopeSize.height / density
bgRender.setFrame(posFrame.x, posFrame.y, bgWidthDp, bgHeightDp)
val bgCanvas = KuiklyCanvas()
bgCanvas.view = bg
val drawBlock = onDraw
bgDrawScope.draw(
requireDensity(),
requireLayoutDirection(),
bgCanvas,
Size(scopeSize.width, scopeSize.height)
) {
drawBlock()
}
} catch (e: Throwable) {
KLog.e("Kuikly.Compose", "drawBehind bgCanvas draw failed: ${e.message}")
}
}

override fun onDetach() {
bgCanvasView?.let { bg ->
(bg.parent as? ViewContainer<*, *>)?.let { parent ->
runCatching { parent.removeDomSubView(bg) }
runCatching { parent.removeChild(bg) }
}
}
bgCanvasView = null
super.onDetach()
}

override val isValidOwnerScope: Boolean get() = isAttached
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class KuiklyPaint : Paint {
override var strokeMiterLimit: Float = 4f
override var style: PaintingStyle = PaintingStyle.Fill
override var shader: Any? = null
override var pathEffect: PathEffect? = null
}

interface Paint {
Expand Down Expand Up @@ -121,4 +122,15 @@ interface Paint {
* When this is null, the [color] is used instead.
*/
var shader: Any?

/**
* The effect to apply to the stroke when drawing. null indicates a solid stroke.
* Currently only [PathEffect.dashPathEffect] is supported.
*
* Default no-op getter/setter keeps existing third-party [Paint] implementations
* source-compatible; [KuiklyPaint] overrides this with a real backing field.
*/
var pathEffect: PathEffect?
get() = null
set(_) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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.compose.ui.graphics

/**
* Kuikly Compose 的 PathEffect 目前只承载 dash 参数,不映射到底层 Skia 的 SkPathEffect。
* 内部通过 KuiklyCanvas 直连 CanvasContext.setLineDash。
*
* 预留 cornerPathEffect / chainPathEffect / stampedPathEffect 扩展位,本期只实现 dashPathEffect。
*/
sealed interface PathEffect {
companion object {
fun dashPathEffect(intervals: FloatArray, phase: Float = 0f): PathEffect =
DashPathEffect(intervals, phase)
}
}

/**
* 虚线特效:[intervals] 为 dash/gap 长度对(px 语义,与官方 Jetpack Compose 一致),
* 在 KuiklyCanvas.drawLine 里会除以 densityValue 换算为 dp/pt 后透传给 CanvasContext.setLineDash。
*
* 注意:[phase] 当前不生效——跨端 CanvasContext.setLineDash 协议未开放 phase 参数,
* iOS 桥内 CGContextSetLineDash 的 phase 硬编码为 0。设置 phase 不会报错也不会有视觉差异,
* 与传 0 等效。若后续启用需三端同步扩展 setLineDash 协议。
*/
internal data class DashPathEffect(
val intervals: FloatArray,
val phase: Float
) : PathEffect {

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DashPathEffect) return false
if (phase != other.phase) return false
if (!intervals.contentEquals(other.intervals)) return false
return true
}

override fun hashCode(): Int {
var result = intervals.contentHashCode()
result = 31 * result + phase.hashCode()
return result
}
}
Loading