Android 自定义View贝塞尔曲线实现书籍翻页的效果(包含原理解释)

先看效果图:

我先来解释一下该翻页的实现原理,大家来看下面这张图:

我们可以把翻页时的图案分为三部分,分别是第一页的图案,第一页的背面图案,以及第二页的图案。

我们将图形进一步数学化:

其中,c、d、b是以e为控制点的贝塞尔曲线上的点。同样,j、i、k是以h为控制点的贝塞尔曲线上的点。

a为翻角的顶点,线段eh为线段af的中垂线。

根据上图,我们可以对线段af左侧做出假设(另一边也同理):

ce=ef/2

p是线段cb的中点

d是线段pe的中点

b是ae和cj的交点

由于红色部分中的曲线db和曲线ik我们无法得知它的函数式,所以红色部分不能直接绘制,但是我们可以绘制出红色部分加黄色部分的区域,记为PathC;黄色区域我们也可以单独绘制(c和d按直线相连),记为PathB。然后我们用clipPath(PathB,Region.Op.DIFFERENCE)来切割出红色区域,Op.DIFFERENCE指的是取PathB中与PathC不相同的区域。

另外一说,绿色区域我们也可以单独绘制。这样一来,三个区域我们就都能得到了。

所以,我们最重要的工作就是计算每个点的坐标,下面我来陈述一下每个点的计算方法:

a点是我们已知的,记为(ax,ay)

f点是屏幕右下角的点,也是已知的,记为(fx,fy)

g是线段af和eh的交点,记为(gx,gy),有gx=(ax+fx)/2,gy=(ay+fy)/2

直线eh的斜率记为Keh,有Keh=(-1)*(fx-ax)/(fy-gy)

直线eh的函数式为:y=Keh*(x-gx)+gy

e点记为(ex,ey),ey=fy,带入直线eh函数式可得ex=gx+(fy-gy)/Keh

h点记为(hx,hy),hx=fx,带入直线eh函数式可得hy=gy+Keh(fx-gx)

c点记为(cx,cy),因为ce=ef/2,则cx=ex-ce=ex-ef/2=ex-(fx-ex)/2,cy=fy

j点记为(jx,jy),和c点同理,jh=hf/2,有jy=hy-jh=hy-fh/2=hy-(fy-hy)/2,jx=fx

d点记为(dx,dy),因为p是cb中点,d是pe中点,有dx=(ex+px)/2=(ex+(cx+bx)/2)/2,dy=(ey+(cy+by)/2)/2

i点记为(ix,iy),和d点同理,有ix=(hx+(kx+jx)/2)/2,iy=(hy+(ky+jy)/2)/2

直线cj的函数式为:y=Kcj(x-jx)+jy,斜率为Kcj=(jy-cy)/(jx-cx)

直线ae的函数式为:y=Kae(x-ax)+ay,斜率为Kae=(ay-ey)/(ax-ex)

直线ah的函数式为:y=Kah(x-ax)+ay,斜率为Kah=(ay-hy)/(ax-hx)

b点记为(bx,by),因为b点cj和ae的交点,计算可得bx=(ay-Kae*ax+Kcj*jx-jy)/(Kcj-Kae),by=Kcj(bx-jx)+jy

k点记为(kx,ky),因为k点cj和ah的交点,计算可得kx=(ay-Kah*ax+Kcj*jx-jy)/(Kcj-Kah),ky=Kcj(kx-jx)+jy

以上就是每个点的计算方式,下面我们创建一个PaperPoint类,来将计算过程写成代码:

public class PaperPoint {
    //拉拽点
    private MyPoint a;
    //右下角的点
    private MyPoint f;
    //贝塞尔点(e为控制点)
    private MyPoint c,d,b,e;
    //贝塞尔点(h为控制点)
    private MyPoint i,j,k,h;
    //eh实际为af中垂线,g为ah和af的交点
    private MyPoint g;

    public PaperPoint(){
        a=new MyPoint();f=new MyPoint();
        g=new MyPoint();e=new MyPoint();
        h=new MyPoint();c=new MyPoint();
        j=new MyPoint();d=new MyPoint();
        i=new MyPoint();b=new MyPoint();
        k=new MyPoint();
    }

    //每个点的计算公式
    private void calculate(){
        g.setX((a.getX()+f.getX())/2);
        g.setY((a.getY()+f.getY())/2);

        float slopeKeh=-(f.getX()-a.getX())/(f.getY()-a.getY());
        e.setX(g.getX()+(f.getY()-g.getY())/slopeKeh);
        e.setY(f.getY());

        h.setX(f.getX());
        h.setY(g.getY()+slopeKeh*(f.getX()-g.getX()));

        c.setX(e.getX()-(f.getX()-e.getX())/2);
        c.setY(f.getY());

        j.setX(f.getX());
        j.setY(h.getY()-(f.getY()-h.getY())/2);

        float slopeKcj=(j.getY()-c.getY())/(j.getX()-c.getX());
        float slopeKae=(a.getY()-e.getY())/(a.getX()-e.getX());
        float slopeKah=(a.getY()-h.getY())/(a.getX()-h.getX());
        b.setX((a.getY()-slopeKae*a.getX()+slopeKcj*j.getX()-j.getY())/(slopeKcj-slopeKae));
        b.setY(slopeKcj*(b.getX()-j.getX())+j.getY());
        k.setX((a.getY()-slopeKah*a.getX()+slopeKcj*j.getX()-j.getY())/(slopeKcj-slopeKah));
        k.setY(slopeKcj*(k.getX()-j.getX())+j.getY());

        d.setX(b.getX()/4+c.getX()/4+e.getX()/2);
        d.setY(b.getY()/4+c.getY()/4+e.getY()/2);

        i.setX(j.getX()/4+k.getX()/4+h.getX()/2);
        i.setY(j.getY()/4+k.getY()/4+h.getY()/2);
    }

    public void set(MyPoint a,MyPoint f) {
        this.a = a;
        this.f = f;
        calculate();
    }

    public MyPoint getA() {
        return a;
    }

    public MyPoint getF() {
        return f;
    }

    public MyPoint getC() {
        return c;
    }

    public MyPoint getD() {
        return d;
    }

    public MyPoint getB() {
        return b;
    }

    public MyPoint getE() {
        return e;
    }

    public MyPoint getI() {
        return i;
    }

    public MyPoint getJ() {
        return j;
    }

    public MyPoint getK() {
        return k;
    }

    public MyPoint getH() {
        return h;
    }

    public MyPoint getG() {
        return g;
    }
}

我们计算好每个点后,就要开始绘制任务了,首先我们来绘制绿色区域的内容:

private PaperPoint pp;
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //......
    //绘制第一页
    canvas.save();
    canvas.clipPath(getPathA());
    canvas.drawBitmap(bitmapBg,matrix,null);
    //绘制文章内容
    drawArticle(canvas,0);
    canvas.restore();
    //......
}

//得到第一页图形
private Path getPathA(){
    path.reset();
    path.lineTo(0,height);
    path.lineTo(pp.getC().getX(),height);
    path.quadTo(pp.getE().getX(),pp.getE().getY(),pp.getB().getX(),pp.getB().getY());
    path.lineTo(pp.getA().getX(),pp.getA().getY());
    path.lineTo(pp.getK().getX(),pp.getK().getY());
    path.quadTo(pp.getH().getX(),pp.getH().getY(),pp.getJ().getX(),pp.getJ().getY());
    path.lineTo(width,0);
    path.close();
    return path;
}

绘制黄色区域加上红色区域所在的总区域:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //......
    //绘制第二页和翻转背面的内容
    canvas.save();
    canvas.clipPath(getPathC());
    canvas.drawBitmap(bitmapBg,matrix,null);
    drawArticle(canvas,1);
    canvas.restore();
    //......
}

//得到第二页和翻转背面的图形
private Path getPathC(){
    pathC.reset();
    pathC.moveTo(pp.getJ().getX(),pp.getJ().getY());
    pathC.quadTo(pp.getH().getX(),pp.getH().getY(), pp.getK().getX(), pp.getK().getY());
    pathC.lineTo(pp.getA().getX(),pp.getA().getY());
    pathC.lineTo(pp.getB().getX(),pp.getB().getY());
    pathC.quadTo(pp.getE().getX(),pp.getE().getY(),pp.getC().getX(),pp.getC().getY());
    pathC.lineTo(pp.getF().getX(),pp.getF().getY());
    pathC.close();
    return pathC;
}

用clipPath切分出红色的翻角区域:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //......
    //绘制第一页的背面
    canvas.save();
    canvas.clipPath(pathC);
    canvas.clipPath(getPathB(), Region.Op.DIFFERENCE);//difference最取出两段path中不同的地方
    canvas.drawBitmap(bitmapBg,matrix,null);
    canvas.restore();
}

//得到第二页的图形
private Path getPathB(){
    pathB.reset();
    pathB.moveTo(pp.getC().getX(), pp.getC().getY());
    pathB.lineTo(pp.getD().getX(),pp.getD().getY());
    pathB.lineTo(pp.getI().getX(),pp.getI().getY());
    pathB.lineTo(pp.getJ().getX(),pp.getJ().getY());
    pathB.lineTo(pp.getF().getX(),pp.getF().getY());
    pathB.close();
    return pathB;
}

到这里,翻页效果已经实现,最后我们来监听点击事件,以此来设置a点坐标:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()){
        case MotionEvent.ACTION_MOVE:
            pp.set(new MyPoint(event.getX(),event.getY()),new MyPoint(Constants.SCREEN_WIDTH,Constants.SCREEN_HEIGHT));
            invalidate();
            break;
    }
    return true;
}

下面是自定义view源码:

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Region;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import com.hualinfo.bean.beizer.MyPoint;
import com.hualinfo.utils.PaperPoint;

import androidx.annotation.Nullable;

public class MyBeizerView extends View {
    private PaperPoint pp;
    private Path path,pathB,pathC;  //第一页路径,第二页路径,第二页和翻转背面的路径
    private Paint txtPaint;
    private Matrix matrix;
    private int width= Constants.SCREEN_WIDTH;
    private int height= Constants.SCREEN_HEIGHT;
    private String[] strs=new String[2];    //文本
    private Bitmap bitmapBg;
    public MyBeizerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init(){
        initBitmap();
        path=new Path();pathB=new Path();pathC=new Path();
        txtPaint=new Paint();
        txtPaint.setColor(Color.WHITE);
        txtPaint.setTextSize(sp2px(16));
        pp=new PaperPoint();
        pp.set(new MyPoint(Constants.SCREEN_WIDTH,Constants.SCREEN_HEIGHT),new MyPoint(Constants.SCREEN_WIDTH,Constants.SCREEN_HEIGHT));
        strs[0]=getResources().getString(R.string.str2);
        strs[1]=getResources().getString(R.string.str3);
    }

    private void initBitmap(){
        matrix=new Matrix();
        bitmapBg= BitmapFactory.decodeResource(getResources(),R.mipmap.bg_article);
        float scaleX=1,scaleY=1;
        //如果图片与圆的直径不一致,等比例缩放图片
        if(bitmapBg.getWidth()!=width||bitmapBg.getHeight()!=height){
            scaleX=width/(bitmapBg.getWidth()*1.0f);
            scaleY=height/(bitmapBg.getHeight()*1.0f);
        }
        matrix.setScale(scaleX,scaleY);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if(pp.getA().getX()==Constants.SCREEN_WIDTH&&pp.getA().getY()==Constants.SCREEN_HEIGHT){
            canvas.drawBitmap(bitmapBg,matrix,null);
            drawArticle(canvas,0);
            return;
        }
        //绘制第一页
        canvas.save();
        canvas.clipPath(getPathA());
        canvas.drawBitmap(bitmapBg,matrix,null);
        //绘制文章内容
        drawArticle(canvas,0);
        canvas.restore();

        //绘制第二页和翻转背面的内容
        canvas.save();
        canvas.clipPath(getPathC());
        canvas.drawBitmap(bitmapBg,matrix,null);
        drawArticle(canvas,1);
        canvas.restore();

        //绘制第一页的背面
        canvas.save();
        canvas.clipPath(pathC);
        canvas.clipPath(getPathB(), Region.Op.DIFFERENCE);//difference最取出两段path中不同的地方
        canvas.drawBitmap(bitmapBg,matrix,null);
        canvas.restore();
    }

    //绘制文章的文本
    private void drawArticle(Canvas canvas,int pos){
        int lineNum=(int)(getWidth()/txtPaint.getTextSize());
        int size=strs[pos].length()/lineNum;
        for(int i=0;i<=size;i++){
            int endPos=(i+1)*lineNum;
            if(endPos>=strs[pos].length())endPos=strs[pos].length()-1;
            canvas.drawText(strs[pos],i*lineNum,endPos,0,sp2px(25*i+20),txtPaint);
        }
    }

    //得到第一页图形
    private Path getPathA(){
        path.reset();
        path.lineTo(0,height);
        path.lineTo(pp.getC().getX(),height);
        path.quadTo(pp.getE().getX(),pp.getE().getY(),pp.getB().getX(),pp.getB().getY());
        path.lineTo(pp.getA().getX(),pp.getA().getY());
        path.lineTo(pp.getK().getX(),pp.getK().getY());
        path.quadTo(pp.getH().getX(),pp.getH().getY(),pp.getJ().getX(),pp.getJ().getY());
        path.lineTo(width,0);
        path.close();
        return path;
    }

    //得到第二页的图形
    private Path getPathB(){
        pathB.reset();
        pathB.moveTo(pp.getC().getX(), pp.getC().getY());
        pathB.lineTo(pp.getD().getX(),pp.getD().getY());
        pathB.lineTo(pp.getI().getX(),pp.getI().getY());
        pathB.lineTo(pp.getJ().getX(),pp.getJ().getY());
        pathB.lineTo(pp.getF().getX(),pp.getF().getY());
        pathB.close();
        return pathB;
    }

    //得到第二页和翻转背面的图形
    private Path getPathC(){
        pathC.reset();
        pathC.moveTo(pp.getJ().getX(),pp.getJ().getY());
        pathC.quadTo(pp.getH().getX(),pp.getH().getY(), pp.getK().getX(), pp.getK().getY());
        pathC.lineTo(pp.getA().getX(),pp.getA().getY());
        pathC.lineTo(pp.getB().getX(),pp.getB().getY());
        pathC.quadTo(pp.getE().getX(),pp.getE().getY(),pp.getC().getX(),pp.getC().getY());
        pathC.lineTo(pp.getF().getX(),pp.getF().getY());
        pathC.close();
        return pathC;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                pp.set(new MyPoint(event.getX(),event.getY()),new MyPoint(Constants.SCREEN_WIDTH,Constants.SCREEN_HEIGHT));
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

    /**
     * 将sp值转换为px值
     */
    public int sp2px(float spValue) {
        float fontScale = getResources().getDisplayMetrics().scaledDensity;
        return (int) (spValue * fontScale + 0.5f);
    }
}

MyPoint类:

public class MyPoint {
    private float x;
    private float y;

    public MyPoint() {
    }

    public MyPoint(float x, float y) {
        this.x = x;
        this.y = y;
    }

    public float getX() {
        return x;
    }
    public void setX(float x) {
        this.x = x;
    }
    public float getY() {
        return y;
    }
    public void setY(float y) {
        this.y = y;
    }
}

xml布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent">
    <com.myviewtext.MyBeizerView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</RelativeLayout>

猜你喜欢

转载自blog.csdn.net/zz51233273/article/details/107812140