手把手、腦把腦教你實現一個無限迴圈的輪播控制元件

抓瓦大叔發表於2019-03-04

人的理想志向往往和他的能力成正比。 —— 約翰遜

摘要

手把手、腦把腦教你實現一個無限迴圈的輪播控制元件

圖片輪播已經成為了很多App必備功能,且不說它具有炫酷的視覺效果,對於很多靠廣告收入的App來說,圖片輪播是必不可少的,因為它通過輪播減少了廣告位對介面的佔用。雖然圖片輪播非常的常用了,但是相信很多開發者對圖片輪播的實現還是一知半曉,作為一個有抱負、有追求的程式設計師,我們還是希望刨根問底,所以,必要時重複造下輪子還是有必要的,何況圖片輪播並沒有我們想象的那麼困難,尤其在Android技術如此成熟的今天,結合官方控制元件來實現還是非常容易的,當然,這篇文章是比較適合剛入門的Android開發者和初級Android開發者,我還是不敢在大牛面前班門弄斧的,希望大牛們多多包涵。

之前我釋出了一個開源的輪播控制元件AdPlayBanner,很多同學都說自己也想實現一個,但是不知道從何下手,本文標題是手把手教學,所以本文會用簡單粗俗的語言教大家,如何按照->->實現這個思路來解決我們所遇到的問題,希望做到真正授之以漁,而非授之以魚。

Stpe1.腦子想

在做任何東西之前的第一步,就是我們得在腦子裡有一個思考過程。

就像我們做這個圖片輪播,首先我們就會想,在Google提供的官方Api中,有沒有類似的控制元件已經有實現相似的功能?然後我們在腦子裡想啊想,終於,想到了兩個比較常用、比較流行的控制元件 ViewPagerRecyclerView尤其ViewPager,它已經基本實現了圖片輪播的功能,只是缺少了自動播放;而RecyclerView我們都知道,它已經支援了水平的瀑布流,大家試想,當我們將RecyclerView設為水平佈局,並且每一個item寬度為螢幕寬度,同樣我們也可以實現圖片輪播的功能。假如,你真的沒有想到這兩個控制元件,你可以通過自定義View來實現,當然,這個過程相對會比較複雜。

OK。在經歷了上面一段腦子思考之後,我就選擇了採用ViewPager來實現,因為它是最接近圖片輪播的一個官方控制元件。那麼我們還會想ViewPager距離我們理想的圖片輪播到底有多少差距,首先,它還不支援自動播放,其次,它並不能從最後一張滑動回第一張。

經歷完上面的腦力勞動之後,就該進行接下來一步,動手做!

Step2.動手做

從腦子想完之後,我們選擇了ViewPager來實現圖片輪播,但是面臨了兩個需要解決的問題:

  1. ViewPager如何實現自動播放?

  2. ViewPager如何實現從最後一張滑到第一張?

有了問題,我們就會想著怎麼去解決。

  • 第一個問題比較簡單, 我們都知道ViewPager的Api裡有一個方法叫做setCurrentItem(int position),顧名思義,就是設定當前的Item為資料來源的第position個資料,那麼我們就可以通過一個runnable的run()方法裡面呼叫這個方法,然後在每次頁面切換完成時,延時執行這個runnable即可。

  • 第二個問題會比較複雜,我們都知道ViewPager是無法從最後一頁設定到第一頁,但是,我們能不能將ViewPager的Adapter裡面設定它的size()為一個非常大的值呢?這樣我們就可以實現無限迴圈了。那我們怎麼保證資料的正確性呢?假如資料來源只有幾個資料,而Adapter裡面的size()非常大,我們就可以通過取餘的方式來保證滑動頁面一直對應著資料來源的幾個資料。還有就是,假如Adapter的size()非常大,我們在Adapter的instantiateItem(ViewGroup container, int position)中就會需要返回很多new出來的View,這樣子會造成不必要的記憶體浪費,所以,我們可以通過一個ArrayList來作為快取,當我們Adapter的destroyItem(ViewGroup container, int position, Object object)方法中,將廢棄的object存到快取中,重複利用,避免了記憶體浪費。

    這兩個問題就這樣輕鬆地被解決了,也許會有人問,為什麼這part叫動手做呢,不是想想就好了嗎?要知道,這是我已經想好的思路,假如你面對的是一個沒有接觸過的問題,假如你不動動手在紙上構思,你的空想並不能給你帶來什麼。

    那麼接下來,我們就該實現了!

    Step3.敲程式碼

    當你梳理清楚了前面兩步的問題,那麼當你敲程式碼實現的時候就非常簡單了。

    (1) 實現MyCircleBanner繼承ViewPager

    首先,實現一個類MyCircleBanner繼承於ViewPager,然後重寫構造方法。

    public class MyCircleBanner extends ViewPager {
        public MyCircleBanner(Context context) {
            super(context);
        }
    }
    複製程式碼

    (2) 實現一個ViewPager的Adapter

    首先,在MyCircleBanner實現一個內部類BannerAdapter繼承PagerAdapter,它要求我們必須重寫getCount()isViewFromObject(View view, Object object),並設定全域性變數mViewCachesmInfos,其中mViewCaches用以快取頁面沒被使用時被ViewPager置空的物件,mInfos作為資料來源集合。

    class BannerAdapter extends PagerAdapter{
    	private final ArrayList<Object> mViewCaches = new ArrayList<>();    //快取ViewPager廢棄的物件
        private List<String> mInfos;	//資料來源
    
        public BannerAdapter(List<String> mInfos) {
            this.mInfos = mInfos;
        }
    
        @Override
        public int getCount() {
            return 0;
        }
    
        @Override
        public boolean isViewFromObject(View view, Object object) {
            return false;
        }
    }
    複製程式碼

    在第二步中,我們討論到了一個問題,那就是,當ViewPager滑到最後一頁時無法滑到第一頁,所以我們可以再getCount()方法裡面下手,返回一個很大的值,我們取為Integer.MAX_VALUE,即2^31 - 1,非常大的一個數,足以模擬近乎無限迴圈,所以getCount()可以這麼實現:

    @Override
    public int getCount() {
        if (null != mInfos) {
            // 當只有一張圖片的時候,不滑動,返回1即可
            if (mInfos.size() == 1) {
                return 1;
            } else {
                // 否則迴圈播輪播,返回Int型的最大值
                return Integer.MAX_VALUE;
            }
        }
        else return 0;  // mInfos為空時返回0
    }
    複製程式碼

    isViewFromObject(View view, Object object)則直接這樣寫:

    @Override
    public boolean isViewFromObject(View view, Object object) {
        return view == object;
    }
    複製程式碼

    在BannerAdapter中,除了實現這兩個方法還不夠的,還需要實現instantiateItemdestroyItem這兩個方法。

    那麼這兩個方法是什麼意思呢?首先public Object instantiateItem(ViewGroup container, int position)這個方法是說,當ViewPager顯示初始化到該頁面時,需要執行的方法,我們可以看到引數有一個container,即整個ViewPager的外佈局,position就是初始化到該頁面的位置,並且需要我們返回一個Object型別,可以理解為返回我們顯示當前ViewPager頁面的View,做一個輪播圖,我們可以直接返回一個ImageView。而另一個方法void destroyItem(ViewGroup container, int position, Object object),就是當該頁面已經超出了使用者的可視範圍時,需要執行的方法。

    在實現這個方法之前,我們來講解一下ViewPager這兩個方法的執行機制:

    手把手、腦把腦教你實現一個無限迴圈的輪播控制元件

    所以ViewPager每一次都是隻有當前顯示頁和相鄰兩頁被初始化,試想,假如我們將size()設定到很大,我們一直向右滑,不斷執行instantiateItem方法,然後我們在instantiateItem方法裡不斷地new一個ImageView出來,要知道destroyItem預設是空實現,那麼就會有越來越多沒有用到的ImageView佔用了記憶體,所以這時做快取非常有必要,那麼這兩個方法的實現可以如下:

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        if (mInfos != null && mInfos.size() > 0) {
            ImageView imageView;
            // 當快取集合數量為0時
            if (mViewCaches.isEmpty()) {
                imageView = new ImageView(context);   // 新建一個ImageView
                // 設定ImageView的基本寬高,和ScaleType
                imageView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
                imageView.setScaleType(ImageView.ScaleType.FIT_XY);
            } else {
                // 當快取集合有資料時,複用,然後快取不再持有它的引用
                imageView = (ImageView) mViewCaches.remove(0);
            }
            // 使用Picasso載入網路圖片
            Picasso.with(context).load(mInfos.get(position % mInfos.size())).into(imageView);
    
            // 把ViewPager這個佈局載入ImageView進來
            container.addView(imageView);
            return imageView;
        } else {
            return null;
        }
    }
    
    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        // 當頁面不可見時,該View就會被ViewPager傳到這個方法的object中,我們拿到該object轉為ImageView
        ImageView imageView = (ImageView) object;
        // 在ViewPager佈局中移除這個view
        container.removeView(imageView);
        // 加到快取裡
        mViewCaches.add(imageView);
    }
    複製程式碼

    所以,我們的BannerAdapter也就大功告成,整個BannerAdapter的程式碼如下:

    class MyAdapter extends PagerAdapter {
        private final ArrayList<Object> mViewCaches = new ArrayList<>();     //快取ViewPager廢棄的物件
        private List<String> mInfos;    //資料來源
        private Context context;
    
        public MyAdapter(List<String> mInfos, Context context) {
            this.mInfos = mInfos;
            this.context = context;
        }
    
        @Override
        public int getCount() {
            if (null != mInfos) {
                // 當只有一張圖片的時候,不可滑動
                if (mInfos.size() == 1) {
                    return 1;
                } else {
                    // 否則迴圈播放滑動
                    return Integer.MAX_VALUE;
                }
            } else {
                return 0;  // mInfos為空時返回0
            }
        }
    
    
        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            if (mInfos != null && mInfos.size() > 0) {
                ImageView imageView;
                // 當快取集合數量為0時
                if (mViewCaches.isEmpty()) {
                    imageView = new ImageView(context);   // 新建一個ImageView
                    // 設定ImageView的基本寬高,和ScaleType
                    imageView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
                    imageView.setScaleType(ImageView.ScaleType.FIT_XY);
                } else {
                    // 當快取集合有資料時,複用,然後快取不再持有它的引用
                    imageView = (ImageView) mViewCaches.remove(0);
                }
                // 使用Picasso載入網路圖片
                Picasso.with(context).load(mInfos.get(position % mInfos.size())).into(imageView);
    
                // 把ViewPager這個佈局載入ImageView進來
                container.addView(imageView);
                return imageView;
            } else {
                return null;
            }
        }
    
        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            // 當頁面不可見時,該View就會被ViewPager傳到這個方法的object中,我們拿到該object轉為ImageView
            ImageView imageView = (ImageView) object;
            // 在ViewPager佈局中移除這個view
            container.removeView(imageView);
            // 加到快取裡
            mViewCaches.add(imageView);
        }
    
        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }
    }
    複製程式碼

    (3) 實現自動輪播功能

    首先需要實現一個Runnable任務,主要就是呼叫setCurrentItem()方法來設定ViewPager滑動到下一頁,當然要判斷一些極端case,例如滑動到最右邊時,處理為返回到第一個。

    getInitPosition()則是獲取到從0-Integer.MAX_VALUE的中間左右位置,該位置並要和資料來源的第一個元素取餘為0,這樣就保證了ViewPager預設是從0-Integer.MAX_VALUE的中間位置開始滑動,使得它左右都可以實現近乎無限迴圈滑動。

    startAdvertPlay()則是把任務延時加到任務佇列,這裡設定延時3s,stopAdvertPlay()則是在ViewPager被Destroy時,清空任務佇列。

    /**
     * 自動播放任務
     */
    private Runnable mImageTimmerTask = new Runnable() {
        @Override
        public void run() {
            if (mSelectedIndex == Integer.MAX_VALUE) {
                // 當滑到最右邊時,返回返回第一個元素
                // 當然,幾乎不可能滑到
                int rightPos = mSelectedIndex % mInfos.size();
                setCurrentItem(getInitPosition() + rightPos + 1, true);
            } else {
                // 常規執行這裡
                setCurrentItem(mSelectedIndex + 1, true);
            }
        }
    };
    
    /**
     * 獲取banner的初始位置,即0-Integer.MAX_VALUE之間的大概中間位置
     * 保證初始位置和資料來源的第1個元素的取餘為0
     *
     * @return
     */
    private int getInitPosition() {
        if (mInfos.isEmpty()) {
            return 0;
        }
        int halfValue = Integer.MAX_VALUE / 2;
        int position = halfValue % mInfos.size();
    	// 保證初始位置和資料來源的第1個元素的取餘為0
        return halfValue - position;
    }
    
    /**
     * 開始廣告滾動任務
     */
    private void startAdvertPlay() {
        stopAdvertPlay();
        mUIHandler.postDelayed(mImageTimmerTask, 1000);
    }
    
    /**
     * 停止廣告滾動任務
     */
    private void stopAdvertPlay() {
        mUIHandler.removeCallbacks(mImageTimmerTask);
    }
    複製程式碼

    (4) 設定ViewPager的監聽器

    實現OnPageChangeListener可以完成自動輪播功能,當ViewPager每次切換介面完成時都會執行三個方法,之所以在onPageScrollStateChanged()方法裡面呼叫startAdvertPlay()是因為當手指按下ViewPager時,我們不會執行這個任務,只有當手指離開ViewPager時,才會執行。

    /**
     * 輪播圖片狀態監聽器
     */
    private OnPageChangeListener mOnPageChangeListener = new OnPageChangeListener() {
    
        @Override
        public void onPageSelected(int position) {
            // 獲取當前的位置
            mSelectedIndex = position;
        }
    
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        }
    
        @Override
        public void onPageScrollStateChanged(int state) {
    		// 當手指離開螢幕時,才會執行
            if (state == ViewPager.SCROLL_STATE_IDLE) {
                startAdvertPlay();
            }
        }
    };
    複製程式碼

    (5) 提供MyCircleBanner呼叫介面

    在完成了上面介面和方法實現之後,那麼就需要在MyCircleBanner內提供介面,傳入資料即可實現輪播控制元件自動播放。

    public void play(List<String> mInfos) {
        if (null != mInfos && mInfos.size() > 0) {
            this.mInfos = mInfos;
            mUIHandler = new Handler(Looper.getMainLooper());
            // new一個Adapter
            MyAdapter adapter = new MyAdapter(mInfos, getContext());
            // 設定adapter
            setAdapter(adapter);
            // 設定監聽器
            addOnPageChangeListener(mOnPageChangeListener);
            // 設定預設位置為中間位置
            setCurrentItem(getInitPosition());
            if (mInfos.size() >= 1) {
                // 開始自動播放
                startAdvertPlay();
            }
        }
    }
    複製程式碼

    所以整個MyCircleBanner的程式碼是這樣的:

    public class MyCircleBanner extends ViewPager {
        private int mSelectedIndex = 0;     // 當前下標
        private Handler mUIHandler;
        private List<String> mInfos = new ArrayList<>();
    
    
        public MyCircleBanner(Context context) {
            this(context, null);
        }
    
        public MyCircleBanner(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        public void play(List<String> mInfos) {
            if (null != mInfos && mInfos.size() > 0) {
                this.mInfos = mInfos;
                mUIHandler = new Handler(Looper.getMainLooper());
                // new一個Adapter
                MyAdapter adapter = new MyAdapter(mInfos, getContext());
                // 設定adapter
                setAdapter(adapter);
                // 設定監聽器
                addOnPageChangeListener(mOnPageChangeListener);
                // 設定預設位置為中間位置
                setCurrentItem(getInitPosition());
                if (mInfos.size() >= 1) {
                    // 開始自動播放
                    startAdvertPlay();
                }
            }
        }
    
        /**
         * 輪播圖片狀態監聽器
         */
        private OnPageChangeListener mOnPageChangeListener = new OnPageChangeListener() {
    
            @Override
            public void onPageSelected(int position) {
                Log.d("TAG", position + "");
                // 獲取當前的位置
                mSelectedIndex = position;
            }
    
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            }
    
            @Override
            public void onPageScrollStateChanged(int state) {
                if (state == ViewPager.SCROLL_STATE_IDLE) {
                    startAdvertPlay();
                }
            }
        };
    
        /**
         * 自動播放任務
         */
        private Runnable mImageTimmerTask = new Runnable() {
            @Override
            public void run() {
                if (mSelectedIndex == Integer.MAX_VALUE) {
                    // 當滑到最右邊時,返回返回第一個元素
                    // 當然,幾乎不可能滑到
                    int rightPos = mSelectedIndex % mInfos.size();
                    setCurrentItem(getInitPosition() + rightPos + 1, true);
                } else {
                    // 常規執行這裡
                    setCurrentItem(mSelectedIndex + 1, true);
                }
            }
        };
    
    
        /**
         * 獲取banner的初始位置,即0-Integer.MAX_VALUE之間的大概中間位置
         * 保證初始位置和資料來源的第1個元素的取餘為0
         *
         * @return
         */
    
        private int getInitPosition() {
            if (mInfos.isEmpty()) {
                return 0;
            }
            int halfValue = Integer.MAX_VALUE / 2;
            int position = halfValue % mInfos.size();
            // 保證初始位置和資料來源的第1個元素的取餘為0
            return halfValue - position;
        }
    
        /**
         * 開始廣告滾動任務
         */
        private void startAdvertPlay() {
            stopAdvertPlay();
            mUIHandler.postDelayed(mImageTimmerTask, 1000);
        }
    
        /**
         * 停止廣告滾動任務
         */
        private void stopAdvertPlay() {
            mUIHandler.removeCallbacks(mImageTimmerTask);
        }
    }
    複製程式碼

    (6) 用法

    到此為止,自定義的輪播控制元件已經完成,我們只要在Xml裡面新增該控制元件,像這樣:

    <com.ryane.teach_circlebanner.MyCircleBanner
        android:id="@+id/mBanner"
        android:layout_width="match_parent"
        android:layout_height="200dp" />
    複製程式碼

    然後,在Activity的oncreate()方法中:

    mBanner = (MyCircleBanner) findViewById(R.id.mBanner);
    
    // 設定資料來源
    List<String> mInfos = new ArrayList<>();
    mInfos.add("http://onq81n53u.bkt.clouddn.com/photo1.jpg");
    mInfos.add("http://onq81n53u.bkt.clouddn.com/photo2.jpg");
    
    // 使用mBanner的介面,直接自動播放 
    mBanner.play(mInfos);
    複製程式碼

    那麼,一個輪播控制元件就完成了。

    手把手、腦把腦教你實現一個無限迴圈的輪播控制元件

    後記

    到此為止,相信大家已經可以自己實現一個圖片輪播了,我把自己的實現過程完整地告訴大家,也是希望大家能夠在遇到問題時,能夠踐行->->實現這個過程,如何能夠靜下心來,認真地走這個過程,那麼我想很多困難都迎刃而解。

    當然,這個Demo只是一個比較簡略的實現,在這裡強烈安利一波我的一個開源控制元件:

    AdPlayBanner:功能豐富、一鍵式使用的圖片輪播外掛

相關文章