1. 程式人生 > >[Android]使用HorizontalScrollView實現廣告欄Banner及相關原理分析

[Android]使用HorizontalScrollView實現廣告欄Banner及相關原理分析

       現在的App中,廣告欄Banner的使用還是挺廣泛的,用於展示各種廣告、活動推薦等。使用HorizontalScrollView可以很簡單的實現一個可自動播放、可滑動、可點選的廣告欄Banner,這個也可以做為一個例子,來學習自定義控制元件的製作。相關原理主要包括兩個方面:

  • onMeasure、onLayout、onDraw等View、ViewGroup相關佈局函式;
  • dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等觸控事件處理函式(包括Click等事件觸發);

       這兩部分搞懂後,製作自定義控制元件就得心應手了。

一、需求

  1. 控制元件每次展示一張圖片,隔一段時間換播下一張,如果當前是最後一張則展示第一張;
  2. 使用者手指觸控控制元件,停止輪播,使用者滑動手指,則根據方向展示相應下一張或前一張圖片;
  3. 如果是第一張圖片繼續往前滑動,需要展示最後一張圖片,如果最後一張圖片往後滑動,展示第一張圖片;
  4. 最下方需要有小白點來指示當前是第幾張圖片;

最終效果如圖:
這裡寫圖片描述

二、初設計

       HorizontalScrollView本來就是一個橫向滑動元件,使用它可以很方便的實現滑動及相應的動畫效果,所以選擇用它來寫這個控制元件,我看網上也有使用ViewPager實現,原理都是大同小異;下面是按上面的需求做的初始設計,在實現的過程中還會碰到其他問題,需要按情況解決。

  1. 佈局
    HorizontalScrollView——LinearLayout——ImageView List
    同時需要在HorizontalScrollView上畫小白點指示當前頁

  2. 定時滾動
    新增一個定時器,每隔一段時間滑動到下一頁,注意最後一頁的迴圈處理。

  3. 使用者事件
    新增事件監控,觸控停止定時器及滑動事件處理

三、具體實現

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就已經做好了。其中涉及的各種知識,在自定義控制元件中,都是必須的,只有熟練掌握,才能寫出屬於自己的個性化控制元件,少踩幾個坑。

這裡寫圖片描述