Rxjava + ViewPager 打造实用图片轮播

背景

说到图片轮播,之前写过一篇文章《造轮子:android自定义专属广告轮播控件》,不过当时是采用ViewFlipper实现图片轮播的,最近开始研究Rxjava技术,发现有个interval的方法,觉得很实用,就打算去实战写一个东西来玩玩。就笔者目前接触的项目,发现图片轮播这种功能,应用非常之多,由此笔者就构想用Rxjava + ViewPaper去写一个通用能自定义能扩展的图片轮播框架。

目的

开源项目,打造一个好用的图片轮播框架。

思路

相信大家之前都用过ViewPaper,所以此处不多做解释,不清楚的童鞋可以百度或谷歌。Rxjava,之前写过两篇文章:《Rxjava实践之路[入门篇]》《Rxjava实践之路[初级篇]》,不太清楚的童鞋可以去看看,或者百度查其他文章了解了解。大体实现思路如下:

  • ViewPaper实现滑动切换页面
  • Rxjava定时使ViewPaper切换页面
1132780-f58fe3a5c5a7c281.jpg

有两个大体思路,接下来我们从细节出发,首先我们考虑以下两个问题:

  • 要不要循环?
  • 要不要自动轮播?

接着就衍生以下几种可能性:

  • 不循环(肯定不轮播,只能手动切换图片)
  • 循环
    • 自动轮播
    • 手动切换图片
1132780-c9a67cd151670731.jpg

然后考虑指示器问题,会衍生以下几个问题:

  • 指示器如何摆放?
  • 指示器图标是否需要自定义?

由此诞生以下几种可能性:

  • 指示器摆放位置产生靠左,靠右,靠中三种选择
  • 指示器肯定需要能自定义图标,满足大众选择要求嘛~

接着考虑怎么去实现以上需求,首先我们将ViewPager切换页面划分为两种:

  • 不循环:此时所做工作,跟平常使用ViewPager切换页面无区别,无特殊处理。
  • 循环: 用过ViewPager的童鞋都清楚,ViewPager怎么可以循环呀?这里做了一个巧妙地工作,这也是ViewPaper实现自动轮播原理所在。这块做详细说明,重点来了,大家擦亮眼睛看清啦~~
1132780-2d7d1306d1b86b65.jpg

ViewPager实现自动轮播原理说明:

假如现在有三张图需要自动轮播,图1,图2,图3。那轮播View集合就需要增加两张图,在原图3后面增加图1,在原图1前增加图3,处理过后的轮播View集合顺序是这样的图3、图1、图2、图3、图1。此刻有些童鞋看着有点懵,这样处理有啥用呀?

1132780-253900d649118aa0.jpg

处理前后比对:
处理前轮播View集合顺序:图1、图2、图3。
处理后轮播View集合顺序:新图3、原图1、原图2、原图3、新图1。

当向右滑动ViewPager,滑动到最后一个位置即新图1,此时做一个巧妙地跳转,ViewPager有个setCurrentItem(int item, boolean smoothScroll)方法,将smoothScroll置为false,跳转到原图1,因为两图是一样的图,而又看不到滑动效果,所以感觉没变一样。
当向左滑动ViewPager,滑动到第一个位置即新图3,同样调用setCurrentItem(item,false)等方法跳转到原图3,至此就达到了循环效果了。
童鞋们,是不是很简单?思路分析到此,接下来代码撸起来~~~

代码解析

首先我们需要写一个xml布局文件,里面包括ViewPager(用于展示图片)、指示器(显示当前图片在第几张)、标题(这个作为可选项),代码如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:orientation="vertical">
    <android.support.v4.view.ViewPager
        android:id="@+id/vp_cycle"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <LinearLayout
        android:id="@+id/ly_cycle_indicator"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:paddingBottom="24dp"
        android:gravity="center"
        android:orientation="horizontal" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_above="@id/ly_cycle_indicator"
        android:orientation="vertical">
        <TextView
            android:id="@+id/tv_cycle_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:gravity="center"
            android:textColor="@android:color/white"
            android:textSize="20sp" />
    </LinearLayout>
</RelativeLayout>

接着写初始化view操作,所做的工作包括初始化一个View,初始化该View需要用到的控件并将该View添加为当前轮播自定义View的子View。代码如下:

    /**
     * 初始化view
     * @author leibing
     * @createTime 2016/09/20
     * @lastModify 2016/09/20
     * @param context 上下文
     * @return
     */
    private void initView(Context context) {
        // 指定布局
        View view = LayoutInflater.from(context).inflate(R.layout.widget_cycle_view, null);
        // findView
        mViewPager = (ViewPager) view.findViewById(R.id.vp_cycle);
        mTitle = (TextView) view.findViewById(R.id.tv_cycle_title);
        mIndicatorLy = (LinearLayout) view.findViewById(R.id.ly_cycle_indicator);
        // 添加view到轮播view
        this.addView(view);
    }

然后给该轮播自定义View添加数据源,主要做以下工作:

  • 添加轮播View
  • 添加指示器
  • 设置轮播适配器
  • 开始订阅自动轮播

代码如下:

    /**
     * 设置数据源
     * @author leibing
     * @createTime 2016/09/20
     * @lastModify 2016/09/20
     * @param mData 轮播view数据源
     * @param defaultPosition 默认显示位置
     * @param defaultImage 默认占位图(图片未加载出来前)
     * @param listener 轮播监听
     * @return
     */
    public void setData(List<CycleModel> mData, int defaultPosition,
                        Drawable defaultImage, CycleViewListener listener){
        // 设置轮播view数据源
        this.mData = mData;
        // 如果数据源不存在或者其大小为0则设置当前布局为不可见
        if (mData == null || mData.size() == 0){
            this.setVisibility(View.GONE);
            return;
        }
        int size = mData.size();
        // 如果默认显示位置超过轮播view数目则默认位置从第一个位置开始
        if (defaultPosition >= size)
            defaultPosition = 0;
        // 轮播view数目为1,则不需要循环
        if (size == 1)
            isCycle = false;
        // 清除mViews数据
        mViews.clear();
        // 添加轮播view
        if (isCycle) {
            // 添加轮播图View,数量为集合数+2
            // 将最后一个View添加进来
            mViews.add(getCycleView(getContext(), mData.get(size - 1).getUrl(), defaultImage));
            for (int i = 0; i < size; i++) {
                mViews.add(getCycleView(getContext(), mData.get(i).getUrl(), defaultImage));
            }
            // 将第一个View添加进来
            mViews.add(getCycleView(getContext() , mData.get(0).getUrl(), defaultImage));
        } else {
            // 只添加对应数量的View
            for (int i = 0; i < size; i++) {
                mViews.add(getCycleView(getContext(), mData.get(i).getUrl(), defaultImage));
            }
        }
        // 设置轮播监听
        cycleViewListener = listener;
        // 初始化指示器
        initIndicators(size, getContext());
        // 设置指示器
        setIndicator(defaultPosition);
        // 设置适配器
        setAdapter(mViews, cycleViewListener, size);
        // 如果已经开始轮播订阅,则取消轮播订阅
        cancelSubscription();
        // 开始轮播
        startWheel(size);
    }

订阅轮播代码如下:

    /**
     * 开始轮播
     * @author leibing
     * @createTime 2016/09/21
     * @lastModify 2016/09/21
     * @param size 轮播view数目
     * @return
     */
    private void startWheel(int size){
        if (size < 2 || !isCycle()){
            // 取消轮播
            setWheel(false);
            return;
        }
        // 设置轮播
        setWheel(true);
        // 开始轮播
        mSubscription = Observable.interval(delay, TimeUnit.MILLISECONDS)
                .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Action1<Long>() {
                    @Override
                    public void call(Long aLong) {
                        if (isWheel && isHasWheel) {
                            mCurrentPosition++;
                            if (mViewPager != null)
                                mViewPager.setCurrentItem(mCurrentPosition, false);
                        }
                    }
                });
    }

ViewPager在页面切换做了相关处理,思路里面已经讲了,而且注释也比较清楚,代码如下:

    @Override
    public void onPageSelected(int position) {
        int max = mViews.size() - 1;
        mCurrentPosition = position;
        if (isCycle()) {
            if (position == 0) {
                // 滚动到mView的1个(界面上的最后一个),将mCurrentPosition设置为max - 1
                mCurrentPosition = max - 1;
            } else if (position == max) {
                // 滚动到mView的最后一个(界面上的第一个),将mCurrentPosition设置为1
                mCurrentPosition = 1;
            }
            position = mCurrentPosition - 1;
        }
        setIndicator(position);
    }

    @Override
    public void onPageScrollStateChanged(int state) {
        if (state == 0 && isCycle()) { // viewPager滚动结束
            //跳转到第mCurrentPosition个页面(没有动画效果,实际效果页面上没变化)
            mViewPager.setCurrentItem(mCurrentPosition, false);
        }
    }

然后就做了一些指示器自定义处理,如指示器位置和指示器图标,代码如下:

    /**
     * 设置指示器图片
     * @author leibing
     * @createTime 2016/09/20
     * @lastModify 2016/09/20
     * @param select   选中时的图片
     * @param unselect 未选中时的图片
     * @return
     */
    public void setIndicators(int select, int unselect) {
        mIndicatorSelected = select;
        mIndicatorUnselected = unselect;
    }
    
    
    /**
     * 指示器靠右显示
     * @author leibing
     * @createTime 2016/09/21
     * @lastModify 2016/09/21
     * @param paddingRight 指示器距右边内边距
     * @param paddingBottom 指示器距底部内边距
     * @return
     */
    public void setAlignParentRight(int paddingRight, int paddingBottom){
        if (mIndicatorLy == null)
            return;

        // 设置为靠右
        mIndicatorLy.setGravity(Gravity.RIGHT);
        // 设置内边距
        mIndicatorLy.setPadding(0,0,paddingRight,paddingBottom);
        // 重新布局
        mIndicatorLy.requestLayout();
    }

    /**
     * 指示器靠右显示
     * @author leibing
     * @createTime 2016/09/21
     * @lastModify 2016/09/21
     * @param paddingLeft 指示器距左边内边距
     * @param paddingBottom 指示器距底部内边距
     * @return
     */
    public void setAlignParentLeft(int paddingLeft, int paddingBottom){
        // 设置为靠左
        mIndicatorLy.setGravity(Gravity.LEFT);
        // 设置内边距
        mIndicatorLy.setPadding(paddingLeft, 0, 0, paddingBottom);
        // 重新布局
        mIndicatorLy.requestLayout();
    }

    /**
     * 指示器设置居中显示
     * @author leibing
     * @createTime 2016/09/21
     * @lastModify 2016/09/21
     * @param paddingBottom 指示器距底部内边距
     * @return
     */
    public void setAlignParentCenter(int paddingBottom){
        if (mIndicatorLy == null)
            return;
        // 设置为居中
        mIndicatorLy.setGravity(Gravity.CENTER);
        // 设置内边距
        mIndicatorLy.setPadding(0, 0, 0, paddingBottom);
        // 重新布局
        mIndicatorLy.requestLayout();
    }

最后,对自动轮播时手动滑动做了优化处理,当手指按下或者滑动的过程中停止轮播,手指离开屏幕开始轮播,代码如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        switch (ev.getAction()){
            case MotionEvent.ACTION_MOVE:
            case MotionEvent.ACTION_DOWN:
                // 手指按下或者滑动的过程中停止轮播
                setWheel(false);
                break;
            case MotionEvent.ACTION_UP:
                // 手指离开屏幕开始轮播
                setWheel(true);
                break;
        }
        return super.dispatchTouchEvent(ev);
    }

注意事项

当前页面不再使用该自定义图片轮播,记得在Activity onDestory方法中取消订阅(为了避免内存泄漏问题),只需调用cancelSubscription()方法,方法代码如下:


    /**
     * 取消轮播订阅
     * @author leibing
     * @createTime 2016/09/22
     * @lastModify 2016/09/22
     * @param
     * @return
     */
    public void cancelSubscription(){
        if (mSubscription != null){
            mSubscription.unsubscribe();
            mSubscription = null;
        }
    }

运行效果图如下:

LbaizxfCycleView.gif

笔者文笔太糟,欢迎吐槽,如有不对之处,请留言指点~~

呼吁大家动手实践,一切将会变得很容易~~~

项目地址:LbaizxfCycleView

关于作者

推荐阅读更多精彩内容