简介:面向Android开发者的翻书效果实现资源,基于贝塞尔曲线完成页面弯曲动画,并已适配AndroidX。主要解决Android P及以上版本XOR绘图模式不可再用时的替代方案,适合阅读类应用、电子书或自定义View动画场景,覆盖初中高级开发者。压缩包共9个文件,核心为7个Java类,包含页面动画、翻页加载、数据实体等模块,另配2个XML布局文件辅助页面展示,整体仅13KB,结构精简便于直接迁移。目前已有639人学习下载。阅读源码可掌握QuadTo与CubicTo绘制三次贝塞尔曲线的方法,理解Porter-Duff混合模式在翻书效果中的组合运用,还能看到ValueAnimator驱动动画更新、硬件加速提升流畅度,以及对触摸事件和预加载的处理。通过封装自定义View维护曲线状态,可快速在项目中集成,省去从零排查版本适配问题的时间,对打磨阅读体验有切实参考价值。
1. 翻书动效:为什么我从缩放动画换成了贝塞尔曲线
做阅读类App时,产品要求“翻书要有纸的质感”,第一版我直接用属性动画叠加缩放和平移,看起来像切页,完全没有卷曲过程。换成贝塞尔曲线重建页面边缘之后,手指按下去、拖起来、松手回弹的路径才接近真实纸张。这套方案不是只画一条曲边,而是把翻页拆成几何模型、触摸事件、动画驱动和版本适配四块,book.rar里的源码正好按这个分层组织:PageBean、PageView、PageAnimation、SimulationPageAnim、HorizonPageAnim、PageLoader,已经适配到AndroidX。提醒一点,AndroidP及以上硬件加速下XOR绘图模式失效,网上大量老代码升级后直接白屏,这篇我会把替换方案和触控细节一起拆开,适合正在做阅读器、漫画App或自定义View的工程师参考。
2. PageBean与触摸点换算:翻书几何模型拆解
翻书效果看起来是动画,本质上是每一帧都在计算一组坐标,再把这些坐标用Path封装成可绘制的闭合区域。如果一上来就写onDraw,很容易被三角区域、翻页背面、阴影搞晕。我一般先把几何模型用PageBean固定下来,再让动画和绘制共用同一份数据。
2.1 PageBean该存什么:不只是起点和终点
很多初版翻书代码只保存一个手指触摸点,然后在onDraw里临时算Path,这样会造成两个问题:一是触摸事件的坐标系和View坐标系混用,二来动画插值时没有中间状态可以复用。project里的PageBean是将一次翻页所需的顶点、控制点、位图引用全部封装在同一个对象中:
public class PageBean { public PointF startPoint = new PointF(); // 卷边与页面下沿的交点 public PointF endPoint = new PointF(); // 卷边与页面上沿的交点 public PointF controlPoint1 = new PointF(); // 三次贝塞尔第一控制点 public PointF controlPoint2 = new PointF(); // 三次贝塞尔第二控制点 public boolean isFlip = false; // 是否翻到下一页 public Bitmap currentPageBitmap; // 当前页内容 public Bitmap nextPageBitmap; // 下一页内容 public float progress; // 0.0f 到 1.0f 动画进度 }这里的startPoint和endPoint描述的是纸张弯曲后露出的那条斜边,也就是“折痕”的两个端点;controlPoint1和controlPoint2用来控制纸张卷曲的鼓包程度。progress字段不是必须的,但我建议保留,因为后面用ValueAnimator驱动时可以直接映射到贝塞尔控制点的偏移量上。
PageBean的核心价值在于让绘制模块不直接依赖触摸坐标。手指可以抖动,但PageBean中的坐标应当是经过平滑或插值处理后的稳定值,这样onDraw中只需要读取PageBean,不需要关心手指是不是刚按下。
2.2 贝塞尔选型:QuadTo与CubicTo的取舍
在Android中,Path的quadTo对应二次贝塞尔曲线,cubicTo对应三次贝塞尔曲线。翻书效果早期有人用quadTo画一条弧线,代码短,但模拟出来的页面边缘过于“圆润”,真正手翻纸时,纸页从平面到弯曲是一个明显的“S”形,二次贝塞尔只有一个控制点,只能朝一个方向弯曲,做不出纸张反翘的效果。
| 对比项 | quadTo 二次贝塞尔 | cubicTo 三次贝塞尔 |
|---|---|---|
| 控制点数量 | 1个 | 2个 |
| 弯曲方向 | 单向弯曲 | 可形成反翘和波浪 |
| 纸张模拟 | 适合简单翻角 | 适合整页卷曲 |
| 计算量 | 略小 | 略大,现代设备可忽略 |
从代码上看,两者差异很直接:
// 二次贝塞尔:一条曲线,适合简单折角 Path quadPath = new Path(); quadPath.moveTo(start.x, start.y); quadPath.quadTo(control.x, control.y, end.x, end.y); // 三次贝塞尔:两个控制点,纸张卷曲更自然 Path cubicPath = new Path(); cubicPath.moveTo(start.x, start.y); cubicPath.cubicTo(control1.x, control1.y, control2.x, control2.y, end.x, end.y);quadTo只传一个控制点,cubicTo传两个控制点。在翻页场景中,第一个控制点通常放在折痕内侧,控制页面“鼓起”的高度,第二个控制点放在折痕外侧,控制页面“回落”的弧度。调整顺序会直接影响视觉:如果两个控制点都在曲线的同一侧,卷边会特别夸张;如果对称分布,看起来又像是页面被对折,缺少纸张张力。
2.3 从触摸点反推控制点:一个可复用的计算公式
翻书效果里,手指位置不能直接作为贝塞尔控制点,否则页面边缘会和手指完全重合,看起来像用手指拽着纸角,而不是从右下角翻页。我常用的做法是固定翻页的起始角和终止角,比如从右下角向左上翻,那么起始点固定为View右下角附近,终止点固定为View左上角附近,手指坐标只决定控制点的偏移量。
private void updatePageBean(PageBean bean, float touchX, float touchY) { float viewW = getWidth(); float viewH = getHeight(); // 折痕起点:右下角 bean.startPoint.x = viewW * 0.95f; bean.startPoint.y = viewH * 0.95f; // 折痕终点:左上角附近,但随手指偏移 bean.endPoint.x = viewW * 0.08f + (touchX / viewW) * viewW * 0.15f; bean.endPoint.y = viewH * 0.10f; // 两个控制点:一个在折痕内侧,一个在折叠方向外侧 float dx = touchX - bean.startPoint.x; float dy = touchY - bean.startPoint.y; bean.controlPoint1.x = bean.startPoint.x + dx * 0.5f; bean.controlPoint1.y = bean.startPoint.y + dy * 0.4f; bean.controlPoint2.x = bean.startPoint.x + dx * 0.8f; bean.controlPoint2.y = bean.startPoint.y + dy * 0.6f; }这段公式里,比例系数0.4和0.6是我在模拟器上反复调出来的经验值。系数越大,卷曲越饱满;系数越小,页面越接近一条直线。注意,controlPoint1和controlPoint2不应该直接取触摸点,因为触摸点通常离起点很近,直接使用会产生一个很小的、尖锐的三角形,看起来像被撕掉一角。实际项目里,你还可以在这里加入速度加权,让快速滑动时控制点更“钝”,慢速拖动时更“锐”。
另外,真正的翻页路径往往需要两条贝塞尔曲线:一条是纸边外轮廓,另一条是纸边内侧的投影线。两条线围成的闭合区域就是“翻起的三角区域”,这个区域在下一章会用来做裁剪和混合。
3. AndroidX下XOR失效:用Porter-Duff混合重写翻页渲染
把几何模型算出来之后,接下来的难点是绘制。老版本翻书代码里,普遍用Canvas.saveLayer配合Paint的XOR模式来挖空翻起区域,代码看起来非常简洁。升级到AndroidX并跑到AndroidP以上真机时,这个方案大概率会失效,要么绘制结果异常,要么干脆不显示内容。这不是AndroidX框架本身的问题,而是AndroidP对硬件加速下支持的特效做了限制。
3.1 为什么AndroidP及以上XOR会失效
XOR模式在软件绘制中一直可用,但在硬件加速的渲染管线里,GPU的光栅化和帧缓冲混合策略并不直接支持逻辑异或操作。AndroidP之前,系统还会通过SwiftShader或离屏软件路径兜底;从AndroidP开始,硬件加速成为默认强制开启项,系统不再为XOR做兼容,导致很多老代码在高级设备上白屏。这一点在Android SDK官方文档中也有说明,只是大多数项目代码没有写注释,接手的人很容易踩坑。
要判断当前View是否能使用XOR,可以在onDraw里临时打印Canvas.isHardwareAccelerated()和Canvas.isOpaque(),前者代表是否硬件加速,后者代表是否全不透明。如果返回true但绘制无效,基本可以确定是XOR受限。我通常在自定义View初始化时关掉硬件加速来做对照:
// 不建议全局关闭,仅用于定位问题 setLayerType(LAYER_TYPE_SOFTWARE, null);如果关闭后XOR恢复显示,说明问题就出在硬件加速路径上。但生产环境里不能靠关闭硬件加速解决问题,那样会增加CPU绘制压力,长列表场景下滑动帧率会明显下降。
3.2 Porter-Duff替代方案:核心思路是把翻页区域分层
XOR的作用本质上是“两个图层的重叠区域做异或”,在翻书效果里,一般是想把已经翻过去的部分和未翻的部分分开处理。用Porter-Duff同样可以做到,只是需要显式地把页面拆成两个图层:先画未翻页面,再画翻起页面,最后用混合模式把两个区域合成。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 先保存一个离屏图层,避免混合影响外部画布 int layerId = canvas.saveLayer(0, 0, getWidth(), getHeight(), null); // 绘制当前页(未翻动部分) Paint paintCurrent = new Paint(Paint.ANTI_ALIAS_FLAG); canvas.drawBitmap(pageBean.currentPageBitmap, 0, 0, paintCurrent); // 绘制翻起页:只绘制折痕右侧的卷曲区域 Paint paintFlip = new Paint(Paint.ANTI_ALIAS_FLAG); canvas.save(); Path foldPath = buildFoldPath(pageBean); canvas.clipPath(foldPath); canvas.drawBitmap(pageBean.nextPageBitmap, 0, 0, paintFlip); canvas.restore(); // 用 SRC_OVER 把翻起区域盖在未翻区域上 paintFlip.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)); canvas.drawBitmap(pageBean.nextPageBitmap, 0, 0, paintFlip); // 清理叠加层 canvas.restoreToCount(layerId); }这段代码里,clipPath先裁剪出翻页三角形区域,把下一页内容只画在折痕右侧,再用SRC_OVER模式叠加到当前页上。注意,PorterDuff.Mode.SRC_OVER的含义是“源图像覆盖目标图像”,如果源区域是透明的,目标图像会正常显示,这正好用来表达“已经翻过去的部分透明,未翻的部分仍然显示”。相比XOR,这种写法多了一步clipPath,但逻辑更清晰,而且完全兼容硬件加速。
3.3 硬件加速下的阴影与渐变:不要忽略边界抗锯齿
翻书效果除了页面本身,还需要在折痕边缘加一层阴影,否则页面会显得“薄而硬”。绘制阴影时,要注意Porter-Duff对渐变和模糊效果的限制。硬件加速下,普通setShadowLayer在自定义View中可能失效,尤其是配合PorterDuffXfermode使用时。我一般会在foldPath边缘单独绘制一条半透明渐变带:
Shader shader = new LinearGradient( start.x, start.y, controlPoint1.x, controlPoint1.y, new int[]{Color.argb(80, 0, 0, 0), Color.TRANSPARENT}, null, Shader.TileMode.CLAMP); Paint shadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); shadowPaint.setShader(shader); canvas.drawPath(foldPath, shadowPaint);这段渐变阴影的起点和终点取自PageBean中的startPoint和controlPoint1,目的是让阴影集中在折痕内侧,外侧自然过渡到透明。如果你发现阴影边缘出现锯齿,不要直接加大模糊半径,可以先检查View是否开了硬件加速,以及Shader的TileMode是否设置正确。CLAMP模式可以把渐变末尾的颜色一直延伸到矩形边界,避免出现硬边。
4. ValueAnimator驱动与PageLoader预加载:动画性能优化
翻书动画最忌讳的是在onDraw里做耗时计算,那样每一帧都会卡顿。正确做法是把动画状态交给ValueAnimator,动画回调里只更新PageBean,然后调用invalidate触发重绘。同时,页面位图的加载不应该和翻页动画在同一帧里完成,需要通过PageLoader提前准备好。
4.1 用ValueAnimator控制贝塞尔控制点位置
我先初始化一个ValueAnimator,动画时长为300毫秒。动画的数值范围是0到1,代表从“手指松开”到“翻页完成”的进度。在动画回调里,根据当前进度对PageBean中的控制点做线性插值,这样手指离开屏幕后,动画仍然能自然收尾。
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); animator.setDuration(300); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float progress = (float) animation.getAnimatedValue(); // 将控制点从当前位置移到“完全翻起”位置 pageBean.controlPoint1.x = startControlX + (targetControlX - startControlX) * progress; pageBean.controlPoint1.y = startControlY + (targetControlY - startControlY) * progress; pageBean.progress = progress; invalidate(); } }); animator.start();这里的参数含义:startControlX/Y是手指松开瞬间的控制点坐标,targetControlX/Y是翻页完成时的目标坐标。把插值计算放在动画回调而不是onDraw里,可以让onDraw保持只做路径拼接和绘制,减少单帧耗时。animation.getAnimatedValue()返回的值已经经过插值器处理,不需要自己再做Easing。DecelerateInterpolator会让动画先快后慢,比较接近手指松开后纸张自然回落的手感。
4.2 PageLoader预加载:下一页Bitmap不能等
翻书过程中,用户把页面翻到一半时,背面需要展示下一页内容。如果等到这一刻才加载Bitmap,在图片较大时会造成明显掉帧。PageLoader在这个项目里的职责就是预先解码当前位置后面两页的图片资源。
public class PageLoader { private final Map<Integer, Bitmap> cache = new LinkedHashMap<Integer, Bitmap>(4, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<Integer, Bitmap> eldest) { return size() > 4; } }; public Bitmap loadPage(int index) { if (cache.containsKey(index)) { return cache.get(index); } Bitmap bitmap = decodeBitmapFromAsset(index); cache.put(index, bitmap); return bitmap; } private Bitmap decodeBitmapFromAsset(int index) { BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inPreferredConfig = Bitmap.Config.RGB_565; opts.inSampleSize = 1; return BitmapFactory.decodeFile("page_" + index + ".jpg", opts); } }LinkedHashMap的accessOrder=true可以实现LRU缓存,当缓存超过4页时,最久没被访问的页面会被自动移除。decodeBitmapFromAsset里把inPreferredConfig设置为RGB_565,因为阅读类页面不需要ARGB_8888那么多颜色位深,可以省一半内存。如果你的页面内容包含透明遮罩或阴影渐变,需要保留为ARGB_8888,否则透明部分会变成黑色块。这是在性能和效果之间最常见的取舍点。
4.3 避免主线程卡顿:invalidateChoreographer与块级绘制
invalidate只是告诉系统下一帧需要重绘,真正绘制发生在Choreographer回调中。如果onDraw里的drawBitmap次数过多,依然会卡。我建议在onDraw开始处先判断PageBean是否已经变化,如果没有变化,直接return,不触发任何绘制。
另外,不要把整个View的内容都当成一个图层处理。先画背景层,再画页面主体层,最后画阴影层。这样即使PageLoader加载下一页晚了几毫秒,也不会影响当前页面区域的绘制。实际处理中,我发现很多卡顿源于Bitmap过大,远超View尺寸,这时可以先在PageLoader里根据View宽高做一次缩放:
opts.inJustDecodeBounds = true; BitmapFactory.decodeFile(path, opts); int sampleSize = Math.max(1, (int) Math.ceil(opts.outWidth / viewWidth)); opts.inJustDecodeBounds = false; opts.inSampleSize = sampleSize; Bitmap bitmap = BitmapFactory.decodeFile(path, opts);inSampleSize是采样率,2表示宽高各缩放为原来的1/4,内存占用降到1/16。这个值应该根据View宽度动态计算,不要写死。如果写死为2,在平板等宽屏设备上会浪费大量内存,翻页滑动时GPU带宽也可能成为瓶颈。
5. 把翻书View集成进ReadActivity:手势冲突与验证技巧
book.rar里的ReadActivity是宿主页面,布局文件activity_book.xml中只放了一个自定义PageView,item_book.xml是用于ListView或GridView的单项布局。把PageView放进Activity并处理触摸事件,比动画实现更容易被忽略,但往往是上线后反馈最集中的问题。
5.1 不要让PageView拦截所有事件
PageView的onTouchEvent会消费触摸事件,否则滑动翻页无法实现。但阅读器页面横向滑动翻页和纵向滚动浏览经常同时存在。我建议在PageView内部先判断手指移动方向,再决定是否拦截事件。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: lastX = event.getX(); lastY = event.getY(); return true; case MotionEvent.ACTION_MOVE: float dx = event.getX() - lastX; float dy = event.getY() - lastY; if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > touchSlop) { getParent().requestDisallowInterceptTouchEvent(true); } updatePageBeanFromTouch(event.getX(), event.getY()); invalidate(); return true; case MotionEvent.ACTION_UP: startFlipAnimation(); return true; } return super.onTouchEvent(event); }这里利用getParent().requestDisallowInterceptTouchEvent(true)阻止父容器拦截横向滑动事件,避免和外层ViewPager或ScrollView发生抢夺。touchSlop一般取ViewConfiguration.get(getContext()).getScaledTouchSlop(),这是系统给出的最小滑动距离阈值,小于这个值会被判定为点击,不应该触发翻页。
5.2 快速验证动画是否正常的检查清单
把项目跑起来后,不要急着看效果,先做三步验证:先在Android 9以上模拟器和真机各跑一遍,确认没有XOR相关异常;然后在开发者选项里开启“显示GPU过度绘制”,观察翻页区域是否出现粉红色块,如果出现,说明图层数量过多;最后把动画时长调大到1000毫秒,手摸屏幕感受一下触摸点是否始终和卷边位置保持对应。
| 检查项 | 判断标准 | 摩擦点 |
|---|---|---|
| XOR兼容 | 无白屏/无异常绘制 | setLayerType不要全开 |
| 图层数量 | 无大面积红色块 | 减少saveLayer次数 |
| 触摸点与卷边 | 手指拖动时卷边贴合 | 控制点比例系数 |
| 动画结束状态 | 完全翻页后无半透明残留 | 清除PorterDuff状态 |
最后再提醒一个容易被忽视的细节:翻页结束后,一定要在动画回调里把PageBean中的临时bitmap释放掉,并且把startPoint和endPoint重置到View边界外,否则下一轮触摸时会留下一条非常细的残影。在模拟器上残影可能看不出,但在高分辨率真机上非常明显。
本文还有配套的精品资源,点击获取