Canvas很强大,能绘制的东西很多,我们前面学到了使用Canvas绘制点、线和矩形等,今天我们看看Canvas的其它绘制。

Canvas绘制圆和椭圆

圆在平时开发中很常见,比如绘制饼状图或指示器圆点等。同绘制点、线一样,绘制圆的方法也是在DrawScope中定义,如下所示:

fun drawCircle(
    color: Color, // 颜色
    radius: Float = size.minDimension / 2.0f, // 半径
    center: Offset = this.center, // 圆心坐标
    /*@FloatRange(from = 0.0, to = 1.0)*/
    alpha: Float = 1.0f, // 透明度
    style: DrawStyle = Fill, // 样式,默认是填充
    colorFilter: ColorFilter? = null, // 颜色效果
    blendMode: BlendMode = DefaultBlendMode // 混合模式
)

绘制一个圆重点就是圆心坐标和半径。上面这些参数中,color是必须的,半径有默认值,是当前Canvas画布的宽或高较小值的一半;center就是圆心坐标,默认值是Canvas的中心点;alpha是透明度,style用来设置圆的样式;colorFilter用来设置圆的效果;blendMode用来设置混合模式。都了解了这些参数后,试着去绘制一个圆:

@Composable
fun DrawCircleTest() {
    Canvas(modifier = Modifier.size(360.dp)) {
        drawCircle(
            color = Color.Blue,
            radius = 300f,
            center = center
        )
    }
}

如上,我们绘制了一个半径为300f的圆,圆心坐标为画布的中心点,效果如下:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

可以看到我们成功地绘制了一个圆。如果想要空心圆,通过设置其style参数即可:

@Composable
fun DrawCircleTest() {
    Canvas(modifier = Modifier.size(360.dp)) {
        drawCircle(
            color = Color.Blue,
            radius = 300f,
            center = center,
            style = Stroke(
                width = 30f
            )
        )
    }
}

上面代码我们只是将style由Fill修改为Stroke,并设置宽度为30f:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

绘制椭圆

一起看下绘制椭圆的方法:

fun drawOval(
    color: Color,
    topLeft: Offset = Offset.Zero,
    size: Size = this.size.offsetSize(topLeft),
    /*@FloatRange(from = 0.0, to = 1.0)*/
    alpha: Float = 1.0f,
    style: DrawStyle = Fill,
    colorFilter: ColorFilter? = null,
    blendMode: BlendMode = DefaultBlendMode
)

参数和drawRect的参数一模一样,是不是很奇怪?先来绘制一个椭圆:

@Composable
fun DrawOvalTest() {
    val topLeft = Offset(100f, 100f)
    val ovalSize = Size(600f, 800f)
    Canvas(modifier = Modifier.size(360.dp)) {
        drawOval(
            color = Color.Blue,
            topLeft = topLeft,
            size = ovalSize
        )
    }
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

既然绘制椭圆和矩形的参数一样,那么我们使用相同的参数去绘制矩形和椭圆,会怎么样呢?来试试:

val topLeft = Offset(100f, 100f)
val ovalSize = Size(600f, 800f)
Canvas(modifier = Modifier.size(360.dp)) {
    drawOval(
        color = Color.Blue,
        topLeft = topLeft,
        size = ovalSize
    )
    drawRect(
        color = Color.Red,
        topLeft = topLeft,
        size = ovalSize
    )
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

这就很明确了,椭圆其实就是对矩形做内切形成的。

绘制圆弧、图片和路径

  • 绘制圆弧 同绘制点、线等方法一样,绘制圆弧的方法也是在DrawScope中定义的:
fun drawArc(
    color: Color, // 颜色
    startAngle: Float, // 起始角度,0代表3点钟方向
    sweepAngle: Float, // 相对于startAngle顺时针绘制的弧度(单位:度)
    useCenter: Boolean, // 设置圆弧是否要关闭边界中心的标志
    topLeft: Offset = Offset.Zero, // 左上角坐标点
    size: Size = this.size.offsetSize(topLeft), // 大小
    /*@FloatRange(from = 0.0, to = 1.0)*/
    alpha: Float = 1.0f, // 透明度
    style: DrawStyle = Fill, // 样式,Fill 或 Stroke
    colorFilter: ColorFilter? = null, // 颜色效果
    blendMode: BlendMode = DefaultBlendMode // 混合模式
)

来看个例子:

@Composable
fun DrawArcTest() {
    Canvas(modifier = Modifier.size(360.dp)) {
        drawArc(
            color = Color.Blue,
            startAngle = 0f,
            sweepAngle = 90f,
            useCenter = true
        )
    }
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

上面的例子我们关闭了边界中心的表示useCenter = true;我们修改下代码:

@Composable
fun DrawArcTest() {
    Canvas(modifier = Modifier.size(360.dp)) {
        drawArc(
            color = Color.Blue,
            startAngle = 90f,
            sweepAngle = 150f,
            useCenter = false
        )
    }
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

可以看到,如果useCenter设置为true,圆弧会连接中心点,反之不会。如果想要绘制空心圆弧,同样设置其style即可:

Canvas(modifier = Modifier.size(360.dp)) {
    drawArc(
        color = Color.Blue,
        startAngle = 90f,
        sweepAngle = 150f,
        useCenter = false,
        style = Stroke(width = 10f)
    )
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

  • 绘制图片 绘制图片方法同样在DrawScope中:
fun drawImage(
    image: ImageBitmap, // 图片资源
    srcOffset: IntOffset = IntOffset.Zero, // 可选偏移量,代表要绘制的原图片的左上偏移量
    srcSize: IntSize = IntSize(image.width, image.height), // 相对于srcOffset绘制的原图片可选尺寸,默认为image的宽度和高度
    dstOffset: IntOffset = IntOffset.Zero, // 可选偏移量,表示绘制给定图片的目标位置的左上偏移量
    dstSize: IntSize = srcSize, // 要绘制的目标图片的可选尺寸,默认为srcSize
    /*@FloatRange(from = 0.0, to = 1.0)*/
    alpha: Float = 1.0f, // 透明度
    style: DrawStyle = Fill, // 样式
    colorFilter: ColorFilter? = null, // 颜色效果
    blendMode: BlendMode = DefaultBlendMode // 混合模式
)

可以看到只有资源是必填参数,其他都是由默认值可选参数,一起绘制一张图片:

@Composable
fun DrawImageTest() {
    val context = LocalContext.current
    val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.small)
    val image = bitmap.asImageBitmap()
    Canvas(modifier = Modifier.size(360.dp)) {
        drawImage(
            image = image
        )
    }
}

通过BitmapFactory获取图片资源,再通过asImageBitmap()方法转成方法需要的ImageBitmap,最后绘制上去:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

参数srcOffset,它类型是IntOffset, 是可选偏移量,代表要绘制的原图片左上偏移量。之前没见过IntOffset,但是见过Offset,Offset设置的时候参数类型是Float。IntOffset的使用方法如下:

@Stable
fun IntOffset(x: Int, y: Int): IntOffset = IntOffset(packInts(x, y))

参数也是x、y。srcSize类型为IntSize,它的作用是相对于srcOffset绘制的源图片的可选尺寸。IntSize使用方法和Size一样,也是传入宽和高,只不过参数类型由Float变为了Int。

再看看dstOffset,类型是IntOffset,也是可选偏移量,表示绘制给定图片的目标位置的左上偏移量,默认为当前的原点,以绘制目标图片的目标位置的左上偏移量作为默认值。

最后看看参数dstSize,类型是IntSize,是绘制的目标图片的可选尺寸,默认是srcSize。设置dstSize就可以设置绘制图片的尺寸。看个例子:

@Composable
fun DrawImageTest() {
    val context = LocalContext.current
    val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.small)
    val image = bitmap.asImageBitmap()
    Canvas(modifier = Modifier.size(360.dp)) {
        drawImage(
            image = image,
            srcOffset = IntOffset(0, 0),
            srcSize = IntSize(100, 100),
            dstOffset = IntOffset(100, 100),
            dstSize = IntSize(800, 800)
        )
    }
}

我们将srcOffset设置为左上角,没有偏移;将srcSize宽高都设置为100;将dstOffset宽高设置为偏移100;将dstSize宽高设置为800。效果如下:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

在实际开发中绘制图片的时候要牢记:srcOffset和srcSize是用来设置源图片的,dstOffset和dstSize才是用来设置目标图片的。

  • 绘制路径 Path类将多种复合路径(如之前学习的点、线段、贝塞尔曲线)等封装在其内部,即:使用Path就可以来绘制前面所绘制的所有图形。来看看Path的定义:
fun drawPath(
    path: Path,
    color: Color,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    alpha: Float = 1.0f,
    style: DrawStyle = Fill,
    colorFilter: ColorFilter? = null,
    blendMode: BlendMode = DefaultBlendMode
)

可以看到,只有第一个参数path之前没见过,其他的我们都已经了解了。参数path类型是Path,这里的Path并不是Android View中的Path,Compose中重写了Path,不过为我们提供了用户相互转换的扩展函数;Android View中的Path可以通过Path.asComposePath方法转为Compose中的Path,Compose中Path可以通过Path.asAndroidPath方法转换为Android View中的Path。

由于有了扩展方法,在使用drawPath的时候如果对Compose中的Path不熟悉,就可以使用Android View中的Path,然后通过扩展方法进行转换,不过不推荐这种做法,因为Compose中的Path已经实现了Android View中的Path功能。来看看Compose中的Path源码:

expect fun Path(): Path
/* expect class */ interface Path {
    /**
     * 绘制路径内部的填充方式
     */
    var fillType: PathFillType
    /**
     * 返回路径的凸度,由路径的内容定义
     */
    val isConvex: Boolean
    /**
     * 如果路径为空(不包含直线或曲线),则返回true
     */
    val isEmpty: Boolean
    /**
     * 在给定坐标点处开始一个新的子路径
     */
    fun moveTo(x: Float, y: Float)
    /**
     * 从当前点以给定偏移量开始一个新的子路径
     */
    fun relativeMoveTo(dx: Float, dy: Float)
    /**
     * 从当前点到给定点添加一条直线段
     */
    fun lineTo(x: Float, y: Float)
    /**
     * 从当前点到当前点相聚给定偏移量的点添加一条直线段
     */
    fun relativeLineTo(dx: Float, dy: Float)
    /**
     * 使用控制点(x1、y1)添加从当前点到给定点(x2、y2)弯曲的二阶贝塞尔曲线段
     */
    fun quadraticBezierTo(x1: Float, y1: Float, x2: Float, y2: Float)
    /**
     * 使用从当前点偏移(dx1、dy1)的控制点
     * 添加一个从当前点弯曲到与当前点偏移(dx2、dy2)的点的二阶贝塞尔曲线段
     */
    fun relativeQuadraticBezierTo(dx1: Float, dy1: Float, dx2: Float, dy2: Float)
    /**
     * 使用控制点(x1、y1)和(x2、y2)
     * 添加从当前点到给定点(x3、y3)弯曲的三阶贝塞尔曲线段
     */
    fun cubicTo(x1: Float, y1: Float, x2: Float, y2: Float, x3: Float, y3: Float)
    /**
     * 添加一个三阶贝塞尔曲线段,曲线从当前点偏移到(dx3、dy3)处的点
     * 使用的偏移量为(dx1、dy1)和(dx2、dy2)处的控制点
     */
    fun relativeCubicTo(dx1: Float, dy1: Float, dx2: Float, dy2: Float, dx3: Float, dy3: Float)
    /**
     * 如果参数forceMoveTo参数为false,则添加直线段和弧段
     * 如果参数forceMoveTo参数为true,则启动一个新的由弧段组成的子路径
     */
    fun arcToRad(
        rect: Rect,
        startAngleRadians: Float,
        sweepAngleRadians: Float,
        forceMoveTo: Boolean
    ) {
        arcTo(rect, degrees(startAngleRadians), degrees(sweepAngleRadians), forceMoveTo)
    }
    /**
     * 如果参数forceMoveTo参数为false,则添加直线段和弧段
     * 如果参数forceMoveTo参数为true,则启动一个新的由弧段组成的子路径
     */
    fun arcTo(
        rect: Rect,
        startAngleDegrees: Float,
        sweepAngleDegrees: Float,
        forceMoveTo: Boolean
    )
    /**
     * 添加一个新的子路径,该子路径由概述给定矩形的4行组成
     */
    fun addRect(rect: Rect)
    /**
     * 添加一个新的子路径,该子路径由一条曲线组成,该曲线形成填充给定矩形的椭圆
     */
    fun addOval(oval: Rect)
    /**
     * 添加一个新的子路径,该子路径具有一个弧段
     */
    fun addArcRad(oval: Rect, startAngleRadians: Float, sweepAngleRadians: Float)
    /**
     * 添加一个子路径,该子路径具有一个弧段,该弧段由遵循给定矩形所界定的椭圆边缘      * 的弧组成
     */
    fun addArc(oval: Rect, startAngleDegrees: Float, sweepAngleDegrees: Float)
    /**
     * 添加一个圆角矩形
     */
    fun addRoundRect(roundRect: RoundRect)
    /**
     * 添加一个新的子路径,该子路径包含给定的“路径”偏移量和给定的“偏移量”
     */
    fun addPath(path: Path, offset: Offset = Offset.Zero)
    /**
     * 关闭最后一个子路径,就像从子路径的当前点到第一个点花了一条直线一样
     */
    fun close()
    /**
     * 清除所有子路径的Path对象,使其返回到创建时的初始状态
     */
    fun reset()
    /**
     * 按给定的偏移量转换每个子路径的所有段
     */
    fun translate(offset: Offset)
    /**
     * 计算路径控制点的边界,并将结果写入边界
     */
    fun getBounds(): Rect
    /**
     * 将路径设置为两个指定路径进行Op操作的结果
     */
    fun op(
        path1: Path,
        path2: Path,
        operation: PathOperation
    ): Boolean
    companion object {
        /**
         * 根据给定“操作”指定的方式组合两条路径
         */
        fun combine(
            operation: PathOperation,
            path1: Path,
            path2: Path
        ): Path {
            // 省略...
        }
    }
}

从上面代码可知Path是一个接口,不过我们可以通过Path的方式进行实例化:

expect fun Path(): Path

熟悉Android中的Path应该对这里面的方法都比较熟悉,很多方法连名字都一样,上面代码中都加了对应的注释,可以根据需求去选择性的使用。我们还是老样子,来看个例子:

@Composable
fun DrawPathTest() {
    val path = Path()
    path.moveTo(100f, 300f)
    path.lineTo(100f, 700f)
    path.lineTo(800f, 700f)
    path.lineTo(900f, 300f)
    path.lineTo(600f, 100f)
    path.close()
    Canvas(modifier = Modifier.size(360.dp)) {
        drawPath(
            path = path,
            color = Color.Red,
            style = Stroke(width = 10f)
        )
    }
}

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

如上图,我们定义了Path,然后移动到一个点,之后通过lineTo方法进行连线,最后close进行闭合。

贝塞尔曲线一直是使用Path时的难点和重点,在Android View中也是,特别是自定义动画的时候也比较重要。我们一起看看绘制贝塞尔曲线的案例:

val path = Path()
path.moveTo(100f, 300f)
path.lineTo(100f, 700f)
// 二阶贝塞尔曲线
path.quadraticBezierTo(800f, 700f, 600f, 100f)
// 三阶贝塞尔曲线
path.cubicTo(700f, 200f, 800f, 400f, 100f, 100f)
path.close()
Canvas(modifier = Modifier.size(360.dp)) {
    drawPath(
        path = path,
        color = Color.Red,
        style = Stroke(width = 10f)
    )
}

我们可以看到,二阶贝塞尔曲线使用控制点(x1、y1)添加从当前点到给定点(x2、y2)进行弯曲;三阶贝塞尔曲线就是使用控制点(x1、y1)和(x2、y2),添加从当前点到给定点(x3、y3)进行弯曲,如下图:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

path还有很多方法,大家可以自己去试着学习下,看看效果。

混合模式

Android View中也有混合模式,我们先看张图:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

可以看到,一个圆形和一个方形通过不同的混合模式产生不同的组合效果。

在Compose中的混合模式,就是上面我们总能看到的BlendMode。BlendMode的源码如下:

@Suppress("INLINE_CLASS_DEPRECATED", "EXPERIMENTAL_FEATURE_WARNING")
@Immutable
inline class BlendMode internal constructor(@Suppress("unused") private val value: Int) {
    companion object {
        /**
         * 删除源图片和目标图片
         */
        val Clear = BlendMode(0)
        /**
         * 放置目标图片,仅绘制源图片
         */
        val Src = BlendMode(1)
        /**
         * 放置源图片,仅绘制目标图片
         */
        val Dst = BlendMode(2)
        /**
         * 将源图片合成到目标图片上
         */
        val SrcOver = BlendMode(3)
        /**
         * 将源图片合成到目标图片下
         */
        val DstOver = BlendMode(4)
        /**
         * 显示源图片,但仅显示两张图重叠的位置
         */
        val SrcIn = BlendMode(5)
        /**
         * 显示目标图片,但仅显示两张图片重叠的位置
         */
        val DstIn = BlendMode(6)
        /**
         * 显示源图片,但仅显示两张图片不重叠的位置
         */
        val SrcOut = BlendMode(7)
        /**
         * 显示目标图片,但仅显示两张图片不重叠的位置
         */
        val DstOut = BlendMode(8)
        /**
         * 将源图片合成到目标图片上,但仅在与目标图片重叠的位置合成
         */
        val SrcAtop = BlendMode(9)
        /**
         * 将目标图片合成到源图片上,但仅在与源图片重叠的位置合成
         */
        val DstAtop = BlendMode(10)
        /**
         * 对源图片和目标图片应用按位异或运算符,这将使它们重叠的地方保持透明
         */
        val Xor = BlendMode(11)
        /**
         * 将源图片和目标图片的组成部分求和
         */
        val Plus = BlendMode(12)
        /**
         * 将源图片和目标图片的颜色分量相乘
         */
        val Modulate = BlendMode(13)
        /**
         * 将源图片和目标图片的分量的逆值相乘,然后将结果相逆
         */
        val Screen = BlendMode(14) // The last coeff mode.
        /**
         * 调整源图片和目标图片的分量以使其适合目标,然后将它们相乘
         */
        val Overlay = BlendMode(15)
        /**
         * 通过从每个颜色通道中选择最小值来合成源图片和目标图片
         */
        val Darken = BlendMode(16)
        /**
         * 通过从每个颜色通道中选择最大值来合成源图片和目标图片
         */
        val Lighten = BlendMode(17)
        /**
         * 将目标除以源的倒数
         */
        val ColorDodge = BlendMode(18)
        /**
         * 将目标的倒数除以源,然后将结果求倒数
         */
        val ColorBurn = BlendMode(19)
        /**
         * 调整源图片和目标图片的分量以使其适合源图片,然后将它们相乘
         */
        val Hardlight = BlendMode(20)
        /**
         * 对于小于0.5的源值使用ColorDodge,对于大于0.5的源值使用ColorBurn
         */
        val Softlight = BlendMode(21)
        /**
         * 从每个通道的较大值中减去较小值
         */
        val Difference = BlendMode(22)
        /**
         * 从两张图片的总和中减去两张图片乘积的两倍
         */
        val Exclusion = BlendMode(23)
        /**
         * 将源图片和目标图片的分量(包括Alpha通道)相乘
         */
        val Multiply = BlendMode(24) // The last separable mode.
        /**
         * 获取源图片的色相以及目标图片的饱和度和光度
         */
        val Hue = BlendMode(25)
        /**
         * 获取源图片的饱和度以及目标图片的色相和亮度
         */
        val Saturation = BlendMode(26)
        /**
         * 获取源图片的色相和饱和度以及目标图片的光度
         */
        val Color = BlendMode(27)
        /**
         * 获取源图片的亮度以及目标图片的色相和饱和度
         */
        val Luminosity = BlendMode(28)
    }
    // 省略...

Compose混合模式中的类型比Android View中多了11种,我们看看怎么使用混合模式:

@Composable
fun DrawBlendModeTest() {
    Canvas(modifier = Modifier.size(360.dp)) {
        drawCircle(
            color = Color.Yellow,
            radius = 175f,
            center = Offset(350f, 350f),
            blendMode = BlendMode.Clear
        )
        drawRect(
            color = Color.Blue,
            topLeft = Offset(300f, 300f),
            size = Size(350f, 350f),
            blendMode = BlendMode.Clear
        )
    }
}

我们绘制了一个圆和一个矩形,圆代表目标图片Dst,矩形代表源图片Src,然后将混合模式都设置为BlendMode.Clear。即删除源图片和目标图片,看下效果:

《Jetpack Compose系列学习》-19 Compose中Canvas的其它绘制

我们代码中为图形添加了背景色,所以使用clear的混合模式后会将其清除显示透明,所以就会显示黑色。

Compose中使用混合模式时一定要注意目标图片和源图片的区别。使用好混合模式可以做出非常炫酷的效果,可以再试试别的混合模式。

代码已上传github: github.com/Licarey/com…