Go back

Implementing an LVGL-Style Border That Occupies Real Layout Space on Android

Published:  at  09:13 PM
⏱️ 1397 words • 7 min read

阅读中文版

Implementing an LVGL-style border on Android by changing FrameLayout measurement and using PorterDuffXfermode to draw rounded borders that occupy layout space.

I have used LVGL when doing embedded work, and its style system left a deep impression on me. LVGL’s Border is “solid” - it will occupy the layout space and squeeze the content inside.

Android is not so happy here. The borders of ShapeDrawable and MaterialCardView are more like “decorations”. Do you want the borders, rounded corners, and padding to be independently controllable? Native components are quite awkward to make.

So I simply made a BorderFrameLayout and transferred the logic of LVGL. BorderFrameLayout hierarchy diagram The different colors in the picture represent the actual occupied space. Green represents Border, red represents internal Padding, and blue represents available space for Child.


Several pitfalls of the native solution

To add a border to a View, the conventional approach is to write shape XML and set it to background. But in actual use, you will encounter these problems:

Rounded corner quilt View covers: The container has rounded corners, and when the sub-View has different colors, it will directly cover the rounded corners. OutlineProvider and clipToOutline work, but the effect is unstable.

Padding and Border are mixed together: If you want to achieve “Border accounts for 2dp, Padding accounts for 10dp”, you have to calculate the addition yourself. Once the logic is complex, it is easy to make mistakes.

Trouble with independent control of each side: Just want the bottom border? Want dotted lines? The XML solution requires writing a bunch of files.

Idea: Inherit FrameLayout and override drawing

Since the native one doesn’t work, then do it yourself.

BorderFrameLayout Split the container boundary into two layers:

  • Background Path: The outer outline of the entire control (including rounded corners)
  • Child Draw Area: After deducting the border, the actual available area of the child View

The Border is drawn between these two layers.

Effect display

In order to facilitate the demonstration effect, I placed a bottom-aligned Button inside the BorderFrameLayout. The position of this Button will be affected by changes in the size and position of the Border and will be offset.

Bottom border only only bottom border You can see that the bottom of the Button is the bottom Border, not the bottom of the regular container.

Right and bottom bezel (L-shaped) Right and bottom borders With the introduction of the right Border, the Button is slightly offset from what it should be.

Dotted border on the top, right and bottom sides Dotted border on the top, right and bottom sides Dotted line Border effect display

Extreme rounded corners Extreme rounded corners scene When the Border fillet is set to be very large and the Border is missing one edge, we get this It looks like a very weird effect, but it is indeed a style that can be set in LVGL.

Single edges, multiple edges, dashed lines, and extreme rounded corners can all be handled correctly.


Implementation details

1. Let Border participate in layout calculations

Overload onLayout and layoutChildren, and count mBorderWidth into the child View’s available space:

private fun layoutChildren(left: Int, top: Int, right: Int, bottom: Int) {
    // parentLeft 等边界基于 (padding + border) 计算
    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)
        // 子 View 会被"挤"到 Border 内部
        child.layout(childLeft, childTop, childLeft + width, childTop + height)
    }
}

getPadding returns mRealPadding + mBorder, making the Android system think that this is normal Padding:

override fun getPaddingLeft(): Int {
    return (mRealPaddingLeft + mBorderLeft).roundToInt()
}

2. Draw Border with mask

At first I tried using Path + Stroke to shrink the border. But in the multi-sided + extreme rounded corner scenario, it is difficult to keep the inner and outer contours consistent. The dashed lines are especially noticeable around corners.

If you draw a Stroke rectangle directly, it will be difficult to align the inner and outer arcs of the rounded corners. So in the end, the PorterDuffXfermode masking scheme was used:

  1. Draw a completely black rounded outline
  2. Use DST_OUT to deduct the middle content area
  3. Get a Mask with only border shape
  4. Use SRC_IN to fill in the Border color
val maskBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8)
val maskCanvas = Canvas(maskBitmap)

// 填充外轮廓
maskCanvas.drawPath(mBackgroundPath, mBorderPaint.apply {
    style = Paint.Style.FILL
    color = Color.BLACK
})

// 扣掉内部区域
mBorderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
maskCanvas.drawPath(generateChildDrawAreaPath(), mBorderPaint)

// 用 Mask 限制 Border 绘制范围
canvas.drawBitmap(maskBitmap, 0f, 0f, mBorderPaint)
mBorderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), mBorderPaint.apply {
    this.color = mBorderColor
})

No matter how wide the Border is or how exaggeratedly rounded the corners are, the edges will always be aligned.

3. dashed border

Add a layer of DashPathEffect in the Mask stage:

if (mBorderStyle == 1) {
    counterPath.addRoundRect(/* 中间轮廓 */)
    maskCanvas.drawPath(counterPath, mBorderPaint.apply {
        style = Paint.Style.STROKE
        pathEffect = DashPathEffect(
            floatArrayOf(maxDashIntervals, maxDashIntervals * 3),
            0f
        )
    })
}

4. Content cropping

Prevent subviews from covering rounded corners:

override fun dispatchDraw(canvas: Canvas) {
    drawBorderAndBackground(canvas)
    canvas.save()
    if (clipContent) {
        canvas.clipPath(mBackgroundPath)
        canvas.clipPath(generateChildDrawAreaPath())
    }
    super.dispatchDraw(canvas)
    canvas.restore()
}

This solution will have a certain Bitmap reconstruction overhead when Border changes frequently. More suitable for container components with stable styles.


XML declarative usage

After defining custom attributes, you can use them directly in the layout:


<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="BorderFrameLayout">
        <attr name="borderRadius" format="dimension" />
        <attr name="borderWidth" format="dimension" />
        <attr name="borderLeft" format="dimension" />
        <attr name="borderTop" format="dimension" />
        <attr name="borderRight" format="dimension" />
        <attr name="borderBottom" format="dimension" />
        <attr name="borderColor" format="color" />
        <attr name="borderStyle" format="enum">
            <enum name="solid" value="0" />
            <enum name="dash" value="1" />
        </attr>
        <attr name="backgroundGradientAngle" format="float" />
        <attr name="backgroundGradientColor" format="color" />
        <attr name="realPaddingLeft" format="dimension" />
        <attr name="realPaddingTop" format="dimension" />
        <attr name="realPaddingRight" format="dimension" />
        <attr name="realPaddingBottom" format="dimension" />
        <attr name="backgroundColor" format="color" />
        <attr name="clipContent" format="boolean" />
    </declare-styleable>
</resources>
<io.serialflow.editor.ui.BorderFrameLayout
    android:layout_width="200dp"
    android:layout_height="200dp"
    app:borderRadius="24dp"
    app:borderWidth="8dp"
    app:borderColor="#FF0000"
    app:borderStyle="dash"
    app:backgroundColor="#FFEEEE"
    app:clipContent="true">

    <!-- 子 View 会被自动挤到边框内部 -->

</io.serialflow.editor.ui.BorderFrameLayout>

Complete code

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

class BorderFrameLayout : FrameLayout {

    private val DEFAULT_CHILD_GRAVITY = Gravity.TOP or Gravity.START
    protected var clipContent: Boolean = true

    private var mBackgroundPath = Path()
    private var mBorderInnerContourPath = Path()

    private var mBorderRadius = 0f
    private var mBorderWidth: Float = 0f

    private var mBorderLeft: Float = 0f
    private var mBorderTop: Float = 0f
    private var mBorderRight: Float = 0f
    private var mBorderBottom: Float = 0f

    private var mRealPaddingLeft: Float = 0f
    private var mRealPaddingTop: Float = 0f
    private var mRealPaddingRight: Float = 0f
    private var mRealPaddingBottom: Float = 0f

    private val mBorderPaint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.FILL
    }

    private var mBorderColor: Int = Color.TRANSPARENT
    private var mBorderStyle: Int = 0
    private var mBackgroundColor: Int = Color.TRANSPARENT
    private var mBackgroundGradientAngle = 0f
    private var mBackgroundGradientColor = Color.TRANSPARENT
    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()
        setBorderWidth(borderLeft, borderTop, borderRight, borderBottom)
    }

    // ... 完整代码见项目源码 ...

}

The source code is availableBorderFrameLayout.kt


Share this post on:

Previous Post
Real-time Desktop Streaming on M5Stack Cardputer via H.264
Next Post
Embedding Files in ESP-IDF Firmware with embed_txtfiles