Android 手寫一個輪播圖(banner)框架

話不多說 先看效果:

四種效果Demo

老規(guī)矩 不想聽俺叨逼叨的請移步: GitHub - SuperBanner

首先總結下需求:
1:支持手指循環(huán)滑動
2:支持定時輪播
3:支持手指觸摸/滑動輪播區(qū)域時停止輪播,手指離開重新輪播
4:支持輪播圖片簡述及導航(指示器)標識
5:支持圖片點擊事件回調(diào)
6:支持自定義item切換速度
7:支持item圓角圖片展示
8:支持item切換動畫(兩種)

關于ViewPager2

2018 年 9 月 21 日谷歌發(fā)布了首個AndroidX 穩(wěn)定版本 ----AndroidX 1.0.0。后續(xù)版本中,谷歌意圖用AndroidX逐步替代android.support.xxx 包 那么自然非凌,隸屬于AndroidX下的ViewPager2也將會替代ViewPager

官方文檔中關于AndroidX概述

然而就在前幾天(2019年11月20日)ViewPager2也更新了一個正式穩(wěn)定版ViewPager2 1.0.0

官方文檔關于ViewPager2的更新及使用方法

不過厚满,考慮到AndroidX的適配問題和現(xiàn)階段的普適程度,此banner效果依然使用ViewPager實現(xiàn)逊拍,所以也不打算展開來講ViewPager2鸠窗,后續(xù)我會單寫一篇文章詳細的介紹和使用ViewPager2并實現(xiàn)此效果停忿,總之中鼠,無論用哪種控件實現(xiàn)可婶,思路才最重要。

Google GitHub的ViewPager2 Demo 各位有興趣的可以跑起來先耍耍:
https://github.com/googlesamples/android-viewpager2

進入正題

首先我們需要在調(diào)用層(Activity)布局文件中定義出我們自定義的ViewPager相關布局并設置一些基本的屬性:

<android.support.v4.view.ViewPager
    android:id="@+id/view_pager"
    android:layout_width="match_parent"
    android:layout_height="180dp" />
<!--指示器的布局-->
<LinearLayout
    android:id="@+id/indicator_ly"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignBottom="@id/view_pager"
    android:layout_alignParentRight="true"
    android:layout_marginRight="25dp"
    android:layout_marginBottom="8dp"
    android:orientation="horizontal"></LinearLayout>

ok 這個時候還需要一個Adapter來設置數(shù)據(jù):

package com.banner.superbanner;
import android.support.annotation.NonNull;
import android.support.v4.view.PagerAdapter;
import android.view.View;
import android.view.ViewGroup;

public class BannerAdapter extends PagerAdapter {

    private BannerBean mBannerBean;
    private OnLoadImageListener mOnLoadImageListener;

    /**
     * @param bannerBean          裝有圖片路徑的數(shù)據(jù)源
     * @param onLoadImageListener 加載圖片的回調(diào)接口 讓調(diào)用層處理加載圖片的邏輯
     */
    private BannerAdapter(BannerBean bannerBean, OnLoadImageListener onLoadImageListener) {
        this.mBannerBean = bannerBean;
        this.mOnLoadImageListener = onLoadImageListener;

    }

    @Override
    public int getCount() {
        return 0;
    }

    @NonNull
    @Override
    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        return super.instantiateItem(container, position);
    }

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

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

這些個方法援雇,用過的都知道不多說矛渴。接下來主要是在getCount()instantiateItem()中搞事情。

構造方法中的BannerBean是我請求服務器后通過Gson解析后生成的實體bean惫搏,你也可以把圖片組裝到List集合或者arr數(shù)組中 具體還要看你們的業(yè)務邏輯具温。

OnLoadImageListener主要是一個callback接口 主要用于將加載圖片的邏輯回調(diào)給調(diào)用層去處理 這個后續(xù)會講到,OnLoadImageListener接口內(nèi)容如下:

package com.banner.superbanner;

import android.content.Context;
import android.widget.View;

public interface OnLoadImageListener {
    //最后一個參數(shù)類型為View而不是ImageView筐赔,主要為了適應item布局的多樣性 使用時強轉(zhuǎn)一下就行了
    void loadImage(Context context, BannerBean bannerBean, int position, View imageView);
}

參數(shù)就不用我多說了吧铣猩,看一下基本就明白了,就是加載圖片時需要的一些信息茴丰。

Activity中請求服務器先把圖片路徑地址拿到:

 OkHttpClient okHttpClient = new OkHttpClient();
        final Request request = new Request.Builder()
                .url("http://192.168.0.105:8080/banner/banner_image.json")
                .get()
                .build();
        Call call = okHttpClient.newCall(request);
        call.enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
                if (response.code() == 200) {
                    Gson gson = new Gson();
                    //Type  type = new TypeToken<BannerBean>(){}.getType();
                    mBannerBean = gson.fromJson(response.body().string(), BannerBean.class);
                    Message message = new Message();
                    message.arg1 = OK;
                    handler.sendMessage(message);
                }

            }
        });

可以看到 請求的host是我的本機內(nèi)網(wǎng)ip 為了測試方便 我直接在Tomcat上放了幾張圖片 并且寫了一個簡單的json文件模擬服務器返回的數(shù)據(jù)

請求到的圖片地址如下:

        "http://192.168.0.105:8080/pic/01.png",
        "http://192.168.0.105:8080/pic/02.png",
        "http://192.168.0.105:8080/pic/03.png",
        "http://192.168.0.105:8080/pic/04.png",
        "http://192.168.0.105:8080/pic/05.png",
        "http://192.168.0.105:8080/pic/06.png"

拿到數(shù)據(jù)源后 在PagerAdapterinstantiateItem()中創(chuàng)建ImageView對象:

 @Override
    public int getCount() {
        return mBannerBean.getData().size();
    }

    @NonNull
    @Override
    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        ImageView iv= new ImageView(container.getContext());
        //等比例縮放圖片,占滿容器
        iv.setScaleType(ImageView.ScaleType.FIT_XY);
        if (null!=mOnLoadImageListener){
            //設置回調(diào),傳入數(shù)據(jù) 讓調(diào)用層(Activity)去處理加載圖片的邏輯
            mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
        }
        //把每一個item(ImageView)添加到ViewPager容器中
        container.addView(iv);
        return iv;
    }

適配器設置完畢后 在Activity中給ViewPager添加適配器并加載圖片:

 mViewPager.setAdapter(new BannerAdapter(mBannerBean, new OnLoadImageListener() {
            @Override
            public void loadImage(Context context, BannerBean bannerBean, int position, View imageView) {
                Glide.with(context)
                        .load(bannerBean.getData().get(position))
                        .into((ImageView)imageView);
            }
        }));

此時運行項目:

初步效果演示

可以看到 總共6張圖片 在我滑動到最后一張的時候 我們需要讓它繼續(xù)從頭開始循環(huán)滑動达皿。

手指滑動“無限循環(huán)”

這里就要說到ViewPagerAdpater中的getCount()函數(shù),這個函數(shù)的返回值就是當前ViewPager的總頁數(shù)(item),當ViewPager滑動到最后一頁 也就是當前item的position為getCount()-1的時候 就會認為已經(jīng)滑動到了末尾。

所以贿肩,我們這里所說的無限循環(huán)滑動其實是一個偽概念 因為我們數(shù)據(jù)源的總大小也才6張圖片 等我們滑到第5個item的時候理論上已經(jīng)滑不動了 但為了做出無限循環(huán)效果峦椰,我們可以給getCount()返回一個非常大的數(shù) 讓它很難滑動到盡頭。

比較主流的做法是直接返回Interger的最大值:

 @Override
    public int getCount() {
        //return mBannerBean.getData().size();
        return Integer.MAX_VALUE; //返回Integer的最大值汰规,實現(xiàn)“手指滑動無限循環(huán)”
    }
Integer最大值SDK文檔解釋

如圖 ,MAX_VALUE的值為: 2的31次方減1 得出的一個常量值:2147483647汤功,換句話說 理論上你需要滑動二十一億四千七百四十八萬三千六百四十七次才能滑動到盡頭....


想必世界上應該還沒有如此耿直的人非要滑那么多次的吧 那么 它就是“無限循環(huán)”

或者你還可以這樣寫:

 @Override
    public int getCount() {
        //return mBannerBean.getData().size();
        //返回數(shù)據(jù)源大小的整數(shù)倍
        return (mBannerBean.getData().size() * 10000 * 100);
    }

這種是直接返回數(shù)據(jù)源的整數(shù)倍的方式,個人推薦這種寫法 原因后續(xù)會講到溜哮。反正不管怎么寫 核心就是返回一個非常大的數(shù) 在相當長的時間內(nèi)滑不到盡頭滔金。

ps: 關于無限循環(huán) 市面上還有一些其他做法 比如重寫 OnPageChangeListener 接口中的onPageSelected 方法或者我看有些人通過動態(tài)添加/復用頭尾item的方式做到所謂“真正意義上的無線循環(huán)”,有興趣請自行瀏覽器了解

ok 我們設置完getCount()返回值后茬射,此時我們?nèi)绻苯舆\行項目 會報出IndexOutOfBoundsException異常鹦蠕,其原因在于:我們設置了ViewPager的item的總大小但并沒有對position進行處理,當postion的值超出了數(shù)據(jù)源(list集合)的大小 就會拋出索引越界異常

所以當前的position如果超出我們數(shù)據(jù)源的最大值(最大值為6) 我們需要把這個position處理成數(shù)據(jù)源范圍內(nèi)的值:

 @NonNull
    @Override
    private Object instantiateItem(@NonNull ViewGroup container, int position) {
        ImageView iv= new ImageView(container.getContext());
        iv.setScaleType(ImageView.ScaleType.FIT_XY);
        Log.i("TEST_POSITION","處理之前的position: "+position);
        //處理position 通過取余數(shù)的方式來限定position的取值范圍
        position = position % mBannerBean.getData().size();
        Log.i("TEST_POSITION","處理之后的position:"+position);
        if (null!=mOnLoadImageListener){
            //設置回調(diào),傳入數(shù)據(jù) 讓調(diào)用層(Activity)去處理加載圖片的邏輯
            mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
        }
        //把每一個item(ImageView)添加到ViewPager容器中
        container.addView(iv);
        return iv;
    }

剛好 我們可以通過取余數(shù)的特性 限定position的取值范圍: 從0到數(shù)據(jù)源大小-1之間
此時運行項目并打印position日志:

可以看到 已經(jīng)可以無限的向右滑動了在抛,我向右滑動了兩輪 此時Log打印出position值為:


position處理前與處理后的值

看到了8~ 如果沒處理position 當position為6的時候 就已經(jīng)索引越界了钟病。我們通過取余處理后 position值就能按順序控制在0-6之間以此類推

ok 看似已經(jīng)實現(xiàn)了手指滑動無限循環(huán) 但有一個小問題 我向右滑動沒問題 但我向左邊滑動到position值為0的item的時候就滑不動了,ViewPager就會認為我左邊已經(jīng)沒有item了刚梭。

解決這個問題 只需要讓ViewPager左滑時 在相當長的時間內(nèi)滑不到0的位置

很簡單,ViewPager中有一個API:


官方文檔API解釋

Set the currently selected page. If the ViewPager has already been through its first layout with its current adapter there will be a smooth animated transition between the current item and the specified item.
設置當前選擇的頁面肠阱。如果ViewPager已經(jīng)使用當前適配器完成了它的第一個布局,那么當前項和指定項之間將有一個平滑的動畫過渡朴读。

一般情況下ViewPager初始化時默認的item位置為0屹徘。

但我們可以使用這個API給ViewPager一個初始位置:

//ViewPager初始化時 滑動到一半的距離
mViewPager.setCurrentItem((mViewPager.getAdapter().getCount()) / 2);

在初始化的時候 給ViewPager設置初始位置為:總條目數(shù)的一半

這樣一來 不論是左滑還是右滑都不會滑到"盡頭"

但問題來了 還記得剛剛提到的實現(xiàn)無限循環(huán)在getcount()中的兩種返回方式的寫法嗎? 一個是返回Integer最大值 一種是返回數(shù)據(jù)源的整數(shù)倍,并且我還推薦使用整數(shù)倍的寫法衅金。

如果你使用的是返回Integer最大值的方式:

你會發(fā)現(xiàn)當你冷啟動App時 ViewPager顯示的item位置經(jīng)過取余處理后 仍然不會在第一位噪伊,一般情況下 我們正常需求肯定都是初始化顯示第一張圖片(position = 0) 為什么會出現(xiàn)這種情況呢簿煌?

原因就在于:這個數(shù)不能被整除

所以你還要對它的余數(shù)進行拼差處理, 太麻煩了 而且這個數(shù)也太大 我們沒必要設置這么大的數(shù)。

所以個人推薦使用整數(shù)倍的方式 鉴吹。

定時輪播

在Android中 想要周期性執(zhí)行任務基本有以下幾種方式:

  • Timer+TimerTask
  • 延時Handler(postDelay)
  • 周期性執(zhí)行任務的線程池

首先pass掉第三種 不解釋 第一種和第二種用哪個都可以 很多人在用postDelay的方式 那咱們就用Timer+TimerTask吧:

Acitivity類中:

    private Timer mTimer;
    private TimerTask mTimerTask;
    /**
     * 開啟一個延時任務并執(zhí)行
     */
    private void executeDelayedTask() {
        //在創(chuàng)建任務之前 一定要檢查清理未回收的任務姨伟,保證只有一組Timer+TimerTask
        killDelayedTask();
        mTimer = new Timer();
        mTimerTask = new TimerTask() {
            @Override
            public void run() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        //顯示下一頁
                        showNextPage();
                    }
                });
            }
        };
     //設置delay參數(shù)為3000毫秒表示用戶調(diào)用schedule() 方法后,要等待3秒才可以第一次執(zhí)行run()方法
     //設置period參數(shù)為4000 表示第一次調(diào)用之后,從第二次開始每隔4秒調(diào)用一次run()方法
        mTimer.schedule(mTimerTask, 3000, 4000);
    }

    /**
     * @Description 取消(清理)延時任務
     */
    private void killDelayedTask() {
        if (mTimer != null) {
            mTimer.cancel();
            mTimer = null;
        }
        if (mTimerTask != null) {
            mTimerTask.cancel();
            mTimerTask = null;
        }
    }

    /**
     * @Description 顯示下一頁
     */
    private void showNextPage() {
        //獲取到當前頁面的位置
        int currentPageLocation = mViewPager.getCurrentItem();
        //設置item位置為: 當前頁面的位置+1
        mViewPager.setCurrentItem(currentPageLocation + 1);
    }

如上 使用Timer+TimerTask 執(zhí)行定時任務 這個任務就是: showNextPage()顯示下一頁。

手指觸摸/滑動輪播區(qū)域時停止輪播豆励,手指離開重新輪播

這個也很簡單 只需要用到ViewPager的一個API

依舊是在Acivity類中:

  /**
      * @Description 在手指按下和移動時 清除延時任務夺荒,待手指松開重新創(chuàng)建任務
     */
    private void setViewPagerTouchListener() {
        mViewPager.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        killDelayedTask();
                        break;
                    case MotionEvent.ACTION_MOVE:
                        killDelayedTask();
                        break;
                    case MotionEvent.ACTION_UP:
                        executeDelayedTask(mDelay,mDelay);
                        break;
                }
                return false;
            }
        });
    }

ActivityonCreate()中初始化一下:

//設置3秒鐘后開始執(zhí)行任務  每個任務之間隔4秒執(zhí)行一次
 superBanner.executeDelayedTask();
//初始化touch事件
 superBanner.setViewPagerTouchListener();

此時運行項目:


定時輪播+手指觸停

注意看 我手指觸摸滑動的時候 此時會停止輪播 當手指松開后 又會繼續(xù)輪播。

底部指示器:

定時輪播完成后 我們想在ViewPager底部顯示一排"指示器",可以隨頁面的滑動更改狀態(tài)

依然實在Activity類中:

  /**
     * @Description 初始化ViewPager底部指示器
     * @param indicatorLayout 指示器的父布局 由調(diào)用者提供
     */
    public void initIndicatorView(Context context, BannerBean bannerBean, ViewGroup indicatorLayout) {
        this.mIndicatorLayout = indicatorLayout;
        for (int i = 0; i < bannerBean.getData().size(); i++) {
            LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(dpToPx(6), dpToPx(6));
            lp.leftMargin = dpToPx(10);
            lp.bottomMargin = dpToPx(6);

            View ivIndicator = new View(context);
            //[R.drawable.indicator_select]為指示器的背景資源 相關樣式可替換
            ivIndicator.setBackgroundResource(R.drawable.indicator_select);
            ivIndicator.setLayoutParams(lp);
            //將一個個指示器(ImageView)添加到父布局中
            indicatorLayout.addView(ivIndicator);
        }

    }

上述代碼段中的dpToPx()作用是將dp值轉(zhuǎn)換為像素值 想必大多人項目的Util中應該有該方法, 還是貼出來吧:

/**
     * @Description 將dp轉(zhuǎn)為px
     */
    private int dpToPx(int dp) {
        //獲取手機屏幕像素密度
        float phoneDensity = getResources().getDisplayMetrics().density;
        //加0.5f是為了四舍五入 避免丟失精度
        return (int) (dp * phoneDensity + 0.5f);

    }

指示器創(chuàng)建完畢后,需要將指示器中的每個view與頁面切換/選中狀態(tài)捆綁:

/**
     * @Description 隨著ViewPager頁面滑動 更新指示器選中狀態(tài)
     * @param position ViewPager中的item的position
     */
    public void updateIndicatorSelectState(int position) {
        //此時傳入的position還未經(jīng)過處理 同樣的需要對position進行取余數(shù)處理
        position = position % mIndicatorLayout.getChildCount();
        //循環(huán)獲取指示器父布局中所有的子View
        for (int i = 0; i < mIndicatorLayout.getChildCount(); i++) {
            //給每個子view設置選中狀態(tài)
            //當i == position為True的時候觸發(fā)選中狀態(tài)反之則設置成未選中
            mIndicatorLayout.getChildAt(i).setSelected(i == position);

        }
    }

如上述代碼段,updateIndicatorSelectState()需要接受一個position , 那么這個position從哪里來良蒸?換句話說技扼,該在何時調(diào)用此方法?

沒錯 那就是需要在ViewPager頁面狀態(tài)發(fā)生改變時調(diào)用嫩痰。所以還要給ViewPager添加一個頁面狀態(tài)事件監(jiān)聽:

   /**
     *@Description 添加ViewPager頁面改變事件的監(jiān)聽
     */
    public void initPageChangeListener(){
        mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float v, int position1) {

            }

            @Override
            public void onPageSelected(int position) {
                //更新指示器選中狀態(tài)
                updateIndicatorSelectState(position);
            }

            @Override
            public void onPageScrollStateChanged(int position) {

            }
        });
    }

三個回調(diào)方法想必大家都很熟悉了吧剿吻,不解釋,初始化后 然后運行項目:

            //初始化指示器
            initIndicatorView();
            //在初始化的時候 讓指示器選中第一個位置
            updateIndicatorSelectState(0);
            //初始化ViewPager頁面選擇狀態(tài)監(jiān)聽
            initPageChangeListener();
無限循環(huán)+自動輪播+觸開離停+底部指示器

至此始赎,我們的基礎的業(yè)務功能已經(jīng)實現(xiàn)和橙。

但是, 這UI樣式有些過時而且頁面切換的時候交互略顯生硬不夠 優(yōu)雅仔燕。

那好 接下來咱們就著手讓它盡可能好看一點

動畫效果及UI美化

想要好看 肯定是要改變UI樣式或者添加動畫造垛。

首先ViewPager中的圖片都是直角 太直了不好看 聽說流行圓角好多年了 那咋辦? 先把ImageView剪裁成圓角再說:

package com.banner.superbanner;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Path;
import android.os.Build;
import android.support.v7.widget.AppCompatImageView;
import android.util.AttributeSet;
import android.view.View;
/**
 *@Description 通過繪出一個圓角矩形的路徑托享,然后用ClipPath裁剪畫布的方式對ImageView的邊角進行剪裁實現(xiàn)圓角
 */
public class CircularBeadImageView extends AppCompatImageView {
    float width,height;
    //此值代表圓角的半徑
    int angle = 30;

    public CircularBeadImageView(Context context) {
        this(context, null);
    }

    public CircularBeadImageView(Context context, AttributeSet attrs) {
        this(context, attrs, -1);
    }

    public CircularBeadImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
  //Android4.0及之前的手機中弓熏,因為硬件加速等原因,在使用clipPath時很有可能 會發(fā)生UnsupportedOperationException異常
        if (Build.VERSION.SDK_INT < 18) {
            setLayerType(View.LAYER_TYPE_SOFTWARE, null);
        } 
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        width = getWidth();
        height = getHeight();
    }

    @Override
    protected void onDraw(Canvas canvas) {
    //主要為了防止屏幕寬高小于圓角半徑值這種詭異的現(xiàn)象出現(xiàn)
        if (width > angle && height > angle) {
            Path path = new Path();
            path.moveTo(angle, 0);
            path.lineTo(width - angle, 0);
            path.quadTo(width, 0, width, angle);
            path.lineTo(width, height - angle);
            path.quadTo(width, height, width - angle, height);
            path.lineTo(angle, height);
            path.quadTo(0, height, 0, height - angle);
            path.lineTo(0, angle);
            path.quadTo(0, 0, 40, 0);
            canvas.clipPath(path);
        }
         super.onDraw(canvas);
    }
}

項目res/layout文件夾下增加布局: item_ly.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/iv_ly"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >
    <com.banner.superbanner.CircularBeadImageView
        android:id="@+id/iv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="fitXY"
        />
</RelativeLayout>

ViewPager的item的布局是在BannerAdapter中instantiateItem()中創(chuàng)建的蹋岩,改一下:

給item添加布局

都是日常操作外恕,不解釋杆逗。不過俗話講:空白留有余韻,所以唯一要注意的是 我給item布局設置了一個padding值 這樣我們的item就可以距離父控件上下左右有些距離 這樣視覺上會更好看

不信看下效果:

內(nèi)邊距+圓角

這個稍微岔個話鳞疲,關于IamgeView圓角的實現(xiàn)方式有很多 關于ViewPager item圓角的方式也有很多罪郊,比如你們?nèi)绻?code>Glide圖片加載框架 就可以通過重寫Glide自帶的加載器直接給ImageView加載圓角,這樣就不用再單給item寫一套布局了(其他圖片框架基本也都支持)尚洽。

舉個栗子悔橄?:

 RequestOptions options = RequestOptions.bitmapTransform(new CenterCropRoundCornerTransform(20));
        Glide.with(container.getContext())
                .load(mBannerBean.getData().get(position))
                .apply(options)
                .into(cb_iv);

CenterCropRoundCornerTransform類是繼承并重寫了Glide專門讓我們加載圓角圖片的CenterCrop類:

package com.banner.superbanner;

import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;

import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;

import java.security.MessageDigest;

public class CenterCropRoundCornerTransform extends CenterCrop {
    private static float radius = 0f;
    /**
      *構造中接受圓角半徑參數(shù)
    */
    public CenterCropRoundCornerTransform(int px) {
        this.radius = px;
    }

    @Override
    protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) {
        Bitmap bitmap = TransformationUtils.centerCrop(pool, toTransform, outWidth, outHeight);
        return roundCrop(pool, bitmap);
    }

    private static Bitmap roundCrop(BitmapPool pool, Bitmap source) {
        if (source == null) return null;

        Bitmap result = pool.get(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_4444);
        if (result == null) {
            result = Bitmap.createBitmap(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_4444);
        }

        Canvas canvas = new Canvas(result);
        Paint paint = new Paint();
        paint.setShader(new BitmapShader(source, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP));
        paint.setAntiAlias(true);
        RectF rectF = new RectF(0f, 0f, source.getWidth(), source.getHeight());
        canvas.drawRoundRect(rectF, radius, radius, paint);
        return result;
    }

    public String getId() {
        return getClass().getName() + Math.round(radius);
    }

    @Override
    public void updateDiskCacheKey(MessageDigest messageDigest) {

    }
}

OK了, 就是這么簡單。

回到正題, UI樣式是修改了 但是item自動切換的時候 依舊感覺很生硬...

其實感覺到“生硬”是因為切換的時候速度太快 一瞬而過 不夠平滑腺毫,這個問題可以通過修改item切換速度來解決癣疟。

但是ViewPager的item切換速度是寫死的 并沒有暴露出API讓我們修改,我們只能通過反射的方式去修改切換速度潮酒。

ViewPager的切換速度是通過Scroll類來控制的睛挚,新建SuperBannerScroller類重寫它:

package com.banner.superbanner;

import android.content.Context;
import android.view.animation.Interpolator;
import android.widget.Scroller;

public class SuperBannerScroller extends Scroller {
    //切換動畫時長(單位:毫秒)
    private int mScrollDuration = 2000; 
    private static final Interpolator sInterpolator = new Interpolator() {
        @Override
        public float getInterpolation(float t) {
            t -= 1.0f;
            return t * t * t * t * t + 1.0f;
        }
    };
    public boolean noDuration;
   /**
    *此方法主要讓調(diào)用層控制是否延時
   */
    public void setNoDuration(boolean noDuration) {
        this.noDuration = noDuration;
    }

    public SuperBannerScroller(Context context) {
        this(context, sInterpolator);
    }

    public SuperBannerScroller(Context context, Interpolator interpolator) {
        super(context, interpolator);
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        if (noDuration) {
            super.startScroll(startX, startY, dx, dy, 0);
        } else {
            //默認延時
            super.startScroll(startX, startY, dx, dy, mScrollDuration);
        }
    }
}

重寫完之后 只需要在初始化ViewPager的時候 反射到具體的參數(shù) 然后替換一下:

/**
     * @Description 通過反射的方式拿到ViewPager的mScroller,然后替換成自己設置的值
     */
    private void updateViewPagerScroller() {
        mSuperBannerScroller = new SuperBannerScroller(this);
        Class<ViewPager> cl = ViewPager.class;
        try {
            Field field = cl.getDeclaredField("mScroller");
            field.setAccessible(true);
            //利用反射設置mScroller域為自己定義的MScroller
            field.set(mViewPager, mSuperBannerScroller);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

初始化調(diào)用一下這個方法急黎,然后運行看下效果:

item切換延時兩秒+內(nèi)邊距

我為了讓效果稍微直觀一點扎狱,我設置切換速度為2秒侧到,其實一秒鐘就已經(jīng)ok了,看起來有木有舒服些淤击?

ps:目前大多數(shù)主流App, 包括但不限于:淘寶床牧、網(wǎng)易云音樂、掌上生活(招行)遭贸、華為應用市場戈咳、優(yōu)酷、京東..等等的banner基本上都是item圓角或者內(nèi)邊距的形式顯示壕吹。此Demo保留了圖片加載由調(diào)用層處理的回調(diào) 你們可以自由加載著蛙。

UI樣式告一段落,下面開始加動畫耳贬。

ViewPager有個API:

官方文檔API說明

API功能翻譯:設置viewpage踏堡。當滾動位置改變時,將為每個附加頁調(diào)用PageTransformer咒劲。這允許應用程序?qū)γ總€頁面應用自定義屬性轉(zhuǎn)換顷蟆,覆蓋默認的滑動行為。
API參數(shù)翻譯:
reverseDrawingOrder:------ 布爾值:如果提供的PageTransformer要求從最后到第一而不是從第一到最后繪制頁面視圖腐魂,則為真帐偎。
transformer------ PageTransformer將修改每個頁面的動畫屬性
pageLayerType ------ 應用于ViewPager頁面的視圖層類型。它應該是LAYER_TYPE_HARDWARE蛔屹、LAYER_TYPE_SOFTWARE或LAYER_TYPE_NONE削樊。

說白了就是可以利用這個API給ViewPager添加頁面切換動畫效果⊥枚荆看下它的源碼:

public void setPageTransformer(boolean reverseDrawingOrder, @Nullable ViewPager.PageTransformer transformer, int pageLayerType) {
        boolean hasTransformer = transformer != null;
        boolean needsPopulate = hasTransformer != (this.mPageTransformer != null);
        this.mPageTransformer = transformer;
        this.setChildrenDrawingOrderEnabled(hasTransformer);
        if (hasTransformer) {
            this.mDrawingOrder = reverseDrawingOrder ? 2 : 1;
            this.mPageTransformerLayerType = pageLayerType;
        } else {
            this.mDrawingOrder = 0;
        }

        if (needsPopulate) {
            this.populate();
        }

    }

這是個重載方法 文檔結合源碼 首先這個方法接收三個參數(shù)漫贞,第一個參數(shù)和最后一個參數(shù)不是重點,自行理解育叁,關鍵是PageTransformer這個參數(shù)迅脐。

PageTransforme是個啥玩意兒呢:

谷歌開發(fā)指南中的解釋

注意圖中標注區(qū)域,一定要搞清楚這些解釋的真正含義 才能自定義各種動畫豪嗽,如果還沒用過PageTransforme自行瀏覽器了解 這里就不深入講了......

為啥不講了谴蔑? 因為我:


哈哈哈開玩笑啦 其實是因為PageTransforme這個東西細節(jié)太多 如果想完全講清楚 需要占用大量篇幅,完全可以單寫一篇文章詳細講解了昵骤。網(wǎng)上有大量相關PageTransforme的文章講解 如果不太清楚PageTransforme的你們就自行了解吧

但本文會使用谷歌開發(fā)指南中ViewPager的兩個動畫例子 來給我們的ViewPager加上動畫效果树碱。

第一個:


DepthPageTransformer頁面深度線性淡出效果

DepthPageTransformer官方示例效果:


DepthPageTransformer效果

怎么實現(xiàn)?很簡單啊 谷歌demo示例代碼都給咱寫好了:

public class DepthPageTransformer implements ViewPager.PageTransformer {
    private static final float MIN_SCALE = 0.75f;

    public void transformPage(View view, float position) {
        int pageWidth = view.getWidth();

        if (position < -1) { // [-Infinity,-1)
            // This page is way off-screen to the left.
            view.setAlpha(0f);

        } else if (position <= 0) { // [-1,0]
            // Use the default slide transition when moving to the left page
            view.setAlpha(1f);
            view.setTranslationX(0f);
            view.setScaleX(1f);
            view.setScaleY(1f);

        } else if (position <= 1) { // (0,1]
            // Fade the page out.
            view.setAlpha(1 - position);

            // Counteract the default slide transition
            view.setTranslationX(pageWidth * -position);

            // Scale the page down (between MIN_SCALE and 1)
            float scaleFactor = MIN_SCALE
                    + (1 - MIN_SCALE) * (1 - Math.abs(position));
            view.setScaleX(scaleFactor);
            view.setScaleY(scaleFactor);

        } else { // (1,+Infinity]
            // This page is way off-screen to the right.
            view.setAlpha(0f);
        }
    }
}

把這個類copy到你項目中,然后在初始化ViewPager的時候調(diào)用:

            //第一個參數(shù)為true表示頁面是按正序添加 反之則為倒序变秦。(一般只有在幀布局的時候才有視覺效果)
            //第二個參數(shù)為具體的動畫樣式的實例成榜,此方法一定要在setAdapter之前調(diào)用!1拿怠J昊椤刘绣!
             mViewPager.setPageTransformer(true, new DepthPageTransformer());

看下效果:


DepthPageTransformer實例效果

第二個:


ZoomOutPageTransformer收縮淡入效果

ZoomOutPageTransformer官方示例效果:


ZoomOutPageTransformer效果

同樣的 這個Demo的示例代碼谷歌也給了我們:

public class ZoomOutPageTransformer implements ViewPager.PageTransformer {
    private static final float MIN_SCALE = 0.85f;
    private static final float MIN_ALPHA = 0.5f;

    public void transformPage(View view, float position) {
        int pageWidth = view.getWidth();
        int pageHeight = view.getHeight();

        if (position < -1) { // [-Infinity,-1)
            // This page is way off-screen to the left.
            view.setAlpha(0f);

        } else if (position <= 1) { // [-1,1]
            // Modify the default slide transition to shrink the page as well
            float scaleFactor = Math.max(MIN_SCALE, 1 - Math.abs(position));
            float vertMargin = pageHeight * (1 - scaleFactor) / 2;
            float horzMargin = pageWidth * (1 - scaleFactor) / 2;
            if (position < 0) {
                view.setTranslationX(horzMargin - vertMargin / 2);
            } else {
                view.setTranslationX(-horzMargin + vertMargin / 2);
            }

            // Scale the page down (between MIN_SCALE and 1)
            view.setScaleX(scaleFactor);
            view.setScaleY(scaleFactor);

            // Fade the page relative to its size.
            view.setAlpha(MIN_ALPHA +
                    (scaleFactor - MIN_SCALE) /
                    (1 - MIN_SCALE) * (1 - MIN_ALPHA));

        } else { // (1,+Infinity]
            // This page is way off-screen to the right.
            view.setAlpha(0f);
        }
    }
}

不多說 直接初始化:

mViewPager.setPageTransformer(true, new ZoomOutPageTransformer());

看下效果:


ZoomOutPageTransformer效果實現(xiàn)

這塊還可以更美化一點 比如說我們經(jīng)常見到的3D畫廊效果,一屏可以顯示多頁 然后縮放漸入挣输∥撤铮縮放漸入我們實現(xiàn)了 怎么能一屏顯示多頁呢?

別想那么復雜, 一個屬性就能搞定:

clipChildren屬性文檔解釋

定義子對象是否被限制在其界限內(nèi)繪制撩嚼。這對于將孩子的大小縮放到100%以上的動畫非常有用停士。在這種情況下,應該將此屬性設置為false完丽,以允許子元素繪制超出其邊界的內(nèi)容恋技。此屬性的默認值為true。
可以是一個布爾值逻族,如“true”或“false”蜻底。

這是ViewGroup中的一個特有屬性 它可以允許子控件越界顯示

在布局的根布局中設置一下:


一屏顯示多頁布局設置

如圖聘鳞,在根布局加上android:clipChildren="false" 然后我把ViewPager的寬度由原來的match_parent改成指定寬度 這樣做是為了讓其不要填滿窗體 這樣其它頁面才能展示到當前屏幕上(實際使用時 這個值不要隨便給薄辅,最好是通過機型屏幕寬度計算出的一個寬度值)。

直接運行項目看下效果:


3D畫廊效果

當然 你們也可以自定義動畫效果抠璃,包括動畫的具體參數(shù) 比如透明度 縮放比 樣式等等參數(shù) 都可以自己調(diào)整站楚。

奧 差點忘了,我們還沒有給`ViewPager添加item點擊事件鸡典。

設置item點擊事件

由于ViewPager并沒有直接提供點擊事件的API 所以目前有很多種方式給ViewPager添加點擊事件 比如在touch中通過對手勢事件的攔截和偏移量的計算源请,還有直接給item的View添加點擊事件 然后再回調(diào)給ViewPager枪芒。那我們就采用第二種方案彻况。
首先定義出一個回調(diào)接口:

package com.banner.superbanner.callback;

/**
  *@Description ViewPager item的點擊事件
  *
 */
public interface OnItemClickAdapterListener {
    void onItemAdapterClick(int position);
}

這個callback主要用于BannerAdapter類中,在BannerAdapter的構造方法中接受回調(diào)的實例舅踪,然后在instantiateItem()中觸發(fā)回調(diào):

 /**
     *@Description item的View的點擊事件
     * @param cb_iv    點擊事件的view
     * @param position ietm的索引(取余過后的)
     */
    private void onItemClick(CircularBeadImageView cb_iv, final int position) {
        if (cb_iv != null) {
            cb_iv.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mOnItemClickAdapterListener != null) {
                        mOnItemClickAdapterListener.onItemAdapterClick(position);
                    }
                }
            });
        }
    }

我把這塊邏輯抽了出來纽甘,單獨寫了個方法 這個方法只需要在instantiateItem()中調(diào)用一下就可以了,然后在Acitivity中給ViewPager設置Adapter時 在BannerAdapter的構造中實現(xiàn)OnItemClickAdapterListener回調(diào) 重寫onItemAdapterClick()就OK了抽碌。

代碼整合與封裝

代碼寫到這里 我們想要的需求也全部實現(xiàn) 但代碼結構亂的一批悍赢。

一方面為了演示方便和自己方便 我直接把ViewPager相關代碼全部寫在了調(diào)用層Activity中,可讀性不強货徙。

第二方面 ViewPager業(yè)務邏輯的具體參數(shù)直接寫死了 沒有提供讓外部賦值的方法左权,不利于擴展。

第三方面 沒有對實例進行非空校驗 沒有對代碼進行容錯考慮痴颊。

這三個方面 就造成了一個問題: 耦合嚴重,健壯性差赏迟。

良好的代碼結構應該是: 高內(nèi)聚 低耦合 。調(diào)用層和實現(xiàn)層要盡量解耦

下面要做一些封裝和抽取 目的就是: 要把所有和ViewPager的相關的業(yè)務邏輯內(nèi)聚到一個類中并對外暴露API 讓調(diào)用層決定banner業(yè)務邏輯中的具體參數(shù) 并封裝成一個簡易框架蠢棱。

需求是锌杀,調(diào)用者可以決定banner:

  • 是否可以手指滑動無限循環(huán)
  • 是否可以定時輪播
  • 是否擁有底部指示器
  • 是否擁有動畫效果
  • 是否需要item點擊事件
  • 是否需要調(diào)用者去處理加載圖片的邏輯
  • 是否需要item圓角展示(glide)
  • Banner頁面切換的速度
  • 定時輪播的間隔時間
  • 底部指示器View的寬高和間距(相對于父布局)
  • item的內(nèi)邊距

新建一個 SuperBanner類 將之前寫在Activity中和ViewPager相關的代碼全部移植到此類中,然后將上述需求整理成具體函數(shù)甩栈,以方法鏈的形式暴露出去,最終調(diào)用層對Banner的設置只需要以下API:

這樣一來糕再,調(diào)用者只需要確定上述的一些參數(shù) banner的實現(xiàn)就和調(diào)用層無關了

由于 SuperBanner類的代碼太多量没,貼出來太影響閱讀體驗 如果感興趣請自行下載Demo了解 注釋都很詳細 。

使用

 private void showTest(){
        //簡單用法
        mSuperBanner.setDataOrigin(imageList).start();
    }

你可以直接設置數(shù)據(jù)源 然后start 但是這樣沒有底部指示器 也沒有其他的一些效果 但默認會有自動輪播和手指滑動無限輪播效果突想。

全部API:

 mSuperBanner.
                //設置數(shù)據(jù)源
                 setDataOrigin(imageList)
                //重載方法殴蹄,設置指示器布局及指示器樣式,不需要就無需調(diào)用 后三個參數(shù)代表指示器的寬高和間距(可選設置 有默認效果)
                .setIndicatorLayoutParam(mIndicatorLayout, R.drawable.indicator_select, 6, 6, 10)
                //設置ViewPager的item切換速度,不需要更改速度就無需調(diào)用
                .setViewPagerScroller(1000)
                //設置自動輪播間隔時間,重載方法 默認開始執(zhí)行定時任務時間為2秒
                .setAutoIntervalTime(3000, 2000)
                //.closeAutoBanner(true)  關閉自動輪播
                //.closeInfiniteSlide(true)  關閉手指滑動無限循環(huán)
                //設置item的padding值(上下左右)
                .setItemPadding(14)
                //設置圓角半徑 一旦設置值(大于0) 就代表item使用圓角樣式
                .setRoundRadius(10)
                //.setSwitchAnimation()  設置ViewPager切換動畫

                //可選實現(xiàn)猾担。實現(xiàn)圖片加載回調(diào)(一定要在start()之前執(zhí)行) 一但實現(xiàn)回調(diào)就表示圖片加載交由調(diào)用層處理 否則由適配器內(nèi)部加載
                .setOnLoadImageListener(new SuperBanner.OnLoadImageListener() {
                    @Override
                    public void onLoadImage(List imageData, int position, View imageView) {
                        if (mOptions == null) {
                            mOptions = RequestOptions.bitmapTransform(new CenterCropRoundCornerTransform(20));
                        }
                        int resourceId = (int) imageData.get(position);
                        Glide.with(MainActivity.this)
                                .load(resourceId)
                                .apply(mOptions)
                                .into((ImageView) imageView);
                    }
                })

                // 可選實現(xiàn)饶套。實現(xiàn)item點擊事件回調(diào)(一定要在start()之前執(zhí)行)
                .setOnItemClickListener(new SuperBanner.OnItemClickListener() {
                    @Override
                    public void onItemClick(int position) {
                        Log.i("BannerItemPosition: ", position + "");
                    }
                })
                // 此函數(shù)要最后執(zhí)行
                .start();

如果有其他需求 直接改源碼就ok 注釋真的很詳細奧~

奧,最后 你可以在Activity/Fragment不可見的時候 關掉輪播定時任務以盡可能的減少內(nèi)存壓力和內(nèi)存泄漏發(fā)生:

 @Override
    protected void onPause() {
        super.onPause();
        //取消輪播
        if (mSuperBanner!=null){
            mSuperBanner.killDelayedTask();
        }
    }

然后在頁面可見時開啟:

 @Override
    protected void onStart() {
        super.onStart();
        //開始輪播
        if (mSuperBanner!=null){
            mSuperBanner.executeDelayedTask();
        }
    }

很希望能幫到你 不足之處還請見諒 懇請斧正 !垒探。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末妓蛮,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子圾叼,更是在濱河造成了極大的恐慌蛤克,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件夷蚊,死亡現(xiàn)場離奇詭異构挤,居然都是意外死亡,警方通過查閱死者的電腦和手機惕鼓,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進店門筋现,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人箱歧,你說我怎么就攤上這事矾飞。” “怎么了呀邢?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵洒沦,是天一觀的道長。 經(jīng)常有香客問我价淌,道長申眼,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任蝉衣,我火速辦了婚禮括尸,結果婚禮上,老公的妹妹穿的比我還像新娘病毡。我一直安慰自己濒翻,他們只是感情好,可當我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布剪验。 她就那樣靜靜地躺著肴焊,像睡著了一般前联。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上娶眷,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天似嗤,我揣著相機與錄音,去河邊找鬼届宠。 笑死烁落,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的豌注。 我是一名探鬼主播伤塌,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼轧铁!你這毒婦竟也來了每聪?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤齿风,失蹤者是張志新(化名)和其女友劉穎药薯,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體救斑,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡童本,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了脸候。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片穷娱。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖运沦,靈堂內(nèi)的尸體忽然破棺而出泵额,到底是詐尸還是另有隱情,我是刑警寧澤茶袒,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布梯刚,位于F島的核電站,受9級特大地震影響薪寓,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜澜共,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一向叉、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧嗦董,春花似錦母谎、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽幸斥。三九已至,卻和暖如春咬扇,著一層夾襖步出監(jiān)牢的瞬間甲葬,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工懈贺, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留经窖,地道東北人。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓梭灿,卻偏偏與公主長得像画侣,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子堡妒,可洞房花燭夜當晚...
    茶點故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容