自定义View(绘制)

自定义 View(绘制)

自定义绘制

图形的位置和尺寸测量

绘制的基本要素
  • 重写 onDraw() 方法
  • 使用 Canvas 来绘制 (drawLine、drawCircle、drawPath …)
  • 使用 Paint 来配置(color、textSize …)
  • 坐标系 (向右为 x 轴正方向,向下为 y 轴正方向)
  • 尺寸单位是像素px,而不是 dp
 // dp 转 px
 public static float dp2px(float value) {
      return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
                Resources.getSystem().getDisplayMetrics());
 }

Path 的方向以及封闭图形的内外判断
fillType(填充类型)
  • FillType.WINDING (默认的填充类型)
    从 Path 内的任一点向外做射线,相交于图形的路径,如果相交方向相同的图形路径,路径的穿插次数大于0 表示该点为内部;如果相交方向相反的图形路径,若方向路径的正反穿插次数相同表示该点为外部。

  • FillType.EVEN_ODD
    从 Path 内的任一点向外做射线,不考虑路径的穿插方向。穿插奇数次则为内部,偶数次为外部。

private val RADIUS = 100.dp
class TestView(context: Context?, attrs: AttributeSet?)
    : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val rectPath = Path()

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 画圆
        rectPath.addCircle(width / 2f, height / 2f, RADIUS, Path.Direction.CCW)
        // 画矩形
        rectPath.addRect(width / 2f - RADIUS, height / 2f,
            width / 2f + RADIUS, height / 2f + RADIUS * 2,
            Path.Direction.CW) // clockwise 顺时针, counter-clockwise 逆时针
        rectPath.fillType = Path.FillType.EVEN_ODD
        canvas.drawPath(rectPath, paint)
    }
}

PathMeasure

用于对 Path 做测量(如长度的测量)


绘制仪表盘
  • 用 drawArc 绘制弧形
  • 三角函数的计算,横向的位移是 cos,纵向的位移是 sin
  • PathDashPathEffect
    加上 PathEffect 后,就只绘制 effect,而不绘制原图形;
    绘制 dash 的 x轴 正方向是按原图形顺时针方向的切面,y轴 正方向指向原图形内部

private val RADIUS = 120.dp// 半径
private const val OPEN_ANGLE = 120f// 仪表盘的下面的开口弧度
private val DASH_WIDTH = 2.dp // 刻度线宽度
private val DASH_HEIGHT = 10.dp// 刻度线长度
private val HAND_POS = 5// 指针指向位置
private val HAND_LENGTH = 100.dp// 指针的长度

class DashboardView(context: Context, attrs: AttributeSet) :
    View(context, attrs) {
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val path = Path()// 圆弧的路径
    private val dash = Path()// 刻度线路径
    private lateinit var pathEffect: PathEffect

    init {
        paint.strokeWidth = 3.dp
        paint.style = Paint.Style.STROKE
        dash.addRect(0f, 0f, DASH_WIDTH, DASH_HEIGHT, Path.Direction.CW)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        path.reset()
        val rect = RectF(
            width / 2f - RADIUS, height / 2f - RADIUS,
            width / 2f + RADIUS, height / 2f + RADIUS
        )
        path.addArc(rect, 90 + OPEN_ANGLE / 2f, 360 - OPEN_ANGLE)
        // 测量
        val pathMeasure = PathMeasure(path, false)
        pathEffect = PathDashPathEffect(
            dash, (pathMeasure.length - DASH_WIDTH) / 20f, 0f,
            PathDashPathEffect.Style.ROTATE
        )
    }


    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 画弧
        canvas.drawPath(path, paint)
        // 画刻度
        paint.pathEffect = pathEffect
        canvas.drawPath(path, paint)
        paint.pathEffect = null
        // 画指针
        canvas.drawLine(width / 2f, height / 2f,
            HAND_LENGTH * cos(mark2Radio(HAND_POS)).toFloat() + width / 2f,
            HAND_LENGTH * sin(mark2Radio(HAND_POS).toFloat()) + height /2f,
            paint
        )
    }

    private fun mark2Radio(mark: Int) =
        // 将以度测量的角度转换为以弧度测量的近似等效角度
        Math.toRadians((90 + OPEN_ANGLE / 2f + (360- OPEN_ANGLE) / 20 * mark).toDouble())

}

饼图
  • 用 drawArc() 绘制扇形
  • 用 Canvas.translate() 来移动扇形,并用 Canvas.save() 和 Canvas.restore() 来保存和恢复位置
  • 用三角函数 cos 和 sin 计算偏移

private val RADIUS = 120.dp
private val ANGLES = floatArrayOf(60f, 100f, 40f, 160f)
private val COLORS = listOf(
    Color.parseColor("#f7ad58"),
    Color.parseColor("#c75450"),
    Color.parseColor("#8776af"),
    Color.parseColor("#3ddc84")
)
private val OFFSET_LENGTH = 10.dp

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

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 画弧
        var startAngle = 0f
        for ((index, angle) in ANGLES.withIndex()) {
            paint.color = COLORS[index]
            if (index == 1) {
                canvas.save()
                canvas.translate(
                    OFFSET_LENGTH * cos(Math.toRadians(startAngle + angle / 2.toDouble())).toFloat(),
                    OFFSET_LENGTH * sin(Math.toRadians(startAngle + angle / 2.toDouble())).toFloat()
                )
            }
            canvas.drawArc(
                width / 2f - RADIUS, height / 2f - RADIUS,
                width / 2f + RADIUS, height / 2f + RADIUS,
                startAngle, angle, true, paint
            )
            startAngle += angle
            if (index == 1) {
                canvas.restore()
            }
        }
    }

}

Xfermode

对多次绘制进行合成,例如蒙版消息:用 A 的形状和 B 的图案。


使用:

  • Canvas.saveLayer() 把绘制区域拉到单独的离屛缓冲里
  • 绘制 A 图形
  • 用 Paint.setXfermode() 设置 Xfermode
  • 绘制 B 图案
  • 用 Paint.setXfermode(null) 恢复 Xfermode,防止污染
  • 用 Canvas.restoreToCount() 把离屏缓冲中合成的图形放回绘制区域

private val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)

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

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val bound = RectF(150.dp, 50.dp, 300.dp, 200.dp)
    private val circleBitmap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888)
    private val squareBitmap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888)

    init {
        val canvas = Canvas(circleBitmap)
        paint.color = Color.parseColor("#d25a61")
        canvas.drawOval(50.dp, 0.dp, 150.dp, 100.dp, paint)
        paint.color = Color.parseColor("#527087")
        canvas.setBitmap(squareBitmap)
        canvas.drawRect(0.dp, 50.dp, 100.dp, 150.dp, paint)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val count = canvas.saveLayer(bound, null)
        canvas.drawBitmap(circleBitmap, 150.dp, 50.dp, paint)
        paint.xfermode = XFERMODE
        canvas.drawBitmap(squareBitmap, 150.dp, 50.dp, paint)
        paint.xfermode = null
        canvas.restoreToCount(count)
    }

}

注:为什么要使用 saveLayer() 才能正确绘制

为了把需要互相作用的图形放在单独的位置来绘制,不会受 View 本身的影响。如果不使用 saveLayer(),绘制的目标区域总是整个 View 的范围,两个图形的交叉区域就错误了。


文字的绘制

绘制文字:drawText()

官方文本的绘制是基于字体的 baseLine 来确认 Y 坐标的,暴力认为 y=baseLine 的值,如果想绘制出来的文字和官方提供的TextView布局出来的文字水平居中,那么就必须计算出baseLine 的值。


文字纵向居中对齐

第一种方式:Paint.getTextBounds() 之后,使用 (bounds.top + bounds.bottom) / 2

第二种方式:Paint.getFontMetrics() 之后,使用 (fontMetrics.ascend + fontMetrics.descend) / 2

top:字符最高点到 baseline 的最大距离
ascent:字符最高点到 baseline 的推荐距离
baseline:字符基线,绘制是基于他的坐标绘制的
descent:字符最低点到 baseline 的推荐距离
bottom:字符最低点到 baseline 的最大距离
baseLine 以上为负值,以下为正值


private val CIRCLE_COLOR = Color.parseColor("#90A4AE")
private val HIGHLIGHT_COLOR = Color.parseColor("#FF4081")
private val RING_WIDTH = 20.dp
private val RADIUS = 120.dp

class SportView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = 100.dp
        textAlign = Paint.Align.CENTER
        typeface = ResourcesCompat.getFont(context, R.font.font)
    }
    private val bounds = Rect()
    private val fontMetrics = Paint.FontMetrics()

    override fun onDraw(canvas: Canvas) {
        // 绘制环
        paint.style = Paint.Style.STROKE
        paint.strokeWidth = RING_WIDTH
        paint.color = CIRCLE_COLOR
        canvas.drawCircle(width / 2f, height / 2f, RADIUS, paint)

        // 绘制进度
        paint.color = HIGHLIGHT_COLOR
        paint.strokeCap = Paint.Cap.ROUND
        canvas.drawArc(
            width / 2f - RADIUS,
            height / 2f - RADIUS,
            width / 2f + RADIUS,
            height / 2f + RADIUS,
            -90f, 255f, false, paint
        )

        // 绘制文字
        paint.style = Paint.Style.FILL
        paint.textSize = 80.dp
        paint.getTextBounds("abab", 0, "abab".length, bounds)// 用于绘制静态文字的垂直居中
        paint.getFontMetrics(fontMetrics)// 用于绘制静态文字的垂直居中
        canvas.drawText(
            "abab",
            width / 2f,
            height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f,// 垂直居中
            paint
        )

        // 绘制文字2
        paint.textAlign = Paint.Align.LEFT
        paint.getTextBounds("abab", 0, "abab".length, bounds)
        paint.getFontMetrics(fontMetrics)
        canvas.drawText(
            "abab",
            0f - bounds.left,
            0f - bounds.top,
            paint
        )

    }
}

换行:breakText() 计算
private val IMAGE_SIZE = 120.dp
private val IMAGE_PADDING = 50.dp

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

    private val text =
        "If we show emotion, we ’re called dramatic. If we want to play against men, we’re nuts. And if we dream of " +
                "equal opportunity, delusional. When we stand for something, we’re unhinged. When we’re too good, there’s " +
                "something wrong with us. And if we get angry, we’re hysterical, irrational, or just being crazy. " +
                "But a woman running a marathon was crazy. A woman boxing was crazy. A woman dunking, crazy. " +
                "Coaching an NBA team, crazy. A woman competing in a hijab; changing her sport; landing a double-cork" +
                "1080; or winning 23 Grand Slams, having a baby, and then coming back for more, crazy, crazy, crazy, crazy " +
                "and crazy. " +
                "So if they want to call you crazy, fine. Show them what crazy can do."
    private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = 16.dp
    }
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = 16.dp
    }
    private val fontMetrics = Paint.FontMetrics()

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas) {
//        val staticLayout = StaticLayout(text, textPaint, width,
//            Layout.Alignment.ALIGN_NORMAL, 1f, 5f, false)
//        staticLayout.draw(canvas)
        canvas.drawBitmap(getAvatar(IMAGE_SIZE.toInt()), width - IMAGE_SIZE, IMAGE_PADDING, paint)
        paint.getFontMetrics(fontMetrics)
        val measuredWidth = floatArrayOf(0f)
        var start = 0
        var count = 0
        var verticalOffset = 0f - fontMetrics.top
        var maxWidth: Float
        while (start < text.length) {
            maxWidth =
                if (verticalOffset + fontMetrics.bottom < IMAGE_PADDING
                    || verticalOffset + fontMetrics.top > IMAGE_PADDING + IMAGE_SIZE
                ) {
                    width.toFloat()
                } else {
                    width.toFloat() - IMAGE_SIZE
                }
            count = paint.breakText(text, start, text.length, true, maxWidth, measuredWidth)
            canvas.drawText(text, start, start + count, 0f, verticalOffset, paint)
            start += count
            verticalOffset += paint.fontSpacing
        }
    }

    private fun getAvatar(width: Int): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = width.toInt()
        return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值