[Android]使用HorizontalScrollView實現廣告欄Banner及相關原理分析
現在的App中,廣告欄Banner的使用還是挺廣泛的,用於展示各種廣告、活動推薦等。使用HorizontalScrollView可以很簡單的實現一個可自動播放、可滑動、可點選的廣告欄Banner,這個也可以做為一個例子,來學習自定義控制元件的製作。相關原理主要包括兩個方面:
- onMeasure、onLayout、onDraw等View、ViewGroup相關佈局函式;
- dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等觸控事件處理函式(包括Click等事件觸發);
這兩部分搞懂後,製作自定義控制元件就得心應手了。
一、需求
- 控制元件每次展示一張圖片,隔一段時間換播下一張,如果當前是最後一張則展示第一張;
- 使用者手指觸控控制元件,停止輪播,使用者滑動手指,則根據方向展示相應下一張或前一張圖片;
- 如果是第一張圖片繼續往前滑動,需要展示最後一張圖片,如果最後一張圖片往後滑動,展示第一張圖片;
- 最下方需要有小白點來指示當前是第幾張圖片;
最終效果如圖:
二、初設計
HorizontalScrollView本來就是一個橫向滑動元件,使用它可以很方便的實現滑動及相應的動畫效果,所以選擇用它來寫這個控制元件,我看網上也有使用ViewPager實現,原理都是大同小異;下面是按上面的需求做的初始設計,在實現的過程中還會碰到其他問題,需要按情況解決。
佈局
HorizontalScrollView——LinearLayout——ImageView List
同時需要在HorizontalScrollView上畫小白點指示當前頁定時滾動
新增一個定時器,每隔一段時間滑動到下一頁,注意最後一頁的迴圈處理。使用者事件
新增事件監控,觸控停止定時器及滑動事件處理
三、具體實現
1. 設計佈局
xml中的佈局只有最外層控制元件,其他的LinearLayout和ImageView都是動態新增進去的,實現如下:
public class ADPager extends HorizontalScrollView {
private LinearLayout container = null;
private LinearLayout.LayoutParams imgLayoutParams = null;
public ADPager(Context context) {
super(context);
init();
}
private void init(){
Context ctx = getContext();
container = new LinearLayout(ctx);
ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
container.setLayoutParams(layoutParams);
//橫向佈局
container.setOrientation(LinearLayout.HORIZONTAL);
imgLayoutParams = new LinearLayout.LayoutParams(getWidth(),getHeight());
this.addView(container);
this.setSmoothScrollingEnabled(true);
//不顯示滑動條
this.setHorizontalScrollBarEnabled(false);
}
public void setImageList(int imgArray[]){
int size = imgArray.length;
if(size > 1){
//如果大於一張圖片,第一張前放最後一張圖片
this.container.addView(makeImageView(imgArray[size - 1]));
}
for(int imgId:imgArray){
this.container.addView(makeImageView(imgId));
}
if(size > 1){
//如果大於一張圖片,最後一張後放第一張圖片
this.container.addView(makeImageView(imgArray[0]));
}
}
public ImageView makeImageView(int resourceId){
ImageView imageView;
Context ctx = getContext();
imageView = new ImageView(ctx);
imageView.setImageResource(resourceId);
imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
imageView.setLayoutParams(imgLayoutParams);
return imageView;
}
在onCreate中呼叫初始化圖片:
int imgIdArray[] = {R.drawable.img1,R.drawable.img2,R.drawable.img3};
ADPager adPager = (ADPager)findViewById(R.id.adpager);
adPager.setImageList(imgIdArray);
然後執行就碰到了第一個坑,根本沒有圖片被展示出來,原因是:在初始化時,我們嘗試使用getWidth和getHeight函式來獲取寬度和高度,然後設定圖片大小,但在View還沒有展示出來時,其實通過這兩個函式是不能獲取寬高的,比如在onCreate/onStart/onResume中,詳見:
Activity中獲取view的高度和寬度為0的原因以及解決方案
在上面的文章中,也提到了幾種獲取的方式,但我們是自定義控制元件,還有他方式來獲取:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
imgLayoutParams.width = getMeasuredWidth();
imgLayoutParams.height = getMeasuredHeight();
}
這種方法使用了onMeasure函式,現在只要知道這個函式是用來測量自己及子View的大小就可以了,後面還會系統總結。
現在已經可以展示出圖片,且可以自由滑動,當然,現在還簡陋的很:
只不過有多張圖片時,我們顯示的是最後一張圖片,是因為我們為了第一張圖片還可以往前滑動,在前面新增的,所以我們需要在初始時,滑動到第一張圖片展示:
public void scrollToPage(int page,boolean isSmooth){
if(page < 0){
page = mTotalSize - 1;
}else if(page >= mTotalSize){
page = 0;
}
//設定當前頁
mCurrPage = page;
int width = getWidth();
//因為第一張前面加了一張,所以頁數需要+1。而只有一張圖片時,scrollTo其實沒有產生效果
if(isSmooth){
this.smoothScrollTo((page + 1) * width, 0);
}else{
this.scrollTo((page + 1) * width, 0);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
scrollToPage(0,false);
}
2. 新增小白點
小白點用來指示當前是哪張圖片,位於中間下方,且不隨圖片滑動而移動。所以只能畫在最上層的HorizontalScrollView上。這裡我們在onDraw畫控制元件函式中,直接在畫版上畫:
private void initPaint(){
mStrokePaint = new Paint();
//抗鋸齒
mStrokePaint.setAntiAlias(true);
//空心線寬
mStrokePaint.setStrokeWidth(1.0f);
//中空
mStrokePaint.setStyle(Paint.Style.STROKE);
//顏色
mStrokePaint.setColor(Color.WHITE);
mFillPaint = new Paint();
mFillPaint.setAntiAlias(true);
mFillPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mFillPaint.setColor(Color.WHITE);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
int width = getWidth();
float density = getContext().getResources().getDisplayMetrics().density;
//半徑轉換為畫素
int radiusInPixel = (int)(CIRCLE_RADIUS * density);
//白點間隔
int margin = radiusInPixel;
//白點區域總寬度
int totalWidth = radiusInPixel * 2 * mTotalSize + margin * (mTotalSize - 1);
//初始第一個點位置
int offsetX = getScrollX() + width / 2 - totalWidth / 2 + radiusInPixel;
int offsetY = (int)(getHeight() - density * 10 - radiusInPixel);
//開始畫點
for(int i = 0;i < mTotalSize; i++){
if(i == mCurrPage){
canvas.drawCircle(offsetX,offsetY,radiusInPixel,mFillPaint);
}else{
canvas.drawCircle(offsetX,offsetY,radiusInPixel,mStrokePaint);
}
offsetX += radiusInPixel * 2 + margin;
}
}
可以看到我程式碼中其實是使用dispatchDraw來畫的,而不是上面說的onDraw函式。是因為我在實現時又踩了一個坑,因為View會先呼叫onDraw來畫自己的東西,然後呼叫dispatchDraw去畫孩子(當然,View沒有孩子,這個只有在ViewGroup中才有用)
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
如果在onDraw中去畫點,則會被後來畫的孩子遮擋住,這個可以將container不新增到父節點中去來測試,可以看到我們畫的圓。所以應該在dispatchDraw中,畫完孩子然後去畫點。
當然,在網上也看到如果沒有背景會跳過onDraw直接呼叫dispatchDraw的說法,實驗結果並不是這樣。
3. 自動滑動
這個比較簡單,不過其中也碰到了一個坑,記憶體洩漏問題。這個可以看下前面的一個雜記:Android記憶體洩露雜記2016-02-26
具體就是匿名Runnable引用外部資料,後來使用WeakReference解決,程式碼如下:
public static class AutoPlayRunable implements Runnable{
private WeakReference<ADPager> reference = null;
public AutoPlayRunable(ADPager adPager){
reference = new WeakReference<ADPager>(adPager);
}
@Override
public void run() {
ADPager adPager = reference.get();
if(adPager != null){
int page = adPager.getCurrPage();
adPager.scrollToPage(page + 1,true);
adPager.postDelayed(adPager.getAutoPlayRunnable(),AUTO_PLAY_DUATION);
}
}
}
現在廣告就可以自動滾動起來了:
4.觸控事件
但是現在還無法通過觸控來順暢控制廣告移動,就和上圖一樣。因為HorizontalScrollView自己處理了觸控事件,通過手指來自由滑動。但這不是我們想要的結果,我們需要的是通過觸控,可以左右滑動,但超過一半,就應該顯示下一張,或者沒超過一半退回,而不是停在中間。然後就是手指滑動的夠快,就算不超過一半也需要到下一張,就和有慣性一樣。想要實現這樣的結果,我們需要重寫觸控事件處理,幸運的是,Android是支援這樣做的。
這需要使用到幾個觸控事件介面,並對其流程足夠了解。一共涉及3個介面,如下:
View裡,有兩個回撥函式 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onTouchEvent(MotionEvent ev);
ViewGroup裡,有三個回撥函式 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onInterceptTouchEvent(MotionEvent ev);
3. public boolean onTouchEvent(MotionEvent ev);
在Activity裡,有兩個回撥函式 :
1. public boolean dispatchTouchEvent(MotionEvent ev);
2. public boolean onTouchEvent(MotionEvent ev);
事件傳遞預設是從父節點開始,直到傳遞到View。也就是說傳遞過程是Activity-ViewGroup-View。
觸控事件是由一系列的ACTION_DOWN、ACTION_MOVE…MOVE…MOVE、ACTION_UP的過程
對上面的介面來說,事件包含三個處理方式,一是分發(dispatchTouchEvent),二是攔截(onInterceptTouchEvent),一個是消費(onTouchEvent),並都有其返回值。
- 分發: dispatchTouchEvent返回true則順序下發會中斷(一般表示事件被消費),後續節點接收不到事件(分發是深度優先的),返回false繼續分發
- 攔截: onInterceptTouchEvent比較複雜,return true可以攔截DOWN、MOVE、UP事件
- 攔截DOWN事件,則表示事件完全由當前ViewGroup來處理,後續MOVE、UP事件也會來找當前ViewGroup
- 攔截MOVE、UP事件,則表示後續事件由當前ViewGroup來處理,之前處理事件的View會收到一個ACTION_CANCEL事件
- 消費: onTouchEvent事件,如果前面沒有被攔截:
- View(如果可點選)預設返回true,表示消費事件
- 消費Down事件,則後續MOVE、UP事件都會來找當前View
- 沒消費Down事件,則其他事件也沒有你什麼事了,不會傳遞給你的
- 許多事件依賴於onTouchEvent處理UP事件,如Click事件
配上虛擬碼:
View mTarget=null;//儲存捕獲Touch事件處理的View
public boolean dispatchTouchEvent(MotionEvent ev) {
//....其他處理,在此不管
if(ev.getAction()==KeyEvent.ACTION_DOWN){
//每次Down事件,都置為Null
if(!onInterceptTouchEvent()){
mTarget=null;
View[] views=getChildView();
for(int i=0;i<views.length;i++){
if(views[i].dispatchTouchEvent(ev))
mTarget=views[i];
return true;
}
}
}
//當子View沒有捕獲down事件時,ViewGroup自身處理。這裡處理的Touch事件包含Down、Up和Move
if(mTarget==null){
return super.dispatchTouchEvent(ev);
}
//...其他處理,在此不管
if(onInterceptTouchEvent()){
//...其他處理,在此不管
}
//這一步在Action_Down中是不會執行到的,只有Move和UP才會執行到。
return mTarget.dispatchTouchEvent(ev);
}
現在我們就可以來想上面的問題了:
- 很明顯,ScrollView通過處理Move事件,來進行子View的滑動及動畫,所以不能動這個事件;
- 然後還需要處理UP事件來確定手指擡起後,展示哪張圖片;
- 最後,還需要得到使用者手指滑動的速度,速度快則直接展示下一頁;
- 還有別忘了,手指落下時移除自動播放,擡起時開始自動播放。(考慮一下,為什麼移除自動播放不能放在onTouchEvent事件中去處理?)
最後程式碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
//停止自動播放
removeCallbacks(mAutoPlayRunnable);
break;
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if(mVelocityTracker == null){
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
switch (ev.getAction()){
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
int width = getWidth();
//計算手指每秒移動畫素
mVelocityTracker.computeCurrentVelocity(1000,2000);
float speedX = mVelocityTracker.getXVelocity();
int page;
if(Math.abs(speedX) > 1000){
//移動速度夠大
page = scrollX / width;
if(speedX > 0){
page -= 1;
}
}else{
//緩慢移動,按當前哪張圖展示多就顯示哪張
page = (int)Math.round(scrollX * 1.0 / width) - 1;
}
scrollToPage(page, true);
//開啟自動播放
postDelayed(mAutoPlayRunnable,AUTO_PLAY_DUATION);
//直接返回,不讓ScrollView處理事件
return true;
}
return super.onTouchEvent(ev);
}
5、點選事件
這一步就很簡單了,我們只需要給最下層的View新增點選事件就可以了。因為前面提到過的,View如果消費了UP事件用於Click事件,就不會傳遞給上層的ScrollView了。這裡其實就也可以回答上面提出的問題,為什麼不能在onTouchEvent中來取消自動播放,因為View預設會消費掉DOWN事件,是傳遞不到ScrollView中的。
四、結語
至此,廣告欄Banner就已經做好了。其中涉及的各種知識,在自定義控制元件中,都是必須的,只有熟練掌握,才能寫出屬於自己的個性化控制元件,少踩幾個坑。