美文网首页Androidandroid基础Android开发那些事
Android 实现带指示器的自动轮播式ViewPager

Android 实现带指示器的自动轮播式ViewPager

作者: 丶蓝天白云梦 | 来源:发表于2016-10-04 09:09 被阅读3572次

    前言

    最近在做项目的时候,有个需求就是实现自动轮播式的ViewPager,最直观的例子就是知乎日报顶部的ViewPager,它内部有着好几个子view,每个一段时间便自动滑动到下一个item view,而底部的指示器也随之跟着改变。使用这种ViewPager的好处是在有限的空间内可以展示出多样化的信息。轮播式ViewPager广泛应用于各种应用内部,用于展示广告等。抱着学习和分享的目的,笔者把轮播式ViewPager写成了一个独立的控件,以方便以后的使用。

    效果展示

    话不多说,我们先来看看实现的效果是怎样的:

    手指触摸滑动.gif

    从上面的动态图可以看到,当我们手指拖动ViewPager的时候,下方的指示器随着页面的滑动而滑动,当点击添加数据的按钮的时候,ViewPager的数据项变多,同时下方的指示器也随之改变,适应了数据项的数目。

    自动滑动.gif

    从上面的动态图可以看到,当我们不用手指进行拖动的时候,该ViewPager会每隔4s左右的时间自动进行滚动,滚动到最后一个item view的时候,下一次会滚到第一个位置。

    GitHub地址及使用介绍

    读者可以直接到我的GitHub中获取源码。
    GitHub:BannerViewPager,控件及其相关文件都放在了该目录下的library模块内,而app模块则是上面效果展示的一个简单应用。

    通过以下几个步骤,就能方便地使用该控件了:
    1、像普通的ViewPager一样,在布局文件中放入该控件如下:

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <com.chenyu.library.bannerViewPager.BannerViewPager
            android:id="@+id/banner"
            android:layout_width="match_parent"
            android:layout_height="200dp">
    
        </com.chenyu.library.bannerViewPager.BannerViewPager>
    
        <!-- others -->
    </LinearLayout>
    

    2、获取BannerViewPager的实例,进行相应的配置,比如我们使用ViewPager的时候,也需要设置它的适配器等。这里笔者实现了一个ViewPagerAdapter,用作BannerViewPager的适配器:

    //获取BannerViewPager实例
    bannerViewPager = (BannerViewPager) findViewById(R.id.banner);
    //实例化ViewPagerAdapter,第一个参数是View集合,第二个参数是页面点击监听器
    mAdapter = new ViewPagerAdapter(mViews, new OnPageClickListener() {
        @Override
        public void onPageClick(View view, int position) {
            Log.d("cylog","position:"+position);
        }
    });
    //设置适配器
    bannerViewPager.setAdapter(mAdapter);
    

    和一般的ViewPager没什么两样,都是:获取实例——创建适配器——设置适配器。而适配器的数据集一般都是一个View集合,用作ViewPager的item view,所以需要事先准备好相应的View集合。此外,一般轮播式ViewPager点击某一项后会打开相应的页面,所以这里提供了一个OnPageClickListener的监听器,在创建适配器的时候同时创建该监听器即可。

    原理简析

    接下来,笔者将简要分析BannerViewPager的实现思路,具体的请读者参考源码~

    实现自动滚动

    首先,我们先思考一下,系统自带的ViewPager是一个独立控件,没有指示器,也没有自动滚动的功能,但是它是一个现成的,可左右滑动的控件,我们肯定是需要ViewPager的,因此,我们可以利用一个布局,把ViewPager包裹起来,同时在这个布局里面再放入indicator(指示器)。

    那么,第一步,先新建BannerViewPager.java继承自FrameLayout,而这个FrameLayout有两个子元素:ViewPager和indicator。至于indicator,下面会说到。在构造函数内对这两个控件进行初始化先:

    public class BannerViewPager extends FrameLayout implements ViewPager.OnPageChangeListener {
    
        private ViewPager mViewPager;
        private ViewPagerIndicator mIndicator;
        private ViewPagerAdapter mAdapter;
        //...
        public BannerViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            this.mContext = context;
            initViews();
        }
    
        private void initViews() {
            //initialize the viewpager
            mViewPager = new ViewPager(mContext);
            ViewPager.LayoutParams lp = new ViewPager.LayoutParams();
            lp.width = ViewPager.LayoutParams.MATCH_PARENT;
            lp.height = ViewPager.LayoutParams.MATCH_PARENT;
            mViewPager.setLayoutParams(lp);
    
            //initialize the indicator
            mIndicator = new ViewPagerIndicator(mContext);
            FrameLayout.LayoutParams indicatorlp = new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT);
            indicatorlp.gravity = Gravity.BOTTOM | Gravity.CENTER;
            indicatorlp.bottomMargin = 20;
            mIndicator.setLayoutParams(indicatorlp);
        }
        //省略...
    }
    

    这里没什么好说的,主要是对ViewPager和ViewPagerIndicator进行初始化,设置它们的布局参数以便在FrameLayout中得到正确的显示。
    接着,对ViewPager实现自动滚动,这个的实现原理也不难,我们只要知道每时每刻的ViewPager的滑动状态、当前的page position值即可,而ViewPager有这样一个监听器:ViewPager.OnPageChangeListener,只要ViewPager进行了滑动,就会回调这个监听器的如下几个方法:

    public interface OnPageChangeListener {
        //只要ViewPager进行了滑动,该方法就会回调
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
        //当前页面被选定的时候,回调
        public void onPageSelected(int position);
        //ViewPager的状态发生改变的时候,回调
        public void onPageScrollStateChanged(int state);
    }
    

    那么,我们为ViewPager设置监听器(调用addOnPageChangeListener方法),并且重写这几个方法以实现我们的需求:

        //保存当前的position值
        private int mCurrentPosition;
        //viewpager's rolling state
        private int mViewPagerScrollState;
    
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            setIndicator(position,positionOffset); //下面会讲到
        }
    
        @Override
        public void onPageSelected(int position) {
            mCurrentPosition = position;
        }
    
        @Override
        public void onPageScrollStateChanged(int state) {
            if(state == ViewPager.SCROLL_STATE_DRAGGING){
                mViewPagerScrollState = ViewPager.SCROLL_STATE_DRAGGING;
            }else if(state == ViewPager.SCROLL_STATE_IDLE){
                mReleasingTime = (int) System.currentTimeMillis();
                mViewPagerScrollState = ViewPager.SCROLL_STATE_IDLE;
            }
        }
    

    每当当前页面被选中的时候,就会调用onPageSelected方法,此时保存当前position值。那么,什么叫做当前页面被选中呢?经过实验验证,当一个Item被完全展示在ViewPager中的时候,就是选中状态,但如果当前正在被手指拖动,即使下一个item滑动到了中间位置,也不是选中状态。接着,我们看onPageScrollStateChanged方法,当ViewPager的状态发生改变的时候,就会触发。那么,ViewPager的状态改变是什么意思呢?ViewPager有如下三种状态:IDLE,停止状态,无手指触摸;DRAGGING,正在被手指拖动;SETTLING,松开手指的时候,ViewPager由于惯性向能滑到的最后一个位置滑去的状态。我们重写的方法中,mViewPageSrollState记录了ViewPager的实时状态,同时停止状态的时候,也记录了一个mReleasingTime值,这个值的作用下面会介绍。通过这个监听器,我们获取到了mCurrentPosition和mViewPageScrollState这两个值。

    接下来,我们要考虑自动任务的问题了。在Android中,自动任务可以使用Handler和Runnable来实现,通过postDelay方法来不断实现循环,代码如下:

        private Handler mHandler = new Handler(){
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what){
                    case MESSAGE_AUTO_ROLLING:
                        if(mCurrentPosition == mAdapter.getCount() - 1){
                            mViewPager.setCurrentItem(0,true);
                        }else {
                            mViewPager.setCurrentItem(mCurrentPosition + 1,true);
                        }
                        postDelayed(mAutoRollingTask,mAutoRollingTime);
                        break;
                    case MESSAGE_AUTO_ROLLING_CANCEL:
                        postDelayed(mAutoRollingTask,mAutoRollingTime);
                        break;
                }
            }
        };
        /**
         * This runnable decides the viewpager should roll to next page or wait.
         */
        private Runnable mAutoRollingTask = new Runnable() {
            @Override
            public void run() {
                int now = (int) System.currentTimeMillis();
                int timediff = mAutoRollingTime;
                if(mReleasingTime != 0){
                    timediff = now - mReleasingTime;
                }
    
                if(mViewPagerScrollState == ViewPager.SCROLL_STATE_IDLE){
                    //if user's finger just left the screen,we should wait for a while.
                    if(timediff >= mAutoRollingTime * 0.8){
                        mHandler.sendEmptyMessage(MESSAGE_AUTO_ROLLING);
                    }else {
                        mHandler.sendEmptyMessage(MESSAGE_AUTO_ROLLING_CANCEL);
                    }
                }else if(mViewPagerScrollState == ViewPager.SCROLL_STATE_DRAGGING){
                    mHandler.sendEmptyMessage(MESSAGE_AUTO_ROLLING_CANCEL);
                }
    
            }
        };
    

    在mAutoRollingTask这个Runnable内,我们根据不同的mViewPagerScrollState来决定是让ViewPager滚动到下一个page还是等待,因为如果用户当前正在触摸ViewPage,那么肯定是不能自动滚动到下一页的,此外,还有一种情况,就是当用户手指离开屏幕的时候,需要等待一段时间才能开始自动滚动任务,否则会造成不好的用户体验,这也就是mReleasingTime的作用之处了。在Handler中,根据Runnable发送过来的不同信息来进行不同的操作,如果需要滚动到下一个页面,则调用ViewPager#setCurrentItem方法来进行滑动,该方法有两个参数,第一个参数是要滑动的位置,第二个参数表示是否开启动画。

    实现指示器

    接下来,我们来考虑,指示器怎么实现。指示器有如下需求:指示器由一系列圆点构成,未被选中的Page所对应的圆点为灰色,而选中的Page所对应的圆点为橙色,橙色的圆点能随着Page的滑动而滑动。当ViewPage的数据变动的时候,比如新增了页面,那么指示器所包含的圆点也会随着变多。

    那么,我们可以这样来实现需求:灰色的圆点作为Indicator的背景,通过onDraw()方法来绘制,而橙色圆点则通过一个子View来显示,利用onLayout()方法来控制它的位置,这样就能实现橙色圆点在灰色圆点上运动的效果了。而它们具体的位置控制,可以利用上面ViewPager.OnPageChangeListener#onPageScrolled方法来获取具体的位置以及位置偏移百分比。

    我们先来实现绘制部分,新建ViewPagerIndicator.java继承自LinearLayout,先对属性初始化:

    public class ViewPagerIndicator extends LinearLayout {
    
        private Context mContext;
        private Paint mPaint;
        private View mMoveView;    
        //省略...
    
        public ViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            this.mContext = context;
            init();
        }
    
        private void init() {
            //setOrientation(LinearLayout.HORIZONTAL);
            setWillNotDraw(false);
            mPaint = new Paint();
            mPaint.setAntiAlias(true);
            mPaint.setColor(Color.GRAY);
    
            mMoveView = new MoveView(mContext);
            addView(mMoveView);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            setMeasuredDimension(mPadding + (mRadius*2 + mPadding) * mItemCount,2*mRadius + 2*mPadding);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            for(int i = 0;i < mItemCount;i++){
                canvas.drawCircle(mRadius + mPadding + mRadius * i *2 + mPadding * i,
                        mRadius + mPadding,mRadius,mPaint);
            }
    
        }
        
        //省略...
    
        private class MoveView extends View {
            private Paint mPaint;
    
            public MoveView(Context context) {
                super(context);
                mPaint = new Paint();
                mPaint.setAntiAlias(true);
                mPaint.setColor(Color.argb(255,255,176,93));
            }
    
            @Override
            protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
                super.onMeasure(widthMeasureSpec, heightMeasureSpec);
                setMeasuredDimension(mRadius*2,mRadius*2);
            }
    
            @Override
            protected void onDraw(Canvas canvas) {
                super.onDraw(canvas);
                canvas.drawCircle(mRadius,mRadius,mRadius,mPaint);
            }
        }
    }
    

    从上面的代码可以看到,在init()方法内,我们调用了setWillNotDraw(false)方法,这个方法有什么用呢?如果有写过自定义View的读者应该知道,ViewGroup默认是不会调用它自身的onDraw()方法的,只有调用了该方法设置为false或者给ViewGroup设置一种背景颜色的情况下才会调用onDraw()方法。
    解决了这个问题后,我们来看onMeasure()方法,在这个方法内,我们要对该indicator的宽高做出测量,以便接下来的布局和绘制流程,而对于我们的需求而言,只要该布局能够包裹住我们的指示器,并且四边留有一定的空间即可,那么布局的宽度就与Page的数量有关了。为了方便起见,这里先给一个默认值,比如5个Page,那么对应5个灰色的圆点。
    我们接着看onDraw()方法,这个方法内部,根据mItemCount的数量,来进行绘制圆形,这里没什么好讲的,只要注意他们之间的距离就可以了。
    接着,我们来绘制橙色的圆点,新建一个内部类,继承自View,同样通过onMeasure、onDraw方法来进行测量、绘制流程,只不过颜色变了而已。

    好了,绘制部分就完成了,接下来就是让这个MoveView进行移动了,由于要使MoveView配合Page的滑动而滑动,我们需要Page的具体位置以及位置偏移量,而这两个数值是在BannerViewPager的内部中获得的,所以我们可以在BannerViewPager中,每一次调用onPageScrolled方法的时候,来调用我们的ViewPagerIndicator的一个方法,而在这个方法内部,来请求布局,这样就能实现MoveView随着Page的滑动而滑动的效果了,具体如下:

    public class ViewPagerIndicator extends LinearLayout {
        //以上省略..
    
        public void setPositionAndOffset(int position,float offset){
            this.mCurrentPosition = position;
            this.mPositionOffset  =offset;
            requestLayout();
        }
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            super.onLayout(changed, l, t, r, b);
            mMoveView.layout(
                    (int) (mPadding + mDistanceBtwItem * (mCurrentPosition + mPositionOffset) ),
                    mPadding,
                    (int) (mDistanceBtwItem * ( 1 + mCurrentPosition + mPositionOffset) ),
                    mPadding+mRadius*2);
        }
    }
    

    setPositionAndOffset方法内调用了requestLayout()方法,这个方法会导致View树的测量、布局、重绘流程的发生,因此在onLayout方法内,通过mCurrentPosition、mPositionOffset这两个值来控制MoveView的位置就可以了。

    好了,到现在为止,ViewPagerIndicator基本已经完成了,但是还有一个问题,如果适配器里面的数据刷新了,page的数量变多了,而指示器的数目却依然没变,上面我们使用的mItemCount是默认值,为5个。因此,我们必须在数据刷新的时候,及时通知Indicator来增加指示器的数目。但是,我们进一步想想,数据列表保存在Adapter中,如果ViewPagerIndicator想要获取数据,那就要得到Adapter的一个引用,或者说Adapter需要得到ViewPagerIndicator的引用以便能够通知它,如果这样做的话,相当于把两个相关性不大的类联系到了一起,耦合度过高,这样不利于以后的维护。
    因此,这里笔者采用了观察者模式来实现Adapter数据刷新时通知ViewPagerIndicator的这样一个需求。先新建两个接口,一个是DataSetSubscriber,观察者;另一个是DataSetSubject,被观察者。

    public interface DataSetSubscriber {    
        void update(int count);
    }
    
    
    public interface DataSetSubject {   
        void registerSubscriber(DataSetSubscriber subscriber);    
        void removeSubscriber(DataSetSubscriber subscriber);    
        void notifySubscriber();
    }
    

    这里实现思路是这样的:在BannerViewPager内实现一个DataSetSubscriber(观察者),在ViewPageAdapter内实现DataSetSubject(被观察者),通过registerSubscriber方法进行注册,当ViewPageAdapter的数据列表发生变动的时候,回调DataSetSubscriber的update()方法,并把当前的数据长度作为参数传递进来,而BannerViewPager再进一步调用ViewPagerIndicator的方法来重新布局即可。

    先来看ViewPagerIndicator.java:

    public class ViewPagerAdapter extends PagerAdapter implements DataSetSubject {
    
        private List<DataSetSubscriber> mSubscribers = new ArrayList<>();
        private List<? extends View> mDataViews;
        private OnPageClickListener mOnPageClickListener;
    
        /**
         * 构造函数
         * @param mDataViews view列表
         */
        public ViewPagerAdapter(List<? extends View> mDataViews,OnPageClickListener listener) {
            this.mDataViews = mDataViews;
            this.mOnPageClickListener = listener;
        }
    
        //省略...
    
        @Override
        public void notifyDataSetChanged() {
            super.notifyDataSetChanged();
            notifySubscriber();
        }
    
        @Override
        public void registerSubscriber(DataSetSubscriber subscriber) {
            mSubscribers.add(subscriber);
        }
    
        @Override
        public void removeSubscriber(DataSetSubscriber subscriber) {
            mSubscribers.remove(subscriber);
        }
    
        @Override
        public void notifySubscriber() {
            for(DataSetSubscriber subscriber : mSubscribers){
                subscriber.update(getCount());
            }
        }
    }```
    由于数据列表的变动一般都会调用notifyDataSetChanged()方法,所以我们在这个方法内再调用notifySubscriber()方法即可。而在BannerViewPager,则实现DataSetSubscriber的update()方法即可,如下所示:
    ```java
    public void setAdapter(ViewPagerAdapter adapter){
        mViewPager.setAdapter(adapter);
        mViewPager.addOnPageChangeListener(this);
    
        mAdapter = adapter;
        mAdapter.registerSubscriber(new DataSetSubscriber() {
            @Override
            public void update(int count) {
                mIndicator.setItemCount(count);
            }
        });
    
        //add the viewpager and the indicator to the container.
        addView(mViewPager);
        addView(mIndicator);
    
        //start the auto-rolling task if needed
        if(isAutoRolling){
            postDelayed(mAutoRollingTask,mAutoRollingTime);
        }
    
    }
    

    在update()方法内,调用了ViewPagerIndicator#setItemCount方法,从而重新布局。
    那么,指示器也实现完毕了。

    实现Page的点击事件处理

    还有最后一个需求,就是对Page的点击进行处理,因为往往ViewPager的内容只是一个概括性的内容,为了得到更加详细的信息,用户通常会点击它的item从而打开一个新的页面,这样就需要我们对点击事件进行处理了。其实实现方式不难,思路类似于笔者之前在RecyclerView的相关文章的处理点击事件中的方式,通过定义一个新的接口:OnPageClickListener,定义一个onPageClick方法。如下:

    public interface OnPageClickListener {    
        void onPageClick(View view,int position);
    }
    

    只要在item view初始化的时候,给每个item view都设置一个View.OnClickListener,并且在onClick方法里面调用我们的onPageClick方法即可。
    具体如下所示,ViewPagerAdapter:

    @Override
    public View instantiateItem(ViewGroup container, int position) {
        View view = mDataViews.get(position);
        final int i = position;
        if(mOnPageClickListener != null){
            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    mOnPageClickListener.onPageClick(v,i);
                }
            });
        }
    
        container.addView(view);
        return view;
    }
    

    在构建适配器的时候,同时实现OnPageClickListener即可。

    以上便是本文的全部内容,非常感谢你的阅读~
    欢迎到GitHub中获取本文的源码,欢迎star or fork。再次感谢!

    相关文章

      网友评论

      • 平平漫世一记儒:这是2016年的文章了不知道作者还会不会回复。问题是,如果我把你demo里面的add(iv6)换成remove(iv5),就是减少数据,那轮播图就会出问题,滚动到最后一页数据时无法向下走。麻烦你看一下。
      • c5dc1c310212:实现自动滑动也可以使用handler吧?这里用手势识别器也可以吧?
        丶蓝天白云梦:@花花公子_ @花花公子_ 这里的自动滑动正是使用handler结合runnable实现的,手势识别也可以用,不过这里只需要处理单击事件,所以简单一点就好
      • c5dc1c310212:1.没有看到这五个图片在中间位置,实现前后都能拖动,2.当拖动图片过半的时候,会出现图片回弹生硬问题.
        丶蓝天白云梦:@花花公子_ 你说的第一点是无限循环的问题吗?当前实现的确没有实现viewpager的无限循环。至于你说的第二点,回弹生硬的问题,我在我的机子上厕所的时候,感觉流畅度还可以啊…如果想要更加流畅,就要修改viewpager的切换速度了,这个可以作为以后的实现部分,谢谢你的提出~
      • 一个冬季:不说什么,点赞,good
      • fendo:赞一个
      • AmatorLee:还有一种情况需要考虑的,就是当activity或者碎片被隐藏的时候,这个时候应该停止自动轮播。不然会有bug~
        丶蓝天白云梦:@AmatorLee 嗯,多谢提醒,我会fix的:smile:
      • 想去山上定居:我也做了一个,但是没有耦合出来,有空分享分享
        丶蓝天白云梦:@黄廉温 好:smile:
      • 大大大大峰哥:有机会学习学习源码

      本文标题:Android 实现带指示器的自动轮播式ViewPager

      本文链接:https://www.haomeiwen.com/subject/zqtjyttx.html