JetPack-Compose 水墨画效果👍👍👍

7,610 阅读8分钟

👍大佬们的点赞就是我写作的动力👍



end_compose_animal.gif
前四章
Jetpack-Compose基本布局
JetPack-Compose - 自定义绘制
JetPack-Compose - Flutter 动态UI?
JetPack-Compose UI终结篇

一、水墨画效果

      前两天看掘金发现一位前端大佬写的水墨变彩色效果很好看,今天咋们用Compose来实现一下效果。效果如下,效果是前端大佬们做的效果。

PorterDuffXfermode.gif

二、分析动态效果

1. 第一我们可以看到图片【黑色】变【彩色】
2. 第二点击之后不规则区域逐渐放大且显示出底部彩色照片

效果也许比较简单,但要做好提供大家使用可能需要做好几点:

图片显示大小正好适配画布大小
点击任意地方从此处开始放大
自定义不规则放大区域

三、素材寻找

我们百度一张觉得喜欢的任意图片,然后用PS打开 图像->调整->黑白。即可得到水墨画效果的黑白图片,导出图片备用。

image.png

image.png

四、PorterDuffXfermode

      Android绘制中可以通PorterDuffXfermode将绘制图形的像素与Canvas中对应位置的像素按照一定规则进行混合,形成新的像素值来更新Canvas中最终像素颜色值,这样会创建很多可能性的特效。使用PorterDuffXfermode时,将其作为参数传给Paint.setXfermode(Xfermode xfermode)方法,再用该画笔paint进行绘图时Android就会使用传入的PorterDuffXfermode,如果不想再使用Xfermode,那么可以执行Paint.setXfermode(null)。

PorterDuffXfermode支持以下十几种像素颜色的混合模式,分别为:CLEAR、SRC、DST、SRC_OVER、DST_OVER、SRC_IN、DST_IN、SRC_OUT、DST_OUT、SRC_ATOP、DST_ATOP、XOR、DARKEN、LIGHTEN、MULTIPLY、SCREEN。

image.png 从上面我们可以看到PorterDuff.Mode为枚举类,一共有16个枚举值:

1.PorterDuff.Mode.CLEAR  
   所绘制不会提交到画布上。
2.PorterDuff.Mode.SRC
   显示上层绘制图片
3.PorterDuff.Mode.DST
   显示下层绘制图片
4.PorterDuff.Mode.SRC_OVER
   正常绘制显示,上下层绘制叠盖。
5.PorterDuff.Mode.DST_OVER
   上下层都显示。下层居上显示。
6.PorterDuff.Mode.SRC_IN
   取两层绘制交集。显示上层。
7.PorterDuff.Mode.DST_IN
   取两层绘制交集。显示下层。
8.PorterDuff.Mode.SRC_OUT
   取上层绘制非交集部分。
9.PorterDuff.Mode.DST_OUT
   取下层绘制非交集部分。
10.PorterDuff.Mode.SRC_ATOP
   取下层非交集部分与上层交集部分
11.PorterDuff.Mode.DST_ATOP
   取上层非交集部分与下层交集部分
12.PorterDuff.Mode.XOR
   异或:去除两图层交集部分
13.PorterDuff.Mode.DARKEN
   取两图层全部区域,交集部分颜色加深
14.PorterDuff.Mode.LIGHTEN
   取两图层全部,点亮交集部分颜色
15.PorterDuff.Mode.MULTIPLY
   取两图层交集部分叠加后颜色
16.PorterDuff.Mode.SCREEN
   取两图层全部区域,交集部分变为透明色

1、将图片适配到Canvas

      一个好的自定义,要让开发者使用起来方便,而我们的图片大小不一,那绘制到画布上肯定会大小不一,那我们将图片宽高设置为画布的宽高即可。

android中我们知道图片的压缩分为质量压缩,采样率压缩等。如果熟悉的应该对于下面的方法不会陌生。

//提供了我们很好的工具用来进行长宽来进行对图片的缩放。获取新的Bitmap。
Bitmap createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,
boolean filter)

我们将两个素材图片扔到资源文件下面,开始将图片绘制到画布上。我们的画布多大,那么图片就应该缩放到我们的画布上。代码如下,我们创建了一个400.dp宽和200.dp高的画布,然后获取和画布大小一致缩放后的Bitmap然后绘制到画布上。

@Preview
@Composable
fun InkColorCanvas() {
    val imageBitmap = getBitmap(R.drawable.csmr)
    val imageBitmap_default = getBitmap(R.drawable.hbmr)
    Canvas(
        modifier = Modifier
            .width(400.dp)
            .height(200.dp)
    ) {
        drawIntoCanvas { canva ->
            //彩色图片,获取新的bitmap,宽高和画布宽高一致适配画布
            val multiColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(), false
            )
            //黑白图片
            val blackColorBitmpa = Bitmap.createScaledBitmap(
                    imageBitmap_default.asAndroidBitmap(),
                    size.width.toInt(),
                    size.height.toInt(),
                    false
                )
            //新建画笔    
            val paint = Paint().asFrameworkPaint()
            //绘制图片到画布上
            canva.nativeCanvas.drawBitmap(multiColorBitmpa, 0f, 0f, paint) 
        }
    }
}

同样我们任意设置画布的宽高,应该自动缩放。不用我们开发者担心。即使屏幕旋转之后也会再次测量自动适配。

 //自动填充整个屏幕,旋转屏幕也自动适配。
 Canvas(modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight()
    )

效果如下:

image.png

2、保存当前画布图层

      不管在基本的UI设计软件中如PhotoShop中,或者视频编辑软件Primere图层是最基本的概念,当然在编程绘制中也有图层的概念。Android绘制中Canvas.saveLayer可以将当前的画布内容作为图层保存到堆栈中,达到图层的概念,创建一个新的Layer到“栈”中,可以使用saveLayer, savaLayerAlpha, 从“栈”中推出一个Layer,可以使用restore,restoreToCount。但Layer入栈时,后续的DrawXXX操作都发生在这个 Layer上,而Layer退栈时,就会把本层绘制的图像“绘制”到上层或是Canvas上,在复制Layer到Canvas上时,可以指定Layer的 透明度(Layer),这是在创建Layer时指定的:public int saveLayerAlpha(RectF bounds, int alpha, int saveFlags)等,一句话多多动手

image.png

//保存图层
val layerId: Int = canva.nativeCanvas.saveLayer(
    0f,
    0f,
    size.width,
    size.height,
    paint,
)

3、绘制黑白Bitmap

      步骤二中我们已经将彩色画布保存为图层推入堆栈内部。我们再次绘制黑白图Bitmap作为顶层图层。

@Preview
@Composable
fun InkColorCanvas() {
    val imageBitmap = getBitmap(R.drawable.csmr)
    val imageBitmap_default = getBitmap(R.drawable.hbmr)
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight()
    ) {
        drawIntoCanvas { canva ->
            val multiColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(), false
            )
            val blackColorBitmpa = Bitmap.createScaledBitmap(
                    imageBitmap_default.asAndroidBitmap(),
                    size.width.toInt(),
                    size.height.toInt(),
                    false
                )
            val paint = Paint().asFrameworkPaint()
            //绘制彩色图片
            canva.nativeCanvas.drawBitmap(multiColorBitmpa, 0f, 0f, paint) 
            //保存图层到堆栈
            val layerId: Int = canva.nativeCanvas.saveLayer(
                0f,
                0f,
                size.width,
                size.height,
                paint,
            )
            //当前图层也是顶层图层绘制黑白Btmap
            canva.nativeCanvas.drawBitmap(blackColorBitmpa, 0f, 0f, paint)

        }
    }
}

下图1-效果图。图2-堆栈图层

image.png

4、混合模式PorterDuff.Mode.DST_IN

        PorterDuff.Mode.DST_IN 取两层绘制交集。显示下层。接下来我们设置画笔混合模式为PorterDuff.Mode.DST_IN且绘制一个中心为屏幕中心,半径为250px的圆。

//PorterDuffXfermode 设置画笔的图形混合模式
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)//画圆
canva.nativeCanvas.drawCircle(size.width / 2, size.height / 2, 250f, paint)            

效果如下:

image.png

到这里我想基本搞定了最重要的部分了。

五、动画扩大混合区域

       那如何让逐渐扩大展示出所有的彩色图片呢?很简单,动画有没有。只要最终半径大于画布的对角线即可,让半径从0变到斜边长的一半以上即可。

勾股定理 斜边=sqrt(size.width.toDouble().pow(2.0) + size.height.toDouble().pow(2))

@Preview
@Composable
fun InkColorCanvas() {
    val imageBitmap = getBitmap(R.drawable.csmr)
    val imageBitmap_default = getBitmap(R.drawable.hbmr)
    val animal = Animatable(0.0f)
    var xbLength = 0.0f
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight().pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            animal.animateTo(
                                xbLength,
                                animationSpec = spring(stiffness = Spring.DampingRatioLowBouncy)
                            )
                        }

                    }
                }
            }
    ) {
        drawIntoCanvas { canva ->
            val multiColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(), false
            )
            val blackColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap_default.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(),
                false
            )
            val paint = Paint().asFrameworkPaint()
            canva.nativeCanvas.drawBitmap(multiColorBitmpa, 0f, 0f, paint) //绘制图片
            //保存图层
            val layerId: Int = canva.nativeCanvas.saveLayer(
                0f,
                0f,
                size.width,
                size.height,
                paint,
            )
            canva.nativeCanvas.drawBitmap(blackColorBitmpa, 0f, 0f, paint)
            //PorterDuffXfermode 设置画笔的图形混合模式
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
            //画圆
            canva.nativeCanvas.drawCircle(
                size.width / 2,
                size.height / 2,
                animal.value,
                paint
            )
            //画布斜边
            xbLength = kotlin.math.sqrt(size.width.toDouble().pow(2.0) + size.height.toDouble().pow(2)).toFloat()
            paint.xfermode = null
            canva.nativeCanvas.restoreToCount(layerId)
        }
    }
}

compose_animal.gif

六、扩大区域跟随按压

      可能想要在任何按下地方开始扩大选取。很简单,获取屏幕指针获取屏幕按压坐标即可,设置为选区圆的起始坐标。
通过pointerInput来获取屏幕按下坐标
通过 remember { mutableStateOf(Offset(0f,0f)) }记住按下的坐标


@Preview
@Composable
fun InkColorCanvas() {
    val imageBitmap = getBitmap(R.drawable.csmr)
    val imageBitmap_default = getBitmap(R.drawable.hbmr)
    val scrrenOffset = remember { mutableStateOf(Offset(0f,0f)) }

    val animalState = remember { mutableStateOf(false) }

    val animal: Float by animateFloatAsState(
        if (animalState.value) {
            1f
        } else {
            0f
        }, animationSpec = TweenSpec(durationMillis = 4000)
    )
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight().pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        val position=awaitPointerEventScope {
                              awaitFirstDown().position
                        }
                        launch {
                            scrrenOffset.value= Offset(position.x,position.y)
                            animalState.value=!animalState.value
                        }

                    }
                }
            }
    ) {
        drawIntoCanvas { canva ->
            val multiColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(), false
            )
            val blackColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap_default.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(),
                false
            )
            val paint = Paint().asFrameworkPaint()
            canva.nativeCanvas.drawBitmap(multiColorBitmpa, 0f, 0f, paint) //绘制图片
            //保存图层
            val layerId: Int = canva.nativeCanvas.saveLayer(
                0f,
                0f,
                size.width,
                size.height,
                paint,
            )
            canva.nativeCanvas.drawBitmap(blackColorBitmpa, 0f, 0f, paint)
            //PorterDuffXfermode 设置画笔的图形混合模式
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
            val xbLength = kotlin.math.sqrt(size.width.toDouble().pow(2.0) + size.height.toDouble().pow(2)).toFloat()*animal
            //画圆
            canva.nativeCanvas.drawCircle(
                scrrenOffset.value.x,
                scrrenOffset.value.y,
                xbLength,
                paint
            )
            //画布斜边
            paint.xfermode = null
            canva.nativeCanvas.restoreToCount(layerId)
        }
    }
}

compose_animal1.gif

七、不规则扩大选区

      上面我们为了方便圆形进行了扩散,因为圆的缩放可以通过半径进行计算。但是其他的形状就不是那么好处理了。当然我们可以粗略或者精细的进行变换区域。这里由于时间问题,我们粗略的进行计算扩大选区的动画。原理当然要清楚了。

image.png

如上图我们的路径形状可以是各式各样的。但是执行到最终也需要扩散到所有边缘才可以。所以我们最终变换结果的路径一定要包围所有的画布区域才可以。如下结合理解

image.png

@Preview
@Composable
fun InkColorCanvas() {
    val imageBitmap = getBitmap(R.drawable.csmr)
    val imageBitmap_default = getBitmap(R.drawable.hbmr)
    val scrrenOffset = remember { mutableStateOf(Offset(0f, 0f)) }

    val animalState = remember { mutableStateOf(false) }

    val animal: Float by animateFloatAsState(
        if (animalState.value) {
            1f
        } else {
            0f
        }, animationSpec = TweenSpec(durationMillis = 6000)
    )
    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            scrrenOffset.value = Offset(position.x, position.y)
                            animalState.value = !animalState.value
                        }

                    }
                }
            }
    ) {
        drawIntoCanvas { canva ->
            val multiColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(), false
            )
            val blackColorBitmpa = Bitmap.createScaledBitmap(
                imageBitmap_default.asAndroidBitmap(),
                size.width.toInt(),
                size.height.toInt(),
                false
            )
            val paint = Paint().asFrameworkPaint()
            canva.nativeCanvas.drawBitmap(multiColorBitmpa, 0f, 0f, paint) //绘制图片
            //保存图层
            val layerId: Int = canva.nativeCanvas.saveLayer(
                0f,
                0f,
                size.width,
                size.height,
                paint,
            )
            canva.nativeCanvas.drawBitmap(blackColorBitmpa, 0f, 0f, paint)
            //PorterDuffXfermode 设置画笔的图形混合模式
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
            val xbLength = kotlin.math.sqrt(size.width.toDouble().pow(2.0) + size.height.toDouble().pow(2)).toFloat() * animal
            //画圆
//            canva.nativeCanvas.drawCircle(
//                scrrenOffset.value.x,
//                scrrenOffset.value.y,
//                xbLength,
//                paint
//            )
            val path = Path().asAndroidPath()
            path.moveTo(scrrenOffset.value.x, scrrenOffset.value.y)
            //随便绘制了哥区域。当然了为了好看曲线可以更美。
            if (xbLength>0) {
                path.addOval(
                    RectF(
                        scrrenOffset.value.x - xbLength,
                        scrrenOffset.value.y - xbLength,
                        scrrenOffset.value.x + 100f + xbLength,
                        scrrenOffset.value.y + 130f + xbLength
                    ), android.graphics.Path.Direction.CCW
                )
                path.addCircle(
                    scrrenOffset.value.x, scrrenOffset.value.y, 100f + xbLength,
                    android.graphics.Path.Direction.CCW
                )
                path.addCircle(
                    scrrenOffset.value.x-100, scrrenOffset.value.y-100, 50f + xbLength,
                    android.graphics.Path.Direction.CCW
                )
            }
            path.close()
            canva.nativeCanvas.drawPath(path, paint)
            //画布斜边
            paint.xfermode = null
            canva.nativeCanvas.restoreToCount(layerId)
        }
    }
}

end_compose_animal.gif

八、总结

      Compose申明式UI必定式未来,当然xml不会遗弃,只是我们需要跳出舒适圈,不找借口,学习动手,我所体会到的Compose在效率和自定义等方面已经带来了很好的体验,相信写Flutter的大伙们极度舒适吧。后面有时间会继续写文章。这些特效都会收集到ComposeUnit中,代码到以后开源共享,希望完成ComposeUnit来和大家见面。最近比较忙写的少可以预览一下。👍👍👍👍大佬们的点赞就是我的动力👍👍👍👍👍,鄙人QQ学习裙730772561

xiaoguounit.gif