仿书页翻页效果实现思路

仿书页翻页效果实现思路

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

https://www.ccc2.icu/archives/zbwGtGWu

最终效果预览

从折纸开始

由于原文公式太多,不便理解,从这里开始以我尝试开发的心路历程,抽丝剥茧演示如何实现。

首先先模拟一个纸张,没有Z轴的效果,将纸张的边线画出来。

1. 选定控制点

先选定手指触摸的纸张的触摸点,表示纸张的端点将被固定在这个位置,该点定义A(x,y)

被拖动的端点定义O(width, height),右下角的端点

2. 根据触摸点绘制折痕

当前折痕不存在Z轴概念,直接绘制,折痕的位置一定是落AO连接线的中B,且因为是折痕,两边一定是延折痕对称,那么镜像的点的连线一定是垂直于折痕的。

根据相似三角形,对应边的比例是相等的,能够求CD点的位置

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;

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;

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;

5. 补充剩余点

求相交点就很容易了,已知2点可以得出直线的方程式

2个方程式相等时就是交点,很容易求得(x,y)坐标,补充剩余点的位置

M N点为垂直平分线相交点,因为折痕上下的弧度应当相同,故取中点

6. 平滑过渡

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

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);

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;

    }

}

Fiddler抓包 2023-07-13
造轮子:滚轮选择器实现及原理解析(一) 2023-07-21

评论区