開源界有一句很有名的話叫“不要重複發明輪子”,當然,我今天的觀點不是要反駁這句話,輪子理論給我們的開發帶來了極大的便利,專案中要實現一些功能,便去網上找找,一般推薦使用一些有名的庫,我本身也是這麼做的,但我想說的是,既要會用輪子,也要知道輪子怎麼造,必要的時候,自己也要造輪子(想要找到一個完全滿意的輪子還是不大容易的)。
由來
之前專案裡面都是用的daimajia的AndroidImageSlider,一開始被驚豔的動畫切換效果吸引了,還有各種自定義屬性動畫啥的,感覺很棒,但隨著專案的進展和時間的推移,我慢慢發現它也不是無所不能的,甚至我發現它只是好看,卻不怎麼實用,我列舉一些我發現的問題:
- 庫中整合了Picasso,Picasso是極其耗記憶體的,這點我在之前的一篇為什麼圖片載入我首先Glide 提到過,當一個頁面既有廣告又有列表(列表中也一般有圖片)會造成頁面嚴重卡頓。
- 動畫效果太炫,實際專案中一個沒用到,一般用標準模式。
- 太多的依賴包,現在的許多專案都是基於android4.0以上版本開發的,nineoldandroid已經不需要了
- 頁面點選事件不直觀,如果有那種像ListView的OnItemClickListener就好了
總而言之,我感覺它現在已經有些臃腫。當然,我也在網上尋找更簡潔實用的替代庫,比如BGABanner-Android,但事實也是殘酷的,不支援網路圖片,還有一個坑等著我跳,看程式碼片段
1 2 3 |
if (mAutoPlayAble && views.size() < 3) { throw new IllegalArgumentException("開啟指定輪播時至少有三個頁面"); } |
低於3張圖片會直接拋異常,這叫我怎麼用,需求也不是我能控制的。
當然以上兩個庫的作者我都很喜歡,這兩個庫也非常不錯,不然也不值得我花時間研究。
我對輪子的要求
結合自己的理解,我認為兩個庫中都有可取之處,也有不足之處,我就取長補短,造自己的輪子,我對這個輪子的要求:
- 無限輪播
- 自動加手動滑動
- 簡單的自定義指示器樣式及位置
- 支援本地圖片及網路圖片
- 滑動流暢,無卡頓,無閃爍
- 廣告頁面不限制個數
- 頁面點選監聽事件
- 簡單易用,高配置,無明顯bug
動畫效果我先打算拋棄了,預設的就好,以實用為主。
開始動手,step by step
系統可以滑動翻頁的控制元件就只有ViewPager和ViewFliper網上還有大神實現用RecyclerView實現了類似ViewPager的效果,這裡暫不做過多研究,這裡就選擇使用最多ViewPager作為滑動翻頁控制元件,使用ViewPager+PagerAdapter可以很容易的實現翻頁切換效果,但存在幾個弊端:
- 不能無限輪迴的翻頁(滑到第一個或者最後一個就不能繼續滑)
- 切換速度太快,系統預設250毫秒,用做廣告欄切換會存在明顯閃爍
- 僅支援手動滑動,不支援自動切換
- 沒有提供直接的類似ListView的OnItemClickListener監聽,使用起來很不方便
當然還會有其他的坑,遇到了再解決,先解決上面的問題
無限輪播效果
這裡採用網上通用的解決辦法,偽輪播(讓使用者看到輪播的假象,實際上用了很多頁面在不斷重複出現,如果使用者滑動幾十億下是可以滑到頭,實際幾乎不可能有人這麼做)看具體實現程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
public class LoopPagerAdapter extends PagerAdapter { private List<View> views; public LoopPagerAdapter(List<View> views) { this.views = views; } @Override public int getCount() { //Integer.MAX_VALUE = 2147483647 return Integer.MAX_VALUE; } @Override public boolean isViewFromObject(View view, Object object) { return view == object; } @Override public Object instantiateItem(ViewGroup container, int position) { if (views.size() > 0) { //position % view.size()是指虛擬的position會在[0,view.size())之間迴圈 View view = views.get(position % views.size()); if (container.equals(view.getParent())) { container.removeView(view); } container.addView(view); return view; } return null; } @Override public void destroyItem(ViewGroup container, int position, Object object) { } } |
還需要做一件事情,就是設定當前position為中間的一個較大的值,如果不設定或者設定的比較小,往左滑動容易滑倒頭
1 |
pager.setCurrentItem(Integer.MAX_VALUE / 2 - Integer.MAX_VALUE / 2 % views.size()); |
改變原生ViewPager切換速度
通過反射拿到ViewPager的滑動器mScroller,改變duration引數,看程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
public void setSliderTransformDuration(int duration) { try { Field mScroller = ViewPager.class.getDeclaredField("mScroller"); mScroller.setAccessible(true); FixedSpeedScroller scroller = new FixedSpeedScroller(pager.getContext(), null, duration); mScroller.set(pager, scroller); } catch (Exception e) { } } //FixedSpeedScroller.java public class FixedSpeedScroller extends Scroller { //預設1秒,可以通過上面的方法控制 private int mDuration = 1000; public FixedSpeedScroller(Context context) { super(context); } public FixedSpeedScroller(Context context, Interpolator interpolator) { super(context, interpolator); } public FixedSpeedScroller(Context context, Interpolator interpolator, int duration){ this(context,interpolator); mDuration = duration; } @Override public void startScroll(int startX, int startY, int dx, int dy, int duration) { // Ignore received duration, use fixed one instead super.startScroll(startX, startY, dx, dy, mDuration); } @Override public void startScroll(int startX, int startY, int dx, int dy) { // Ignore received duration, use fixed one instead super.startScroll(startX, startY, dx, dy, mDuration); } } |
自動切換實現
這裡可以有多種方式,使用Handler或者Timer都可以的,這裡採用handler實現,isAutoPlay可以控制是否禁止控制元件自動輪播,autoPlayDuration是輪播間隔時間,還需注意觸控時應當停止輪播,放開恢復正常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
/** * 開始自動輪播 */ public void startAutoPlay() { if (isAutoPlay) { handler.sendEmptyMessageDelayed(WHAT_AUTO_PLAY, autoPlayDuration); } } /** * 停止自動輪播 */ public void stopAutoPlay() { if (isAutoPlay) { handler.removeMessages(WHAT_AUTO_PLAY); } } @Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: stopAutoPlay(); break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: startAutoPlay(); break; } return super.dispatchTouchEvent(ev); } |
先附上一張預覽圖:
![](https://i.iter01.com/images/268d449e4d2577ea77631537d9cd2faaedc7094136835cb6f2ec155cf23a3b09.gif)
先寫這麼多吧,後續完整程式碼我會上傳到github,如果大家有興趣,我會抽時間寫剩下的內容,這個控制元件的程式碼也是借鑑了很多優秀的開源庫,並結合自己的理解寫的。
炫麗的效果固然吸引人眼球,平凡實用的東西才愈久彌香
完整程式碼已上傳到github:BannerLayoutDemo
新增指示器–繪製指示器樣式
github上也有各種各樣很棒的指示器,可作為獨立控制元件,這裡我先簡單處理直接整合到內部,後期有需求再進行重構,最簡單的指示器是用兩張不同狀態的小圖片做的,但我認為這樣做相對實現是簡單的,但對於修改卻顯得有些麻煩,適配也是問題,簡單修改何必大動干戈呢?
這裡借鑑了daimajia的思路,指示器是用程式碼繪出來的,怎麼繪呢?看程式碼,以選中的狀態為例,繪製一次便可,將drawable存起來使用:
1 2 3 4 5 6 7 8 9 10 |
Drawable selectedDrawable; GradientDrawable selectedGradientDrawable = new GradientDrawable(); //設定指示器的顏色 selectedGradientDrawable.setColor(selectedIndicatorColor); //設定指示器的形狀 selectedGradientDrawable.setShape(GradientDrawable.RECTANGLE); //設定指示器的大小 selectedGradientDrawable.setSize(selectedIndicatorWidth, selectedIndicatorHeight); selectedLayerDrawable = new LayerDrawable(new Drawable[]{selectedGradientDrawable}); selectedDrawable = selectedLayerDrawable; |
這樣指示器的形狀,大小,顏色可以隨意換了,支援換膚也更容易
新增指示器–位置
大家都知道在xml檔案中使用RelativeLayout父佈局可以控制子佈局的位置,用程式碼怎麼去做呢?首先,BannerLayout是繼承與RelativeLayout的,看程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 |
RelativeLayout.LayoutParams params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); switch (indicatorPosition) { case centerBottom://下中 params.addRule(RelativeLayout.CENTER_HORIZONTAL); params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); break; case rightBottom://右下 params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); break; } //新增指示器容器佈局到BannerLayout addView(indicatorContainer, params); |
兩個屬性必須分開新增不能寫成
1 |
params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT|RelativeLayout.ALIGN_PARENT_BOTTOM); |
如何設定指示器margin和padding這裡就不再多說
切換指示器
我之前的想法是這樣的,不用迴圈做,只需知道上一個選中的頁面和即將要跳轉的頁面位置即可,實現思路是這樣的
1 2 3 4 5 6 7 |
private void switchIndicator(int currentPosition) { if (oldPosition != -1){ ((ImageView)indicatorContainer.getChildAt(oldPosition)).setImageDrawable(unSelectedDrawable); } ((ImageView)indicatorContainer.getChildAt(currentPosition)).setImageDrawable(selectedDrawable); oldPosition = currentPosition; } |
但實際的執行效果卻可能出現錯亂的現象,不知是哪裡出了問題,目前就採用了迴圈來做,後期可能會改進,這樣做雖然簡單,但效率始終不高
1 2 3 4 5 |
private void switchIndicator(int currentPosition) { for (int i = 0; i < indicatorContainer.getChildCount(); i++) { ((ImageView) indicatorContainer.getChildAt(i)).setImageDrawable(i == currentPosition ? selectedDrawable : unSelectedDrawable); } } |
新增頁面點選監聽回撥
用法就像ListView的setOnItemClickListener一樣,來看看程式碼如何實現
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private OnBannerItemClickListener onBannerItemClickListener; //給每個頁面新增點選事件 imageView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (onBannerItemClickListener != null) { //不直接處理點選事件,轉交給onBannerItemClickListener onBannerItemClickListener.onItemClick(position); } } }); public void setOnBannerItemClickListener(OnBannerItemClickListener onBannerItemClickListener) { this.onBannerItemClickListener = onBannerItemClickListener; } public interface OnBannerItemClickListener { void onItemClick(int position); } |
使用
1 2 3 4 5 6 |
bannerLayout.setOnBannerItemClickListener(new BannerLayout.OnBannerItemClickListener() { @Override public void onItemClick(int position) { //處理點選事件 } }); |
遇到的一些坑
上篇講到了BGABanner-Android,部分思想也是參考這個庫的,例如指示器位置的處理方案,但我要說的坑也在這裡,低於3張圖片就會直接拋異常,我試著將異常不丟擲看看,一張或者兩張圖片的時候切換效果慘不忍睹(我猜想和ViewPager的懶載入機制有關),所以作者處理為直接拋異常,但這樣不行啊,需求不可控制啊,必須解決這個問題,不然就像定時炸彈。
問題解決思路:既然輪播是偽的,圖片的張數也可以是偽的,只需要給使用者看起來是那樣就行了,1張也可以是3×1張相同的圖片,2張也可以是2×2張相同圖片,3張及以上沒問題就無需處理,關鍵程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
//新增本地圖片路徑 public void setViewRes(List<Integer> viewRes) { List<View> views = new ArrayList<>(); itemCount = viewRes.size(); //主要是解決當item為小於3個的時候滑動有問題,這裡將其拼湊成3個以上 if (itemCount < 1) {//當item個數0 throw new IllegalStateException("item count not equal zero"); } else if (itemCount < 2) {//當item個數為1 views.add(getImageView(viewRes.get(0), 0)); views.add(getImageView(viewRes.get(0), 0)); views.add(getImageView(viewRes.get(0), 0)); } else if (itemCount < 3) {//當item個數為2 views.add(getImageView(viewRes.get(0), 0)); views.add(getImageView(viewRes.get(1), 1)); views.add(getImageView(viewRes.get(0), 0)); views.add(getImageView(viewRes.get(1), 1)); } else { for (int i = 0; i < viewRes.size(); i++) { views.add(getImageView(viewRes.get(i), i)); } } setViews(views); } |
新增網路地址和本地的思路差不多,使用的是Glide來處理載入網路圖片,還有就是新增各種自定義屬性。
github地址BannerLayoutDemo。
完整程式碼實現請看原始碼,歡迎fork和star以及提出你寶貴的意見