Android 手写一个轮播图(banner)框架

话不多说 先看效果:

四种效果Demo

老规矩 不想听俺叨逼叨的请移步: GitHub - SuperBanner

首先总结下需求:
1:支持手指循环滑动
2:支持定时轮播
3:支持手指触摸/滑动轮播区域时停止轮播,手指离开重新轮播
4:支持轮播图片简述及导航(指示器)标识
5:支持图片点击事件回调
6:支持自定义item切换速度
7:支持item圆角图片展示
8:支持item切换动画(两种)

关于ViewPager2

2018 年 9 月 21 日谷歌发布了首个AndroidX 稳定版本 ----AndroidX 1.0.0。后续版本中,谷歌意图用AndroidX逐步替代android.support.xxx 包 那么自然,隶属于AndroidX下的ViewPager2也将会替代ViewPager

官方文档中关于AndroidX概述

然而就在前几天(2019年11月20日)ViewPager2也更新了一个正式稳定版ViewPager2 1.0.0

官方文档关于ViewPager2的更新及使用方法

不过,考虑到AndroidX的适配问题和现阶段的普适程度,此banner效果依然使用ViewPager实现,所以也不打算展开来讲ViewPager2,后续我会单写一篇文章详细的介绍和使用ViewPager2并实现此效果,总之,无论用哪种控件实现,思路才最重要。

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

进入正题

首先我们需要在调用层(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来设置数据:

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          装有图片路径的数据源
     * @param onLoadImageListener 加载图片的回调接口 让调用层处理加载图片的逻辑
     */
    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数组中 具体还要看你们的业务逻辑。

OnLoadImageListener主要是一个callback接口 主要用于将加载图片的逻辑回调给调用层去处理 这个后续会讲到,OnLoadImageListener接口内容如下:

package com.banner.superbanner;

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

public interface OnLoadImageListener {
    //最后一个参数类型为View而不是ImageView,主要为了适应item布局的多样性 使用时强转一下就行了
    void loadImage(Context context, BannerBean bannerBean, int position, View imageView);
}

参数就不用我多说了吧,看一下基本就明白了,就是加载图片时需要的一些信息。

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是我的本机内网ip 为了测试方便 我直接在Tomcat上放了几张图片 并且写了一个简单的json文件模拟服务器返回的数据

请求到的图片地址如下:

        "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"

拿到数据源后 在PagerAdapterinstantiateItem()中创建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){
            //设置回调,传入数据 让调用层(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张图片 在我滑动到最后一张的时候 我们需要让它继续从头开始循环滑动。

手指滑动“无限循环”

这里就要说到ViewPagerAdpater中的getCount()函数,这个函数的返回值就是当前ViewPager的总页数(item),当ViewPager滑动到最后一页 也就是当前item的position为getCount()-1的时候 就会认为已经滑动到了末尾。

所以,我们这里所说的无限循环滑动其实是一个伪概念 因为我们数据源的总大小也才6张图片 等我们滑到第5个item的时候理论上已经滑不动了 但为了做出无限循环效果,我们可以给getCount()返回一个非常大的数 让它很难滑动到尽头。

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

 @Override
    public int getCount() {
        //return mBannerBean.getData().size();
        return Integer.MAX_VALUE; //返回Integer的最大值,实现“手指滑动无限循环”
    }
Integer最大值SDK文档解释

如图 ,MAX_VALUE的值为: 2的31次方减1 得出的一个常量值:2147483647,换句话说 理论上你需要滑动二十一亿四千七百四十八万三千六百四十七次才能滑动到尽头....


想必世界上应该还没有如此耿直的人非要滑那么多次的吧 那么 它就是“无限循环”

或者你还可以这样写:

 @Override
    public int getCount() {
        //return mBannerBean.getData().size();
        //返回数据源大小的整数倍
        return (mBannerBean.getData().size() * 10000 * 100);
    }

这种是直接返回数据源的整数倍的方式,个人推荐这种写法 原因后续会讲到。反正不管怎么写 核心就是返回一个非常大的数 在相当长的时间内滑不到尽头。

ps: 关于无限循环 市面上还有一些其他做法 比如重写 OnPageChangeListener 接口中的onPageSelected 方法或者我看有些人通过动态添加/复用头尾item的方式做到所谓“真正意义上的无线循环”,有兴趣请自行浏览器了解

ok 我们设置完getCount()返回值后,此时我们如果直接运行项目 会报出IndexOutOfBoundsException异常,其原因在于:我们设置了ViewPager的item的总大小但并没有对position进行处理,当postion的值超出了数据源(list集合)的大小 就会抛出索引越界异常

所以当前的position如果超出我们数据源的最大值(最大值为6) 我们需要把这个position处理成数据源范围内的值:

 @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 通过取余数的方式来限定position的取值范围
        position = position % mBannerBean.getData().size();
        Log.i("TEST_POSITION","处理之后的position:"+position);
        if (null!=mOnLoadImageListener){
            //设置回调,传入数据 让调用层(Activity)去处理加载图片的逻辑
            mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
        }
        //把每一个item(ImageView)添加到ViewPager容器中
        container.addView(iv);
        return iv;
    }

刚好 我们可以通过取余数的特性 限定position的取值范围: 从0到数据源大小-1之间
此时运行项目并打印position日志:

可以看到 已经可以无限的向右滑动了,我向右滑动了两轮 此时Log打印出position值为:


position处理前与处理后的值

看到了8~ 如果没处理position 当position为6的时候 就已经索引越界了。我们通过取余处理后 position值就能按顺序控制在0-6之间以此类推

ok 看似已经实现了手指滑动无限循环 但有一个小问题 我向右滑动没问题 但我向左边滑动到position值为0的item的时候就滑不动了,ViewPager就会认为我左边已经没有item了。

解决这个问题 只需要让ViewPager左滑时 在相当长的时间内滑不到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已经使用当前适配器完成了它的第一个布局,那么当前项和指定项之间将有一个平滑的动画过渡。

一般情况下ViewPager初始化时默认的item位置为0。

但我们可以使用这个API给ViewPager一个初始位置:

//ViewPager初始化时 滑动到一半的距离
mViewPager.setCurrentItem((mViewPager.getAdapter().getCount()) / 2);

在初始化的时候 给ViewPager设置初始位置为:总条目数的一半

这样一来 不论是左滑还是右滑都不会滑到"尽头"

但问题来了 还记得刚刚提到的实现无限循环在getcount()中的两种返回方式的写法吗? 一个是返回Integer最大值 一种是返回数据源的整数倍,并且我还推荐使用整数倍的写法。

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

你会发现当你冷启动App时 ViewPager显示的item位置经过取余处理后 仍然不会在第一位,一般情况下 我们正常需求肯定都是初始化显示第一张图片(position = 0) 为什么会出现这种情况呢?

原因就在于:这个数不能被整除

所以你还要对它的余数进行拼差处理, 太麻烦了 而且这个数也太大 我们没必要设置这么大的数。

所以个人推荐使用整数倍的方式 。

定时轮播

在Android中 想要周期性执行任务基本有以下几种方式:

  • Timer+TimerTask
  • 延时Handler(postDelay)
  • 周期性执行任务的线程池

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

Acitivity类中:

    private Timer mTimer;
    private TimerTask mTimerTask;
    /**
     * 开启一个延时任务并执行
     */
    private void executeDelayedTask() {
        //在创建任务之前 一定要检查清理未回收的任务,保证只有一组Timer+TimerTask
        killDelayedTask();
        mTimer = new Timer();
        mTimerTask = new TimerTask() {
            @Override
            public void run() {
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        //显示下一页
                        showNextPage();
                    }
                });
            }
        };
     //设置delay参数为3000毫秒表示用户调用schedule() 方法后,要等待3秒才可以第一次执行run()方法
     //设置period参数为4000 表示第一次调用之后,从第二次开始每隔4秒调用一次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 执行定时任务 这个任务就是: showNextPage()显示下一页。

手指触摸/滑动轮播区域时停止轮播,手指离开重新轮播

这个也很简单 只需要用到ViewPager的一个API

依旧是在Acivity类中:

  /**
      * @Description 在手指按下和移动时 清除延时任务,待手指松开重新创建任务
     */
    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秒钟后开始执行任务  每个任务之间隔4秒执行一次
 superBanner.executeDelayedTask();
//初始化touch事件
 superBanner.setViewPagerTouchListener();

此时运行项目:


定时轮播+手指触停

注意看 我手指触摸滑动的时候 此时会停止轮播 当手指松开后 又会继续轮播。

底部指示器:

定时轮播完成后 我们想在ViewPager底部显示一排"指示器",可以随页面的滑动更改状态

依然实在Activity类中:

  /**
     * @Description 初始化ViewPager底部指示器
     * @param indicatorLayout 指示器的父布局 由调用者提供
     */
    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值转换为像素值 想必大多人项目的Util中应该有该方法, 还是贴出来吧:

/**
     * @Description 将dp转为px
     */
    private int dpToPx(int dp) {
        //获取手机屏幕像素密度
        float phoneDensity = getResources().getDisplayMetrics().density;
        //加0.5f是为了四舍五入 避免丢失精度
        return (int) (dp * phoneDensity + 0.5f);

    }

指示器创建完毕后,需要将指示器中的每个view与页面切换/选中状态捆绑:

/**
     * @Description 随着ViewPager页面滑动 更新指示器选中状态
     * @param position ViewPager中的item的position
     */
    public void updateIndicatorSelectState(int position) {
        //此时传入的position还未经过处理 同样的需要对position进行取余数处理
        position = position % mIndicatorLayout.getChildCount();
        //循环获取指示器父布局中所有的子View
        for (int i = 0; i < mIndicatorLayout.getChildCount(); i++) {
            //给每个子view设置选中状态
            //当i == position为True的时候触发选中状态反之则设置成未选中
            mIndicatorLayout.getChildAt(i).setSelected(i == position);

        }
    }

如上述代码段,updateIndicatorSelectState()需要接受一个position , 那么这个position从哪里来?换句话说,该在何时调用此方法?

没错 那就是需要在ViewPager页面状态发生改变时调用。所以还要给ViewPager添加一个页面状态事件监听:

   /**
     *@Description 添加ViewPager页面改变事件的监听
     */
    public void initPageChangeListener(){
        mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float v, int position1) {

            }

            @Override
            public void onPageSelected(int position) {
                //更新指示器选中状态
                updateIndicatorSelectState(position);
            }

            @Override
            public void onPageScrollStateChanged(int position) {

            }
        });
    }

三个回调方法想必大家都很熟悉了吧,不解释,初始化后 然后运行项目:

            //初始化指示器
            initIndicatorView();
            //在初始化的时候 让指示器选中第一个位置
            updateIndicatorSelectState(0);
            //初始化ViewPager页面选择状态监听
            initPageChangeListener();
无限循环+自动轮播+触开离停+底部指示器

至此,我们的基础的业务功能已经实现。

但是, 这UI样式有些过时而且页面切换的时候交互略显生硬不够 优雅

那好 接下来咱们就着手让它尽可能好看一点

动画效果及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的边角进行剪裁实现圆角
 */
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时很有可能 会发生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) {
    //主要为了防止屏幕宽高小于圆角半径值这种诡异的现象出现
        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()中创建的,改一下:

给item添加布局

都是日常操作,不解释。不过俗话讲:空白留有余韵,所以唯一要注意的是 我给item布局设置了一个padding值 这样我们的item就可以距离父控件上下左右有些距离 这样视觉上会更好看

不信看下效果:

内边距+圆角

这个稍微岔个话,关于IamgeView圆角的实现方式有很多 关于ViewPager item圆角的方式也有很多,比如你们如果用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;
    /**
      *构造中接受圆角半径参数
    */
    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;
   /**
    *此方法主要让调用层控制是否延时
   */
    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的时候 反射到具体的参数 然后替换一下:

/**
     * @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();
        }
    }

初始化调用一下这个方法,然后运行看下效果:

item切换延时两秒+内边距

我为了让效果稍微直观一点,我设置切换速度为2秒,其实一秒钟就已经ok了,看起来有木有舒服些?

ps:目前大多数主流App, 包括但不限于:淘宝、网易云音乐、掌上生活(招行)、华为应用市场、优酷、京东..等等的banner基本上都是item圆角或者内边距的形式显示。此Demo保留了图片加载由调用层处理的回调 你们可以自由加载。

UI样式告一段落,下面开始加动画。

ViewPager有个API:

官方文档API说明

API功能翻译:设置viewpage。当滚动位置改变时,将为每个附加页调用PageTransformer。这允许应用程序对每个页面应用自定义属性转换,覆盖默认的滑动行为。
API参数翻译:
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();
        }

    }

这是个重载方法 文档结合源码 首先这个方法接收三个参数,第一个参数和最后一个参数不是重点,自行理解,关键是PageTransformer这个参数。

PageTransforme是个啥玩意儿呢:

谷歌开发指南中的解释

注意图中标注区域,一定要搞清楚这些解释的真正含义 才能自定义各种动画,如果还没用过PageTransforme自行浏览器了解 这里就不深入讲了......

为啥不讲了? 因为我:


哈哈哈开玩笑啦 其实是因为PageTransforme这个东西细节太多 如果想完全讲清楚 需要占用大量篇幅,完全可以单写一篇文章详细讲解了。网上有大量相关PageTransforme的文章讲解 如果不太清楚PageTransforme的你们就自行了解吧

但本文会使用谷歌开发指南中ViewPager的两个动画例子 来给我们的ViewPager加上动画效果。

第一个:


DepthPageTransformer页面深度线性淡出效果

DepthPageTransformer官方示例效果:


DepthPageTransformer效果

怎么实现?很简单啊 谷歌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的时候调用:

            //第一个参数为true表示页面是按正序添加 反之则为倒序。(一般只有在帧布局的时候才有视觉效果)
            //第二个参数为具体的动画样式的实例,此方法一定要在setAdapter之前调用!!!!
             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效果实现

这块还可以更美化一点 比如说我们经常见到的3D画廊效果,一屏可以显示多页 然后缩放渐入。缩放渐入我们实现了 怎么能一屏显示多页呢?

别想那么复杂, 一个属性就能搞定:

clipChildren属性文档解释

定义子对象是否被限制在其界限内绘制。这对于将孩子的大小缩放到100%以上的动画非常有用。在这种情况下,应该将此属性设置为false,以允许子元素绘制超出其边界的内容。此属性的默认值为true。
可以是一个布尔值,如“true”或“false”。

这是ViewGroup中的一个特有属性 它可以允许子控件越界显示

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


一屏显示多页布局设置

如图,在根布局加上android:clipChildren="false" 然后我把ViewPager的宽度由原来的match_parent改成指定宽度 这样做是为了让其不要填满窗体 这样其它页面才能展示到当前屏幕上(实际使用时 这个值不要随便给,最好是通过机型屏幕宽度计算出的一个宽度值)。

直接运行项目看下效果:


3D画廊效果

当然 你们也可以自定义动画效果,包括动画的具体参数 比如透明度 缩放比 样式等等参数 都可以自己调整。

奥 差点忘了,我们还没有给`ViewPager添加item点击事件。

设置item点击事件

由于ViewPager并没有直接提供点击事件的API 所以目前有很多种方式给ViewPager添加点击事件 比如在touch中通过对手势事件的拦截和偏移量的计算,还有直接给item的View添加点击事件 然后再回调给ViewPager。那我们就采用第二种方案。
首先定义出一个回调接口:

package com.banner.superbanner.callback;

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

这个callback主要用于BannerAdapter类中,在BannerAdapter的构造方法中接受回调的实例,然后在instantiateItem()中触发回调:

 /**
     *@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()中调用一下就可以了,然后在Acitivity中给ViewPager设置Adapter时 在BannerAdapter的构造中实现OnItemClickAdapterListener回调 重写onItemAdapterClick()就OK了。

代码整合与封装

代码写到这里 我们想要的需求也全部实现 但代码结构乱的一批。

一方面为了演示方便和自己方便 我直接把ViewPager相关代码全部写在了调用层Activity中,可读性不强。

第二方面 ViewPager业务逻辑的具体参数直接写死了 没有提供让外部赋值的方法,不利于扩展。

第三方面 没有对实例进行非空校验 没有对代码进行容错考虑。

这三个方面 就造成了一个问题: 耦合严重,健壮性差

良好的代码结构应该是: 高内聚 低耦合 。调用层和实现层要尽量解耦

下面要做一些封装和抽取 目的就是: 要把所有和ViewPager的相关的业务逻辑内聚到一个类中并对外暴露API 让调用层决定banner业务逻辑中的具体参数 并封装成一个简易框架。

需求是,调用者可以决定banner:

  • 是否可以手指滑动无限循环
  • 是否可以定时轮播
  • 是否拥有底部指示器
  • 是否拥有动画效果
  • 是否需要item点击事件
  • 是否需要调用者去处理加载图片的逻辑
  • 是否需要item圆角展示(glide)
  • Banner页面切换的速度
  • 定时轮播的间隔时间
  • 底部指示器View的宽高和间距(相对于父布局)
  • item的内边距

新建一个 SuperBanner类 将之前写在Activity中和ViewPager相关的代码全部移植到此类中,然后将上述需求整理成具体函数,以方法链的形式暴露出去,最终调用层对Banner的设置只需要以下API:

这样一来,调用者只需要确定上述的一些参数 banner的实现就和调用层无关了

由于 SuperBanner类的代码太多,贴出来太影响阅读体验 如果感兴趣请自行下载Demo了解 注释都很详细 。

使用

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

你可以直接设置数据源 然后start 但是这样没有底部指示器 也没有其他的一些效果 但默认会有自动轮播和手指滑动无限轮播效果。

全部API:

 mSuperBanner.
                //设置数据源
                 setDataOrigin(imageList)
                //重载方法,设置指示器布局及指示器样式,不需要就无需调用 后三个参数代表指示器的宽高和间距(可选设置 有默认效果)
                .setIndicatorLayoutParam(mIndicatorLayout, R.drawable.indicator_select, 6, 6, 10)
                //设置ViewPager的item切换速度,不需要更改速度就无需调用
                .setViewPagerScroller(1000)
                //设置自动轮播间隔时间,重载方法 默认开始执行定时任务时间为2秒
                .setAutoIntervalTime(3000, 2000)
                //.closeAutoBanner(true)  关闭自动轮播
                //.closeInfiniteSlide(true)  关闭手指滑动无限循环
                //设置item的padding值(上下左右)
                .setItemPadding(14)
                //设置圆角半径 一旦设置值(大于0) 就代表item使用圆角样式
                .setRoundRadius(10)
                //.setSwitchAnimation()  设置ViewPager切换动画

                //可选实现。实现图片加载回调(一定要在start()之前执行) 一但实现回调就表示图片加载交由调用层处理 否则由适配器内部加载
                .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);
                    }
                })

                // 可选实现。实现item点击事件回调(一定要在start()之前执行)
                .setOnItemClickListener(new SuperBanner.OnItemClickListener() {
                    @Override
                    public void onItemClick(int position) {
                        Log.i("BannerItemPosition: ", position + "");
                    }
                })
                // 此函数要最后执行
                .start();

如果有其他需求 直接改源码就ok 注释真的很详细奥~

奥,最后 你可以在Activity/Fragment不可见的时候 关掉轮播定时任务以尽可能的减少内存压力和内存泄漏发生:

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

然后在页面可见时开启:

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

很希望能帮到你 不足之处还请见谅 恳请斧正 !。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容