Android Compose UI绘制翻页效果实践

2,802 阅读16分钟

前言

在最近的几篇文章中我们,我们使用Compose做了很多效果和分析,具体可以看看下面的文章。

以上文章中也做了很多效果,如“手电筒效果”、“键盘模拟”、”Pager+Tab“以及”MeasurePolicy实现环形菜单“,今天我们重点来了解一下Compose绘制相关逻辑和一些特点。

因为MeasurePolicy属于测量和布局方面的用法,本篇来了解另一个重要的知识点——绘制。

本篇效果

本篇做个简单的翻书效果,效果如下

fire_162.gif

Compose UI Canvas 基础知识

和传统的Canvas 2D一样,Compose 沿袭了很多Canvas 2D的绘制逻辑,同时对一些方法进行了改造,一些情况下,便利性也有明显的提升,但是也有一部分弱化。

下面,我们进行一下汇总.

Compose UI Canvas 优化部分

  • 默认参数: 绘制图片、文本、圆时可以使用默认参数,再也不会像Canvas 2D api那样啰嗦了。
  • 自动save和restore: 在Compose中,Canvas的一些方法可以自动save和restore
  • Brush: 提供了比较便捷的Shader创建逻辑
  • 不需要创建Paint:很多方法不需要手动创建Paint,极大的方便了很多初始化操作
  • 绘制矩阵升级为4x4

Compose UI Canvas 弱化部分

实际上,弱化部分相比优化部分,明显要多的多,其次很多很多重要的功能也没了。

  • 不支持drawTextOnPath:没有此类方法
public void drawTextOnPath(@NonNull String text, @NonNull Path path, float hOffset,
        float vOffset, @NonNull Paint paint) {
    super.drawTextOnPath(text, path, hOffset, vOffset, paint);
}
  • 不支持drawBitmapMesh:没有此类方法
public void drawBitmap(@NonNull Bitmap bitmap, @NonNull Matrix matrix, @Nullable Paint paint) {
    super.drawBitmap(bitmap, matrix, paint);
}
  • 不支持图片绘制时变换:Compose无法在绘制时进行变换,如果有图像变化,只能提前先变换图片再绘制
  • 矩阵一些方法转换为Android方法时不支持或会crash
  • 没有Camera 3D及相关方法
  • 没有PathMeasure及相关方法
  • 没有Region及相关方法
  • 对象很难复用: Paint、Offset、Size大量对象无法复用,因此,用Canvas绘制频繁刷新的功能时需要谨慎。
  • 一些作用域中不支持文本绘制: 如drawIntoCanvas无法绘制文本
  • 不支持截图
  • Brush创建的对象无法复用
  • save/restore相比Canvas 2D 中的save/restoreToCount灵活度下降
  • 矩阵Matrix缺失部分高级API:如左乘和右乘

实际上,Compose UI对绘制弱化很多,很多高级api只能借助底层实现,这也不是Android Develper想看到的。显然Compose UI这种行为的目的是为了跨平台,而让Android平台做出牺牲。

另一方面,性能也是弱化了的,比如大量对象无法复用、其次是隐式操作创建对象很难优化,例如Paint创建本身比较耗时的 ,却难以复用,Compose UI在提高便利性是以牺牲性能为代价的。因此,理论上很难胜任一些频繁刷新的场景,这种情况会引发很内存抖动或者卡顿。

同样Matrix也是,其裁剪掉了很多Android Canvas 2D的高级方法,当然,Compose 团队似乎也意识到了一些问题,从下面的注释中我们就能看到 —— This class needs some optimization。

// TODO(mount): This class needs some optimization
@kotlin.jvm.JvmInline
value class Matrix(
    val values: FloatArray = floatArrayOf(
        1f, 0f, 0f, 0f,
        0f, 1f, 0f, 0f,
        0f, 0f, 1f, 0f,
        0f, 0f, 0f, 1f
    )
) {
 //省略一些代码
}

Compose UI Matrix

我们上面刚刚说过矩阵,这里我们再详细说一下。

实际上,Compose UI 中的Matrix有一些缺陷,比如一些操作无法转换为Android Graphics Matrix,而Compose 代码中并没有对此进行兼容。

fun android.graphics.Matrix.setFrom(matrix: Matrix) {
    require(
        matrix[0, 2] == 0f &&
            matrix[1, 2] == 0f &&
            matrix[2, 2] == 1f &&
            matrix[3, 2] == 0f &&
            matrix[2, 0] == 0f &&
            matrix[2, 1] == 0f &&
            matrix[2, 3] == 0f
    ) {
        "Android does not support arbitrary transforms"
    }
//省略调一些代码
}

比如绕y旋转180度,就能引发crash,不知道这个问题何时能修复。

关于Compose UI的Matrix

我们说过,此矩阵是4x4的矩阵,实际上在我们之前的文章中提到过《 Android系统中的坐标与矩阵体系》一些矩阵,其中有Canvas 2D,Camera、Open GL,而Compose UI的矩阵是和open GL看齐的。

我们知道,open gl的世界坐标系是4x4的,在Android中,open gl的矩阵和Canvas 2D的矩阵规则也是不一样的。open gl是列向量表示,而Canvas 2D是行向量表示。

如:向X轴平移100个单位的变换

下面我们可以看到,opengl 和android graphics 2d 的表达形式是有区别的,因此,一定要注意细节,防止计算错误。

open gl Matrix

1000
0100
0010
100001

Android Graphic Matrix

10100
010
001

Compose UI Matrix

那么,Compose UI呢,实际上,Compose UI几乎完全沿袭了open gl的Matrix,但是另一方面,Compose UI的Matrix缺少很多东西

因此,一些计算需要手动计算,比如平移100,操作也是第12个元素

val matrix = Matrix()
matrix[3,0] = 100; //第12个元素

等价于下面操作

1000
0100
0010
100001

从这里我们看到,Compose UI弃用了Android自身的Matrix,从而向open gl es对齐,但4x4的矩阵与3x3矩阵互相转换是有成本的,理论上这也是Compose UI无法支持draw Bitmap with Matrix变换的原因之一

Compose UI 坐标体系

实际上这部分和Android的坐标体系一样,左上角为原点,x轴正方向默认向右,y轴正方向默认向下,因此,我们没有必要再去了解。

本篇难点

实际上,Compose UI Graphics对于Android Graphics功能的弱化可以用四个字来形容——不尽人意。

我们能看到Compose UI团队急于实现跨平台,然而,这种做法的弊端就是只能裁剪掉Android平台特有的高级API;另外,绘制的过程中,一些UI的更新需要通过状态机制来实现,这也是Compose UI自身的特点,但是各种remember非常多,管理起来也不是很方便。当然,这样做的另一个目的是展示一致性,我们在《Android Jetpack Compose开发体验》一文中提到过,Compose UI是通过适配原有View的机制去实现跨平台的,这种代价是需要为每个平台和系统版本都要做兼容,总体上代价相对Flutter而言有些高,如果是跨平台app,其bug理论上会多于flutter。可想而知,Flutter都能写open gl和游戏了,但是Compose UI却还在为每个平台适配而烦恼,同时也是因为这些因素,Compose UI需要大量使用编译器代码生成机制,导致很多代码只能在运行时定位,对开发者而言可读性也是要差一些的。因此,这里建议Compose UI学习Flutter,自搞一套UI体系吧。

难点1:截图问题

Compose UI实际上无法对节点截图的,可行办法是在Compose组件上套一层Android原生View,当然,如果是iOS那么可能得套一层iOS相关View了。

不过,还有一种使用反射的方法,反射调用Canvas的绘制也是可以的,本篇就是使用的这种方法。

另外有同学提到,可以使用开源方案github capturable无反射截图,这种方案的原理是利用DrawScope#draw方法实现绘制,但是其版本要求支持 fundation 1.6.5,而本篇的版本是fundation 1.5.1,因此,如果要优化截图代码,那就升级到1.6.5版本以上。

第一步是获取drawNode

androidx.compose.ui.node.LayoutNodeDrawScope#drawNode#performDraw

drawNode是DrawModifierNode的实例,我们可以看到其有扩展方法,拿到drawNode然后通过drawNode#performDraw便可实现绘制当前Compose节点

fun DrawModifierNode.performDraw(canvas: Canvas) {
    val coordinator = requireCoordinator(Nodes.Draw)
    val size = coordinator.size.toSize()
    val drawScope = coordinator.layoutNode.mDrawScope
    drawScope.drawDirect(canvas, size, coordinator, this)
}

下面是截图代码

private fun drawIdleState(
    canvas: ContentDrawScope, //内容绘制作用域
    page: Page
) {

    if (page.snapshot) {
        //如果不想StackOverflow的话,立即置为false,否则就做倒霉蛋吧
        page.snapshot = false

//反射获取LayoutNodeDrawScope
        val LayoutNodeDrawScopeKlass =
            Class.forName("androidx.compose.ui.node.LayoutNodeDrawScope")
            //判断canvas是不是LayoutNodeDrawScope 实例
        if (LayoutNodeDrawScopeKlass.isInstance(canvas)) {
        //创建图像
            val imageBitmap = ImageBitmap(canvas.size.width.toInt(), canvas.size.height.toInt())
            
            //获取drawNode
            val drawNodeField = LayoutNodeDrawScopeKlass.getDeclaredField("drawNode")
            drawNodeField.isAccessible = true
            val drawModifierNode = drawNodeField.get(canvas) as DrawModifierNode
            
            //注意,performDraw是三个参数,因为是扩展方法,第一个是drawNode的类
            val performDrawMethod =
                LayoutNodeDrawScopeKlass.getDeclaredMethod(
                    "performDraw",
                    DrawModifierNode::class.java,
                    Canvas::class.java
                )
                
                
            performDrawMethod.isAccessible = true
            
            //创建Canvas
            val snapshotCanvas = Canvas(imageBitmap)
            val frontColor = page.frontColor
            page.frontColor = Color.Transparent

            snapshotCanvas.save()

            performDrawMethod.invoke(canvas, drawModifierNode, snapshotCanvas)

            snapshotCanvas.restore()
            Log.d(TAG, "performDrawMethod = $imageBitmap")
            page.imageBitmap = imageBitmap
            page.frontColor = frontColor
        }
    }

   // 绘制背景
    canvas.drawRect(page.frontColor)
    //绘制内容
    canvas.drawContent()  
}

在上面的代码中我们使用了反射,但是我们一定要注意StackOverflow,因为反射之后仍然会调用到drawIdleState方法

 if (page.snapshot) {
        //如果不想StackOverflow的话,立即置为false,否则就做倒霉蛋吧
        page.snapshot = false
 }

通过上面代码我们就能实现,绘制效果如下

企业微信20240501-121656@2x.png

通过这种方式我们就实现了截图

难点2: 图像翻转

另外一个问题当然是图像翻转,为什么说他是问题呢?

因为我们前面说过,Compose UI的Canvas不支持draw image with matrix 相关的方法,因此需要提前处理,当然,处理的前提就是得借助Canvas#contat实现矩阵转换,这在Android中也有,只是因为比draw image with matrix 难度高一些,很少有人选择用,但是Compose UI只有这个,没得选。

企业微信20240501-081314@2x.png

下面是图像翻转代码

//翻转图像
snapshotCanvas.save()
val matrix = Matrix()
matrix[0,0] = -1f;
matrix[3,0] = canvas.size.width;
snapshotCanvas.concat(matrix)
snapshotCanvas.restore()

如下图,下面的左侧图片是旋转之后的,显然我们需要给translateX到右侧,才能和可见区域重合

企业微信20240501-122640@2x.png

这里我们没用matrix#rotateY方法,原因是会crash

手动把x值旋转到-1,即cos(180),同时,由于旋转后x轴方向是旋转了180度,而旋转是绕着(0,y)的点,因此,会出现在视图左侧,所以再调整图像平移屏幕宽度(因为本篇图片的宽度和屏幕宽度相同,所以这里就用屏幕宽度了)。

当然,如果我们使用Android的旋转,操作也是类似,看似复杂,其实可以做很多优化,但Compose UI这方面优化空间很少。

int save = canvas.save();
canvas.translate(getWidth()/2f,getHeight()/2f);
canvas.drawBitmap(bitmap,0,0,mCommonPaint);  //原图
matrix.reset();
matrix.getValues(vertex);
vertex[0] = -1;
matrix.setValues(vertex);
matrix.postTranslate(bitmap.getWidth() ,0);
canvas.concat(matrix);
canvas.drawBitmap(bitmap,0,0,mCommonPaint);  // 变换之后的图片
canvas.restoreToCount(save);

是不是很难理解,很正常,建议多尝试几次,熟悉一下。

我们可以换种思路去理解,通过欧拉角-矩阵旋转角度去理解,如下面的矩阵,但要注意的是下面的矩阵形式稍微有点差异,因为其是行向量矩阵,不过无论行向量还是列向量,转换思路基本一样。

注意:下图为行向量矩阵
注意:下图为行向量矩阵
注意:下图为行向量矩阵

企业微信20240501-082629@2x.png

不过,这里我们是纯180度旋转,直接就是-1了,如果60度,可以借助open gl的一些矩阵(android.opengl.Matrix) 方法去计算,如矩阵乘法,因为前面说过,Compose UI的矩阵和open gl几乎一样,理论上计算结果也是一样的。

难点3:折角区域计算

实际上,翻页效果很多,也有特定的实现方式,但是公式理解难度还有些高,不利于优化,本篇,我们换一种思路,通过计算法线的方式实现

fire_163.gif

一般来说,右下角和右上角的翻书效果是比较有规律的,我们在点击位置和右下角连线,就能计算出垂直方向的法线,那么,我们还可以利用此机制计算出1/2、3/4位置的法线。1/2处的是比较重要的,为什么这么说呢,因为这里可以等分折角区域和露出区域。

为了计算方便,我们直接把Canvas原点平移到右下角或者右上角,这样计算难度就会小很多。

var pointerPoint = Offset(
    pointerOffset.x - size.width,
    pointerOffset.y - 0
)
// atan2斜率范围在 -PI到PI之间,因此第三象限为atan2 =  atan - PI, 那么atan = PI  + atan2

val startPoint = Offset(0F, 0F);

val pointerRotate = atan2(pointerPoint.y - startPoint.y, pointerPoint.x - startPoint.x) + PI


val xLength = min(
    hypot(
        pointerPoint.x - startPoint.x,
        pointerPoint.y - startPoint.y
    ) / cos(pointerRotate),
    size.width * 2.0
).toFloat()

//由于计算出来的Y按0,0点计算的,因此需要转换为
val yLength = xLength / tan(pointerRotate).toFloat()

难点4:背图旋转

fire_159.gif

我们从效果图中就能看出,背面的图片需要旋转的,这里要注意的,旋转位置应该是图片【露出区域】的1/2位置的地方

//绘制折角
clipPath(foldPath){
    //这里我们铺满把,就不旋转灰色背景了,反正都要裁剪
    canvas.drawRect(page.backColor, topLeft = Offset(-size.width,0f),size= Size(size.width,size.height))
    val t = atan2(pointerPoint.y,(pointerPoint.x - XHalfAxisPoint.x)) + PI
    //我们要把(XHalfAxisPoint.x,0)作为旋转中心,这里要计算新的夹角,但是在第三象限计算夹角需要做转换,转为第一象限便于计算,当然也可以使用atan
    val degree = Math.toDegrees(t).toFloat()
    Log.d(TAG,"drawBottomRightDragState degree = $degree")
    rotate(degrees = Math.toDegrees(t).toFloat(),pivot = Offset(XHalfAxisPoint.x,0f)){ //图片按“露出”的1/2位置(XHalfAxisPoint.x,0f))旋转
        page.imageBitmap?.let {
            //由于原点在(size.width,size.height),所以,x轴为负值,当然,图片展示在地下是不对的,需要和灰色背景一样往上移动size.height
            // (我们这里使用的size.height,其实因为这里和image大小一样,理论上应该用image.width)
            drawImage(it,  Offset(-xLength,0f))
        }
    }
}

难点5: 越界处理

在拖动过程中,与x轴或者y轴相交的位置容易超过页面宽度,这种给人感觉是撕书的感觉,我们不让书页出现撕裂的感觉,必须限制绘制范围,即限制x轴交点的范围,同时限制Y轴的范围。

fire_160.gif

仅仅限制X的范围还是不够的,我们仍然需要重新计算y轴交点的范围,同时,为了保证法线关系,我们需要限制触摸点pointerPoint的值。

if (_xLength > size.width*2) {
    //如果满足这个条件,意味着需要重新计算pointerPoint,因为没有形成垂直关系
    xLength = (size.width * 2);
    yLength = xLength / tan(pointerRotate).toFloat()

    var adjustRotate = atan(abs(yLength) / abs(xLength))

    val pointerDistance = abs(yLength * cos(adjustRotate))
    val y = abs(pointerDistance * sin(PI/2 - adjustRotate))
    val x = abs(pointerDistance * cos(PI/2 - adjustRotate))

    pointerPoint = Offset(
        -x.toFloat(),
        -y.toFloat()
    )
}

事件处理

上面我们处理了基本的难点,这里我们就可以处理事件了,其实我们这里只做了简单的分段,右上角-中间位置-右下角,这里我们主要通过判断点击区域来判断的。

pointerInput("DraggerInput") {
    detectDragGestures(
        onDragStart = { it ->

            val offsetLeft = 150.dp.toPx();
            val offsetTop = 150.dp.toPx();

            dragState = if (Rect(
                    size.width - offsetLeft,
                    size.height - 100.dp.toPx(),
                    size.width.toFloat(),
                    size.height.toFloat()
                ).contains(it)
            ) {
            //右下角
                Page.STATE_DRAGING_BOTTOM
            } else if (Rect(
                    size.width - offsetLeft,
                    0F,
                    size.width.toFloat(),
                    100.dp.toPx()
                ).contains(it)
            ) {
                Page.STATE_DRAGING_TOP  //右上角
            } else if (Rect(
                    size.width - offsetLeft - 20.dp.toPx(),
                    offsetTop,
                    size.width - 20.dp.toPx(),
                    size.height - offsetTop
                ).contains(it)
            ) {
            //中间位置
                Page.STATE_DRAGING_MIDDLE
            } else {
                Page.STATE_DRAGING_EXCEEDE
            }

        },
        onDragEnd = {
            dragState = Page.STATE_IDLE
            pointerOffset = Offset(size.width.toFloat(), size.height.toFloat())
        }
    ) { change, dragAmount ->
        if (dragState == Page.STATE_DRAGING_BOTTOM || dragState == Page.STATE_DRAGING_MIDDLE || dragState == Page.STATE_DRAGING_TOP) {
            pointerOffset = change.position
        }
    }
}

用法

这里我们就不封装了,直接就行。

不过,这里要说的是,这个方案很多功能都没有实现,比如后退,中间位置拖拽旋转等,另外阴影部分也没有添加,总之不是很完善,后续有机会继续完善吧。

Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colorScheme.background
) {

    BookPager{
        Image(
            modifier = Modifier.fillMaxWidth(),
            alignment = Alignment.Center,
            contentScale = ContentScale.FillWidth,
            painter = painterResource(id = R.mipmap.img_checken),
            contentDescription = ""
        )
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .padding(5.dp),
            text = "\t\t我为什么要关心她?"
                    +"\n\t\t你曾经说过,此生只爱她一个人的?因此你一直单身,对吧!"
                    +"\n\t\t梁医生,你忘了?"
                    +"\n\t\t我不是医生,我只是个打工仔,我也没有忘记,但是那份爱已经不会再有了"
                    +"\n\t\t嗨,她可是主动让我找你哦!"
                    +"\n\t\t听说,她小孩生病了!——张铭生说到。"
                    +"\n\t\t她真会找时间,她永远会在最困难的时候找我,永远会在没有困难的时候离我而去。"
                    +"\n\t\t事实或许相反,她离开你时已经是迫不得已,张铭生调高嗓门说到。"
                    +"\n\t\t"
        )
    }
    BookPager{
        Image(
            modifier = Modifier.fillMaxWidth(),
            contentScale = ContentScale.FillWidth,
            alignment = Alignment.Center,
            painter = painterResource(id = R.mipmap.img_02),
            contentDescription = ""
        )
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .padding(5.dp),
            text = "\t\t他清楚的知道,这个实验成功的概率是多么的低,他望着窗台的透进来的晨光,内心无比的焦灼,这才是早上九点种,但他仿佛看到了落日的余晖。"
                    +"\n\t\t病床的男人抽搐不停,他已经没有多长时间了,长期的抽搐,导致他无法入眠,如果这种状态再延续下去,走向人生的重点已成必然。"
                    +"\n\t\t梁雨,你有什么遗言么?"
                    +"\n\t\t他从窗台方向转向过来,看见他的初中老同学张铭生。"
                    +"\n\t\t我能有什么遗言,孤家寡人而已!"
                    +"\n\t\t嗯~啊?不想给张桐说几句么?听说她离婚了"
        )
    }
}

总结

本篇到这里就结束了,本篇主要重点还是了解Compose UI的绘制,总得来说,就目前而言,Compose UI Canvas使用起来有很多问题,如果官方继续走适配模式这条路的话,你就只能忍受这种问题。另外,通过这几天看代码发现,Compose UI的性能优化难度偏高,同时代码可读性也是比较差,主要是存在大量的注解,让代码定位变得很复杂。作为开发者,我们是面向老板编程,很多时候我们决定用不了什么框架,但是开发效率和维护成本显然是开发团队和老板要思考的问题。

目前而言,Compose UI和flutter相比较,compose ui对于绘制、展示一致性的问题还缺乏全面的兼容,这些问题会在跨平台项目中暴露出来。且因为过多的注解和编译器生成机制,其源码可读性也很差,学习成本也是比较高的,这就很难说服一些忠诚的原生android 开发者和flutter开发者选择compose ui。

总而言之,如果你要做跨平台应用,目前来说,Flutter的优势仍然比明显,但是如果仅仅是Android项目,选Compose和Flutter都是可以的。

本篇源码

作为惯例,我们在这里附上源码,仅供参考

class BookActivity() : ComponentActivity() {

    private val TAG = "BookPager";

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ComposeTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {

                    BookPager{
                        Image(
                            modifier = Modifier.fillMaxWidth(),
                            alignment = Alignment.Center,
                            contentScale = ContentScale.FillWidth,
                            painter = painterResource(id = R.mipmap.img_checken),
                            contentDescription = ""
                        )
                        Text(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(5.dp),
                            text = "\t\t我为什么要关心她?"
                                    +"\n\t\t你曾经说过,此生只爱她一个人的?因此你一直单身,对吧!"
                                    +"\n\t\t梁医生,你忘了?"
                                    +"\n\t\t我不是医生,我只是个打工仔,我也没有忘记,但是那份爱已经不会再有了"
                                    +"\n\t\t嗨,她可是主动让我找你哦!"
                                    +"\n\t\t听说,她小孩生病了!——张铭生说到。"
                                    +"\n\t\t她真会找时间,她永远会在最困难的时候找我,永远会在没有困难的时候离我而去。"
                                    +"\n\t\t事实或许相反,她离开你时已经是迫不得已,张铭生调高嗓门说到。"
                                    +"\n\t\t"
                        )
                    }
                    BookPager{
                        Image(
                            modifier = Modifier.fillMaxWidth(),
                            contentScale = ContentScale.FillWidth,
                            alignment = Alignment.Center,
                            painter = painterResource(id = R.mipmap.img_02),
                            contentDescription = ""
                        )
                        Text(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(5.dp),
                            text = "\t\t他清楚的知道,这个实验成功的概率是多么的低,他望着窗台的透进来的晨光,内心无比的焦灼,这才是早上九点种,但他仿佛看到了落日的余晖。"
                                    +"\n\t\t病床的男人抽搐不停,他已经没有多长时间了,长期的抽搐,导致他无法入眠,如果这种状态再延续下去,走向人生的重点已成必然。"
                                    +"\n\t\t梁雨,你有什么遗言么?"
                                    +"\n\t\t他从窗台方向转向过来,看见他的初中老同学张铭生。"
                                    +"\n\t\t我能有什么遗言,孤家寡人而已!"
                                    +"\n\t\t嗯~啊?不想给张桐说几句么?听说她离婚了"
                        )
                    }
                }
            }
        }
    }


    @Composable
    fun BookPager(page: Page = Page(), content: @Composable ColumnScope.() -> Unit) {
        var pointerOffset by remember {
            mutableStateOf(Offset(0f, 0f))
        }
        var dragState by remember {
            mutableIntStateOf(Page.STATE_IDLE)
        }

        Column(
            modifier = Modifier
                .fillMaxSize()
                .onSizeChanged {
                    pointerOffset = Offset(it.width.toFloat(), it.height.toFloat())
                }
                .pointerInput("DraggerInput") {
                    detectDragGestures(
                        onDragStart = { it ->

                            val offsetLeft = 150.dp.toPx();
                            val offsetTop = 150.dp.toPx();

                            dragState = if (Rect(
                                    size.width - offsetLeft,
                                    size.height - 100.dp.toPx(),
                                    size.width.toFloat(),
                                    size.height.toFloat()
                                ).contains(it)
                            ) {
                                Page.STATE_DRAGING_BOTTOM
                            } else if (Rect(
                                    size.width - offsetLeft,
                                    0F,
                                    size.width.toFloat(),
                                    100.dp.toPx()
                                ).contains(it)
                            ) {
                                Page.STATE_DRAGING_TOP
                            } else if (Rect(
                                    size.width - offsetLeft - 20.dp.toPx(),
                                    offsetTop,
                                    size.width - 20.dp.toPx(),
                                    size.height - offsetTop
                                ).contains(it)
                            ) {
                                Page.STATE_DRAGING_MIDDLE
                            } else {
                                Page.STATE_DRAGING_EXCEEDE
                            }

                        },
                        onDragEnd = {
                            dragState = Page.STATE_IDLE
                            pointerOffset = Offset(size.width.toFloat(), size.height.toFloat())
                        }
                    ) { change, dragAmount ->
                        if (dragState == Page.STATE_DRAGING_BOTTOM || dragState == Page.STATE_DRAGING_MIDDLE || dragState == Page.STATE_DRAGING_TOP) {
                            pointerOffset = change.position
                        }
                    }
                }
                .drawWithContent {
                    if (dragState == Page.STATE_DRAGING_TOP) {
                        drawTopRightRightDragState(this, pointerOffset, page)
                    } else if (dragState == Page.STATE_DRAGING_BOTTOM) {
                        drawBottomRightDragState(this, pointerOffset, page)
                    } else if (dragState == Page.STATE_DRAGING_MIDDLE) {
                        drawMiddleDragState(this, pointerOffset, page)
                    } else {
                        drawIdleState(this, page)
                    }
                },
            content = content
        )

    }

    private fun drawIdleState(
        canvas: ContentDrawScope,
        page: Page
    ) {

        if (page.snapshot) {
            //如果不想StackOverflow的话,立即置为false,否则就做倒霉蛋吧
            page.snapshot = false

            val LayoutNodeDrawScopeKlass =
                Class.forName("androidx.compose.ui.node.LayoutNodeDrawScope")
            if (LayoutNodeDrawScopeKlass.isInstance(canvas)) {
                val imageBitmap = ImageBitmap(canvas.size.width.toInt(), canvas.size.height.toInt())
                val drawNodeField = LayoutNodeDrawScopeKlass.getDeclaredField("drawNode")
                drawNodeField.isAccessible = true
                val drawModifierNode = drawNodeField.get(canvas) as DrawModifierNode
                val performDrawMethod =
                    LayoutNodeDrawScopeKlass.getDeclaredMethod(
                        "performDraw",
                        DrawModifierNode::class.java,
                        Canvas::class.java
                    )
                performDrawMethod.isAccessible = true
                val snapshotCanvas = Canvas(imageBitmap)
                val frontColor = page.frontColor
                page.frontColor = Color.Transparent

                snapshotCanvas.save()

                //翻转图像
                val matrix = Matrix()
                matrix[0,0] = -1f;
                matrix[3,0] = canvas.size.width;
                snapshotCanvas.concat(matrix)

                performDrawMethod.invoke(canvas, drawModifierNode, snapshotCanvas)

                snapshotCanvas.restore()
                Log.d(TAG, "performDrawMethod = $imageBitmap")
                page.imageBitmap = imageBitmap
                page.frontColor = frontColor
            }
        }

        canvas.drawRect(page.frontColor)
        canvas.drawContent()
    }

    private fun drawMiddleDragState(
        canvas: ContentDrawScope,
        pointerOffset: Offset,
        page: Page
    ) {

        val size = canvas.size
        val foldPath = page.foldPath
        val pageOutline = page.pageOutline
        val blankOutline = page.blankOutline
        val clipPath = page.clipPath

        canvas.translate(size.width, 0F){
            val pointerPoint = Offset(
                pointerOffset.x - size.width,
                pointerOffset.y - 0
            )

            val verticalPoint = Offset(pointerPoint.x, 0F)
            val halfVerticalPoint = Offset(pointerPoint.x / 2F, 0F)

            foldPath.reset()
            foldPath.moveTo(verticalPoint.x, verticalPoint.y)
            foldPath.lineTo(halfVerticalPoint.x, halfVerticalPoint.y)
            foldPath.lineTo(halfVerticalPoint.x, size.height)
            foldPath.lineTo(verticalPoint.x, size.height)
            foldPath.close()


            pageOutline.reset()
            pageOutline.moveTo(-size.width, 0F)
            pageOutline.lineTo(-size.width, size.height)
            pageOutline.lineTo(0F, size.height)
            pageOutline.lineTo(0F, 0F)
            pageOutline.close()

            blankOutline.reset()
            blankOutline.moveTo(0F, 0F)
            blankOutline.lineTo(halfVerticalPoint.x, halfVerticalPoint.y)
            blankOutline.lineTo(halfVerticalPoint.x, size.height)
            blankOutline.lineTo(0F, size.height)
            blankOutline.close()

            clipPath.reset()
            clipPath.op(pageOutline, blankOutline, PathOperation.Difference)

            canvas.withTransform({
                clipPath(clipPath)
                translate(-size.width, 0F)
            }){
                canvas.drawRect(page.frontColor)
                canvas.drawContent()
            }

            clipPath(foldPath){
                canvas.drawRect(page.backColor, topLeft = Offset(verticalPoint.x,0f),size= Size(size.width,size.height))
                page.imageBitmap?.let {
                    drawImage(it, Offset(verticalPoint.x,0f))
                }
            }

            canvas.drawLine(start = Offset(size.width, pointerPoint.y), end=pointerPoint, color = Color.Red)
            canvas.drawLine(start =halfVerticalPoint, end=Offset(halfVerticalPoint.x, size.height), color = Color.Blue)
            canvas.drawLine(start = verticalPoint, end=Offset(verticalPoint.x, size.height), color = Color.Blue)

        }
    }

    private fun drawBottomRightDragState(
        canvas: ContentDrawScope,
        pointerOffset: Offset,
        page: Page
    ) {

        val size = canvas.size
        val blankOutline = page.blankOutline
        val foldPath = page.foldPath
        val clipPath = page.clipPath
        val pageOutline = page.pageOutline


        canvas.translate(size.width, size.height){

            var startPoint = Offset(0F, 0F)

            var pointerPoint = Offset(
                pointerOffset.x - size.width,
                pointerOffset.y - size.height
            )

            // atan2斜率范围在 -PI到PI之间,因此第三象限为atan2 =  atan - PI, 那么atan = PI  + atan2
            val pointerRotate = atan2(pointerPoint.y - startPoint.y, pointerPoint.x - startPoint.x) + PI

            val _xLength = hypot(
                pointerPoint.x - startPoint.x,
                pointerPoint.y - startPoint.y
            ) / cos(pointerRotate);

            var xLength = 0F
            var yLength = 0F


            if (_xLength > size.width*2) {
                //如果满足这个条件,意味着需要重新计算pointerPoint,因为没有形成垂直关系
                xLength = (size.width * 2);
                yLength = xLength / tan(pointerRotate).toFloat()

                var adjustRotate = atan(abs(yLength) / abs(xLength))

                val pointerDistance = abs(yLength * cos(adjustRotate))
                val y = abs(pointerDistance * sin(PI/2 - adjustRotate))
                val x = abs(pointerDistance * cos(PI/2 - adjustRotate))

                pointerPoint = Offset(
                    -x.toFloat(),
                    -y.toFloat()
                )
            }else{
                xLength = _xLength.toFloat()
                yLength = (xLength / tan(pointerRotate)).toFloat()
            }

            val XHalfAxisPoint = Offset(-xLength / 2F, 0F)
            val YHalfAxisPoint = Offset(0F, -yLength / 2F)


            val controlOffset = abs(Page.CONTROL_MAX_OFFSET * (2 * pointerPoint.x / size.width))

            val ld = Offset(
                (pointerPoint.x + XHalfAxisPoint.x) / 2F + controlOffset,
                (pointerPoint.y + XHalfAxisPoint.y) / 2F
            )
            val rt = Offset(
                (pointerPoint.x + YHalfAxisPoint.x) / 2F,
                (pointerPoint.y + YHalfAxisPoint.y) / 2F + controlOffset
            )


            val XControlAxisPoint = Offset(-xLength * 3 / 4F, 0F)
            val YControlfAxisPoint = Offset(0F, -yLength * 3 / 4F)


            foldPath.reset()
            foldPath.moveTo(XHalfAxisPoint.x, XHalfAxisPoint.y)
            foldPath.quadraticBezierTo(ld.x, ld.y, pointerPoint.x, pointerPoint.y)
            foldPath.quadraticBezierTo(rt.x, rt.y, YHalfAxisPoint.x, YHalfAxisPoint.y)
            foldPath.close()


            pageOutline.reset()
            pageOutline.moveTo(-size.width, -size.height)
            pageOutline.lineTo(-size.width, 0F)
            pageOutline.lineTo(0F, 0F)
            pageOutline.lineTo(0F, -size.height)
            pageOutline.close()

            blankOutline.reset()
            blankOutline.moveTo(0F, 0F)
            blankOutline.lineTo(YHalfAxisPoint.x, YHalfAxisPoint.y)
            blankOutline.lineTo(XHalfAxisPoint.x, XHalfAxisPoint.y)
            blankOutline.close()

            clipPath.reset()
            //剔除被裁剪的部分blankOutline
            clipPath.op(pageOutline, blankOutline, PathOperation.Difference)

            canvas.clipPath(clipPath){
                canvas.translate(-size.width, -size.height){
                    canvas.drawRect(page.frontColor)
                    canvas.drawContent()
                }
            }


            //绘制折角
            clipPath(foldPath){
                //这里我们铺满把,就不旋转灰色背景了,反正都要裁剪
                canvas.drawRect(page.backColor, topLeft = Offset(-size.width,-size.height),size= Size(size.width,size.height))
                val t = atan2(pointerPoint.y,(pointerPoint.x - XHalfAxisPoint.x)) + PI
                //我们要把(XHalfAxisPoint.x,0)作为旋转中心,这里要计算新的夹角,但是在第三象限计算夹角需要做转换,转为第一象限便于计算,当然也可以使用atan
                val degree = Math.toDegrees(t).toFloat()
                Log.d(TAG,"drawBottomRightDragState degree = $degree")
                rotate(degrees = Math.toDegrees(t).toFloat(),pivot = Offset(XHalfAxisPoint.x,0f)){ //图片按“露出”的1/2位置(XHalfAxisPoint.x,0f))旋转
                    page.imageBitmap?.let {
                        //由于原点在(size.width,size.height),所以,x轴为负值,当然,图片展示在地下是不对的,需要和灰色背景一样往上移动size.height
                        // (我们这里使用的size.height,其实因为这里和image大小一样,理论上应该用image.width)
                        drawImage(it,  Offset(-xLength ,-size.height))
                    }
                }
            }


            //绘制原点与触点的连线
            canvas.drawLine(start=Offset(0F, 0F), end = pointerPoint, color = Color.Red)
            //绘制切线
            canvas.drawLine(start=XHalfAxisPoint, end =YHalfAxisPoint, color = Color.Blue)
            //绘制1/2等距离切线
            canvas.drawLine(start=Offset(-xLength + 0.5f, 0F),end = Offset(0F, -yLength),  color = Color.Blue)
            //绘制3/4等距离切线
            canvas.drawLine(start=XControlAxisPoint, end =YControlfAxisPoint, color = Color.Blue)

        }


    }

    private fun drawTopRightRightDragState(
        canvas: ContentDrawScope,
        pointerOffset: Offset,
        page: Page
    ) {
        val size = canvas.size
        val blankOutline = page.blankOutline
        val foldPath = page.foldPath
        val clipPath = page.clipPath
        val pageOutline = page.pageOutline

        canvas.translate(size.width, 0F) {

            var pointerPoint = Offset(
                pointerOffset.x - size.width,
                pointerOffset.y - 0
            )
            // atan2斜率范围在 -PI到PI之间,因此第三象限为atan2 =  atan - PI, 那么atan = PI  + atan2

            val startPoint = Offset(0F, 0F);

            val pointerRotate = atan2(pointerPoint.y - startPoint.y, pointerPoint.x - startPoint.x) + PI


            val _xLength = hypot(
                pointerPoint.x - startPoint.x,
                pointerPoint.y - startPoint.y
            ) / cos(pointerRotate)

            var xLength =  0F
            var yLength = 0F


            if (_xLength > size.width * 2.0) {
                //如果满足这个条件,意味着需要重新计算pointerPoint,因为没有形成垂直关系
                xLength = (size.width * 2);
                yLength = xLength / tan(pointerRotate).toFloat()

                var adjustRotate = atan(abs(yLength) / abs(xLength))

                val pointerDistance = abs(yLength * cos(adjustRotate))
                val y = abs(pointerDistance * sin(PI/2 - adjustRotate))
                val x = abs(pointerDistance * cos(PI/2 - adjustRotate))

                 pointerPoint = Offset(
                     -x.toFloat(),
                    y.toFloat()
                )

            }else{
                 xLength = _xLength.toFloat();
                 yLength = xLength / tan(pointerRotate).toFloat()

            }

            val XHalfAxisPoint = Offset(-xLength / 2F, 0F)
            val YHalfAxisPoint = Offset(0F, -yLength / 2F)

            val controlOffset = abs(Page.CONTROL_MAX_OFFSET * (2 * pointerPoint.x / size.width))

            val ld = Offset(
                (pointerPoint.x + XHalfAxisPoint.x) / 2F + controlOffset,
                (pointerPoint.y + XHalfAxisPoint.y) / 2F
            )
            val rt = Offset(
                (pointerPoint.x + YHalfAxisPoint.x) / 2F,
                (pointerPoint.y + YHalfAxisPoint.y) / 2F - controlOffset
            )

            val XControlAxisPoint = Offset(-xLength * 3 / 4F, 0F)
            val YControlfAxisPoint = Offset(0F, -yLength * 3 / 4F)


            foldPath.reset()


            foldPath.moveTo(XHalfAxisPoint.x, XHalfAxisPoint.y)
            foldPath.quadraticBezierTo(ld.x, ld.y, pointerPoint.x, pointerPoint.y)
            foldPath.quadraticBezierTo(rt.x, rt.y, YHalfAxisPoint.x, YHalfAxisPoint.y)
            foldPath.close()


            foldPath.moveTo(XHalfAxisPoint.x, XHalfAxisPoint.y)
            foldPath.quadraticBezierTo(ld.x, ld.y, pointerPoint.x, pointerPoint.y)
            foldPath.quadraticBezierTo(rt.x, rt.y, YHalfAxisPoint.x, YHalfAxisPoint.y)
            foldPath.close()


            pageOutline.reset()
            pageOutline.moveTo(-size.width, 0F)
            pageOutline.lineTo(-size.width, size.height)
            pageOutline.lineTo(0F, size.height)
            pageOutline.lineTo(0F, 0F)
            pageOutline.close()

            blankOutline.reset()
            blankOutline.moveTo(0F, 0F)
            blankOutline.lineTo(YHalfAxisPoint.x, YHalfAxisPoint.y)
            blankOutline.lineTo(XHalfAxisPoint.x, XHalfAxisPoint.y)
            blankOutline.close()


            clipPath.reset()
            clipPath.op(pageOutline, blankOutline, PathOperation.Difference)
            canvas.clipPath(clipPath){
                canvas.translate(-size.width, 0F){
                    canvas.drawRect(page.frontColor)
                    canvas.drawContent()
                }
            }


            //绘制折角
            clipPath(foldPath){
                //这里我们铺满把,就不旋转灰色背景了,反正都要裁剪
                canvas.drawRect(page.backColor, topLeft = Offset(-size.width,0f),size= Size(size.width,size.height))
                val t = atan2(pointerPoint.y,(pointerPoint.x - XHalfAxisPoint.x)) + PI
                //我们要把(XHalfAxisPoint.x,0)作为旋转中心,这里要计算新的夹角,但是在第三象限计算夹角需要做转换,转为第一象限便于计算,当然也可以使用atan
               // val degree = Math.toDegrees(t).toFloat()
              //  Log.d(TAG,"drawTopRightDragState degree = $degree")
                rotate(degrees = Math.toDegrees(t).toFloat(),pivot = Offset(XHalfAxisPoint.x,0f)){ //图片按“露出”的1/2位置(XHalfAxisPoint.x,0f))旋转
                    page.imageBitmap?.let {
                        //由于原点在(size.width,size.height),所以,x轴为负值,当然,图片展示在地下是不对的,需要和灰色背景一样往上移动size.height
                        // (我们这里使用的size.height,其实因为这里和image大小一样,理论上应该用image.width)
                        drawImage(it,  Offset(-xLength+0.5f,0f))
                    }
                }
            }


            canvas.drawLine(start = Offset(0F, 0F), end = pointerPoint, color = Color.Red)
            canvas.drawLine(start = Offset(-xLength, 0F), end = Offset(0F, -yLength), color = Color.Blue)
            canvas.drawLine(start = XHalfAxisPoint, end = YHalfAxisPoint, color = Color.Blue)
            canvas.drawLine(start = XControlAxisPoint,end =  YControlfAxisPoint, color = Color.Blue)

        }


    }

}

@Stable
class Page {

    val textPaint: android.graphics.Paint = TextPaint();

    var paint: Paint = Paint();

    var foldPath: Path = Path()

    var blankOutline = Path()

    var pageOutline = Path()

    var clipPath = Path()

    var frontColor = Color.White
    var backColor = Color.LightGray

    var snapshot = true

    var imageBitmap : ImageBitmap? = null;

    init {
        paint.style = PaintingStyle.Fill
        paint.color = Color.Red
        paint.isAntiAlias = true

        textPaint.textSize = 36F;
        textPaint.color = 0xFF000000.toInt();
    }


    companion object {
        const val STATE_IDLE = 0
        const val STATE_DRAGING_EXCEEDE = 1
        const val STATE_DRAGING_TOP = 2
        const val STATE_DRAGING_MIDDLE = 3
        const val STATE_DRAGING_BOTTOM = 4

        const val CONTROL_MAX_OFFSET = 50

    }
}

以上源码,后续我们也会放到git上

附: Github源码