Jetpack Compose图形绘制

在实操前,我们先来了解下Jetpack Compose图形绘制。


图形绘制的两大核心点

热身

Canvas
Canvas是自定义图形的核心可组合项。在布局中放置 Canvas 的方式与放置其他 Compose 界面元素相同。在 Canvas 中,您可以通过精确控制元素的样式和位置来绘制元素。
Canvas 可组合项使用特殊的 Compose Canvas对象,与Android View系统的Canvas不同。

Canvas(modifier = Modifier.fillMaxSize()) {
}

Canvas 会自动提供 DrawScope(一个维护自身状态且限定了作用域的绘图环境)。这让您可以为一组图形元素设置参数。DrawScope 提供了一些有用的字段。
Compose中的Canvas可

DrawScop
每个Canvas都维护了一个DrawScop,DrawScop辅助图形绘制,提供了包括:center(中心点坐标)、size(可绘制区域大小)、drawContext,以及很多常用图形绘制方法(drawLinedrawCircledrawRectdrawArcdrawPathdrawImagedrawOvaldrawPointsdrawRoundRectrotatetranslate等)。

Paint
画笔,使用的是androidx.compose.ui.graphics包中的类,注意与Android 原生画笔Paint区分。
创建对象时实际实例化的是AndroidPaint类,该类继承Paint接口,并实现了属性的get、set方法,其包含的属性与原生Paint基本一致。
可通过Compose Paint 获取原生Paint:

val paint = Paint()
//获取成原生Paint
val nativePaint = paint.asFrameworkPaint()

查看AndroidPaint的源码你会发现根本使用的还是原生的Paint,如下:

//给原生类定义一个NativePaint的别名
actual typealias NativePaint = android.graphics.Paint

actual fun Paint(): Paint = AndroidPaint()
//实现androidx.compose.ui.graphics.Paint接口,
class AndroidPaint : Paint {
    private var internalPaint = makeNativePaint()
    private var _blendMode = BlendMode.SrcOver
    private var internalShader: Shader? = null
    private var internalColorFilter: ColorFilter? = null

    override fun asFrameworkPaint(): NativePaint = internalPaint

    override var alpha: Float
        get() = internalPaint.getNativeAlpha()
        set(value) {
            internalPaint.setNativeAlpha(value)
        }
    ......
}

好了到这里图像绘制基本结束完成了,下面开始进行实战。

实战

随着冬奥的火热进行,某物也被大众喜爱,出现一墩难求的局面。程序员界也不例外,出现各种语言版本的手绘一个,如:js版、python版等,最近正好学到Jetpack Compose 图形绘制,也来追个墩。
好了不啰嗦了,开始进入正题(可能涉及到侵权问题,这里就不上图了,自己脑补下吧)

通过分析可以划分出各身体部位:身体、耳朵、手、脚、面部、眼睛、鼻子和嘴巴、Logo文字五环、光环。

下面我们就按各部位作为步骤一一绘制。
画布坐标变换
为了方便绘制在画布中心,不用每个参数都与center坐标进行相加/减处理,这里我们将坐标原点设置在画笔中心。如下:

Canvas(modifier = modifier){
        //转换成原生canvas绘制,替换DrawScope
        drawIntoCanvas { canvas->
            val paint = Paint().apply {
                color = Color.Black
                strokeWidth = 3f
                isAntiAlias = true
                style = PaintingStyle.Stroke
            }
          //先保存,方便绘制完成后在恢复
            canvas.save()
            //将坐标原点转换到屏幕中心
            canvas.translate(center.x,center.y)
            //原点上绘制一个位置参考圆形
            canvas.drawCircle(Offset(0f,0f),5f,paint)
            //这里绘制墩

            canvas.restore()
    }
}

步骤一、绘制身体
使用一个椭圆替代身体,椭圆大小为宽500x高600,为了保证椭圆的中心点在坐标原点,需要设置一个偏移量,偏移量为:x轴 -宽/2, y轴 -高/2。
核心代码:

//步骤一:绘制一个宽x高=500x600的椭圆
canvas.drawOval(Rect(-ovalSize.width/2,-ovalSize.height/2,ovalSize.width/2,ovalSize.height/2),paint)
身体

步骤二、绘制两个耳朵
左耳:绘制实心圆弧,从-215度开始旋转185角度,大小为:Size(90f,110f),偏移量为:Offset(-180f,-310f)
右耳:实心圆弧,从95度开始旋转205角度,大小为:Size(90f,110f),偏移量为:Offset(95f,-310f)
注意要设置不以圆弧的中心点为参考
核心代码如下:

//绘制左耳
    canvas.drawArc(
        rect = Rect(Offset(-180f,-310f),Size(90f,110f)),
        startAngle = -215f,//起始角度
        sweepAngle = 185f,//旋转的角度
        useCenter = false,//不以圆弧的中心点为参考
        paint = paint
    )

    //绘制右耳
    canvas.drawArc(
        rect = Rect(Offset(95f,-310f),Size(90f,110f)),
        startAngle = -160f,
        sweepAngle = 205f,
        useCenter = false,
        paint = paint
    )
耳朵

步骤三、绘制两只手
先确定两只手与身体连接部位的角度,计算出在椭圆上的点的坐标。如下图:

image.png

坐标点可以通过椭圆是任意点坐标公式计算,不知道的可以参考求圆和椭圆上任意角度的点的坐标

开始和结束坐标点确定后,通过drawPath绘制赛贝尔曲线实现效果,这里要注意左右手的方向。
核心代码:

//左,起始点和结束点夹角为15°
    val leftPath = Path().apply {
        val coordinate1 = getOvalSideCoordinate(ovalSize,170)
        //计算椭圆上坐标点
        val sx = coordinate1.x
        val sy = coordinate1.y

        val coordinate2 = getOvalSideCoordinate(ovalSize,155)
        val ex = coordinate2.x
        val ey = coordinate2.y

        moveTo(-sx,-sy)
        cubicTo(-sx,-sy,-sx-40,-sy+30,-sx-80,-sy+65)
        cubicTo(-sx-82,-sy+66,-sx-80-40,-sy+65+70,-sx-50,-sy+60+85)
        cubicTo(-sx-50,-sy+60+85,-ex-20,-ey+60+25,-ex,-ey)
        close()
    }
    canvas.drawPath(path = leftPath,paint)

同理,右手的爱心绘制,根据右手的位置大致可以确定爱心的起始位置:

val hearPath = Path().apply {
        moveTo(ex+50,ey-70)
        //左半部分爱心
        cubicTo(ex+50,ey-70,ex+50,ey-90,ex+35,ey-80)
        cubicTo(ex+35,ey-80,ex+20,ey-60,ex+40,ey-40)

        //右半部分爱心
        cubicTo(ex+40,ey-40,ex+80,ey-60,ex+60,ey-78)
        cubicTo(ex+60,ey-78,ex+50,ey-80,ex+45,ey-70)
        close()
    }
    paint.color = Color.Red
    canvas.drawPath(path = hearPath,paint)

步骤四、绘制两只脚
首先为了保证两只脚的对称,要以y轴为对称线,并量只脚与身体椭圆交点的夹角要相同,否则两只脚粗细会不相同,参考绘制手。
这里设置的夹角为:23°,如下图(图画的比较丑,将就看吧):

image.png

//步骤四:画脚
private fun drawLegs(canvas: Canvas, paint: Paint, oval1Size: Size) {
    val angle = 90
    val height = 100 //腿高
    val offsetAngle1 = 30
    val offsetAngle2 = 7
    paint.style = PaintingStyle.Fill
    //左脚
    val leftPath = Path().apply {
        //左边起始点
        val point1 = getOvalSideCoordinate(oval1Size,angle+offsetAngle1)
        //右边结束点
        val point2 = getOvalSideCoordinate(oval1Size,angle+offsetAngle2)
        //从左边起始点开始
        moveTo(-point1.x,-point1.y)
        lineTo(-point1.x,-point1.y + height-10)
        cubicTo(-point1.x,-point1.y + height-10,-point1.x,-point1.y+height+10,-point1.x+15,-point1.y+height+10)
        lineTo(-point2.x,-point1.y+height+10)
        cubicTo(-point2.x,-point1.y+height+10,-point2.x+10,-point1.y+height+10,-point2.x,-point1.y + height-5)
        lineTo(-point2.x,-point2.y)
        close()
    }
    canvas.drawPath(leftPath,paint)
    ...
}

步骤五、绘制面部
这个比较简单,绘制5个紧挨着的椭圆就可以了,从最里面开始颜色依次为:蓝->红->紫->黄->绿。

//步骤五:面部,绘制5色仅贴着的椭圆,颜色依次:蓝->红->紫->黄->绿
private fun drawFiveOvals(canvas: Canvas, paint: Paint){
    paint.strokeWidth = 7f
    //最里面椭圆宽度和高度
    var width = 360f
    var height = 300f
    //从最里面圆环向外依次width和height需要增加的值
    val offValue = paint.strokeWidth * 2
    val colors = arrayOf(Color(0xff87CEEB),Color(0xff8B0000),Color(0xff6A5ACD),Color(0xffFFD700),Color(0xff32CD32))
    //不包含5
    for (i in 0 until 5){
        paint.color = colors[i]
        canvas.drawOval(Rect(Offset(-width/2,-(height-70 - paint.strokeWidth * i)), Size(width,height)),paint)
        width += offValue
        height += offValue
    }
    //恢复
    paint.strokeWidth = 5f
    paint.color = Color.Black
}
面部

步骤六、绘制眼睛
眼睛拆分为:实心黑色椭圆 + 白色圆环 + 白色实心小圆
依次绘制出后,对画布进行旋转和平移达到需要的效果,不知道话可以通过修改参数值逐步尝试。

save()
rotate(40f)
translate(-80f,100f)
paint.style = PaintingStyle.Fill
drawOval(Rect(Offset(-120f,-200f), Size(width,height)),paint)
paint.style = PaintingStyle.Stroke
paint.color = Color.White
drawCircle(Offset(-55f,-140f),35f,paint)
paint.style = PaintingStyle.Fill
drawCircle(Offset(-50f,-155f),10f,paint)
restore()
眼睛

步骤七、绘制鼻子和嘴巴
鼻子:类似绘制爱心,注意要以y轴对称
嘴巴:先画嘴巴最顶上的一个弧形(无填充),接着绘制带填充的弧度为290f,最后绘制一个红色填充的椭圆。

//步骤七:鼻子和嘴巴
private fun drawNoseAndMouth(canvas: Canvas, paint: Paint){
    canvas.apply {
        val noseWidth = 30f
        val noseHeight = 30f
        val path = Path().apply {
            moveTo(-noseWidth/2,-100f)
            cubicTo(-noseWidth/2,-100f,-noseWidth,-(100-noseHeight/2),0f,-(100 - noseHeight))
            cubicTo(0f,-(100 - noseHeight),noseWidth,-(100-noseHeight/2),noseWidth/2,-100f)
            close()
        }
        paint.style = PaintingStyle.Fill
        drawPath(path,paint)

        paint.style = PaintingStyle.Fill
        drawArc(-(noseWidth+100)/2,-70f,(noseWidth+100)/2,40f,-55f,290f,false,paint)
        paint.color = Color.White
        drawArc(-(noseWidth+40)/2,-80f,(noseWidth+40)/2,-50f,0f,180f,false,paint)
        paint.color = Color(0xffA52A2A)
        drawOval(-(noseWidth+60)/2,-20f,(noseWidth+60)/2,36f,paint)
        paint.color = Color.Black
    }
}
鼻子和嘴巴

步骤八、绘制Logo文字和五环
先用drawImage绘制logo图片,这里注意:获取图片的时候要用到resource,该值使用LocalContext.current.resource获取,要放在Canvas的外部。

//绘制logo
drawImage(imageBitmap, Offset(-imageBitmap.width/2f,imageBitmap.height/2f + 90),paint)

绘制文字:BEIJING 2022,在Compose封装的Canvas中没有直接提供绘制文字的方式,但提供了nativeCanvas(原生canvas),通过nativeCanvas可以去绘制文字,paint也需要使用原生Paint类。

canvas.apply {
        ...
        //绘制文字
        val text="BEIJING 2022"
        //创建原生Paint对象
        val nativePaint = android.graphics.Paint().apply {
            color = android.graphics.Color.BLACK
            strokeWidth = 2f
            textSize = 22f
            textSkewX = -0.5f
            typeface = Typeface.create(Typeface.SANS_SERIF,Typeface.BOLD)
        }
        //测量文字宽度
        val width = nativePaint.measureText(text)
        //注意:这里绘制文字要使用nativeCanvas,即使用原生的canvas绘制,Compose提供的Canvas不支持绘制文字
        nativeCanvas.drawText(text,-width/2,220f,nativePaint)
        ...
}

绘制奥运五环,五环特点:上面三个圆环,下面两个,环环相扣。
这里先绘制上面三个圆环,两环之间距离为5,紧接着沿Y轴向下偏移圆环半径长度,继续绘制两个圆环。

//绘制五个圆环叠在一起,上面3个,下面2个,
        //下面两个相对上面三个的y轴偏移量:圆环的半径
        //上面三个圆环间距为5
        val colors = arrayOf(Color(0xff87CEEB),Color(0xff000000),Color(0xff8B0000),Color(0xffFFD700),Color(0xff32CD32))
        val radius = 12f   //圆环半径
        val offsetY = 240f //上面圆环距离绘图中心点y轴上的偏移量
        val space = 5f     //圆环间距
        paint.style = PaintingStyle.Stroke
        paint.strokeWidth = 3f  //圆环线条粗细
        for (i in 0 until 5){
            paint.color = colors[i]
            if (i < 3){//上层圆环
                drawCircle(Offset((radius * 2 + space) * (i-1),offsetY),radius, paint)
            }else{//下层圆环
                val sel = if(i <= 3) -1 else 1
                drawCircle(Offset(sel * (radius + space/2),offsetY + radius),radius, paint)
            }
        }

由于需要环环相扣,所以在绘制几个小圆弧达到环环相扣的效果。

//画出交叉感觉
        paint.color = colors[0]
        //画出蓝环扣在黄环中
        drawArc(Rect(Offset(-radius * 2 - space,offsetY),radius = radius),-10f,30f,false,paint)
        paint.color = colors[1]
        //黑环 一部分扣在黄环上 一部分扣在绿环上
        drawArc(Rect(Offset(0f,offsetY),radius = radius),90f,30f,false,paint)
        drawArc(Rect(Offset(0f,offsetY),radius = radius),-10f,30f,false,paint)
        paint.color = colors[2]
        //绘制红环扣在绿环中
        drawArc(Rect(Offset(radius * 2 + space,offsetY),radius = radius),90f,30f,false,paint)

logo、文字、五环

步骤九、绘制光环效果
最后这个光环效果,同样采用drawPath,绘制贝塞尔曲线的方式实现,多修改下数值可以达到想要的效果。
设置了很多参考点,代码比较多不贴了。

有想看效果图的,自己从后面代码下载吧。

后续后来想了下针对多个贝塞尔曲线坐标点的可以把各个点放到一个list中,然后对这个list进行以step为2进行遍历,再设置贝塞尔曲线,就没那么多代码了,这里就不折腾了。

欢迎留言,一起学习,共同进步!

github - 示例源码
gitee - 示例源码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容