文章目录
- Android ViewPager2 实现阅读器横向翻页效果(三)--- 实时动态分页及章节切换效果的原理及实现
- 关键概念引入
- 初始数据准备
- ViewPager Adapter 动态分页 及 第一次分页
- 分页后更新窗口 及 首页尾页的特殊处理
- 翻页状态监听 及 动态章节切换
Android ViewPager2 实现阅读器横向翻页效果(三)— 实时动态分页及章节切换效果的原理及实现
本系列最终效果图如下:
本文实现的效果如下:
关键概念引入
在第二章中,我们引入了Java Bean NovelContentPage,它可以表示文本分页之后一个页面的文字内容,也可用通过isTempPage属性表示一个完整的(临时的)章节,如下图所示:
那么这些NovelContentPage,还需要一个类来存放和管理它们,这里我们就使用一个窗的概念(Window of Pages)来封装我们的页面:
Class NovelPageWindow
public class NovelPageWindow {
private ArrayList<NovelContentPage> pages;
private boolean isSingleWindow = true;//表示当前Window仅装载一个章节的内容
private int chapID;//仅single window有效
public ArrayList<NovelContentPage> getPages() {
return pages;
}
public void setPages(ArrayList<NovelContentPage> pages) {
this.pages = pages;
}
public int getPageNum(){
return pages!=null?pages.size():1;
}
public boolean isSingleWindow() {
return isSingleWindow;
}
public void setSingleWindow(boolean singleWindow) {
isSingleWindow = singleWindow;
}
public int getChapID() {
return chapID;
}
public void setChapID(int chapID) {
this.chapID = chapID;
}
}
从意义上来说,NovelPageWindow存放的是当前ViewPager将要展示的页面内容,根据实际可能的情形,Window可分为如下几种:
为表征当前页面相对于缓冲区(临时页)的位置,映入枚举类型WindowType
private enum WindowType{left,right,match}
其中,left和right主要是为了在用户翻到本章节的首页或尾页时,向ViewPager追加内容,以实现跨章节翻页的无缝衔接。
为了对window中的页面有清晰的索引,我们需要引入两个索引变量:
private int window_page_index;//页面在整个window中的索引
private int chap_page_index;//页面在自身章节中的索引
其对应关系如下图所示:
下面,我将以一组章节为例,按照动态分页的逻辑顺序来进行讲解:
初始数据准备
String content = "第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n"
+"第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n第一章第一章第一章第一章第一章第一章第一章\n";
String content2 = "第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n"
+"第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n第二章第二章第二章第二章第二章第二章第二章\n";
String content3 = "第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n"
+"第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n第三章第三章第三章第三章第三章第三章第三章\n";
NovelContentPage full_content1 = new NovelContentPage();
full_content1.setPage_id(0);full_content1.setPage_content(content);full_content1.setTempPage(true);full_content1.setBelong_to_chapID(0);
NovelContentPage full_content2 = new NovelContentPage();
full_content2.setPage_id(1);full_content2.setPage_content(content2);full_content2.setTempPage(true);full_content2.setBelong_to_chapID(1);
NovelContentPage full_content3 = new NovelContentPage();
full_content3.setPage_id(2);full_content3.setPage_content(content3);full_content3.setTempPage(true);full_content3.setBelong_to_chapID(2);
ArrayList<NovelContentPage> pages;
NovelPageWindow chap1 = new NovelPageWindow();
pages = new ArrayList<>();pages.add(full_content1);
chap1.setPages(pages);
NovelPageWindow chap2 = new NovelPageWindow();
pages = new ArrayList<>();pages.add(full_content2);
chap2.setPages(pages);
NovelPageWindow chap3 = new NovelPageWindow();
pages = new ArrayList<>();pages.add(full_content3);
chap3.setPages(pages);
chapList.add(chap1);
chapList.add(chap2);
chapList.add(chap3);
准备三个章节的文字内容,将它们分别以临时页的形式装入NovelContentPage中,再将这三个临时Page装入三个window中,最后用一个ArrayList chapList来存放所有的三个章节。
接下来进行章节初始化:
选定上述三个章节中的其中一章作为起始章节,显然当前的windowType是Match,当前的window是single window:
chap_index = 0;
chap_page_index = 0
window_page_index = chap_page_index;
currentNovelChap = chapList.get(chap_index);
windowType = WindowType.match;
currentNovelChap.setSingleWindow(true);
当前的窗口如下图所示
ViewPager Adapter 动态分页 及 第一次分页
为了保证分页的准确性和灵活性,并且能适应动态的字体调整,这里选择本系列第二章所讲的Attach后分页的方式。这就要求预先绘制TextView再进行分页。所以我们采用在adapter中先用临时页进行绘制,在进行分页后覆盖的方式:
public class PageAdapter extends RecyclerView.Adapter<PageAdapter.PageViewHolder> {
private ArrayList<NovelContentPage> pages;
private Context context;
private NovelPageWindow chap;
private PageListener pageListener;
public PageAdapter(Context context, NovelPageWindow novelPageWindow){
this.chap = novelPageWindow;
this.context = context;
}
public void setPageListener(PageListener pageListener) {
this.pageListener = pageListener;
}
@NonNull
@Override
public PageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
PageViewHolder pageViewHolder = new PageViewHolder(LayoutInflater.from(context).inflate(R.layout.page_layout, parent, false));
return pageViewHolder;
}
@Override
public void onBindViewHolder(@NonNull PageViewHolder holder, int position) {
pages = chap.getPages();
NovelContentPage novelContentPage = pages.get(position);
holder.tv_content.setText(novelContentPage.getPage_content());
holder.tv_id.setText(String.valueOf(novelContentPage.getPage_id()));
if (novelContentPage.isTempPage()) {
Log.d("bind view","found temp page: "+position);
holder.tv_content.post(()->{
ArrayList<NovelContentPage> page = PageSplit.getPage(novelContentPage.getPage_content(), holder.tv_content);
if (chap.isSingleWindow())pages = page;
else {
int i = pages.indexOf(novelContentPage);
pages.addAll(i,page);
pages.remove(novelContentPage);
}
chap.setPages(pages);
if (pageListener!=null)pageListener.onPageSplitDone(chap,page,novelContentPage.getBelong_to_chapID());
});
}
}
@Override
public int getItemCount() {
return chap.getPageNum();
}
class PageViewHolder extends RecyclerView.ViewHolder {
public RelativeLayout mContainer;
public TextView tv_id;
public TextView tv_content;
public PageViewHolder(@NonNull View itemView) {
super(itemView);
tv_id = itemView.findViewById(R.id.page_id);
tv_content = itemView.findViewById(R.id.page_content);
mContainer = itemView.findViewById(R.id.card_container);
}
}
public interface PageListener{
void onPageSplitDone(NovelPageWindow update_chap, ArrayList<NovelContentPage> new_pages, int chapID);
}
}
其中的重点在onBindViewHolder,对tv_content设置好临时的长章节文本后,利用post方法,在TextView绘制后对其进行分页。并且将分页的结果利用PageListener接口进行回调。
分页后此时的窗口如下图所示:
分页后更新窗口 及 首页尾页的特殊处理
分页后,分页的信息由PageListener接口回调,要更新窗口则需要实现onPageSplitDone方法,如下:
pageListener = (update_chap, pages1, chapID) -> {
chapList.get(chapID).setPages(pages1);
//根据chap_page_index 调整window_page_index
int total_chap = currentNovelChap.getPageNum();
int total_window = pageView.getAdapter().getItemCount();
switch(windowType){
case left:{
NovelPageWindow left_chap = chapList.get(chap_index - 1);
window_page_index = chap_page_index + left_chap.getPageNum();
}
break;
case right:{
window_page_index = chap_page_index;
}
break;
case match:{
window_page_index = chap_page_index;
if (chap_page_index!=0 && init_page)init_page = false;
}
break;
default:
}
pageView.post(()->{
PageAdapter adapter = new PageAdapter(context,update_chap);
adapter.setName("split update");
adapter.setPageListener(pageListener);
pageView.setAdapter(adapter);
pageView.setCurrentItem(window_page_index,false);
});
Log.d("page split",String.format("chap %d/%d",chap_page_index, total_chap -1));
Log.d("page split",String.format("window %d/%d",window_page_index, total_window -1));
}
分页后首先要根据当前页相对于新分页内容的位置,更新窗口页面索引window_page_index,这里在初次分页时,窗口类型为match无需对索引进行更改。接下来需要更新PageView以应用分页结果,注意这里一定不能用notifyDataSetChanged()方法进行更新,这样会导致部分页面未加载而产生空白页,必须新建一个adapter对原来的进行覆盖,该操作需要在原布局绘制完成后再进行。
下面是使用notifyDataSetChanged()方法和新建adapter覆盖的效果比较:
如果当前页正好是一章的开头或者结尾,则需要提前将上一章/下一章的临时页添加到窗口中:
pageListener = (update_chap, pages1, chapID) -> {
//窗口更新部分同上,省略
...
//首页尾页的特殊处理
if (chap_page_index == 0 && window_page_index == 0){
Log.d("page split","start from the first page of a chap");
if(chap_index > 0){
ArrayList<NovelContentPage> temp_pages = new ArrayList<>(chapList.get(chap_index - 1).getPages());
window_page_index += temp_pages.size();
temp_pages.addAll(currentNovelChap.getPages());
NovelPageWindow novelPageWindow = new NovelPageWindow();
novelPageWindow.setPages(temp_pages);
novelPageWindow.setSingleWindow(false);
after_update = true;
PageAdapter adapter = new PageAdapter(context, novelPageWindow);
adapter.setPageListener(pageListener);
adapter.setName("split addon");
pageView.post(()->{
pageView.setAdapter(adapter);
});
if (init_page)init_page = false;
windowType = WindowType.left;
}
else {
Log.d("page split","start from the first page of the novel");
windowType = WindowType.match;
if (init_page)init_page = false;
}
}
if (chap_page_index == (total_chap-1) && window_page_index == (total_window-1)){
Log.d("page split","start from the last page of a chap");
if((chap_index+1)<chapList.size()){
window_page_index = chap_page_index;
ArrayList<NovelContentPage> temp_pages = new ArrayList<>(currentNovelChap.getPages());
temp_pages.addAll(chapList.get(chap_index+1).getPages());//single page
NovelPageWindow novelPageWindow = new NovelPageWindow();
novelPageWindow.setPages(temp_pages);
novelPageWindow.setSingleWindow(false);
after_update = true;
PageAdapter adapter = new PageAdapter(context, novelPageWindow);
adapter.setPageListener(pageListener);
pageView.post(()->{
pageView.setAdapter(adapter);
});
if (init_page)init_page = false;
windowType = WindowType.right;
}
else {
Log.d("page split","start from the last page of the novel");
windowType = WindowType.match;
if (init_page)init_page = false;
}
}
};
此时的窗口如下图所示:
结合上一节讲过的adapter的内容,可知,==pageView.post()==执行后,新增的临时页将被分解为一组属于上一章的新的页面。但注意此时的窗口类型不再是match,而是根据临时页的位置,被设定为left或者right,第二次分页后窗口索引需要随之更改。分页后的窗口如下图所示:
翻页状态监听 及 动态章节切换
完成了初始章节的分页后,接下来需要更具用户的翻页情况,实时获取当前的页数章节数,并及时新增章节,以达到流畅的阅读体验。关于页面切换监听,可以查阅谷歌官方文档如下:
我们只需要实现其中的onPageSelected 和 onPageScrollStateChanged两个方法,翻页过程中其调用顺序为:onPageScrollStateChanged(SCROLL_STATE_DRAGGING) -> onPageScrollStateChanged(SCROLL_STATE_SETTLING) -> onPageSelected -> onPageScrollStateChanged(SCROLL_STATE_IDLE)
要重点注意的是:onPageSelected只有翻到一个新的页时才会回调,若翻到一半再翻回去则不会触发。这种情况只会调用onPageScrollStateChanged。
于是,我们在onPageSelected回调时更新 window_page_index 和 chap_page_index 两个索引;在onPageScrollStateChanged回调时作章节切换的判断,代码如下:
pageView.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
if (init_page)return;
if (after_update){
after_update = false;
return;
}
System.out.println("position = " + position);
int total_window_pages = pageView.getAdapter().getItemCount();
int total_chap_pages = currentNovelChap.getPageNum();
window_page_index = position;
switch(windowType){
case left:{
NovelPageWindow left_chap = chapList.get(chap_index - 1);
chap_page_index = position - left_chap.getPages().size();
}
break;
case right:{
chap_page_index = position;
}
break;
case match:chap_page_index = position;
break;
default:
}
Log.d("page change",String.format("window %d/%d",window_page_index, total_window_pages-1));
Log.d("page change",String.format("chap %d/%d",chap_page_index, total_chap_pages-1));
}
@Override
public void onPageScrollStateChanged(int state) {
super.onPageScrollStateChanged(state);
if (state == SCROLL_STATE_IDLE){
int total_window_pages = pageView.getAdapter().getItemCount();
int total_chap_pages = currentNovelChap.getPages().size();
if (window_page_index == total_window_pages -1){
Log.d("page change","reach the last page of the window");
if ((chap_index+1)>=chapList.size()){
Log.d("page change","also the last chap of the book!");
return;
}
window_page_index = chap_page_index;
ArrayList<NovelContentPage> temp_pages = new ArrayList<>(currentNovelChap.getPages());
temp_pages.addAll(chapList.get(chap_index+1).getPages());//single page
NovelPageWindow novelPageWindow = new NovelPageWindow();
novelPageWindow.setPages(temp_pages);
novelPageWindow.setSingleWindow(false);
after_update = true;
PageAdapter adapter = new PageAdapter(context, novelPageWindow);
adapter.setPageListener(pageListener);
pageView.post(()->{
pageView.setAdapter(adapter);
pageView.setCurrentItem(window_page_index,false);
});
windowType = WindowType.right;
}
if (window_page_index == 0){
Log.d("page change","reach the first page of the window");
if ((chap_index-1)<0){
Log.d("page change","also the first chap of the book!");
return;
}
ArrayList<NovelContentPage> temp_pages = new ArrayList<>(chapList.get(chap_index - 1).getPages());
window_page_index += temp_pages.size();
temp_pages.addAll(currentNovelChap.getPages());
NovelPageWindow novelPageWindow = new NovelPageWindow();
novelPageWindow.setPages(temp_pages);
novelPageWindow.setSingleWindow(false);
after_update = true;
PageAdapter adapter = new PageAdapter(context, novelPageWindow);
adapter.setPageListener(pageListener);
pageView.post(()->{
pageView.setAdapter(adapter);
pageView.setCurrentItem(window_page_index,false);
});
windowType = WindowType.left;
}
if (chap_page_index == total_chap_pages){
Log.d("page change","start the new chap (next)"+(chap_index+1));
chap_index++;
if (chap_index>=chapList.size())return;
currentNovelChap = chapList.get(chap_index);
chap_page_index = 0;
windowType = WindowType.left;
}
if (chap_page_index == -1){
Log.d("page change","start the new chap (last)"+(chap_index-1));
chap_index--;
if (chap_index < 0)return;
currentNovelChap = chapList.get(chap_index);
chap_page_index = currentNovelChap.getPages().size()-1;
windowType = WindowType.right;
}
}
}
});
回调函数onPageSelected中的索引更新逻辑与上一节中window_page_index索引更新的逻辑类似,这里不再赘述。
回调函数onPageScrollStateChanged中,状态 SCROLL_STATE_IDLE 表示翻页动作完全结束,此时通过window_page_index索引来判断是否到达了窗口的第一页或最后一页,此时就需要将新的临时页面添加到窗口中来,为保证窗口不会过大,需要丢弃旧缓存,如下图所示:
回调函数onPageScrollStateChanged中,我们还通过chap_page_index索引来判断是否跨章,需要注意的是,跨章后窗口的类型需要根据缓存相对于当前页面的位置进行更新。
接下来,上图中的临时页会在adapter绘制时被分页,并重新分配窗口,一切都循环起来,便实现了流畅的翻页过程。
最后,别忘了将上述的adpter,listner添加到pageView对象中,并按照本系列第一章所述的为pageview添加PageTransformer:
PageAdapter adapter = new PageAdapter(this,currentNovelChap);
adapter.setPageListener(pageListener);
pageView.setAdapter(adapter);
pageView.setOffscreenPageLimit(5);
pageView.setCurrentItem(window_page_index,false);
pageView.setPageTransformer(new ViewPager2.PageTransformer() {
@Override
public void transformPage(@NonNull View page, float position) {
TextView viewId = page.findViewById(R.id.page_id);
String s_id = viewId.getText().toString();
if (position <= 0.0f) {
page.setTranslationX(0.0f);
page.setTranslationZ(0.0f);
} else {
page.setTranslationX((-page.getWidth() * position));
page.setTranslationZ(-position);
}
}
});