Skip to content

Instantly share code, notes, and snippets.

@chaosgoo
Created February 2, 2026 08:26
Show Gist options
  • Select an option

  • Save chaosgoo/eed6cda2ed7c166defee22991601c1fd to your computer and use it in GitHub Desktop.

Select an option

Save chaosgoo/eed6cda2ed7c166defee22991601c1fd to your computer and use it in GitHub Desktop.
package io.serialflow.editor.ui
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.Gravity
import android.widget.FrameLayout
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import io.serialflow.editor.R
/**
* 这是一个容器组件
* 具有类似于LVGL的Border属性的FrameLayout
*/
class BorderFrameLayout : FrameLayout {
/**
* [DEFAULT_CHILD_GRAVITY] copy from [FrameLayout]
*/
private val DEFAULT_CHILD_GRAVITY = Gravity.TOP or Gravity.START
// 圆角部分是否会将内容裁剪
protected var clipContent: Boolean = true
/**
* 背景Path
* 为了实现圆角效果而创建的辅助Path, 是整个控件的轮廓
* 同时也是Border的外轮廓,和[mBorderInnerContourPath]包围区域构成轮廓
*/
private var mBackgroundPath = Path()
/**
* 实际Border的内边界, 此范围内为内部child允许摆放范围
*/
private var mBorderInnerContourPath = Path()
// 圆角半径, 轮廓的四个圆角半径必须相同
private var mBorderRadius = 0f
// 描述Border宽度,LVGL中四边Border只要存在,他们的宽度就相等.
private var mBorderWidth: Float = 0f
// border尺寸 + LVGL中Padding尺寸 = Android中Padding
// border尺寸
private var mBorderLeft: Float = 0f
private var mBorderTop: Float = 0f
private var mBorderRight: Float = 0f
private var mBorderBottom: Float = 0f
// 真实的Padding
private var mRealPaddingLeft: Float = 0f
private var mRealPaddingTop: Float = 0f
private var mRealPaddingRight: Float = 0f
private var mRealPaddingBottom: Float = 0f
// border画笔
private val mBorderPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
}
// border颜色
private var mBorderColor: Int = Color.TRANSPARENT
// border风格, 1:"dash" 虚线 0: "solid" 实心
private var mBorderStyle: Int = 0
// 背景色
private var mBackgroundColor: Int = Color.TRANSPARENT
// 渐变角度
private var mBackgroundGradientAngle = 0f
// 渐变色
private var mBackgroundGradientColor = Color.TRANSPARENT
// 用于处理轮廓的Path
private val counterPath = Path()
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(
context, attrs, defStyle
) {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BorderFrameLayout, defStyle, 0)
mBorderRadius = typedArray.getDimension(R.styleable.BorderFrameLayout_borderRadius, 0f)
mBorderColor = typedArray.getColor(R.styleable.BorderFrameLayout_borderColor, Color.TRANSPARENT)
if (mBorderColor != Color.TRANSPARENT) {
mBorderColor = mBorderColor or 0xff000000.toInt()
}
mBorderStyle = typedArray.getInt(R.styleable.BorderFrameLayout_borderStyle, 0)
mBackgroundColor = typedArray.getColor(R.styleable.BorderFrameLayout_backgroundColor, Color.TRANSPARENT)
mBackgroundGradientAngle = typedArray.getFloat(R.styleable.BorderFrameLayout_backgroundGradientAngle, 0f)
mBackgroundGradientColor = typedArray.getColor(R.styleable.BorderFrameLayout_backgroundGradientColor, Color.TRANSPARENT)
clipContent = typedArray.getBoolean(R.styleable.BorderFrameLayout_clipContent, true)
mRealPaddingLeft = typedArray.getDimension(R.styleable.BorderFrameLayout_realPaddingLeft, 0f)
mRealPaddingTop = typedArray.getDimension(R.styleable.BorderFrameLayout_realPaddingTop, 0f)
mRealPaddingRight = typedArray.getDimension(R.styleable.BorderFrameLayout_realPaddingRight, 0f)
mRealPaddingBottom = typedArray.getDimension(R.styleable.BorderFrameLayout_realPaddingBottom, 0f)
val borderWidth = typedArray.getDimension(R.styleable.BorderFrameLayout_borderWidth, 0f)
val borderLeft = typedArray.getDimension(R.styleable.BorderFrameLayout_borderLeft, borderWidth)
val borderTop = typedArray.getDimension(R.styleable.BorderFrameLayout_borderTop, borderWidth)
val borderRight = typedArray.getDimension(R.styleable.BorderFrameLayout_borderRight, borderWidth)
val borderBottom = typedArray.getDimension(R.styleable.BorderFrameLayout_borderBottom, borderWidth)
typedArray.recycle()
// Initialize with XML values
setBorderWidth(borderLeft, borderTop, borderRight, borderBottom)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
// layoutChildren 内部通过 getPaddingXXXWithForeground() 已经处理了 border
// 所以这里直接传递原始边界,避免 border 被重复计算
layoutChildren(left, top, right, bottom)
}
/**
* super.onLayout会无视自定义的Border
*
* 所以修改FrameLayout原始的layoutChildren代码,补充border的计算逻辑
* **[layoutChildren] 内容和android-33\android\widget\FrameLayout.java没有区别**
* 只是被自动转换成kotlin语法
* 让考虑border的关键在于
* * [getPaddingLeftWithForeground]
* * [getPaddingTopWithForeground]
* * [getPaddingRightWithForeground]
* * [getPaddingBottomWithForeground]
*
**/
private fun layoutChildren(left: Int, top: Int, right: Int, bottom: Int) {
val count = childCount
val parentLeft: Int = getPaddingLeftWithForeground()
val parentRight: Int = right - left - getPaddingRightWithForeground()
val parentTop: Int = getPaddingTopWithForeground()
val parentBottom: Int = bottom - top - getPaddingBottomWithForeground()
for (i in 0 until count) {
val child = getChildAt(i)
if (child.visibility != GONE) {
val lp = child.layoutParams as LayoutParams
val width = child.measuredWidth
val height = child.measuredHeight
var childLeft: Int
var childTop: Int
var gravity = lp.gravity
if (gravity == -1) {
gravity = DEFAULT_CHILD_GRAVITY
}
val layoutDirection = layoutDirection
val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection)
val verticalGravity = gravity and Gravity.VERTICAL_GRAVITY_MASK
childLeft = when (absoluteGravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.CENTER_HORIZONTAL -> parentLeft + (parentRight - parentLeft - width) / 2 +
lp.leftMargin - lp.rightMargin
Gravity.RIGHT -> {
parentRight - width - lp.rightMargin
}
Gravity.LEFT -> parentLeft + lp.leftMargin
else -> parentLeft + lp.leftMargin
}
childTop = when (verticalGravity) {
Gravity.TOP -> parentTop + lp.topMargin
Gravity.CENTER_VERTICAL -> parentTop + (parentBottom - parentTop - height) / 2 +
lp.topMargin - lp.bottomMargin
Gravity.BOTTOM -> parentBottom - height - lp.bottomMargin
else -> parentTop + lp.topMargin
}
child.layout(childLeft, childTop, childLeft + width, childTop + height)
}
}
}
override fun dispatchDraw(canvas: Canvas) {
drawBorderAndBackground(canvas)
// 储存画布,接下来可能会裁切
canvas.save()
// FIXME clip会导致边缘存在锯齿
// 子控件的paint是他自己的,所以这里设置xfermode没用,暂时不知道如何处理得到更好的抗锯齿效果
if (clipContent) {
// 确定边缘
canvas.clipPath(mBackgroundPath)
// 确定允许绘制的区域,这样就不会覆盖Border了
canvas.clipPath(generateChildDrawAreaPath())
}
// 绘制内部控件
super.dispatchDraw(canvas)
// 还原裁切
canvas.restore()
}
fun setCoordPad(left: Float, top: Float, right: Float, bottom: Float) {
mRealPaddingLeft = left
mRealPaddingTop = top
mRealPaddingRight = right
mRealPaddingBottom = bottom
setPadding(
(mRealPaddingLeft + mBorderLeft).roundToInt(),
(mRealPaddingTop + mBorderTop).roundToInt(),
(mRealPaddingRight + mBorderRight).roundToInt(),
(mRealPaddingBottom + mBorderBottom).roundToInt()
)
}
fun setBorderRadius(radius: Float) {
// 半径不能比短边的一半还要大
val maxR = minOf(height, width) / 2f
mBorderRadius = if (radius > minOf(height, width) / 2f) maxR else radius
}
fun setBorderStyle(style: Int) {
mBorderStyle = style
}
fun setBorderColor(color: Int) {
mBorderColor = color or 0xff000000.toInt()
}
fun setBorderWidth(width: Float) {
setBorderWidth(width, width, width, width)
}
fun setBorderWidth(
leftWidth: Float,
topWidth: Float,
rightWidth: Float,
bottomWidth: Float
) {
// border取最大的
mBorderWidth = maxOf(leftWidth, topWidth, rightWidth, bottomWidth)
// 检查是不是有更小的border并且矫正
setPadding(
(if (leftWidth != 0f && leftWidth != mBorderWidth) {
mBorderLeft = mBorderWidth
// 矫正leftBorder非0时候不等于最大Border宽度情况
mBorderWidth
} else {
mBorderLeft = leftWidth
leftWidth
} + mRealPaddingLeft).roundToInt(),
(if (topWidth != 0f && topWidth != mBorderWidth) {
mBorderTop = mBorderWidth
// 矫正topBorder非0时候不等于最大Border宽度情况
mBorderWidth
} else {
mBorderTop = topWidth
topWidth
} + mRealPaddingTop).roundToInt(),
(if (rightWidth != 0f && rightWidth != mBorderWidth) {
mBorderRight = mBorderWidth
// 矫正rightBorder非0时候不等于最大Border宽度情况
mBorderWidth
} else {
mBorderRight = rightWidth
rightWidth
} + mRealPaddingRight).roundToInt(),
(if (bottomWidth != 0f && bottomWidth != mBorderWidth) {
mBorderBottom = mBorderWidth
// 矫正bottomBorder非0时候不等于最大Border宽度情况
mBorderWidth
} else {
mBorderBottom = bottomWidth
bottomWidth
} + mRealPaddingBottom).roundToInt()
)
}
fun setBackgroundGradColor(color: Int) {
// 容器的背景不能为透明
mBackgroundGradientColor = color
}
fun setBackgroundGradAngle(angle: Float) {
mBackgroundGradientAngle = angle
}
override fun setBackgroundColor(color: Int) {
mBackgroundColor = color
}
/**
* 绘制背景和轮廓
* @param canvas 布局的canvas
*/
private fun drawBorderAndBackground(canvas: Canvas) {
/**
* [mBackgroundPath]整个控件的边界
*/
generateBackgroundContour(mBackgroundPath)
/**
* 1.绘制背景
*/
canvas.drawPath(mBackgroundPath, mBorderPaint.apply {
// 处理渐变色,计算渐变色方向
if (mBackgroundGradientColor != Color.TRANSPARENT) {
val xDir = width / 2f * sin(mBackgroundGradientAngle * PI / 180f).toFloat()
val yDir = height / 2f * cos(mBackgroundGradientAngle * PI / 180f).toFloat()
this.shader = LinearGradient(
width / 2f,
height / 2f,
width / 2f - xDir,
height / 2f - yDir,
mBackgroundColor,
mBackgroundGradientColor,
Shader.TileMode.MIRROR
)
}
this.color = mBackgroundColor
this.style = Paint.Style.FILL
this.strokeWidth = mBorderWidth
})
// clear shader
mBorderPaint.shader = null
// 限制绘制区域为背景轮廓内,用xfermode会导致滚动的时候,绘制的裁切失效.所以这里还是用clip来做
canvas.save()
canvas.clipPath(mBackgroundPath, Region.Op.INTERSECT)
// 还原clip
canvas.restore()
// 没宽度说明没有border,提前结束
if (mBorderWidth == 0f) {
return
}
/**
* 2.绘制Border
* 由于Android的PorterDuff.Mode不能像canvas的clip一样进行多次合成计算,所以这里需要创建一个辅助图层
* [maskBitmap]是border的Mask层
* [maskCanvas]是为了绘制Mask层而创建的Canvas
*/
/*=================== 生成Mask =================== */
val maskBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8)
val maskCanvas = Canvas(maskBitmap)
// 由于这个Canvas本身就是"干净"的,所以无需save操作
// 先绘制出整个控件的背景轮廓,
maskCanvas.drawPath(mBackgroundPath, mBorderPaint.apply {
style = Paint.Style.FILL
// 由于背景可能是透明的,透明的画笔会影响mask的绘制
// 所以这里手动在mask绘制的时候设置画笔颜色.只要不是透明的就行
color = Color.BLACK
})
// 设置paint模式为[PorterDuff.Mode.DST_OUT],代表以原始内容为基础
// 接下来的绘制得到保留背景和轮廓不重合的部分遮罩
mBorderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
maskCanvas.drawPath(generateChildDrawAreaPath(), mBorderPaint.apply {
color = Color.WHITE
})
// 根据轮的类型, 再次处理这一层遮罩范围
// 如果是虚线类型边框, 则绘制虚线线段相反的部分
// --- --- --- --- --- 这是虚线
// - - - - 这是虚线线段相反的部分
if (mBorderStyle == 1) {
counterPath.reset()
val offset = mBorderWidth / 2f
val maxDashIntervals = mBorderWidth
counterPath.addRoundRect(
0f + offset,
0f + offset,
width.toFloat() - offset,
height.toFloat() - offset,
mBorderRadius - offset,
mBorderRadius - offset,
Path.Direction.CCW
)
maskCanvas.drawPath(counterPath,
mBorderPaint.apply {
style = Paint.Style.STROKE
this.strokeWidth = mBorderWidth
pathEffect = DashPathEffect(
floatArrayOf(maxDashIntervals, maxDashIntervals * 3),
0f
)
this.color = Color.BLACK
})
}
// 还原画笔模式
mBorderPaint.xfermode = null
// 这个时候就得到了Border的MASK.利用这个mask就可以限制Border的绘制范围了
/*=================== 绘制Border =================== */
// 现在回到原始Canvas上,利用刚才的mask绘制Border
// 开辟一个新的Layer
val originCanvasLayer = canvas.saveLayer(
0f,
0f,
width.toFloat(),
height.toFloat(),
null,
)
// 把mask绘制到新的Layer上
canvas.drawBitmap(maskBitmap, 0f, 0f, mBorderPaint)
// 设置模式为[PorterDuff.Mode.SRC_IN],代表接下来的绘制只可以在mask的范围内绘制
mBorderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
// 由于mask的存在,直接填充就行了. mask已经处理好虚线和实线的可绘制区域范围
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), mBorderPaint.apply {
style = Paint.Style.FILL
this.color = mBorderColor
})
// 标记释放Mask
maskBitmap.recycle()
// 还原xfermode
mBorderPaint.xfermode = null
// 还原图层
canvas.restoreToCount(originCanvasLayer)
}
/**
* 生成背景轮廓,等价于border的外边界
* 主要用于绘制背景及其圆角
*/
private fun generateBackgroundContour(backgroundPath: Path) {
// 重置Path
backgroundPath.reset()
// 从左上角,顺时针开始添加
backgroundPath.addRoundRect(
0f,
0f,
width.toFloat(),
height.toFloat(),
mBorderRadius,
mBorderRadius,
Path.Direction.CCW
)
}
/**
* 此处计算和[UiParamsBox]里面不同,额外处理了mRealPaddingXXX
* 因为[BorderFrameLayout]既存在Border又存在Padding,
* 而[UiParamsBox]把自己的Padding假装成Border,Padding部分交给了内部的View
*/
private fun generateChildDrawAreaPath(): Path {
val r = RectF(
if (mBorderLeft == 0f) -mBorderWidth.toFloat() else paddingLeft.toFloat() - mRealPaddingLeft,
if (mBorderTop == 0f) -mBorderWidth.toFloat() else paddingTop.toFloat() - mRealPaddingTop,
if (mBorderRight == 0f) width.toFloat() + mBorderWidth else width.toFloat() - paddingRight + mRealPaddingRight,
if (mBorderBottom == 0f) height.toFloat() + mBorderWidth else height.toFloat() - paddingBottom + mRealPaddingBottom,
)
return Path().apply {
addRoundRect(
r,
maxOf(mBorderRadius - mBorderWidth, 0f).toFloat(),
maxOf(mBorderRadius - mBorderWidth, 0f).toFloat(),
Path.Direction.CCW
)
}
}
/**
* 对于BoxComponent来说,他的Padding是积木系统中Padding部分加上Border部分
*/
override fun getPaddingLeft(): Int {
return (mRealPaddingLeft + mBorderLeft).roundToInt()
}
/**
* 对于BoxComponent来说,他的Padding是积木系统中Padding部分加上Border部分
*/
override fun getPaddingRight(): Int {
return (mRealPaddingRight + mBorderRight).roundToInt()
}
/**
* 对于BoxComponent来说,他的Padding是积木系统中Padding部分加上Border部分
*/
override fun getPaddingTop(): Int {
return (mRealPaddingTop + mBorderTop).roundToInt()
}
/**
* 对于BoxComponent来说,他的Padding是积木系统中Padding部分加上Border部分
*/
override fun getPaddingBottom(): Int {
return (mRealPaddingBottom + mBorderBottom).roundToInt()
}
/**
* 修改了返回的值,函数名是和FrameLayout在onLayoutChild内部调用的保持统一
*/
private fun getPaddingLeftWithForeground(): Int {
return paddingLeft
}
/**
* 修改了返回的值,函数名是和FrameLayout在onLayoutChild内部调用的保持统一
*/
private fun getPaddingRightWithForeground(): Int {
return paddingRight
}
/**
* 修改了返回的值,函数名是和FrameLayout在onLayoutChild内部调用的保持统一
*/
private fun getPaddingTopWithForeground(): Int {
return paddingTop
}
/**
* 修改了返回的值,函数名是和FrameLayout在onLayoutChild内部调用的保持统一
*/
private fun getPaddingBottomWithForeground(): Int {
return paddingBottom
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment