仿书页翻页效果实现思路
in Android with 0 comment

下面这篇文章明了书页翻页的原理,但是不是特别清晰,知道各种测量,但是不明白为什么要这么计算,以下以个人思路重新梳理一遍,方便理解
【转】Android 实现书籍翻页效果----原理篇

最终效果预览

文末附上完整demo代码
2023-07-14T09:13:44.png

从折纸开始

由于原文公式太多,不便理解,从这里开始以我尝试开发的心路历程,抽丝剥茧演示如何实现。
首先先模拟一个纸张,没有Z轴的效果,将纸张的边线画出来。

1. 选定控制点

先选定手指触摸的纸张的触摸点,表示纸张的端点将被固定在这个位置,该点定义为A(x,y)
被拖动的端点定义为O(width, height),右下角的端点
2023-07-14T09:18:42.png

2. 根据触摸点绘制折痕

当前折痕不存在Z轴概念,直接绘制,折痕的位置一定是落在AO连接线的中点B,且因为是折痕,两边一定是延折痕对称,那么镜像的点的连线一定是垂直于折痕的。
根据相似三角形,对应边的比例是相等的,能够求得C,D点的位置

centerAO.x = touchP.x / 2 + startP.x /2;
centerAO.y = touchP.y / 2 + startP.y /2;
// 相似三角形,不用取绝对值。分子必定为正数。根据分母的正负决定C点位置
pointC.x  = centerAO.x - (startP.y - centerAO.y ) * (startP.y - centerAO.y) / (startP.x - centerAO.x);
pointC.y  = startP.y;
pointD.y  = centerAO.y - (startP.x - centerAO.x ) * (startP.x - centerAO.x) / (startP.y - centerAO.y);
pointD.x  = startP.x;

2023-07-17T08:00:29.png

3. 模拟Z轴空间的折痕

现在视觉效果已经大致有了,翻过来的折页紧贴着纸张,没有空间感,下面开始优化
下面需要在AB范围内选择一个CD的平行线作为折痕,因为存在Z轴,部分纸张被弯曲了,实际折痕一定无法达到CD位置
定义一个折痕比率foldRatio,越大表示离CD越远,暂时取值中点,绘制出EF点,分别与XY轴边线相交

// 计算三维空间中Z轴底部的折痕位置
pointE.x = pointC.x - (startP.x - pointC.x) * foldRatio;
pointE.y = pointC.y;
pointF.y = pointD.y - (startP.y - pointD.y) * foldRatio;
pointF.x = pointD.x;

2023-07-17T08:09:58.png

4. 折痕过渡参考线

选取CD EF中间等分位置绘制出一个参考线,理论上线条到这个线的时候就应该拐弯了,拐弯的部分代表折痕卷曲空鼓的部分,这里当然不一定是正中间,感兴趣的也可以定义一个参数去控制

// 卷曲边缘在折痕和预期折痕的中间位置
pointG.x = pointE.x /2 + pointC.x / 2;
pointG.y = pointE.y;
pointH.x = pointF.x;
pointH.y = pointF.y / 2 + pointD.y /2;

2023-07-17T08:20:11.png

5. 补充剩余点

求相交点就很容易了,已知2点可以得出直线的方程式
2个方程式相等时就是交点,很容易求得(x,y)坐标,补充剩余点的位置
M N点为垂直平分线相交点,因为折痕上下的弧度应当相同,故取中点
2023-07-17T08:34:59.png

6. 平滑过渡

此时三维的折痕雏形已经有了,但是直接连接AIKMGE,过渡显得特别硬,这个时候就需要贝塞尔曲线登场了,这里用的是二阶贝塞尔曲线,这里的K G就是控制点,M为端点,连线后的效果
2023-07-17T08:43:41.png

7. 上色

这里定义3个颜色,下一页的颜色,上一页的颜色,背面的颜色
下一页的颜色:最好绘制,因为最终会被盖住一部分,所以默认直接绘制全屏即可
背面颜色:AIMNJ区域,是个规则图形,这个也比较好计算,直接path连接即可得到
上一页的颜色:上一页根据拖拽的位置不同,产生的区域也是不规则的,所以使用canvas的裁切,将不需要的部分裁切掉,剩余部分上色即可

clip.reset();
clip.moveTo(touchP.x, touchP.y);
clip.lineTo(pointI.x, pointI.y);
clip.quadTo(pointK.x, pointK.y, pointM.x, pointM.y);
clip.quadTo(pointG.x, pointG.y, pointE.x, pointE.y);
clip.lineTo(startP.x, startP.y);
clip.lineTo(pointF.x, pointF.y);
clip.quadTo(pointH.x, pointH.y,pointN.x, pointN.y);
clip.quadTo(pointL.x, pointL.y,pointJ.x, pointJ.y);
clip.close();
canvas.save();
canvas.clipPath(clip, Region.Op.DIFFERENCE);;
pagePaint.setColor(Color.GREEN);
canvas.drawRect(canvas.getClipBounds(),pagePaint);
canvas.restore();
back.reset();
back.moveTo(touchP.x, touchP.y);
back.lineTo(pointI.x, pointI.y);
back.quadTo(pointK.x, pointK.y, pointM.x, pointM.y);
back.lineTo(pointN.x, pointN.y);
back.quadTo(pointL.x, pointL.y,pointJ.x, pointJ.y);
back.close();
canvas.drawPath(back, linePaint);

2023-07-17T08:49:04.png

8.完整代码

现在实现整体思路已完成,只需要在touch事件里动态修改A点坐标即可实现动画。
翻页点通过修改O点坐标即可实现

package com.example.myapplication.page;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Region;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import androidx.annotation.Nullable;

public class TurnPageView extends View {
    /**
     * 折痕比率,越大纸的翻起高度越高
     */
    private float foldRatio = 0.5f;
    private Paint textPaint;
    private Paint pagePaint;
    private Paint linePaint;
    private Paint guideLinePaint;

    private PointF touchP = new PointF();
    private PointF startP = new PointF();
    private PointF centerAO = new PointF();
    private PointF pointC = new PointF();
    private PointF pointD = new PointF();
    private PointF pointE = new PointF();
    private PointF pointF = new PointF();
    private PointF pointG = new PointF();
    private PointF pointH = new PointF();
    private PointF pointI = new PointF();
    private PointF pointJ = new PointF();
    private PointF pointK = new PointF();
    private PointF pointL = new PointF();
    private PointF pointM = new PointF();
    private PointF pointN = new PointF();
    private Path clip = new Path();
    private Path back = new Path();
    public TurnPageView(Context context) {
        this(context, null);
    }

    public TurnPageView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TurnPageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public TurnPageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        guideLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        pagePaint = new Paint(Paint.ANTI_ALIAS_FLAG);

        textPaint.setTextSize(40);
        textPaint.setStrokeWidth(3);
        textPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        textPaint.setColor(Color.BLACK);

        linePaint.setColor(Color.RED);
        linePaint.setStyle(Paint.Style.FILL_AND_STROKE);
        linePaint.setStrokeWidth(2);

        pagePaint.setStyle(Paint.Style.FILL);

        guideLinePaint.setColor(Color.BLACK);
        guideLinePaint.setAlpha(100);
        guideLinePaint.setStyle(Paint.Style.STROKE);
        guideLinePaint.setStrokeWidth(2);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        touchP.x = right  - left - 300;
        touchP.y = bottom - top - 600;
        startP.x = right - left;
        startP.y = bottom - top;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        calculate();
        drawGuideLines(canvas);
        drawPoints(canvas);

    }
    private void calculate(){
        centerAO.x = touchP.x / 2 + startP.x /2;
        centerAO.y = touchP.y / 2 + startP.y /2;
        // 相似三角形,不用取绝对值。分子必定为正数。根据分母的正负决定C点位置
        pointC.x  = centerAO.x - (startP.y - centerAO.y ) * (startP.y - centerAO.y) / (startP.x - centerAO.x);
        pointC.y  = startP.y;
        // 相似三角形,不用取绝对值。分子必定为正数。根据分母的正负决定C点位置
        pointD.y  = centerAO.y - (startP.x - centerAO.x ) * (startP.x - centerAO.x) / (startP.y - centerAO.y);
        pointD.x  = startP.x;
        // 计算三维空间中Z轴底部的折痕位置
        pointE.x = pointC.x - (startP.x - pointC.x) * foldRatio;
        pointE.y = pointC.y;
        pointF.y = pointD.y - (startP.y - pointD.y) * foldRatio;
        pointF.x = pointD.x;
        // 卷曲边缘在折痕和预期折痕的中间位置
        pointG.x = pointE.x /2 + pointC.x / 2;
        pointG.y = pointE.y;
        pointH.x = pointF.x;
        pointH.y = pointF.y / 2 + pointD.y /2;
        // 获取底部折痕和翻过来的纸的相交点,理论上从这里就应该要开始变化成弧线了,需要作为贝塞尔曲线的起始点
        getNodeForTwoLine(pointI, pointE, pointF, touchP, pointC);
        getNodeForTwoLine(pointJ, pointE, pointF, touchP, pointD);
        // 弧线的控制点,2条线刚好是弧线的切线
        getNodeForTwoLine(pointK, pointG, pointH, touchP, pointC);
        getNodeForTwoLine(pointL, pointG, pointH, touchP, pointD);
        // C点到GH边缘的垂线交点,理论上交点应当是弧线开始变方向的点,刚好GH是其切线
        pointM.x = pointG.x /2 + pointK.x /2;
        pointM.y = pointG.y /2 + pointK.y /2;
        pointN.x = pointL.x /2 + pointH.x /2;
        pointN.y = pointL.y /2 + pointH.y /2;
    }
    private void drawGuideLines(Canvas canvas){
        pagePaint.setColor(Color.BLUE);
        canvas.drawRect(canvas.getClipBounds(),pagePaint);
        // 绘制AO
        canvas.drawLine(startP.x, startP.y, touchP.x, touchP.y, guideLinePaint);
        // 绘制AC,OC
        canvas.drawLine(pointC.x, pointC.y, touchP.x, touchP.y, guideLinePaint);
        canvas.drawLine(startP.x, startP.y, pointC.x, pointC.y, guideLinePaint);
        // 绘制AD,OD
        canvas.drawLine(pointD.x, pointD.y, touchP.x, touchP.y, guideLinePaint);
        canvas.drawLine(startP.x, startP.y, pointD.x, pointD.y, guideLinePaint);
        // 绘制CD
        canvas.drawLine(pointD.x, pointD.y, pointC.x, pointC.y, guideLinePaint);
        // 绘制EF
        canvas.drawLine(pointE.x, pointE.y, pointF.x, pointF.y, guideLinePaint);
        // 绘制gh
        canvas.drawLine(pointG.x, pointG.y, pointH.x, pointH.y, guideLinePaint);


        clip.reset();
        clip.moveTo(touchP.x, touchP.y);
        clip.lineTo(pointI.x, pointI.y);
        clip.quadTo(pointK.x, pointK.y, pointM.x, pointM.y);
        clip.quadTo(pointG.x, pointG.y, pointE.x, pointE.y);
        clip.lineTo(startP.x, startP.y);
        clip.lineTo(pointF.x, pointF.y);
        clip.quadTo(pointH.x, pointH.y,pointN.x, pointN.y);
        clip.quadTo(pointL.x, pointL.y,pointJ.x, pointJ.y);
        clip.close();
        canvas.save();
        canvas.clipPath(clip, Region.Op.DIFFERENCE);;
        pagePaint.setColor(Color.GREEN);
        canvas.drawRect(canvas.getClipBounds(),pagePaint);
        canvas.restore();
        back.reset();
        back.moveTo(touchP.x, touchP.y);
        back.lineTo(pointI.x, pointI.y);
        back.quadTo(pointK.x, pointK.y, pointM.x, pointM.y);
        back.lineTo(pointN.x, pointN.y);
        back.quadTo(pointL.x, pointL.y,pointJ.x, pointJ.y);
        back.close();
        canvas.drawPath(back, linePaint);
    }
    private void drawPoints(Canvas canvas) {
        // 触摸点
        drawPoint(canvas, "A", touchP.x, touchP.y);
        // 起始端点
        drawPoint(canvas, "O", startP.x, startP.y);
        // AO中点
        drawPoint(canvas, "B", centerAO.x, centerAO.y);
        // C点:垂直平分线和O点水平相交
        drawPoint(canvas, "C", pointC.x, pointC.y);
        // D点:垂直平分线和O点垂直相交
        drawPoint(canvas, "D", pointD.x, pointD.y);
        // EF点:z轴底部折痕
        drawPoint(canvas, "E", pointE.x, pointE.y);
        drawPoint(canvas, "F", pointF.x, pointF.y);

        // GH点:弯曲边缘(弧线控制点)
        drawPoint(canvas, "G", pointG.x, pointG.y);
        drawPoint(canvas, "H", pointH.x, pointH.y);
        // 相交点
        drawPoint(canvas, "I", pointI.x, pointI.y);
        drawPoint(canvas, "J", pointJ.x, pointJ.y);
        // 相交点
        drawPoint(canvas, "K", pointK.x, pointK.y);
        drawPoint(canvas, "L", pointL.x, pointL.y);
        // 相交点
        drawPoint(canvas, "M", pointM.x, pointM.y);
        drawPoint(canvas, "N", pointN.x, pointN.y);

    }
    private void drawPoint(Canvas canvas, String text, float x, float y) {
        canvas.drawCircle(x, y, 5, textPaint);
        canvas.drawText(text, x - 40, y, textPaint);
    }


    private void getNodeForTwoLine(PointF pointF,PointF lineA1, PointF lineA2, PointF lineB1, PointF lineB2){
        // y = ax + b;
        float a1,b1,a2,b2;
        // 实际就是求斜率和偏移
        a1 = (lineA1.y - lineA2.y) / (lineA1.x - lineA2.x);
        b1 = lineA1.y - a1 * lineA1.x;
        a2 = (lineB1.y - lineB2.y) / (lineB1.x - lineB2.x);
        b2 = lineB1.y - a2 * lineB1.x;

        //a1x +b1 = a2x + b2
        //x = b2-b1/a1-a2
        pointF.x = (b2 - b1) / (a1 - a2);
        pointF.y = a1 * pointF.x + b1;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        touchP.x = event.getX();
        touchP.y = event.getY();
        invalidate();
        return true;
    }
}
Responses