Android自定義View——從零開始實現書籍翻頁效果(一)

版權聲明:本文爲博主原創文章,未經博主容許不得轉載php

系列教程:Android開發之從零開始系列html

源碼:AnliaLee/BookPage,歡迎starjava

你們要是看到有錯誤的地方或者有啥好的建議,歡迎留言評論android

前言:本篇是系列博客的第三篇,此次咱們要研究 書籍翻頁效果 。不知道你們平時有沒用過iReader、掌閱這些小說軟件,裏面的翻頁效果感受十分的酷炫。有心想研究研究如何實現,因而網上找了找,發現這方面的教學資料很是少,所幸能找到何明桂大大Android 實現書籍翻頁效果----原理篇這樣的入門博客(感謝大大 Orz),咱們就以這篇博客爲切入點從零實現咱們本身的翻頁效果。因爲此次坑比較深,預計會寫好幾期,感興趣的小夥伴能夠點下關注以便及時收到更新提醒,謝謝你們的支持 ~git

本篇只着重於思路和實現步驟,裏面用到的一些知識原理不會很是細地拿來說,若是有不清楚的api或方法能夠在網上搜下相應的資料,確定有大神講得很是清楚的,我這就不獻醜了。本着認真負責的精神我會把相關知識的博文連接也貼出來(其實就是懶不想寫那麼多哈哈),你們能夠自行傳送。爲了照顧第一次閱讀系列博客的小夥伴,本篇會出現一些在以前系列博客就講過的內容,看過的童鞋自行跳過該段便可github

國際慣例,先上效果圖,本次主要實現了基本的上下翻頁效果右側最大翻頁距離的限制spring


計算與繪製各個標識點

相關博文連接

Android 實現書籍翻頁效果----原理篇
Android 自定義View (一)canvas

在看這篇博客以前,但願你們能先了解一下書籍翻頁的實現原理,博客連接我已經貼出來了。經過原理講解咱們知道,整個書籍翻頁效果界面分紅了三個區域,A爲當前頁區域,B爲下一頁區域,C爲當前頁背面,如圖所示api

書籍翻頁效果的實現就是要以咱們觸摸屏幕位置的座標爲基礎繪製出這三個區域,造成模擬翻頁的特效。要繪製這三個區域,咱們須要經過一組特定的點來完成,這些點的座標須要經過兩個已知的點(觸摸點相對邊緣角)計算獲得,下圖我將各個特定點的位置和計算公式貼出來,你們對照着原理一塊兒理解(渣畫工望體諒 ╮(╯▽╰)╭ ),其中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;
}
複製代碼

效果如圖

至此本篇教程就告一段落了,固然還有許多功能須要繼續完善,例如橫向翻頁、翻頁動畫、陰影效果等等,這些都會在後面的教程中一一解決。若是你們看了感受還不錯麻煩點個贊,大家的支持是我最大的動力~

相關文章
相關標籤/搜索