版權宣告:本文為博主原創文章,未經博主允許不得轉載
系列教程:Android開發之從零開始系列
原始碼:AnliaLee/BookPage,歡迎star
大家要是看到有錯誤的地方或者有啥好的建議,歡迎留言評論
前言:本篇是系列部落格的第三篇,這次我們要研究 書籍翻頁效果 。不知道大家平時有沒用過iReader、掌閱這些小說軟體,裡面的翻頁效果感覺十分的酷炫。有心想研究研究如何實現,於是網上找了找,發現這方面的教學資料非常少,所幸能找到何明桂大大的Android 實現書籍翻頁效果----原理篇這樣的入門部落格(感謝大大 Orz),我們就以這篇部落格為切入點從零實現我們自己的翻頁效果。由於這次坑比較深,預計會寫好幾期,感興趣的小夥伴可以點下關注以便及時收到更新提醒,謝謝大家的支援 ~
本篇只著重於思路和實現步驟,裡面用到的一些知識原理不會非常細地拿來講,如果有不清楚的api或方法可以在網上搜下相應的資料,肯定有大神講得非常清楚的,我這就不獻醜了。本著認真負責的精神我會把相關知識的博文連結也貼出來(其實就是懶不想寫那麼多哈哈),大家可以自行傳送。為了照顧第一次閱讀系列部落格的小夥伴,本篇會出現一些在之前系列部落格就講過的內容,看過的童鞋自行跳過該段即可
國際慣例,先上效果圖,本次主要實現了基本的上下翻頁效果與右側最大翻頁距離的限制
計算與繪製各個標識點
相關博文連結
在看這篇部落格之前,希望大家能先了解一下書籍翻頁的實現原理,部落格連結我已經貼出來了。通過原理講解我們知道,整個書籍翻頁效果介面分成了三個區域,A為當前頁區域,B為下一頁區域,C為當前頁背面,如圖所示
書籍翻頁效果的實現就是要以我們觸控螢幕位置的座標為基礎繪製出這三個區域,形成模擬翻頁的特效。要繪製這三個區域,我們需要通過一組特定的點來完成,這些點的座標需要通過兩個已知的點(觸控點、相對邊緣角)計算得到,下圖我將各個特定點的位置和計算公式貼出來,大家對照著原理一起理解(渣畫工望體諒 ╮(╯▽╰)╭ ),其中b點是由ae和cj的交點,k點是由ah和cj的交點
簡單總結一下,a是觸控點,f是觸控點相對的邊緣角,eh我們設定為af的垂直平分線,則g是af的中點,ab、ak、dj是直線;曲線cdb是起點為c,控制點為e,終點為b的二階貝塞爾曲線;曲線kij是起點為k,控制點為h,終點為j的二階貝塞爾曲線,區域A、B、C就由這些點和線劃分開來。我們將這些點稱為標識點,下一步就是模擬設定a和f點的位置,將這組標識點繪製到螢幕上來驗證我們的計算公式是否正確,建立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區域
相關博文連結
前文我們提到ab、ak、dj是直線;曲線cdb是起點為c,控制點為e,終點為b的二階貝塞爾曲線;曲線kij是起點為k,控制點為h,終點為j的二階貝塞爾曲線。通過觀察分析得知,區域A是由View左上角,左下角,曲線cdb, 直線ab、ak,曲線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連線而成的閉合區域,但由於d和i是曲線上的點,我們沒辦法直接從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的大小進行重新測量,在實現觸控翻頁之前先把這個結了。重寫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:clickable為true,否則無法監聽到ACTION_MOVE和ACTION_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;
}
複製程式碼
效果如圖
至此本篇教程就告一段落了,當然還有許多功能需要繼續完善,例如橫向翻頁、翻頁動畫、陰影效果等等,這些都會在後面的教程中一一解決。如果大家看了感覺還不錯麻煩點個贊,你們的支援是我最大的動力~