Android 自定义View Demo
约 2025 字大约 7 分钟
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)
}
}
饼图
class PieView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = 150F.dp
private val angles = floatArrayOf(60F, 90F, 150F, 60F)
private val colors = listOf(Color.RED, Color.YELLOW, Color.BLUE, Color.GREEN)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val center = min(width / 2F, height / 2F)
var startAngle = 0F
val translateIndex = 3
for ((index, angle) in angles.withIndex()) {
paint.color = colors[index]
if (index == translateIndex) {
// 保存状态
canvas.save()
// 偏移
canvas.translate(
50F * cos(Math.toRadians((startAngle + angle / 2).toDouble()).toFloat()),
50F * sin(Math.toRadians((startAngle + angle / 2).toDouble()).toFloat()),
)
}
canvas.drawArc(
center - radius,
center - radius,
center + radius,
center + radius,
startAngle,
angle,
true,
paint,
)
if (index == translateIndex) {
// 恢复状态
canvas.restore()
}
startAngle += angle
}
}
}
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)
换⾏
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"/>