Skip to content

Android 自定义View Demo

约 2031 字大约 7 分钟

Android

2023-07-01

简单绘制

仪表盘饼图
效果图效果图
仪表盘
class DashboardView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {  // 开启抗锯齿
        strokeWidth = 3F.dp
        style = Paint.Style.STROKE
    }
    private val path = Path()
    private val dash = Path()

    private lateinit var pathDashPathEffect: PathDashPathEffect


    private val radius = 150F.dp
    private val openAngle = 120F

    private val dashWidth = 2F.dp
    private val dashLength = 10F.dp

    init {
        dash.addRect(0F, 0F, dashWidth, dashLength, Path.Direction.CW)
    }


    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        val center = min(width / 2F, height / 2F)
        path.reset()
        path.addArc(
            center - radius,
            center - radius,
            center + radius,
            center + radius,
            90 + openAngle / 2,
            360 - openAngle,
        )

        val pathMeasure = PathMeasure(path, false)
        val spacing = (pathMeasure.length - dashWidth) / 20F
        // 每隔spacing绘制一个dash phase 偏移
        pathDashPathEffect = PathDashPathEffect(dash, spacing, 0F, PathDashPathEffect.Style.MORPH)

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val center = min(width / 2F, height / 2F)

        // 画弧
        canvas.drawPath(path, paint)

        // 画刻度
        paint.pathEffect = pathDashPathEffect
        canvas.drawPath(path, paint)
        paint.pathEffect = null

        // 画刻度线
        val scale = 5
        val radians = Math.toRadians(150.0 + ((360 - openAngle) / 20 * scale)).toFloat()
        val lineLength = 0.8F * radius
        canvas.drawLine(center, center, center + lineLength * cos(radians), center + lineLength * sin(radians), paint)
    }
}

Xfermode

效果图 离屏缓冲

  • 必须使用saveLayer()才能正确绘制 因为互相作⽤的图形放在单独的位置来绘制,不会受 View 本身的影响。 如果不使⽤ saveLayer(),绘制的⽬标区域将总是整个 View 的范围,两个图形的交叉区域就错误了。
class XfermodeView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val bounds = RectF(0.dp, 0.dp, 150.dp, 150.dp)
    private val circleBitMap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888).also {
        paint.color = Color.RED
        Canvas(it).drawOval(50.dp, 0.dp, 150.dp, 100.dp, paint)
    }
    private val squareBitMap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888).also {
        paint.color = Color.GREEN
        Canvas(it).drawRect(0.dp, 50.dp, 100.dp, 150.dp, paint)
    }

    private val xfermode = PorterDuffXfermode(PorterDuff.Mode.XOR)


    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val saveLayer = canvas.saveLayer(bounds, null) // 离屏缓冲
        // 需要注意circleBitMap 和 squareBitMap 的大小
        canvas.drawBitmap(circleBitMap, 0.dp, 0.dp, paint)
        paint.xfermode = xfermode
        canvas.drawBitmap(squareBitMap, 0.dp, 0.dp, paint)
        paint.xfermode = null
        // 把离屏缓冲中的合成后的图形放回绘制区域
        canvas.restoreToCount(saveLayer)
    }
}

文字绘制

居中

  • Paint.getTextBounds() 之后,使用 (bounds.top + bounds.bottom) / 2 (适用静态文本)
  • Paint.getFontMetrics() 之后,使用 (fontMetrics.ascent + fontMetrics.descent) / 2 (适用动态文本)
val fontMetrics = paint.fontMetrics
canvas.drawText(text, centerX, centerY - (fontMetrics.ascent + fontMetrics.descent) / 2F, paint)

多行绘制

val staticLayout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
    .build()
staticLayout.draw(canvas)

换⾏

效果图

breakText

class MultilineTextView(context: Context, attrs: AttributeSet) : View(context, attrs) {

    private val text =
        "在当今瞬息万变的社会环境下,我们不可否认,信息技术的飞速发展给人们的生活带来了翻天覆地的变化。正如一位伟人曾经说过的那样:“现代社会,人们所面临的诸多挑战无疑需要我们不懈努力,勇于创新,迎难而上。”随着经济全球化的推进,我们理所当然地看到,多元化的社会结构无疑将为我们带来更多的机遇和挑战。在这个大背景下,我们应该积极拥抱变化,拥抱机遇,展现出强大的生命力和韧性。正如一句古语所说:“机遇总是留给有准备的人。”对于我们个体而言,不仅要具备丰富的知识和专业技能,更要具备宽广的视野和创新的思维。我们应该不断地充实自己,积累经验,不断迭代自己,与时俱进。正所谓:“博观而约取,厚积而薄发。”只有如此,我们才能在激烈的竞争中立于不败之地。在社会交往中,沟通能力也显得尤为重要。健康、有效的沟通不仅能够拉近人与人之间的距离,也能够促进信息的传递和交流。我们应该注重提升自己的沟通技巧,以便更好地理解他人,使我们的声音被倾听。总的来说,我们应该珍惜每一个机遇,坚定信心,迎难而上,不断充实自己,提高自己的综合素质。只有这样,我们才能在这个多元、开放、竞争激烈的社会中脱颖而出,展现出自己的价值和潜力。未来属于那些敢于追梦、勇往直前的人们。让我们携手共进,共同创造一个更加美好、繁荣的明天!"
    private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = 16.dp
    }

    private val imageWidth = 150.dp
    private val imageTop = 50.dp

    private val bitmap = getAvatar(R.drawable.png, imageWidth)


    private val fontMetrics = Paint.FontMetrics()
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)


        val imageHeight = bitmap.height
        canvas.drawBitmap(bitmap, width - 150.dp, imageTop, paint)

        paint.getFontMetrics(fontMetrics)

        var start = 0
        var count: Int
        var verticalOffice = -fontMetrics.top
        var maxWidth: Float
        while (start < text.length) {
            if (verticalOffice + fontMetrics.bottom > imageTop && verticalOffice + fontMetrics.top < imageTop + imageHeight) {
                maxWidth = width.toFloat() - imageWidth
            } else {
                maxWidth = width.toFloat()

            }
            // 返回需要截断的文本长度
            count = paint.breakText(text, start, text.length, true, maxWidth, floatArrayOf(0F))
            canvas.drawText(text, start, start + count, 0F, verticalOffice, paint)
            start += count
            verticalOffice += paint.fontSpacing
        }
    }

    // 获取指定宽度缩放的Bitmap
    private fun getAvatar(resourceId: Int, width: Float): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, resourceId, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = width.toInt()
        return BitmapFactory.decodeResource(resources, resourceId, options)
    }
}

范围裁切

Canvas 的范围裁切 clipRect()

clipPath()切出来的没有抗锯⻮效果

属性动画

ViewPropertyAnimator

view.animate()
    .translationX(200.dp)
    .translationY(300.dp)
    .setDuration(1000)
    .setStartDelay(100)

ObjectAnimator

自定义属性动画

//  注意 setter 方法里需要调用 invalidate() 来触发重绘
val objectAnimator = ObjectAnimator.ofFloat(view, "radius", 50.dp, 150.dp)
objectAnimator.startDelay = 1000
objectAnimator.duration = 1000
// 动画的速度曲线
objectAnimator.interpolator = DecelerateInterpolator()
objectAnimator.start()

AnimatorSet

将多个 Animator 合并在一起使用,先后顺序或并列顺序都可以:

val animatorSet = AnimatorSet()
animatorSet.playSequentially(objectAnimator1, objectAnimator2)
// animatorSet.playTogether(objectAnimator1, objectAnimator2)
animatorSet.start()

PropertyValuesHolder

用于设置更加详细的动画,例如多个属性应用于同一个对象

val holder1 = PropertyValuesHolder.ofFloat("radius", 100.dp)
val holder2 = PropertyValuesHolder.ofFloat("offset", 50.dp)
val objectAnimator = ObjectAnimator.ofPropertyValuesHolder(view, holder1, holder2)
objectAnimator.duration = 1000
objectAnimator.start()

配合使用 Keyframe ,对一个属性分多个段:

val keyframe1 = Keyframe.ofFloat(0F, 70.dp)
val keyframe2 = Keyframe.ofFloat(0.3F, 80.dp)
val keyframe3 = Keyframe.ofFloat(1F, 100.dp)
val ofKeyframe = PropertyValuesHolder.ofKeyframe("radius", keyframe1, keyframe2, keyframe3)

val objectAnimator = ObjectAnimator.ofPropertyValuesHolder(view, ofKeyframe)

TypeEvaluator 插值器

class ColorTypeEvaluator(private val colorList: List<Int>) : TypeEvaluator<Int> {
    override fun evaluate(fraction: Float, startValue: Int, endValue: Int): Int {
        val index: Int = ((endValue - startValue) * fraction + startValue).toInt()
        return colorList[index]
    }
}

val objectAnimator = ObjectAnimator.ofObject(view, "color", ColorTypeEvaluator(colorList), 1, 4)

硬件加速 离屏缓冲

setLayerType() 设置一个离屏缓冲,让后面的绘制都单 独写在这个离屏缓冲内。

  • 参数为 LAYER_TYPE_SOFTWARE 时,使用软件来绘制 View Layer,绘制到一个 Bitmap,并顺便关闭硬件加速
  • 参数为 LAYER_TYPE_HARDWARE 时,使用硬件来绘制 View Layer,绘制到一个 OpenGL texture(如果硬件加速关闭,那么行为和 VIEW_TYPE_SOFTWARE 一致)
  • 参数为 LAYER_TYPE_NONE 时,关闭 View Layer

Bitmap 和 Drawable

互转

drawable.toBitmap() // Drawable -> Bitmap
bitmap.toDrawable(resources) // Bitmap -> Drawable

Drawable

需要共享在多个 View 之间的绘制代码,写在 Drawable 里,然后在多个自 定义 View 里只要引用相同的 Drawable 就好,而不用互相粘贴代码。

// 网格
class MeshDrawable : Drawable() {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        strokeWidth = 2.dp
        color = Color.CYAN
    }

    private val interval = 50.dp

    override fun draw(canvas: Canvas) {

        var x = bounds.left.toFloat()
        while (x < bounds.right) {
            canvas.drawLine(x, bounds.top.toFloat(), x, bounds.bottom.toFloat(), paint)
            x += interval
        }

        var y = bounds.top.toFloat()
        while (y < bounds.bottom) {
            canvas.drawLine(bounds.left.toFloat(), y, bounds.right.toFloat(), y, paint)
            y += interval
        }
    }

    override fun setAlpha(alpha: Int) {
        paint.alpha = alpha
    }

    override fun getAlpha() = paint.alpha

    override fun setColorFilter(colorFilter: ColorFilter?) {
        paint.colorFilter = colorFilter
    }

    override fun getColorFilter(): ColorFilter = paint.colorFilter

    override fun getOpacity(): Int {
        return when (paint.alpha) {
            0 -> PixelFormat.TRANSPARENT
            0xFF -> PixelFormat.OPAQUE
            else -> PixelFormat.TRANSPARENT
        }
    }
}

使用

val meshDrawable = MeshDrawable()
meshDrawable.bounds = Rect(0, 0, width, height) // 必须调用Drawable.setBounds()
meshDrawable.draw(canvas)

手写 MaterialEditText

MaterialEditText.kt

class MaterialEditText(context: Context, attrs: AttributeSet?) : AppCompatEditText(context, attrs) {
    private val textSize = 12.dp
    private val textMargin = 8.dp
    private val horizontalOffice = 5.dp
    private val verticalOffice = 12.dp
    private val extraVerticalOffice = 12.dp

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    var userFloatingLabel = false
        set(value) {
            if (field != value) {
                field = value
                invalidate()
                if (field) {
                    setPadding(paddingLeft, (paddingTop + textSize + textMargin).toInt(), paddingRight, paddingBottom)
                } else {
                    setPadding(paddingLeft, (paddingTop - textSize - textMargin).toInt(), paddingRight, paddingBottom)
                }
            }
        }
    private var floatingLabelShow = false

    private var floatingLabelFraction = 0F
        set(value) {
            field = value
            invalidate()
        }

    private val animator by lazy {
        ObjectAnimator.ofFloat(this, "floatingLabelFraction", 0F, 1F)
    }

    init {
        paint.textSize = 12.dp


        val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.MaterialEditText)
//        val obtainStyledAttributes = context.obtainStyledAttributes(attrs, intArrayOf(R.attr.userFloatingLabel))
        userFloatingLabel = obtainStyledAttributes.getBoolean(R.styleable.MaterialEditText_userFloatingLabel, false)
//        userFloatingLabel = obtainStyledAttributes.getBoolean(0, false) // 拿第0个
        obtainStyledAttributes.recycle()
    }


    override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
        super.onTextChanged(text, start, lengthBefore, lengthAfter)
        if (floatingLabelShow && text.isNullOrEmpty()) {
            floatingLabelShow = false
            animator.reverse()
        } else if (!floatingLabelShow && !text.isNullOrEmpty()) {
            floatingLabelShow = true
            animator.start()
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        paint.alpha = (0xFF * floatingLabelFraction).toInt()
        val curVerticalOffice = verticalOffice + extraVerticalOffice * (1 - floatingLabelFraction)
        canvas.drawText(hint.toString(), horizontalOffice, curVerticalOffice, paint)
    }

}

attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MaterialEditText">
        <attr name="userFloatingLabel" format="boolean"/>
    </declare-styleable>
</resources>

使用


<com.test.myapplication.MaterialEditText
        android:hint="username"
        app:userFloatingLabel="true"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>