Android自定义View——从零开始实现书籍翻页效果(一)

18,439 阅读11分钟

版权声明:本文为博主原创文章,未经博主允许不得转载

系列教程:Android开发之从零开始系列

源码:AnliaLee/BookPage,欢迎star

大家要是看到有错误的地方或者有啥好的建议,欢迎留言评论

前言:本篇是系列博客的第三篇,这次我们要研究 书籍翻页效果 。不知道大家平时有没用过iReader、掌阅这些小说软件,里面的翻页效果感觉十分的酷炫。有心想研究研究如何实现,于是网上找了找,发现这方面的教学资料非常少,所幸能找到何明桂大大Android 实现书籍翻页效果----原理篇这样的入门博客(感谢大大 Orz),我们就以这篇博客为切入点从零实现我们自己的翻页效果。由于这次坑比较深,预计会写好几期,感兴趣的小伙伴可以点下关注以便及时收到更新提醒,谢谢大家的支持 ~

本篇只着重于思路和实现步骤,里面用到的一些知识原理不会非常细地拿来讲,如果有不清楚的api或方法可以在网上搜下相应的资料,肯定有大神讲得非常清楚的,我这就不献丑了。本着认真负责的精神我会把相关知识的博文链接也贴出来(其实就是懒不想写那么多哈哈),大家可以自行传送。为了照顾第一次阅读系列博客的小伙伴,本篇会出现一些在之前系列博客就讲过的内容,看过的童鞋自行跳过该段即可

国际惯例,先上效果图,本次主要实现了基本的上下翻页效果右侧最大翻页距离的限制


计算与绘制各个标识点

相关博文链接

Android 实现书籍翻页效果----原理篇
Android 自定义View (一)

在看这篇博客之前,希望大家能先了解一下书籍翻页的实现原理,博客链接我已经贴出来了。通过原理讲解我们知道,整个书籍翻页效果界面分成了三个区域,A为当前页区域,B为下一页区域,C为当前页背面,如图所示

书籍翻页效果的实现就是要以我们触摸屏幕位置的坐标为基础绘制出这三个区域,形成模拟翻页的特效。要绘制这三个区域,我们需要通过一组特定的点来完成,这些点的坐标需要通过两个已知的点(触摸点相对边缘角)计算得到,下图我将各个特定点的位置和计算公式贴出来,大家对照着原理一起理解(渣画工望体谅 ╮(╯▽╰)╭ ),其中b点是由aecj的交点,k点是由ahcj的交点

简单总结一下,a是触摸点,f是触摸点相对的边缘角,eh我们设置为af的垂直平分线,则gaf的中点,abakdj直线曲线cdb是起点为c,控制点为e,终点为b二阶贝塞尔曲线曲线kij是起点为k,控制点为h,终点为j二阶贝塞尔曲线,区域ABC就由这些点和线划分开来。我们将这些点称为标识点,下一步就是模拟设定af点的位置,将这组标识点绘制到屏幕上来验证我们的计算公式是否正确,创建BookPageView

public class BookPageView extends View {
    private Paint pointPaint;//绘制各标识点的画笔
    private Paint bgPaint;//背景画笔

    private MyPoint a,f,g,e,h,c,j,b,k,d,i;

    private int defaultWidth;//默认宽度
    private int defaultHeight;//默认高度
    private int viewWidth;
    private int viewHeight;

    public BookPageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context,attrs);
    }

    private void init(Context context, @Nullable AttributeSet attrs){
        defaultWidth = 600;
        defaultHeight = 1000;

        viewWidth = defaultWidth;
        viewHeight = defaultHeight;

        a = new MyPoint(400,800);
        f = new MyPoint(viewWidth,viewHeight);
        g = new MyPoint();
        e = new MyPoint();
        h = new MyPoint();
        c = new MyPoint();
        j = new MyPoint();
        b = new MyPoint();
        k = new MyPoint();
        d = new MyPoint();
        i = new MyPoint();
        calcPointsXY(a,f);

        pointPaint = new Paint();
        pointPaint.setColor(Color.RED);
        pointPaint.setTextSize(25);

        bgPaint = new Paint();
        bgPaint.setColor(Color.GREEN);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //为了看清楚点与View的位置关系绘制一个背景
        canvas.drawRect(0,0,viewWidth,viewHeight,bgPaint);
        //绘制各标识点
        canvas.drawText("a",a.x,a.y,pointPaint);
        canvas.drawText("f",f.x,f.y,pointPaint);
        canvas.drawText("g",g.x,g.y,pointPaint);

        canvas.drawText("e",e.x,e.y,pointPaint);
        canvas.drawText("h",h.x,h.y,pointPaint);

        canvas.drawText("c",c.x,c.y,pointPaint);
        canvas.drawText("j",j.x,j.y,pointPaint);

        canvas.drawText("b",b.x,b.y,pointPaint);
        canvas.drawText("k",k.x,k.y,pointPaint);

        canvas.drawText("d",d.x,d.y,pointPaint);
        canvas.drawText("i",i.x,i.y,pointPaint);
    }

    /**
     * 计算各点坐标
     * @param a
     * @param f
     */
    private void calcPointsXY(MyPoint a, MyPoint f){
        g.x = (a.x + f.x) / 2;
        g.y = (a.y + f.y) / 2;

        e.x = g.x - (f.y - g.y) * (f.y - g.y) / (f.x - g.x);
        e.y = f.y;

        h.x = f.x;
        h.y = g.y - (f.x - g.x) * (f.x - g.x) / (f.y - g.y);

        c.x = e.x - (f.x - e.x) / 2;
        c.y = f.y;

        j.x = f.x;
        j.y = h.y - (f.y - h.y) / 2;

        b = getIntersectionPoint(a,e,c,j);
        k = getIntersectionPoint(a,h,c,j);

        d.x = (c.x + 2 * e.x + b.x) / 4;
        d.y = (2 * e.y + c.y + b.y) / 4;

        i.x = (j.x + 2 * h.x + k.x) / 4;
        i.y = (2 * h.y + j.y + k.y) / 4;
    }

    /**
     * 计算两线段相交点坐标
     * @param lineOne_My_pointOne
     * @param lineOne_My_pointTwo
     * @param lineTwo_My_pointOne
     * @param lineTwo_My_pointTwo
     * @return 返回该点
     */
    private MyPoint getIntersectionPoint(MyPoint lineOne_My_pointOne, MyPoint lineOne_My_pointTwo, MyPoint lineTwo_My_pointOne, MyPoint lineTwo_My_pointTwo){
        float x1,y1,x2,y2,x3,y3,x4,y4;
        x1 = lineOne_My_pointOne.x;
        y1 = lineOne_My_pointOne.y;
        x2 = lineOne_My_pointTwo.x;
        y2 = lineOne_My_pointTwo.y;
        x3 = lineTwo_My_pointOne.x;
        y3 = lineTwo_My_pointOne.y;
        x4 = lineTwo_My_pointTwo.x;
        y4 = lineTwo_My_pointTwo.y;

        float pointX =((x1 - x2) * (x3 * y4 - x4 * y3) - (x3 - x4) * (x1 * y2 - x2 * y1))
                / ((x3 - x4) * (y1 - y2) - (x1 - x2) * (y3 - y4));
        float pointY =((y1 - y2) * (x3 * y4 - x4 * y3) - (x1 * y2 - x2 * y1) * (y3 - y4))
                / ((y1 - y2) * (x3 - x4) - (x1 - x2) * (y3 - y4));

        return  new MyPoint(pointX,pointY);
    }
}

实体类MyPoint用来存放我们的标识点坐标

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

界面布局:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.anlia.pageturn.BookPageView
        android:id="@+id/view_book_page"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="15dp"
        android:layout_marginTop="15dp"/>
</RelativeLayout>

在Activity中进行注册

bookPageView = (BookPageView) findViewById(R.id.view_book_page);

效果如图


连接各标识点绘制A、B、C区域

相关博文链接

Android-贝塞尔曲线

安卓自定义View进阶:Path基本操作

android 自定义view 缓存技术

Android中Canvas绘图之PorterDuffXfermode使用及工作原理详解

Android 自定义View学习(五)——Paint 关于PorterDuffXfermode学习

前文我们提到abakdj直线曲线cdb是起点为c,控制点为e,终点为b二阶贝塞尔曲线曲线kij是起点为k,控制点为h,终点为j二阶贝塞尔曲线。通过观察分析得知,区域A是由View左上角左下角曲线cdb, 直线abak曲线kij右上角连接而成的区域,修改BookPageView,利用path绘制处区域A

public class BookPageView extends View {
	//省略部分代码...
    private Paint pathAPaint;//绘制A区域画笔
    private Path pathA;
    private Bitmap bitmap;//缓存bitmap
    private Canvas bitmapCanvas;

    private void init(Context context, @Nullable AttributeSet attrs){
		//省略部分代码...
        pathAPaint = new Paint();
        pathAPaint.setColor(Color.GREEN);
        pathAPaint.setAntiAlias(true);//设置抗锯齿

        pathA = new Path();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
		//省略部分代码...
        bitmap = Bitmap.createBitmap((int) viewWidth, (int) viewHeight, Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(bitmap);
        bitmapCanvas.drawPath(getPathAFromLowerRight(),pathAPaint);
        canvas.drawBitmap(bitmap,0,0,null);
    }

    /**
     * 获取f点在右下角的pathA
     * @return
     */
    private Path getPathAFromLowerRight(){
        pathA.reset();
        pathA.lineTo(0, viewHeight);//移动到左下角
        pathA.lineTo(c.x,c.y);//移动到c点
        pathA.quadTo(e.x,e.y,b.x,b.y);//从c到b画贝塞尔曲线,控制点为e
        pathA.lineTo(a.x,a.y);//移动到a点
        pathA.lineTo(k.x,k.y);//移动到k点
        pathA.quadTo(h.x,h.y,j.x,j.y);//从k到j画贝塞尔曲线,控制点为h
        pathA.lineTo(viewWidth,0);//移动到右上角
        pathA.close();//闭合区域
        return pathA;
    }
}

效果如图

区域C理论上应该是由点a,b,d,i,k连接而成的闭合区域,但由于di是曲线上的点,我们没办法直接从d出发通过path绘制路径连接b点(i,k同理),也就不能只用path的情况下直接绘制出区域C,我们需要用PorterDuffXfermode方面的知识“曲线救国”。我们试着先将点a,b,d,i,k连接起来,观察闭合区域与区域A之间的联系。修改BookPageView

private void init(Context context, @Nullable AttributeSet attrs){
	//省略部分代码...
	pathCPaint = new Paint();
	pathCPaint.setColor(Color.YELLOW);
	pathCPaint.setAntiAlias(true);//设置抗锯齿
	
	pathC = new Path();
}

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

	bitmap = Bitmap.createBitmap((int) viewWidth, (int) viewHeight, Bitmap.Config.ARGB_8888);
	bitmapCanvas = new Canvas(bitmap);
	bitmapCanvas.drawPath(getPathAFromLowerRight(),pathAPaint);
	bitmapCanvas.drawPath(getPathC(),pathCPaint);
	canvas.drawBitmap(bitmap,0,0,null);
}

/**
 * 绘制区域C
 * @return
 */
private Path getPathC(){
	pathC.reset();
	pathC.moveTo(i.x,i.y);//移动到i点
	pathC.lineTo(d.x,d.y);//移动到d点
	pathC.lineTo(b.x,b.y);//移动到b点
	pathC.lineTo(a.x,a.y);//移动到a点
	pathC.lineTo(k.x,k.y);//移动到k点
	pathC.close();//闭合区域
	return pathC;
}

效果如图

我们将两条曲线也画出来对比观察

观察分析后可以得出结论,区域C由直线ab,bd,dj,ik,ak连接而成的区域 减去 与区域A交集部分 后剩余的区域。于是我们设置区域C画笔Xfermode模式为DST_ATOP

pathCPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP));

效果如图

最后是区域B,因为区域B处于最底层,我们直接将区域B画笔Xfermode模式设为DST_ATOP,在区域A、C之后绘制即可,修改BookPageView

private void init(Context context, @Nullable AttributeSet attrs){
	//省略部分代码...
	pathBPaint = new Paint();
	pathBPaint.setColor(getResources().getColor(R.color.blue_light));
	pathBPaint.setAntiAlias(true);//设置抗锯齿
	pathBPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP));
	
	pathB = new Path();
}

@Override
protected void onDraw(Canvas canvas) {
	super.onDraw(canvas);
	//省略部分代码...
	bitmap = Bitmap.createBitmap((int) viewWidth, (int) viewHeight, Bitmap.Config.ARGB_8888);
	bitmapCanvas = new Canvas(bitmap);
	bitmapCanvas.drawPath(getPathAFromLowerRight(),pathAPaint);
	bitmapCanvas.drawPath(getPathC(),pathCPaint);
	bitmapCanvas.drawPath(getPathB(),pathBPaint);
	canvas.drawBitmap(bitmap,0,0,null);
}

/**
 * 绘制区域B
 * @return
 */
private Path getPathB(){
	pathB.reset();
	pathB.lineTo(0, viewHeight);//移动到左下角
	pathB.lineTo(viewWidth,viewHeight);//移动到右下角
	pathB.lineTo(viewWidth,0);//移动到右上角
	pathB.close();//闭合区域
	return pathB;
}

效果如图

翻页可以从右下方翻自然也可以从右上方翻,我们将f点设在右上角,由于View上下两部分是呈镜像的,所以各标识点的位置也应该是镜像对应的,因为区域B和C的绘制与f点没有关系,所以我们只需要修改区域A的绘制逻辑,新增getPathAFromTopRight方法

public class BookPageView extends View {
	//省略部分代码...
    private void init(Context context, @Nullable AttributeSet attrs){
        a = new MyPoint(400,200);
		f = new MyPoint(viewWidth,0);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
		//省略部分代码...
        bitmap = Bitmap.createBitmap((int) viewWidth, (int) viewHeight, Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(bitmap);
//        bitmapCanvas.drawPath(getPathAFromLowerRight(),pathAPaint);
        bitmapCanvas.drawPath(getPathAFromTopRight(),pathAPaint);
        bitmapCanvas.drawPath(getPathC(),pathCPaint);
        bitmapCanvas.drawPath(getPathB(),pathBPaint);
    }

    /**
     * 获取f点在右上角的pathA
     * @return
     */
    private Path getPathAFromTopRight(){
        pathA.reset();
        pathA.lineTo(c.x,c.y);//移动到c点
        pathA.quadTo(e.x,e.y,b.x,b.y);//从c到b画贝塞尔曲线,控制点为e
        pathA.lineTo(a.x,a.y);//移动到a点
        pathA.lineTo(k.x,k.y);//移动到k点
        pathA.quadTo(h.x,h.y,j.x,j.y);//从k到j画贝塞尔曲线,控制点为h
        pathA.lineTo(viewWidth,viewHeight);//移动到右下角
        pathA.lineTo(0, viewHeight);//移动到左下角
        pathA.close();
        return pathA;
    }
}

效果如图


测量及自适应View的宽高

相关博文链接

浅谈自定义View的宽高获取

教你搞定Android自定义View

之前由于测试效果没有对View的大小进行重新测量,在实现触摸翻页之前先把这个结了。重写View的onMeasure方法

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
	super.onMeasure(widthMeasureSpec, heightMeasureSpec);
	int height = measureSize(defaultHeight, heightMeasureSpec);
	int width = measureSize(defaultWidth, widthMeasureSpec);
	setMeasuredDimension(width, height);

	viewWidth = width;
	viewHeight = height;
	f.x = width;
	f.y = height;
	calcPointsXY(a,f);//将初始化计算放在这
}

private int measureSize(int defaultSize,int measureSpec) {
	int result = defaultSize;
	int specMode = View.MeasureSpec.getMode(measureSpec);
	int specSize = View.MeasureSpec.getSize(measureSpec);

	if (specMode == View.MeasureSpec.EXACTLY) {
		result = specSize;
	} else if (specMode == View.MeasureSpec.AT_MOST) {
		result = Math.min(result, specSize);
	}
	return result;
}

通过触摸控制各标识点位置

我们的需求是,在上半部分翻页时f点在右上角,在下半部分翻页时f则在右下角,当手指离开屏幕时回到初始状态,根据需求,修改BookPageView

public class BookPageView extends View {
	//省略部分代码...
    public static final String STYLE_TOP_RIGHT = "STYLE_TOP_RIGHT";//f点在右上角
    public static final String STYLE_LOWER_RIGHT = "STYLE_LOWER_RIGHT";//f点在右下角
	
    private void init(Context context, @Nullable AttributeSet attrs){ 
		//省略部分代码...
        a = new MyPoint();
        f = new MyPoint();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int height = measureSize(defaultHeight, heightMeasureSpec);
        int width = measureSize(defaultWidth, widthMeasureSpec);
        setMeasuredDimension(width, height);

        viewWidth = width;
        viewHeight = height;
        a.x = -1;
        a.y = -1;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        bitmap = Bitmap.createBitmap((int) viewWidth, (int) viewHeight, Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(bitmap);
        if(a.x==-1 && a.y==-1){
            bitmapCanvas.drawPath(getPathDefault(),pathAPaint);
        }else {
            if(f.x==viewWidth && f.y==0){
                bitmapCanvas.drawPath(getPathAFromTopRight(),pathAPaint);
            }else if(f.x==viewWidth && f.y==viewHeight){
                bitmapCanvas.drawPath(getPathAFromLowerRight(),pathAPaint);
            }

            bitmapCanvas.drawPath(getPathC(),pathCPaint);
            bitmapCanvas.drawPath(getPathB(),pathBPaint);
        }
        canvas.drawBitmap(bitmap,0,0,null);
    }

    /**
     * 设置触摸点
     * @param x
     * @param y
     * @param style
     */
    public void setTouchPoint(float x, float y, String style){
        switch (style){
            case STYLE_TOP_RIGHT:
                f.x = viewWidth;
                f.y = 0;
                break;
            case STYLE_LOWER_RIGHT:
                f.x = viewWidth;
                f.y = viewHeight;
                break;
            default:
                break;
        }
        a.x = x;
        a.y = y;
        calcPointsXY(a,f);
        postInvalidate();
    }

    /**
     * 回到默认状态
     */
    public void setDefaultPath(){
        a.x = -1;
        a.y = -1;
        postInvalidate();
    }

    /**
     * 绘制默认的界面
     * @return
     */
    private Path getPathDefault(){
        pathA.reset();
        pathA.lineTo(0, viewHeight);
        pathA.lineTo(viewWidth,viewHeight);
        pathA.lineTo(viewWidth,0);
        pathA.close();
        return pathA;
    }

    public float getViewWidth(){
        return viewWidth;
    }

    public float getViewHeight(){
        return viewHeight;
    }
}

在Activity中监听View的onTouch状态

bookPageView.setOnTouchListener(new View.OnTouchListener() {
	@Override
	public boolean onTouch(View v, MotionEvent event) {
		switch (event.getAction()){
			case MotionEvent.ACTION_DOWN:
				if(event.getY() < bookPageView.getViewHeight()/2){//从上半部分翻页
					bookPageView.setTouchPoint(event.getX(),event.getY(),bookPageView.STYLE_TOP_RIGHT);
				}else if(event.getY() >= bookPageView.getViewHeight()/2) {//从下半部分翻页
					bookPageView.setTouchPoint(event.getX(),event.getY(),bookPageView.STYLE_LOWER_RIGHT);
				}
				break;
			case MotionEvent.ACTION_MOVE:
				bookPageView.setTouchPoint(event.getX(),event.getY(),"");
				break;
			case MotionEvent.ACTION_UP:
				bookPageView.setDefaultPath();//回到默认状态
				break;
		}
		return false;
	}
});

注意,要设置android:clickabletrue,否则无法监听到ACTION_MOVEACTION_UP状态

<com.anlia.pageturn.BookPageView
	android:id="@+id/view_book_page"
	android:layout_width="300dp"
	android:layout_height="450dp"
	android:layout_marginLeft="15dp"
	android:layout_marginTop="15dp"
	android:clickable="true"/>

效果如图

到这里我们已经实现了基本的翻页效果,但要还原真实的书籍翻页效果,我们还需要设置一些限制条件来完善我们的项目


限制右侧翻页的最大距离

对于一般的书本来说,最左侧应该是钉起来的,也就是说如果我们从右侧翻页,翻动的距离是有限制的,最下方翻页形成的曲线起点(c点)的x坐标不能小于0(上方同理),按照这个限定条件,修改我们的BookPageView

/**
 * 设置触摸点
 * @param x
 * @param y
 * @param style
 */
public void setTouchPoint(float x, float y, String style){
	switch (style){
		case STYLE_TOP_RIGHT:
			f.x = viewWidth;
			f.y = 0;
			break;
		case STYLE_LOWER_RIGHT:
			f.x = viewWidth;
			f.y = viewHeight;
			break;
		default:
			break;
	}
	MyPoint touchPoint = new MyPoint(x,y);
	//如果大于0则设置a点坐标重新计算各标识点位置,否则a点坐标不变
	if(calcPointCX(touchPoint,f)>0){
		a.x = x;
		a.y = y;
		calcPointsXY(a,f);
	}else {
		calcPointsXY(a,f);
	}
	postInvalidate();
}
/**
 * 计算C点的X值
 * @param a
 * @param f
 * @return
 */
private float calcPointCX(MyPoint a, MyPoint f){
	MyPoint g,e;
	g = new MyPoint();
	e = new MyPoint();
	g.x = (a.x + f.x) / 2;
	g.y = (a.y + f.y) / 2;

	e.x = g.x - (f.y - g.y) * (f.y - g.y) / (f.x - g.x);
	e.y = f.y;

	return e.x - (f.x - e.x) / 2;
}

效果如图

至此本篇教程就告一段落了,当然还有许多功能需要继续完善,例如横向翻页、翻页动画、阴影效果等等,这些都会在后面的教程中一一解决。如果大家看了感觉还不错麻烦点个赞,你们的支持是我最大的动力~