文章出處

2016-6-19

前言

View輪播效果在app中很常見,一想到左右滑動的效果就很容易想到使用ViewPager來實現。對于像我們常說的banner這樣的效果,具備無限滑動的功能是可以用ViewPager實現的,不過使用ViewFlow更簡單些。
最近項目里的一個頁面的banner功能出了問題,使用的是viewPager + handler實現的,之前的代碼實在是設計的過于復雜,就自己重新實現了一遍。整體來說,ViewPager可以實現無限滾動,但方式比較繞。

ViewPager的使用

首先來簡單概括下ViewPager的使用。

1.編寫PagerAdapter。

需要實現PagerAdapter中以下方法:

  • Object instantiateItem(ViewGroup container, int position)
    ViewPager每次最多需要保持1-3個View,此方法就是我們提供page view的地方。生成的View對象一定要添加到container中才可以正常顯示。返回的Object對象是和此View關聯的一個自定義對象(類似View.setTag),比如可以把一個對應View的數據對象返回。一般的,沒有特殊需要時,我們返回View對象本身。

  • boolean isViewFromObject(View view, Object object)
    就是指示ViewPager中的View對象和instantiateItem返回的Object對象的關系。如果在instantiateItem我們返回的是View本身,那么此處return view == object就可以。

  • void destroyItem(ViewGroup container, int position, Object object)
    要知道PagerView是每次最多顯示3個page view的,為了像ListView對應的BaseAdapter那樣復用View對象,此方法為我們提供了回收添加到ViewPager中的不再顯示的對象的方式。
    object就是instantiateItem返回的對象,container、position正是instantiateItem的position,container。
    根據前面的分析,在destroyItem中,我們把position處的page view從container移除即可,此處的object對象正是instantiateItem中add到container的page view對象。執行完container.removeView((View) object)后,可以使用一個List來維護回收的View,這樣可以避免創建大量的View對象——就像ListView的BaseAdapter那樣——轉而使用List中的可服用View對象,確切的說,如果展示的是同一“類型”的視圖(布局orView),那么最多需要4個View對象,我們就可以滿足ViewPager的顯示需要了。

  • public int getCount()
    返回ViewPager要展示的page view的總數量。ViewPager的左右滑動正是根據getCount()以及當前展示的page的位置來控制的。

2. ViewPager和PagerAdapter關聯同步

ViewPager和PagerAdapter的關系就如同ListView和BaseAdapter的關系,是視圖和視圖數據適配器的關系——滿滿都是模式。

  1. ViewPager.setAdapter(PagerAdapter adapter)
    首先把創建好的PagerAdapter對象設置給ViewPager對象,這樣,它們就關聯了。ViewPager就展示了此PagerAdapter的數據。
  2. ViewPager.setCurrentItem(int item)
    設置viewPager當前展示的page位置,默認是0。

  3. PagerAdapter.notifyDataSetChanged()
    當PagerAdapter的數據發生改變時,必須執行此方法和關聯的ViewPager進行同步,否則運行中會產生異常。
    不過PagerAdapter不像BaseAdapter那樣,notifyDataSetChanged方法在UI表現上是有問題的,建議每次數據發生變化后,直接使用setAdapter重新關聯。原因下面會有說明

實現無限滑動的思路

典型的,為了讓ViewPager可以無限滑動,我們讓getCount返回一個很大的值,例如Integer.MAX_VALUE,然后setCurrentItem把ViewPager顯示的當前Page設置在總頁數的中間位置。
思路如上,下面給出完整的代碼:

...
import com.nostra13.universalimageloader.core.DisplayImageOptions;
import com.nostra13.universalimageloader.core.ImageLoader;

public class BannerPagerAdapter extends PagerAdapter {
    private ArrayList<ImageView> reusableImgViews = new ArrayList<>();
    private ArrayList<String> bannerPicList = new ArrayList<>();
    private Activity activity;
   
    private DisplayImageOptions displayImageOptions = new DisplayImageOptions.Builder()
            .cacheInMemory(true).cacheOnDisk(true)
            .bitmapConfig(Bitmap.Config.RGB_565)
            .resetViewBeforeLoading(true)
            .considerExifParams(true)
            .build();

    public BannerPagerAdapter(Activity activity) {
        bannerPicList.add("http://img1.gtimg.com/auto/pics/hv1/63/227/1381/89857473.jpg");
        bannerPicList.add("http://images.cnitblog.com/i/316630/201408/092010425847554.png");
        bannerPicList.add("http://img5.imgtn.bdimg.com/it/u=854234410,2851953187&fm=15&gp=0.jpg");
        bannerPicList.add("http://img0.imgtn.bdimg.com/it/u=1615470112,4224934998&fm=15&gp=0.jpg");

        this.activity = activity;
    }

    @Override
    public int getCount() {
        return Integer.MAX_VALUE;
    }

    public int getStartPageIndex() {
        int index = getCount() / 2;
        int remainder = index % bannerPicList.size();
        index = index - remainder;
        return index;
    }

    @Override
    public boolean isViewFromObject(View view, Object object) {
        return view == object;
    }

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        ImageView imgView;
        if (reusableImgViews.size() == 0) {
            imgView = new ImageView(activity);
            imgView.setScaleType(ImageView.ScaleType.FIT_XY);
        } else {
            imgView = reusableImgViews.remove(reusableImgViews.size() - 1);
        }
        
        String url = bannerPicList.get(getBannerIndexOfPosition(position));
        ImageLoader.getInstance().displayImage(url, imgView, displayImageOptions);
        
        container.addView(imgView);
        return imgView;
    }

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        container.removeView((View) object);
        reusableImgViews.add((ImageView) object);
    }

    private int getBannerIndexOfPosition(int position) {
        return position % bannerPicList.size();
    }
}

在Activity的onCreate中:

void onCreate(Bundle savedInstanceState) {
    ...
    viewPager = (ViewPager) findViewById(R.id.banner_viewpager);
    BannerPagerAdapter adapter = new BannerPagerAdapter(this);
    viewPager.setAdapter(adapter);
    viewPager.setCurrentItem(adapter.getStartPageIndex());
    ...    
}

以上代碼實現簡單的無限滑動足夠了,但是,ViewPager有幾個局限性,甚至是坑值得注意。

ViewPager的局限性

1. setCurrentItem卡頓

當getCount返回的頁數非常大的時候,比如10億,調用setCurrentItem會引起ANR。這個和getCount以及當前的page位置有關。通過查看源碼可以發現,ViewPager中的populate(int newCurrentItem)calculatePageOffsets(ItemInfo curItem, int curIndex, ItemInfo oldCurInfo)這兩個方法中,有for循環的執行次數和getCount成正比,具體細節有興趣的朋友可以觀察源碼。
經過我的實驗,在pageCount非常大的時候,setCurrentItem方法如果引起ViewPager的頁碼切換跨度大于1時,就會引起明顯的卡頓。正巧的是,我們使用ViewPager實現滑動效果(handler自動++或--頁碼)的時候,每次頁碼僅僅是增加或者減小1,所以不會卡頓。但是,如果代碼中有邏輯setCurrentItem引起頁碼變化大于1,比如當前在第3頁,直接切換到getCount() / 2頁時,直接就ANR了。
有意思的是,在onCreate中setAdapter之后,第一次viewPager.setCurrentItem(adapter.getStartPageIndex())并不會引起ANR,應該是onCreate時ViewPager還沒有執行一些內部計算的原因。

setCurrentItem引起的ANR和是否指定第二個參數smoothScroll沒有關系。

2. notifyDataSetChanged后滑動效果不對

這個情況是UI表現上,ViewPager的左右滑動效果的小bug。
在正常使用ViewPager,沒有任何無限滑動的邏輯的情況下:
假設第一次setAdapter的時候,getCount返回1,此時ViewPager只有一個page,不可以左右滑動。
然后改變Adapter對象的內部數據集合大小,getCount返回3,notifyDataSetChanged后,此時可以滑動3個頁面。
接下來再修改數據集合,讓getCount返回1,notifyDataSetChanged后,此時按期望,ViewPager是不可以滑動的,但是,實際效果是:ViewPager可以滑動——看得見之前3頁時的額外View——看到1個還是2個和——notifyDataSetChanged時ViewPager的正在顯示的page有關,但是無法滑動到除position為1的其它頁碼。

大家有興趣可以自己試下,解決方法很奇葩:
就是每次adapter的數據發生變化后,根據需要先setCurrentItem到默認起始位置,之后執行setAdapter就行。PagerAdapter的notifyDataSetChanged并不像它應該承諾的那樣,而為了實現在Adapter數據發生變化后通知更新ViewPager的目的:需要再次執行viewPager.setAdapter(adapter)

3. 關于viewPager設計的吐槽

ViewPager顯然是按照了ListView那樣的方式來計算總頁數的,但是對于一個每次只顯示3頁的View來說,每次左滑和右滑的時候調用一個讓子類重寫的判斷是否還有左邊page view和右邊page view的方法豈不更好?
setCurrentItem里面的邏輯簡直了,竟然和getCount成正比耗費時間,那就只能當設計者根本沒有考慮使用此View在非常大量數據的情況了!真不知道ViewPager是性能卓越了,還是功能豐富了,比起ViewFlow,不知道它多出那么多代碼的情況下,還有notifyDataSetChanged和setAdapter的UI表現不同這樣的狗血。

更好的無限滑動的解決方案

由于ViewPager的總頁數很大時對setCurrentItem造成的限制。需要避免getCount返回很大值來實現可以“無限”左右滑動的假象。

1. getCount、getPageIndexOfPosition

getCount返回一個很小的值,例如360來讓viewPager保證可以左右滑動就行。這里假設實際有n個View,那么getCount返回n + 2就可以了,但是,為了避免頻繁的setCurrentItem來重置當前頁,這個值用不著太小。
舉個例子,對于有n = 5個View需要通過ViewPager來實現無限滑動的情況,getCount返回300,那么在instantiateItem等地方,需要根據ViewPager顯示的page的position來得到實際的數據集合里顯示的數據的索引:
getPageIndexOfPosition方法的邏輯很直接:

public int getItemIndexForPosition(int position) {
    return position % data.size();
}

position就是ViewPager對應展示的page view的位置,position和要展示的數據集合的大小的余數就是對應數據集合的數據的索引。

2. setCurrentItem重置viewPager的當前頁

當getCount返回一個不是很大的值的時候,ViewPager很快就會到達左右邊界,就無法繼續滑動了。
解決方式是在ViewPager快要切換到邊界時,使用setCurrentItem把它重置回中間位置。
為ViewPager提供繼承自SimpleOnPageChangeListener的類的對象:

viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
    @Override
    public void onPageSelected(int position) {
        if (position <= 2 || position >= adapter.getCount() - 3) {
            // 重置頁面
            int page = adapter.getItemIndexForPosition(position);
            int newPosition = adapter.getStartPageIndex() + page;
            viewPager.setCurrentItem(newPosition);
        }
    }
 });

注意2點:

  • 重置前后顯示的實際數據的位置需要保持不變。

  • 如果考慮到用戶體驗,為了保證滑動過程中切換page不是非常生硬,可以先setCurrentItem到newPosition +/- 1位置,之后再setCurrentItem(newPosition, true)動畫滑動到正確位置。

上面就通過減少getCount的值,結合setCurrentItem完成了ViewPager的無限滑動。

自動輪播

使用handler的sendEmptyMessageDelayed很容易讓ViewPager以固定頻率自帶切換頁面。這里強調下,使用線程當然也可以,就是性能上看,避免線程來完成這種“定時”效果——大材小用,Thread是為了不卡頓主線程執行耗時的操作,簡單的定時操作handler消息輪詢就可以了,app中不要讓thread泛濫。

這里handler的所有操作都應該在UI線程中被調用,沒有同步的必要:

class AutoScrollHandler extends Handler {
    boolean pause = false;

    @Override
    public void handleMessage(Message msg) {
        if (!pause) {
            viewPager.setCurrentItem(viewPager.getCurrentItem() + 1);
        }
        sendEmptyMessageDelayed(msg.what, 3000);
    }

    void startLoop() {
        pause = false;
        removeCallbacksAndMessages(null);
        sendEmptyMessageDelayed(1, 3000);
    }

    void stopLoop() {
        removeCallbacksAndMessages(null);
    }
}

上面pause是為了實現在手指拖拽ViewPager的時候暫停自動輪播,在SimpleOnPageChangeListener中:

@Override
public void onPageScrollStateChanged(int state) {
    switch (state) {
        case ViewPager.SCROLL_STATE_DRAGGING:
            autoSkipHandler.pause = true;
            break;
        case ViewPager.SCROLL_STATE_IDLE:
            autoSkipHandler.pause = false;
            break;
    }
}                

總結

在要展示的View為1個時,沒有必要滑動的。
當界面不可見時,可以暫停自動輪播。這樣,在onPause和onResume中stopLoop和startLoop,一些情況下onStart和onStop是不執行的。
ViewPager本身的局限性是不適合超大量數據,當然這個假設在實際中又幾乎不成立,即便是百萬級別的view要展示,viewPager還是不會卡頓。
這里強調的是:既然ViewPager每次只展示最多3個page,而且左右滑動的邏輯可以在每次滑動時進行檢查,那么對于任意大的數據集合,它都應該不會卡頓。而且,沒有必要在非常大的頁碼跨度的情況下執行那些根本看不出差別的滑動效果!
實現一個自己的可切換顯示View的ViewGroup不是什么難事。最好的,ViewFlow就有這種內置的無限循環滑動的效果,而且自帶了簡單的pageIndicator那樣的小圓點效果。
項目地址是:https://github.com/pakerfeldt/android-viewflow
非常建議使用。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()