演示开始主要是在CurlDraw.ktGitHub地址package eu.wewox.pagecurl.page import android.graphics.Bitmap import android.graphics.Canvas import android.os.Build import androidx.compose.ui.Modifier import androidx.compose.ui.draw.CacheDrawScope import androidx.compose.ui.draw.DrawResult import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.rotateRad import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import eu.wewox.pagecurl.ExperimentalPageCurlApi import eu.wewox.pagecurl.config.PageCurlConfig import eu.wewox.pagecurl.utils.Polygon import eu.wewox.pagecurl.utils.lineLineIntersection import eu.wewox.pagecurl.utils.rotate import java.lang.Float.max import kotlin.math.atan2 ExperimentalPageCurlApi internal fun Modifier.drawCurl( config: PageCurlConfig, posA: Offset, posB: Offset, ): Modifier drawWithCache { // Fast-check if curl is in left most position (gesture is fully completed) // In such case do not bother and draw nothing if (posA size.toRect().topLeft posB size.toRect().bottomLeft) { returndrawWithCache drawNothing() } // Fast-check if curl is in right most position (gesture is not yet started) // In such case do not bother and draw the full content if (posA size.toRect().topRight posB size.toRect().bottomRight) { returndrawWithCache drawOnlyContent() } // Find the intersection of the curl line ([posA, posB]) and top and bottom sides, so that we may clip and mirror // content correctly val topIntersection lineLineIntersection( Offset(0f, 0f), Offset(size.width, 0f), posA, posB ) val bottomIntersection lineLineIntersection( Offset(0f, size.height), Offset(size.width, size.height), posA, posB ) // Should not really happen, but in case there is not intersection (curl line is horizontal), just draw the full // content instead if (topIntersection null || bottomIntersection null) { returndrawWithCache drawOnlyContent() } // Limit x coordinates of both intersections to be at least 0, so that page do not look like teared from the book val topCurlOffset Offset(max(0f, topIntersection.x), topIntersection.y) val bottomCurlOffset Offset(max(0f, bottomIntersection.x), bottomIntersection.y) // 这一步很简单准备一个lambda函数来绘制被曲线裁剪后的内容 val drawClippedContent prepareClippedContent(topCurlOffset, bottomCurlOffset) // 这就是棘手的部分需要准备一个lambda函数来绘制带有阴影的背面。 val drawCurl prepareCurl(config, topCurlOffset, bottomCurlOffset) onDrawWithContent { drawClippedContent() drawCurl() } } /** * The simple method to draw the whole unmodified content. */ private fun CacheDrawScope.drawOnlyContent(): DrawResult onDrawWithContent { drawContent() } /** * The simple method to draw nothing. */ private fun CacheDrawScope.drawNothing(): DrawResult onDrawWithContent { /* Empty */ } // 只绘制页面还没有被卷起来的那一部分 ExperimentalPageCurlApi private fun CacheDrawScope.prepareClippedContent( topCurlOffset: Offset, bottomCurlOffset: Offset, ): ContentDrawScope.() - Unit { // Make a quadrilateral from the left side to the intersection points val path Path() path.lineTo(topCurlOffset.x, topCurlOffset.y) path.lineTo(bottomCurlOffset.x, bottomCurlOffset.y) path.lineTo(0f, size.height) return result{ // Draw a content clipped by the constructed path clipPath(path) { thisresult.drawContent() } } } ExperimentalPageCurlApi private fun CacheDrawScope.prepareCurl( config: PageCurlConfig, topCurlOffset: Offset, bottomCurlOffset: Offset, ): ContentDrawScope.() - Unit { // 构建一个四边形表示页面中应作为背面镜像的部分 // 在所有情况下多边形都应包含4个点即使由于阴影渲染导致背面仅是一个小“角落”只有3个点也应如此否则在切换3点和4点的多边形时会产生视觉伪影 val polygon Polygon( sequence { // 找到旋涡线与右侧的交点 // 如果找到交点则将其添加到多边形点列表中 suspend fun SequenceScopeOffset.yieldEndSideInterception() { val offset lineLineIntersection( topCurlOffset, bottomCurlOffset, Offset(size.width, 0f), Offset(size.width, size.height) ) ?: return yield(offset) yield(offset) } // 如果顶部交点位于页面卷曲区域的范围内则从顶部取两个点否则取与右侧的交点 if (topCurlOffset.x size.width) { yield(topCurlOffset) yield(Offset(size.width, topCurlOffset.y)) } else { yieldEndSideInterception() } // 如果底部交点位于页面卷曲的边界内则从底部取两个点否则取与右侧的交点 if (bottomCurlOffset.x size.width) { yield(Offset(size.width, size.height)) yield(bottomCurlOffset) } else { yieldEndSideInterception() } }.toList() ) // 计算X轴与卷曲线之间的角度以弧度为单位用于将镜像内容旋转到卷曲后页的右侧位置 val lineVector topCurlOffset - bottomCurlOffset val angle Math.PI.toFloat() - atan2(lineVector.y, lineVector.x) * 2 // 准备一个lambda函数来绘制后页的阴影 val drawShadow prepareShadow(config, polygon, angle) // 预先将折痕右侧的内容在它自己的区域内水平翻转。折痕可以倾斜 // 因此这里使用随 y 变化的仿射变换而不是固定 pivot 的第二次 scale。 // 外层的物理翻页镜像会把字形再次翻转为正向这个变换又会把 // [foldX(y), width] 映射回自身所以显示的仍是当前被卷起的那段内容。 val uprightContentTransform Matrix().apply { val foldSlope if (size.height 0f) { 0f } else { (bottomCurlOffset.x - topCurlOffset.x) / size.height } this[0, 0] -1f this[1, 0] foldSlope this[3, 0] size.width topCurlOffset.x } return result{ withTransform({ // 以折痕底部为支点翻转并旋转整张当前页。内容仍然使用完整页面坐标 // 随后的裁剪只保留折痕右侧因此卷页会从当前被卷起的位置连续显示文字。 scale(-1f, 1f, pivot bottomCurlOffset) rotateRad(angle, pivot bottomCurlOffset) }) { thisresult.drawShadow() clipPath(polygon.toPath()) { // 纸张颜色先画在内容下面避免用半透明遮罩冲淡文字颜色。 drawRect(config.backPageColor.copy(alpha 1f)) withTransform({ transform(uprightContentTransform) }) { // 直接绘制内容保留文字自身的颜色和不透明度。 thisresult.drawContent() } } } } } ExperimentalPageCurlApi private fun CacheDrawScope.prepareShadow( config: PageCurlConfig, polygon: Polygon, angle: Float ): ContentDrawScope.() - Unit { // Quick exit if no shadow is requested if (config.shadowAlpha 0f || config.shadowRadius 0.dp) { return { /* No shadow is requested */ } } // Prepare shadow parameters val radius config.shadowRadius.toPx() val shadowColor config.shadowColor.copy(alpha config.shadowAlpha).toArgb() val transparent config.shadowColor.copy(alpha 0f).toArgb() val shadowOffset Offset(-config.shadowOffset.x.toPx(), config.shadowOffset.y.toPx()) .rotate(2 * Math.PI.toFloat() - angle) // Prepare shadow paint with a shadow layer val paint Paint().apply { val frameworkPaint asFrameworkPaint() frameworkPaint.color transparent frameworkPaint.setShadowLayer( config.shadowRadius.toPx(), shadowOffset.x, shadowOffset.y, shadowColor ) } // Hardware acceleration supports setShadowLayer() only on API 28 and above, thus to support previous API versions // draw a shadow to the bitmap instead return if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { prepareShadowApi28(radius, paint, polygon) } else { prepareShadowImage(radius, paint, polygon) } } private fun prepareShadowApi28( radius: Float, paint: Paint, polygon: Polygon, ): ContentDrawScope.() - Unit { drawIntoCanvas { it.nativeCanvas.drawPath( polygon .offset(radius).toPath() .asAndroidPath(), paint.asFrameworkPaint() ) } } private fun CacheDrawScope.prepareShadowImage( radius: Float, paint: Paint, polygon: Polygon, ): ContentDrawScope.() - Unit { // Increase the size a little bit so that shadow is not clipped val bitmap Bitmap.createBitmap( (size.width radius * 4).toInt(), (size.height radius * 4).toInt(), Bitmap.Config.ARGB_8888 ) Canvas(bitmap).apply { drawPath( polygon // As bitmap size is increased we should translate the polygon so that shadow remains in center .translate(Offset(2 * radius, 2 * radius)) .offset(radius).toPath() .asAndroidPath(), paint.asFrameworkPaint() ) } return { drawIntoCanvas { // As bitmap size is increased we should shift the drawing so that shadow remains in center it.nativeCanvas.drawBitmap(bitmap, -2 * radius, -2 * radius, null) } } }