Android 如何從零開始寫一款書籍閱讀器的示例
一款書籍閱讀器,需要以下功能才能說的上比較完整:
- 文字頁面展示,即書頁;
- 頁面之間的跳轉(zhuǎn)動畫,即翻頁動作;
- 能夠在每一頁上記錄閱讀進度,即書簽;
- 能夠自由選擇文字并標(biāo)注,即筆記;
- 能夠設(shè)置一些屬性,如屏幕亮度,字體大小,主體顏色等,即個性化設(shè)置。
書籍閱讀器
這篇文章帶來的就是如何打造這么一款閱讀器。(由于整體代碼量比較大,所以我只能說說我的實現(xiàn)思路再加上部分的核心代碼來說明,不會有太多的代碼展示。)
翻頁動作——搭建整個閱讀器的框架
在閱讀器上的翻頁動作無外乎仿真和平移這兩種動畫,翻頁時需要準(zhǔn)備兩張頁面,一張是當(dāng)前頁,另一張是需要翻轉(zhuǎn)的下一頁。翻頁的過程就是對這兩個頁面的剪輯。
這里就不贅述翻頁的原理了(仿真翻頁可以由貝塞爾曲線計算坐標(biāo)繪制實現(xiàn),平移翻頁則是簡單坐標(biāo)平移變化),這里提供一些參考鏈接。
現(xiàn)在要做的就是將翻頁動作與 View 結(jié)合起來,我們新建一個 PageAnimController 內(nèi)部實現(xiàn)翻頁動畫和動畫切換,同時設(shè)置 PageCarver 來監(jiān)聽翻頁動作,目的是為了能夠讓 view 檢測到翻頁動作。
public interface PageCarver { void drawPage(Canvas canvas, int index);//繪制頁內(nèi)容 Integer requestPrePage();//請求翻到上一頁 Integer requestNextPage();//請求翻到下一頁 void requestInvalidate();//刷新界面 Integer getCurrentPageIndex();//獲取當(dāng)前頁 /** * 開始動畫的回調(diào) * * @param isCancel 是否是取消動畫 */ void onStartAnim(boolean isCancel); /** * 結(jié)束動畫的回調(diào) * * @param isCancel 是否是取消動畫 */ void onStopAnim(boolean isCancel); }
新建 BaseReaderView 作為閱讀器的基礎(chǔ)視圖,兩者結(jié)合以便控制閱讀器的翻頁效果。
public abstract class BaseReaderView extends View implements PageAnimController.PageCarver{ /** * 將View的繪制事件傳送給 PageAnimController 實現(xiàn)動畫繪制過程中 * @param canvas * @return */ @Override protected void onDraw(Canvas canvas) { if (pageAnimController == null || !pageAnimController.dispatchDrawPage(canvas, this)) { drawPage(canvas, currentPageIndex); } } /** * 將View的觸摸事件傳送給 PageAnimController 以便實現(xiàn)翻頁動畫 * @param event * @return */ @Override public boolean onTouchEvent(MotionEvent event) { pageAnimController.dispatchTouchEvent(event, this); return true; } }
但是在翻頁動畫中是需要無數(shù)次的調(diào)用 drawPage 來繪制界面的,為了減少界面計算的開支必須要有一個 Bitmap 緩存來降低消耗。復(fù)用時可以直接使用已經(jīng)生成的bitmap.
/** * <p> * 頁面快照,用來存儲閱讀器每一頁的內(nèi)容 * * @author cpacm 2017/10/9 */ public class PageSnapshot { private int pageIndex; private Bitmap mBitmap; private Canvas mCanvas; public Canvas beginRecording(int width, int height) { if (mBitmap == null) { mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444); mCanvas = new Canvas(mBitmap); } else { mCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); } return mCanvas; } public void draw(Canvas canvas) { if (null != mBitmap) { canvas.drawBitmap(mBitmap, 0, 0, null); } } public void destroy() { if (mBitmap != null && !mBitmap.isRecycled()) { mBitmap.recycle(); mBitmap = null; } } }
基礎(chǔ)模型如下圖所示:
頁面切換模型
現(xiàn)在我們來總結(jié)一下,這一部分我們搭建了閱讀器最基礎(chǔ)的框架,包括
(1) 翻頁動畫與閱讀器視圖的結(jié)合,能夠確保在View中正確監(jiān)聽翻頁動作,保證整個翻頁動作的準(zhǔn)確性。
(2) 利用 Bitmap 緩存優(yōu)化繪圖流程,保證翻頁動畫的流暢性。而后包括文字,圖片等元素的顯示都是繪制在這個 Bitmap 上的。
書頁——組合模式,保證閱讀器高度可定制化
閱讀器模塊圖
一般來說,閱讀器獲取數(shù)據(jù)都是一章一章來的,不管是從網(wǎng)絡(luò)上還是本地。而獲取過來的數(shù)據(jù)閱讀器要進行分頁才能展示。如上圖所示,書頁展示由 PageElement 模塊負(fù)責(zé),該模塊接收從 BookReaderView 傳入的章節(jié)數(shù)據(jù),然后再經(jīng)底下的4個模塊計算來分頁。
分頁模塊
- PageElement,分頁模塊:功能包括將傳入的章節(jié)數(shù)據(jù)分成數(shù)個 PageData (生成的 PageData 個數(shù)即為該章節(jié)頁數(shù),PageData 記錄了每一頁開頭文字在章節(jié)的位置,同時包含該頁面HeaderData, LineData,HeadrData 和 FooterData 數(shù)據(jù)等。各個 Data 里面記錄了相應(yīng)的文字信息,可以快速的定位到章節(jié)內(nèi)容中。);繪制頁面;緩存章節(jié)數(shù)據(jù)以便無縫切換章節(jié)。
- HeaderElement,頁頭部分:顯示章節(jié)的標(biāo)題;繪制每一頁的頭部。
- LineElement,文字行部分:測量一行文字需要的字?jǐn)?shù);測量行高;繪制行文字;繪制筆記內(nèi)容;測量每一個字在屏幕中的位置,用于筆記功能;
- ImageElement,圖片部分:測量圖片的寬高;繪制圖片。
- FooterElement,頁尾部分:繪制每一頁的頁尾,包括進度,時間和電量。
//摘自 PageElement 的 onDraw 方法 @Override public void draw(Canvas canvas) { int index = drawPageIndex - startPageIndex; if (index < 0 || index >= pages.size()) return; BookPageData bookPageData = pages.get(index); int offsetX = bookSettingParams.paddingLeft; int offsetY = bookSettingParams.paddingTop; if (bookPageData == null) return; canvas.drawColor(bookSettingParams.getBgColor()); bookHeaderElement.setChapterTitle(bookPageData.getChapterName()); bookHeaderElement.setX(offsetX); bookHeaderElement.setY(offsetY); if (bookPageData.isChapterFirstPage()) { bookHeaderElement.drawFirstPage(canvas); } else { bookHeaderElement.draw(canvas); } bookFooterElement.setProgress(bookPageData.getPageIndex(), bookPageData.getPageNums()); bookFooterElement.setX(offsetX); bookFooterElement.setY(offsetY + getHeight() - bookFooterElement.getHeight()); bookFooterElement.draw(canvas); for (int i = 0; i < bookPageData.getDataList().size(); i++) { BookData bookData = bookPageData.getDataList().get(i); if (bookData instanceof BookLineData) { BookLineData bookLineData = (BookLineData) bookData; bookLineElement.setLineText(bookLineData.getContent()); bookLineElement.setX(bookLineData.getPosition().x); bookLineElement.setY(bookLineData.getPosition().y); bookLineElement.drawWithDigests(canvas, bookLineData, bookReaderView.getCurrentDigests(index)); //bookLineElement.draw(canvas); } else if (bookData instanceof BookImageData) { BookImageData bookImageData = (BookImageData) bookData; bookImageElement.setX(bookImageData.getPosition().x); bookImageElement.setY(bookImageData.getPosition().y); bookImageElement.syncDrawWithinBitmap(canvas, bookImageData, bookReaderView.getCacheBitmap(drawPageIndex)); } } }
將書頁分成幾部分組合起來可以有效的減少代碼的耦合,而且可以自由的控制每一部分的修改,添加和移除。比如當(dāng)以后我想要加個批注的功能,可以再添加一個新的 Element ,再復(fù)寫其測量方法和繪制方法,就可以很方便的使用了。
總結(jié)一下:
(1) PageElement 利用各個 Element 模塊將章節(jié)數(shù)據(jù)進行測量分頁,每一頁 PageData 記錄著 LineData,ImageData,HeaderData和FooterData信息。繪圖時需要將各個信息填入 Element 中
(2) 繪圖時調(diào)用 PageElement 的 draw 方法,其 draw 方法再調(diào)用 各個 Element 的 draw 方法以完成整個繪圖流程。
另外還需要提到的一點是閱讀器內(nèi)部維護了一個書頁的隊列,該隊列緩存了由三個章節(jié)數(shù)據(jù)轉(zhuǎn)化而來的書頁列表。比如說你正在閱讀第六章,那么隊列里面緩存的就是第五章,第六章和第七章的數(shù)據(jù),這樣就能實現(xiàn)上下章翻頁的無縫切換而不需要在翻至下一章時因為等待新的章節(jié)數(shù)據(jù)加載而中斷整個閱讀體驗。
/** * <p> * 章節(jié)緩存構(gòu)成方案如下: * | -6,-5,-4,-3,-2,-1,0 | 1,2,3,4,5,6,7,8,9 | 10,11,12,13,14,15 | = pages * | cacheChapter1 | cacheChapter2 | cacheChapter3 | * startPageIndex = pageIndex:-6 endPageIndex = pageIndex:16 * currentChapterStartIndex => pageIndex:1 => pages[7] * currentChapterEndIndex => pageIndex:10 => pages[16] * </p> */
書簽,筆記——記錄閱讀進度
書簽
書簽的本質(zhì)就是記錄當(dāng)前頁的第一個文字在整章文本的位置,然后再加上書籍的id,章節(jié)的id(或序號)就能準(zhǔn)確定位。
筆記
要記錄筆記就需要文字選擇器來選擇文字,這個時候就需要知道每一個字在當(dāng)前的坐標(biāo)位置(之前用 LineElement 測量文字時已經(jīng)生成每個文字的位置)。
為了達到上圖的效果,就必須要處理在當(dāng)前頁的觸摸事件:
文字選擇流程
有些細(xì)節(jié)的處理沒有放到流程中,但大致意思是能明白的
// TextSelectorElement 上的觸摸分發(fā)方法 public boolean dispatchTouchEvent(final MotionEvent ev) { int key = ev.getAction(); currentTouchPoint.set(ev.getX(), ev.getY()); switch (key) { case MotionEvent.ACTION_DOWN: isPressInvalid = false; hasConsume = true; isDown = true; mTouchDownPoint.set(ev.getX(), ev.getY()); // 該方法中會記錄isBookDigestDown的值 checkIsPressDigests(ev.getX(), ev.getY()); //判斷是否處于選擇模式 if (!isSelect) { if (isBookDigestDown == 0) { postLongClickPerform(0);//提交長按時間 } } else { // 判斷是否觸摸到選擇光標(biāo)上,若是則可以拖動光標(biāo)移動 checkCurrentMoveCursor(ev); } break; case MotionEvent.ACTION_MOVE: float move = PointF.length(ev.getX() - mTouchDownPoint.x, ev.getY() - mTouchDownPoint.y); if (move > moveSlop) { isPressInvalid = true; } if (isPressInvalid) { removeLongPressPerform(); if (isSelect) { // 關(guān)閉彈窗(包括筆記編輯框等) onCloseView(); // 移動光標(biāo) onMove(ev); } else { //未處于選擇模式下,相當(dāng)于一個普通的點擊事件 onPress(ev); } } break; case MotionEvent.ACTION_UP: hasConsume = false; removeLongPressPerform(); if (isSelect) { // -1 表示為未觸摸到光標(biāo) if (moveCursor == -1) { // 取消選擇模式 setSelect(false); hasConsume = true; } else { //停止移動時,會打開筆記生成彈框 onOpenDigestsView(); } moveCursor = -1; } else { if (isBookDigestDown == 1) { onOpenNoteView(); hasConsume = true; } else if (isBookDigestDown == 2) { onOpenEditView(); hasConsume = true; } else { // 模擬成一個普通的點擊事件,會取消當(dāng)前的選擇模式 onPress(ev); } } invalidate(); break; case MotionEvent.ACTION_CANCEL: hasConsume = false; removeLongPressPerform(); break; default: break; } // 判斷選擇器是否消耗了當(dāng)前事件 return hasConsume || isSelect; }
當(dāng)然,筆記也要記錄當(dāng)前選擇的書籍id,章節(jié)id(或序號),文字在章節(jié)中的位置這些信息,方便定點跳轉(zhuǎn)。
設(shè)置——為閱讀器添磚加瓦
閱讀器設(shè)置界面
閱讀器的設(shè)置一般包括:界面亮度的調(diào)整,字體大小的調(diào)整,上下章的跳轉(zhuǎn),書籍目錄筆記和書簽的展示,翻頁動畫的更改,日夜主題的更改。當(dāng)一些設(shè)置需要閱讀器能夠在參數(shù)變化時及時響應(yīng),就得需要在設(shè)置變化時能及時更新 BookReaderView 下的各個 Element 模塊。
這里我是通過一個輔助類貫穿整個閱讀器來幫助更新各個模塊,該類記錄了閱讀器內(nèi)部所有可設(shè)置的屬性,當(dāng)各個模塊被通知需要更新時重新從該類中讀取參數(shù)并設(shè)置(比如畫筆的顏色,頁面的間距,字體的大小等)。
// 摘自 PageElement 下的設(shè)置屬性變化方法 // BookSettingParams 即為記錄閱讀器設(shè)置屬性的輔助類 @Override public void update(ReaderSettingParams params) { bookSettingParams = (BookSettingParams) params; bookHeaderElement.update(bookSettingParams); bookFooterElement.update(bookSettingParams); bookLineElement.update(bookSettingParams); bookImageElement.update(bookSettingParams); initPageElement(); }
語音朗讀——為閱讀器添加輔助功能
語音朗讀
此處的語音朗讀使用的是訊飛的TTS引擎。如何使用引入TTS我這里就不具體描述了,重要的是在TTS的 onSpeakProgress(int progress, int beginPos, int endPos) 方法中可以獲取當(dāng)前句子的朗讀進度。
當(dāng)我們傳入一章文字時,TTS會自動幫助我們分段(會以,。等標(biāo)點符號切割整篇文字),然后按段落來進行朗讀。上面 progress 代表該段落在整篇文字的進度,beginPos 代表該段落的起始字符在整篇文字的位置,endPos 代表該段落的末尾字符在整篇文字的位置。
既然能夠知道朗讀的位置,那就能知道朗讀時文字在屏幕的位置了(之前有說過 LineData 記錄了每個字符在屏幕中的位置),那剩下的就是怎么繪制的問題了。
/** * <p> * 聽書tts播放模組 * * @author cpacm 2017/12/13 */ public class BookSpeechElement extends ResElement implements SynthesizerListener { // .... 省略部分代碼 // 從每一頁數(shù)據(jù) PageData 中的 LineData 列表中獲取要繪制的區(qū)域 private void updateDrawRect(int startPos, int endPos) { if (endPos <= offsetPosition || endPos == this.endPos) return; this.endPos = endPos; this.tempPos = startPos; int s = this.startPos + startPos + bookPageData.getStartPos() - offsetPosition; int e = this.startPos + endPos + bookPageData.getStartPos() - offsetPosition; drawRect.clear(); for (BookLineData line : lineData) { if (line.startPos > e || line.endPos <= s) continue; if (line.startPos <= s && line.endPos <= e) { Rect startRect = line.getCharArea().get(s); Rect endRect = line.getCharArea().get(line.endPos - 1); Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom); drawRect.add(rect); } if (line.startPos > s && line.endPos <= e) { Rect startRect = line.getCharArea().get(line.startPos); Rect endRect = line.getCharArea().get(line.endPos - 1); Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom); drawRect.add(rect); } if (line.startPos > s && line.endPos > e) { Rect startRect = line.getCharArea().get(line.startPos); Rect endRect = line.getCharArea().get(e); Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom); drawRect.add(rect); } if (line.startPos <= s && line.endPos > e) { Rect startRect = line.getCharArea().get(s); Rect endRect = line.getCharArea().get(e); Rect rect = new Rect(startRect.left, startRect.top, endRect.right, endRect.bottom); drawRect.add(rect); } } // 刷新當(dāng)前書頁 bookReaderView.flashCurrentPageSnapshot(); } @Override public void draw(Canvas canvas) { if (!isSpeaking()) return; for (Rect rect : drawRect) { canvas.drawLine(rect.left, rect.bottom, rect.right, rect.bottom, paint); } } @Override public void destroy() { exitTts(); } /*################## 語音合成的回調(diào) ###################*/ @Override public void onSpeakBegin() {} @Override public void onBufferProgress(int progress, int beginPos, int endPos, String info) { } @Override public void onSpeakPaused() {} @Override public void onSpeakResumed() {} @Override public void onSpeakProgress(int progress, int beginPos, int endPos) { // 根據(jù)朗讀的進度更新UI updateDrawRect(beginPos, endPos); } @Override public void onCompleted(SpeechError speechError) {} @Override public void onEvent(int i, int i1, int i2, Bundle bundle) {} }
總結(jié)
首先聲明一點,整篇文章只是闡述了我自己從零開始做書籍閱讀器時一些思路和使用的一些技巧,并沒有覆蓋到閱讀器的各個角落。如果你想要自己實現(xiàn)一款閱讀器,那你必須要有扎實的基礎(chǔ)知識,比如View的繪制流程和事件分發(fā)流程,Canvas的繪圖知識等,這篇文章也只是給大家提個思路而已。如果有問題或者新的想法歡迎交流!
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
- Android WebView如何判定網(wǎng)頁加載的錯誤
- Android webView字體突然變小的原因及解決
- Android 解決WebView多進程崩潰的方法
- Android 中 WebView 的基本用法詳解
- 在Android環(huán)境下WebView中攔截所有請求并替換URL示例詳解
- 解決Android webview設(shè)置cookie和cookie丟失的問題
- Android實現(xiàn)閱讀進度記憶功能
- android閱讀器長按選擇文字功能實現(xiàn)代碼
- android仿新聞閱讀器菜單彈出效果實例(附源碼DEMO下載)
- Android實現(xiàn)閱讀APP平移翻頁效果
- Android編程實現(xiàn)小說閱讀器滑動效果的方法
- Android使用WebView實現(xiàn)離線閱讀功能
相關(guān)文章
Android實現(xiàn)CoverFlow效果控件的實例代碼
這篇文章主要介紹了Android實現(xiàn)CoverFlow效果控件的實例代碼,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-05-05Android實現(xiàn)可收縮和擴展的TextView
這篇文章主要為大家詳細(xì)介紹了Android實現(xiàn)可收縮和擴展的TextView,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-03-03Android編程實現(xiàn)保存圖片到系統(tǒng)圖庫的方法示例
這篇文章主要介紹了Android編程實現(xiàn)保存圖片到系統(tǒng)圖庫的方法,結(jié)合實例形式分析了Android保存圖片到系統(tǒng)圖庫的常見操作方法、注意事項與相關(guān)問題解決技巧,需要的朋友可以參考下2017-08-08Android實現(xiàn)網(wǎng)絡(luò)多線程斷點續(xù)傳下載實例
本示例介紹在Android平臺下通過HTTP協(xié)議實現(xiàn)斷點續(xù)傳下載。具有一定的參考價值,感興趣的小伙伴們可以參考一下。2016-10-10