用Compose实现手写春联效果

21529ecfc16287ea7e4108d4320cb842.png

/   今日科技快讯   /

近日,尽管“元宇宙”这个词已经存在了近30年,但直到Facebook在2021年10月下旬更名为Meta,似乎才重新点燃了投资者对它的兴趣。其中,虚拟现实(VR)和增强现实(AR)技术在这个概念中扮演着重要角色。

就风险资本对VR/AR领域的兴趣而言,去年第四季度与其他任何季度都不同。根据企业服务数据库公司Crunchbase的数据,近19亿美元的风险资本流入了AR/VR软硬件领域的初创企业,比以往任何季度都要多。数据还显示,第四季度风险投资增长帮助2021年成为VR/AR投资第二高的年份,有近39亿美元风险投资进入初创企业,仅次于2018年。当时,由于Magic Leap和商汤等公司进行大规模融资,该领域的风险投资接近44亿美元。

/   作者简介   /

本篇文章来自投稿小能手程序员江同学的投稿,文章主要分享了他Compose实现手写春联效果,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

程序员江同学的博客地址:

https://juejin.cn/user/668101431009496/posts

/   前言   /

又是一年新春,在这里先给大家拜个早年了。每逢春节,写春联贴春联都是一项必不可少的活动。本次主要使用Compose,实现手写春联的效果。如果对你有所帮助,欢迎点个赞或者评论鼓励一下~

爆竹声中一岁除

春风送暖入屠苏

千门万户曈曈日

总把新桃换旧符

效果图

d6f834629706cad9ed23ffe0d022ef6c.gif

生成的春联

6f8f484cd9fb1960ed5c7688ec51d593.png

/   主要思路   /

事件监听

我们需要实现手写春联效果,首先就是要做事件监听,Android中自然是监听Action_Down,Action_Move,Action_UP,Compose中应该如何处理呢?其实Compose中也可以利用pointerInteropFilter监听Action_Down,Action_Move,Action_UP,如下所示。

Column(modifier = Modifier.pointerInteropFilter {
    when (it.action) {
        MotionEvent.ACTION_DOWN -> {}
        MotionEvent.ACTION_MOVE -> {}
        MotionEvent.ACTION_UP -> {}
        else ->  false
    }
     true
})

路径绘制

当我们手写春联的时候,实际上就是把我们触摸过的点连接起来,最直接的想法当然是通过Path来绘制,即把各个点连接成Path,然后通过drawPath来绘制。但是问题在于春联是毛笔效果,在写的过程中路径的粗细会发生变化,而drawPath只支持固定的宽度,因此不符合我们的要求。

所以我们换个思路,drawPath其实也是将各个点连接起来,如果我们将触摸过程中的点记录下来,然后在这一系列的点上画圆不就行了吗?每个圆的半径可以自定义,但这样会带来以下问题。

19fc6cb872ba4192d74d21fe61aa3817.png

可以看出:android触摸中的MOVE时间取点的频率不是非常高,会隔一定的像素取点。当轻触滑动时会出现不连续圆的情况,明显不符合笔锋效果。

贝塞尔曲线

上面的问题在于MOVE过程中回调的次数有限,因此只会产生一系列不连续的点,而不是一条线,该如何解决呢?

我们可以想一下Path,其实它也只是定义了一系列的点,然后通过贝塞尔曲线将这些点连接起来,从而实现了曲线效果,我们是不是也可以通过类似的方式,将上面这些点连成线呢?

private fun onActionMove(event: MotionEvent) {
        val lastPoint = viewStates.value.curPoint
        val curPoint = ControllerPoint(event.x, event.y)
        val lineWidth = calWidth(event = event)
        curPoint.width = lineWidth
        if (viewStates.value.pointList.size < 2) {
            //初始化贝塞尔曲线
            bezier.init(lastPoint, curPoint)
        } else {
            //添加下一个点
            bezier.addNode(curPoint)
        }
        val curDis = getDistance(event)
        //在两个点之间插入10个点,它们都在两个点连接的贝塞尔曲线上
        val steps: Int = 1 + (curDis / STEP_FACTOR).toInt()
        val step = 1.0 / steps
        val list = mutableListOf<ControllerPoint>()
        var t = 0.0
        // 插入10个点
        while (t < 1.0) {
            val point: ControllerPoint = bezier.getPoint(t)
            list.add(point)
            t += step
        }
        addPoints(list)
        _viewStates.value = _viewStates.value.copy(curPoint = curPoint)
    }

如上所示,主要做了以下工作:

  1. 当目前列表中只有1个点时,初始化贝塞尔曲线,即以上一个点为起始点,当前点为终点

  2. 当列表中已经有2个点时,往贝塞尔曲线中加入当前点,将原来的终点变为起点,当前点变为新的终点

  3. 在贝塞尔曲线的起点与终点之间插入多个点,它们的位置都在贝塞尔曲线上,具体的数量由STEP_FACTOR决定,我们目前暂定为10个

  4. 当我们在2个点之间,插入了多个点之后,它们之间的空白就会被填补,看起来不像一条线一样

可变的宽度

上文说到毛笔的路径粗细是不断变化的,一般来说是越慢的地方笔划越粗,越快的地方笔划越细,同时两个相邻的点之间的宽度应该是渐变的,而不是突变的,计算笔画宽度的代码如下:

private fun calWidth(event: MotionEvent): Float {
     // 滑动距离
        val distance = getDistance(event)
        // 滑动距离加个影响系数定义为速度
        val calVel = distance * 0.002
        // 速度越大宽度越小,速度越小宽度越大
        val width = NORMAL_WIDTH * maxOf(exp(-calVel), 0.2)
        return width.toFloat()
    }

如上所示,主要做了以下工作:

  1. 虽然决定笔划粗细的是速度,但我们可以假定两次MOVE的间隔是大致相同的,因此计算出滑动距离即可

  2. 因为我们希望笔划粗细有个最大值与最小值,因此我们需要给滑动距离加个影响系数,使exp(-calVel)的结果尽量在0.2与1之间

  3. 速度越大宽度越小,速度越小宽度越大,当滑动速度为0时,exp(-calVel)即为1,而滑动速度越快,exp(-calVel)越接近于0

上面主要是MOVE回调的点的宽度计算,除了MOVE回调的点,贝塞尔曲线加入的点的宽度也应该在起点与终点的宽度之间渐变。

private double getW(double t){
        return getWidth(mSource.width, mDestination.width, t);
    }

    private double getWidth(double w0, double w1, double t){
        return w0 + (w1 - w0) * t;
    }

绘制性能优化

上面我们通过保存MOVE过程中的点的方式实现绘制,当随着笔划越来越多,需要绘制的点也越来越多,在onDraw中对列表进行遍历然后绘制是比较耗性能的,同时每当列表更新,列表都会重新遍历。我们可以建立一个缓冲bitmap,ACTION_UP事件中将当前所有点绘制到缓冲bitmap中。在draw时直接将缓冲bitmap绘制到canvas中,如下所示:

fun SpringBoard() {
 //定义内存图片
 val bitmap = remember {
        Bitmap.createBitmap(itemSize.toInt(), itemSize.toInt(), Bitmap.Config.ARGB_8888)
    }
    val newCanvas = remember { android.graphics.Canvas(bitmap) }
    val paint = remember { Paint().apply { color = android.graphics.Color.BLACK } }
    BoxWithConstraints(){
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .pointerInteropFilter(onTouchEvent = {
                    when (it.action) {
                        MotionEvent.ACTION_UP -> {
                            //绘制到Bitmap上
                            states.pointList.forEach { point ->
                                newCanvas.drawCircle(point.x, point.y, point.width, paint)
                            }
                            //清空当前列表
                            viewModel.dispatch(SpringBoardViewAction.ActionUp(it))
                        }
                    }
                    true
                })
        ) {
            //绘制Bitmap,即之前的笔划
            drawImage(bitmap.asImageBitmap())
            //绘制当前列表,即当前笔划
            states.pointList.forEach {
                drawCircle(Color.Black, it.width, Offset(it.x, it.y))
            }
        }
    }
}

如上所示,主要做了以下工作:

  1. 定义内存图片bitmap,并通过bitmap获取newCanvas

  2. 在ACTION_UP时将当前点的列表绘制到bitmap中并且清空当前点的列表

  3. 在onDraw中绘制bitmap,即绘制之前的笔划,同时绘制当前的pointList,即当前的笔划

长按保存到本地

我们在长按时,需要将春联保存到本地,这需要我们把Compose代码转化成Bitmap,这个在View中比较成熟,但是在Compose中我没有找到相关方法。

我们可以仿照上面的实现,把内容绘制在一个bitmap上,然后直接保存这个bitmap上就好了。

@Composable
fun SpringPreview() {
    BoxWithConstraints(
        modifier = Modifier
            .pointerInput(Unit) {
                detectTapGestures(
                    onLongPress = {
                     //长按时保存bitmap到本地
                        BitmapUtils.saveBitmapToGallery(context, bitmap, "春联")
                    }
                )
            }
    ) {
     //将背景与图片列表绘制到bitmap上
        newCanvas.drawColor(android.graphics.Color.RED)
        for (i in states.bitmapList.indices) {
            newCanvas.drawBitmap(states.bitmapList[i], 0f, itemSize * i, paint)
        }

        Canvas(modifier = Modifier.fillMaxSize()) {
            //绘制bitmap
            drawImage(bitmap.asImageBitmap(), Offset.Zero)
        }
    }
}
  1. 上面提到我们会将笔划绘制到bitmap中,一个字即是一个bitmap

  2. 我们的春联的内容即为上面的bitmap的列表,再加上一个红色的背景

  3. 我们将红色背景与bitmap列表都绘制到一个bitmap中,再将这个bitmap中绘制到Compose中

  4. 上面这个bitmap就是我们想要的图片,长按时将其保存到本地即可

项目地址如下所示:

https://github.com/shenzhen2017/compose-handwriting

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

在微软工作365天,还你一个我眼中更加真实的微软

PermissionX 1.5发布,支持申请Android特殊权限啦

欢迎关注我的公众号

学习技术或投稿

ec0edffed3c7ec2facf495f0b9bfb14f.png

767daeb47edaa28e340074cfb923a1f3.png

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值