前言
在最近的几篇文章中我们,我们使用Compose做了很多效果和分析,具体可以看看下面的文章。
以上文章中也做了很多效果,如“手电筒效果”、“键盘模拟”、”Pager+Tab“以及”MeasurePolicy实现环形菜单“,今天我们重点来了解一下Compose绘制相关逻辑和一些特点。
因为MeasurePolicy属于测量和布局方面的用法,本篇来了解另一个重要的知识点——绘制。
本篇效果
本篇做个简单的翻书效果,效果如下
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
1 | 0 | 0 | 0 |
---|---|---|---|
0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 |
100 | 0 | 0 | 1 |
Android Graphic Matrix
1 | 0 | 100 |
---|---|---|
0 | 1 | 0 |
0 | 0 | 1 |
Compose UI Matrix
那么,Compose UI呢,实际上,Compose UI几乎完全沿袭了open gl的Matrix,但是另一方面,Compose UI的Matrix缺少很多东西
因此,一些计算需要手动计算,比如平移100,操作也是第12个元素
val matrix = Matrix()
matrix[3,0] = 100; //第12个元素
等价于下面操作
1 | 0 | 0 | 0 |
---|---|---|---|
0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 |
100 | 0 | 0 | 1 |
从这里我们看到,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
}
通过上面代码我们就能实现,绘制效果如下
通过这种方式我们就实现了截图
难点2: 图像翻转
另外一个问题当然是图像翻转,为什么说他是问题呢?
因为我们前面说过,Compose UI的Canvas不支持draw image with matrix 相关的方法,因此需要提前处理,当然,处理的前提就是得借助Canvas#contat实现矩阵转换,这在Android中也有,只是因为比draw image with matrix 难度高一些,很少有人选择用,但是Compose UI只有这个,没得选。
下面是图像翻转代码
//翻转图像
snapshotCanvas.save()
val matrix = Matrix()
matrix[0,0] = -1f;
matrix[3,0] = canvas.size.width;
snapshotCanvas.concat(matrix)
snapshotCanvas.restore()
如下图,下面的左侧图片是旋转之后的,显然我们需要给translateX到右侧,才能和可见区域重合
这里我们没用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);
是不是很难理解,很正常,建议多尝试几次,熟悉一下。
我们可以换种思路去理解,通过欧拉角-矩阵旋转角度去理解,如下面的矩阵,但要注意的是下面的矩阵形式稍微有点差异,因为其是行向量矩阵,不过无论行向量还是列向量,转换思路基本一样。
注意:下图为行向量矩阵
注意:下图为行向量矩阵
注意:下图为行向量矩阵
不过,这里我们是纯180度旋转,直接就是-1了,如果60度,可以借助open gl的一些矩阵(android.opengl.Matrix) 方法去计算,如矩阵乘法,因为前面说过,Compose UI的矩阵和open gl几乎一样,理论上计算结果也是一样的。
难点3:折角区域计算
实际上,翻页效果很多,也有特定的实现方式,但是公式理解难度还有些高,不利于优化,本篇,我们换一种思路,通过计算法线的方式实现
一般来说,右下角和右上角的翻书效果是比较有规律的,我们在点击位置和右下角连线,就能计算出垂直方向的法线,那么,我们还可以利用此机制计算出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:背图旋转
我们从效果图中就能看出,背面的图片需要旋转的,这里要注意的,旋转位置应该是图片【露出区域】的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轴的范围。
仅仅限制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源码