自定义BannerViewPager

虽然网上这种自定义ViewPager实现的banner挺多,但还是自己动手写下会好很多。权当练手吧。
一、效果预览

自定义BannerViewPager_第1张图片
image.png

二、需求分析

1.重写ViewPager控件实现bannerView
2.需要定时轮播
3.需要无限轮播
4.内存优化
5.ViewPager自身切换速率太快,需要重新设置

三、自定义View套路代码
直接继承自ViewPager

public class BannerViewPager extends ViewPager {
    //内存优化,复用的View.
    private SparseArray mConvertViews;

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

    public BannerViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
        //内存优化,缓存页面数据,下面会讲到
        mConvertViews = new SparseArray<>();
    }
}

四、采用Adapter设计模式
原始的ViewPager就是采用了adapter设计模式,方便用户使用,所以这里我们也仿照源码思想,采用adapter设计模式。
新建一个BannerAdapter类:

public abstract class BannerAdapter {
    //Observable,观察者模式,下面讲到
    private BannerViewPager.AdapterDataObservable mObservable = new BannerViewPager.AdapterDataObservable();
    //返回页面的View
    public abstract View getView(int position);
    //返回BannerView的页面数量,如果是无限轮播,记住要返回真是页面数量。
    public abstract int getCount();
    //ViewPager页面切换特效,方便自定义,返回null采用默认特效
    public Transformer getTransformer() {
        return null;
    }

    //************************** 观察者设计模式 **************************
    public void registerAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.registerObserver(observer);
    }

    public void unregisterAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.unregisterObserver(observer);
    }

    public final void notifyDataSetChanged() {
        mObservable.notifyChanged();
    }
}

BannerViewPager中定义setAdapter方法:

public void setAdapter(BannerAdapter adapter) {
        if (mBannerAdapter != null) {
            mBannerAdapter.unregisterAdapterDataObserver(mObserver);
        }
        this.mBannerAdapter = adapter;
        if (mBannerAdapter == null) {
            throw new IllegalArgumentException("BannerAdapter不能为null");
        }
        mBannerAdapter.registerAdapterDataObserver(mObserver);
        //设置切换动画
        if (mBannerAdapter.getTransformer() != null) {
            mTransformer = mBannerAdapter.getTransformer();
            mTransformer.bind(this);
        }
        mBannerPagerAdapter = new BannerPagerAdapter();
        setAdapter(mBannerPagerAdapter);
        setCurrentItem(mBannerAdapter.getCount());
    }

在这个方法中,我们主要做了这几件事:

1.观察者设计模式的处理。
2.bannerViewPager翻页动画。
3.给ViewPager设置真实的adapter:BannerPagerAdapter,下面就看下这个Adapter内部类。

在BannerViewPager中定义BannerPagerAdapter类:

private class BannerPagerAdapter extends PagerAdapter {
        @Override
        public int getCount() {
            return mBannerAdapter.getCount() == 1 ? 1 : Integer.MAX_VALUE;
        }
        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }
        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            //先转化为实际position
            final int realPosition = position % mBannerAdapter.getCount();
            View bannerItemView = mBannerAdapter.getView(realPosition);
            container.addView(bannerItemView);
            //点击事件
            bannerItemView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mItemClickListener != null) {
                        mItemClickListener.onBannerItemClick(realPosition);
                    }
                }
            });
            return bannerItemView;
        }
        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            container.removeView((View) object);
        }
    }

其中有页面点击事件处理,很简单的回调

    public BannerItemClickListener mItemClickListener;
    public interface BannerItemClickListener {
        void onBannerItemClick(int position);
    }
    /**
     * 条目点击事件
     */
    public void setOnItemClickListener(BannerItemClickListener listener) {
        this.mItemClickListener = listener;
    }

这里说下无限轮播的思路,奥妙都在BannerPagerAdapter 这个类中,
getCount方法返回Integer.MAX_VALUE,代表ViewPager有2^31-1个页面
instantiateItem方法中根据postion返回每个页面的View,postion最大为Integer.MAX_VALUE-1,而实际页面数只有有限的几个,所以需要做下转换final int realPosition = position % mBannerAdapter.getCount();
这样处理后,假设有实际页面ABC,最终的效果就是[position=0,页面A] [position=1,页面B] [position=2,页面C] [position=3,页面A] [position=4,页面B] [position=5,页面C] [position=6,页面A] [position=7,页面B] ...

五、自动轮播
自动轮播我们采用handler,handler.postDelayed(Runnable r, long delayMillis),同时传入的runable的run方法里,再次postDelayed调用自身runnable方法,形成一个递归。

//初始化handler
@SuppressLint("HandlerLeak")
    private void initHandler() {
        mHandler = new Handler();
    }
//定义一个runnable,并在run方法内再次mHandler.postDelayed自身
    private final Runnable task = new Runnable() {
        @Override
        public void run() {
            setCurrentItem(getCurrentItem() + 1);
            mHandler.postDelayed(task, mCutDownTime);
        }
    };

接着定义开始和停止滚动的两个方法,其实就是控制handler的执行和停止任务:

    public void startScroll() {
        if (mBannerAdapter == null) {
            return;
        }

        boolean scrollable = mBannerAdapter.getCount() != 1;
        if (scrollable && mHandler != null) {
            mHandler.postDelayed(task, mCutDownTime);
        }
    }

    public void stopScroll() {
        if (mBannerAdapter == null) {
            return;
        }
        if (mHandler != null) {
            mHandler.removeCallbacks(task);
        }
    }

mCutDownTime就是轮播间隔时间,当然也得要提供给用户api控制这个间隔

   private int mCutDownTime = 5000;
    /**
     * 设置每个条目切换时间
     */
    public void setCutDownTime(int millis) {
        this.mCutDownTime = millis;
    }

到此,自动轮播处理完毕。

六、自定义Scroller改变ViewPager默认切换速率
通过查看源码我们发现,ViewPager的滚动内部采用scroller控制,那么我们自己定义一个scroller将他自带的替换掉即可。

public class BannerScroller extends Scroller {

    private int mDuration = 1000;

    public BannerScroller(Context context) {
        super(context);
    }

    public void setScrollerDuration(int duration) {
        this.mDuration = duration;
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy) {
        super.startScroll(startX, startY, dx, dy,mDuration);
    }

    @Override
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        super.startScroll(startX, startY, dx, dy, mDuration);
    }
}

mDuration即控制滑动时间的变量。
在BannerViewPager的构造方法中,替换默认mScroller为我们自定义的Scroller,不过遗憾的是ViewPager并没有提供相关api,且mScroller为私有的,我们这里只能采取一些手段了:反射。

    public BannerViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);

        mConvertViews = new SparseArray<>();
        //反射修改ViewPager默认的滚动速率
        try {
            final Field field = ViewPager.class.getDeclaredField("mScroller");
            mBannerScroller = new BannerScroller(context);
            field.setAccessible(true);
            field.set(this, mBannerScroller);
        } catch (Exception e) {
            e.printStackTrace();
        }
        initHandler();
    }

到这里,自定义ViewPager也算勉强可以用了,不过还有不少值得优化的地方。

七、内存优化
回到BannerPagerAdapter这个类中,instantiateItem方法,每次获取页面,直接调用mBannerAdapter.getView(realPosition);,我们的轮播图条目数可以有Integer.MAX_VALUE-1个的,这个方法就要调用无数次,页面就要初始化无数次,而实际的页面只有有限的几个,能不能优化呢,将初始化后的页面缓存起来即可!

//用SparseArray缓存,键为postion,真实的postion;值为页面数据。
private SparseArray mConvertViews;
public View getConvertView(int position) {
        final View convertView = mConvertViews.get(position, null);
        if (convertView == null || convertView.getParent() != null) {
            //健壮性判断,如果缓存的convertView有它的parent,那么返回null。
            return null;
        }
        return convertView;
    }
    //内存优化后的BannerPagerAdapter 
    private class BannerPagerAdapter extends PagerAdapter {

        @Override
        public int getCount() {
            return mBannerAdapter.getCount() == 1 ? 1 : Integer.MAX_VALUE;
        }

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

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            //先转化为实际position
            final int realPosition = position % mBannerAdapter.getCount();
            View bannerItemView = getConvertView(realPosition);
            //先从缓存中拿,如果拿不到,那么就重新初始化
            if (bannerItemView == null) {
                bannerItemView = mBannerAdapter.getView(realPosition);
            }
            container.addView(bannerItemView);
            bannerItemView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mItemClickListener != null) {
                        mItemClickListener.onBannerItemClick(realPosition);
                    }
                }
            });
            return bannerItemView;
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            container.removeView((View) object);
            //页面从viewpager移除的同时,缓存到mConvertViews中
            mConvertViews.put(position % mBannerAdapter.getCount(), (View) object);
        }
    }

八、bug修复
在使用中,存在这样一个现象,比如嵌套在RecyclerView中使用,如果该bannerViewPager被滑出屏幕再滑进屏幕,ViewPager的第一次切换没有动画,很生硬。
查看源码:

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        mFirstLayout = true;
    }

这个onAttachedToWindow方法中,将mFirstLayout 置为true,在切换时,会先判断该字段,只有mFirstLayout =false时才启用滑动动画,所以重写该方法即可,同样mFirstLayout 为私有,采取反射

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        try {
            //缓存反射字段
            if (mFirstLayoutField == null) {
                //反射耗时,缓存一下
                mFirstLayoutField = ViewPager.class.getDeclaredField("mFirstLayout");
            }
            mFirstLayoutField.setAccessible(true);
            mFirstLayoutField.set(this,false);
        } catch (Exception e) {
            e.printStackTrace();
        }
        startScroll();
    }
    //离开屏幕,暂停轮播。
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        stopScroll();
    }

九、处理点击暂停
手指在触摸或滑动bannerView的时候不应自动轮播,需要停止。
开始我想重写onTouchEvent或onInterceptTouchEvent,都没能达到效果,于是我就翻看了其他BannerView的处理,发现他们都是在dispatchTouchEvent中处理的。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL
                || action == MotionEvent.ACTION_OUTSIDE) {
            startScroll();
        } else if (action == MotionEvent.ACTION_DOWN) {
            stopScroll();
        }
        return super.dispatchTouchEvent(ev);
    }

后来我分析了一下事件分发:

对于onTouchEvent,
ACTION_DOWN 不经过此方法,向下传递到它的子view时,子view的onTouchEvent返回true(bannerViewPager的子view设置了点击事件),因此事件不再回传给该BannerViewPager。
ACTION_MOVE,ACTION_UP经过此方法,子view不处理这两种事件,交给该BannerViewPager处理拖拽滑动事件。
对于onInterceptTouchEvent,
ACTION_DOWN 经过此方法
ACTION_MOVE ACTION_UP不经过此方法,因为在move和up的时候,onTouchEvent处理了拖拽滑动事件,此时mFirstTouchTarget被置为null, dispatchTouchEvent处理分发事件时,就不走onInterceptTouchEvent方法了。

十、加入切换动画
我使用了仿魅族商店的切换动画,具体原理看这里
https://www.jianshu.com/p/e67aa68d2766
算法什么的我就无耻的直接拿过来用了
定义接口:

public interface Transformer {
    void bind(BannerViewPager viewPager);
    int getChildDrawingOrder(int childCount, int n);
}

仿魅族切换动画

public class MZTransformImpl implements Transformer{
    //中间放大系数
    private float mScaleMax = 1.0f;
    //两边缩小系数
    private float mScaleMin = 0.9f;
    //重叠部分
    private float mCoverWidth = 80f;

    private ArrayList childCenterXAbs = new ArrayList<>();
    private SparseArray childIndex = new SparseArray<>();

    private BannerViewPager mBannerViewPager;

    @Override
    public void bind(BannerViewPager viewPager) {
        this.mBannerViewPager = viewPager;
        mBannerViewPager.setPageTransformer(true, new SPageTransformer());//默认调用了 setChildrenDrawingOrderEnabledCompat(true);使得getChildDrawingOrder起作用
        mBannerViewPager.setClipToPadding(false);
        mBannerViewPager.setOverScrollMode(ViewPager.OVER_SCROLL_NEVER);
    }

    public int getChildDrawingOrder(int childCount, int n) {
        if (n == 0 || childIndex.size() != childCount) {
            childCenterXAbs.clear();
            childIndex.clear();
            int viewCenterX = getViewCenterX(mBannerViewPager);
            for (int i = 0; i < childCount; ++i) {
                int indexAbs = Math.abs(viewCenterX - getViewCenterX(mBannerViewPager.getChildAt(i)));
                //两个距离相同,后来的那个做自增,从而保持abs不同
                if (childIndex.get(indexAbs) != null) {
                    ++indexAbs;
                }
                childCenterXAbs.add(indexAbs);
                childIndex.append(indexAbs, i);
            }
            Collections.sort(childCenterXAbs);//1,0,2  0,1,2
        }
        //那个item距离中心点远一些,就先draw它。(最近的就是中间放大的item,最后draw)
        return childIndex.get(childCenterXAbs.get(childCount - 1 - n));
    }

    private int getViewCenterX(View view) {
        int[] array = new int[2];
        view.getLocationOnScreen(array);
        return array[0] + view.getWidth() / 2;
    }

    class SPageTransformer implements ViewPager.PageTransformer {
        private float reduceX = 0.0f;
        private float itemWidth = 0;
        private float offsetPosition = 0f;

        @Override
        public void transformPage(View view, float position) {
            if (offsetPosition == 0f) {
                float paddingLeft = mBannerViewPager.getPaddingLeft();
                float paddingRight = mBannerViewPager.getPaddingRight();
                float width = mBannerViewPager.getMeasuredWidth();
                offsetPosition = paddingLeft / (width - paddingLeft - paddingRight);
            }
            float currentPos = position - offsetPosition;
            if (itemWidth == 0) {
                itemWidth = view.getWidth();
                //由于左右边的缩小而减小的x的大小的一半
                reduceX = (2.0f - mScaleMax - mScaleMin) * itemWidth / 2.0f;
            }
            if (currentPos <= -1.0f) {
                view.setTranslationX(reduceX + mCoverWidth);
                view.setScaleX(mScaleMin);
                view.setScaleY(mScaleMin);
            } else if (currentPos <= 1.0) {
                float scale = (mScaleMax - mScaleMin) * Math.abs(1.0f - Math.abs(currentPos));
                float translationX = currentPos * -reduceX;
                if (currentPos <= -0.5) {//两个view中间的临界,这时两个view在同一层,左侧View需要往X轴正方向移动覆盖的值()
                    view.setTranslationX(translationX + mCoverWidth * Math.abs(Math.abs(currentPos) - 0.5f) / 0.5f);
                } else if (currentPos <= 0.0f) {
                    view.setTranslationX(translationX);
                } else if (currentPos >= 0.5) {//两个view中间的临界,这时两个view在同一层
                    view.setTranslationX(translationX - mCoverWidth * Math.abs(Math.abs(currentPos) - 0.5f) / 0.5f);
                } else {
                    view.setTranslationX(translationX);
                }
                view.setScaleX(scale + mScaleMin);
                view.setScaleY(scale + mScaleMin);
            } else {
                view.setScaleX(mScaleMin);
                view.setScaleY(mScaleMin);
                view.setTranslationX(-reduceX - mCoverWidth);
            }
        }
    }
}

回到BannerViewPager类中,setAdapter方法内设置切换动画

    public void setAdapter(BannerAdapter adapter) {
        ```
        //设置切换动画
        if (mBannerAdapter.getTransformer() != null) {
            mTransformer = mBannerAdapter.getTransformer();
            mTransformer.bind(this);
        }
        ```
    }

重写getChildDrawingOrder控制绘制顺序

    /**
     * 控制子View的绘制顺序
     */
    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        if (mTransformer != null) {
            return mTransformer.getChildDrawingOrder(childCount, i);
        }
        return super.getChildDrawingOrder(childCount, i);
    }

注意,如果要采用仿魅族切换样式,在BannerViewpager需要设置paddingLeft和paddingRight,在xml中定义即可。

十一、最后讲下观察者设计模式
源码中Adaper里很多到用到了观察者模式,Adapter中数据变化,调用notifyDataSetChanged,那么对应的View也会变化,这都是有一定模板套路的,也可参考上一篇https://www.jianshu.com/p/d75edebb6c8f的讲解。
这里就总结下套路:
首先要弄明白,谁是观察者(observer),谁是被观察者(observable)
记住一点:被观察者发生变化,观察者就会响应变化。
举个例子,手机是被观察者吧,人就是观察者,手机来微信了,就是被观察者发生变化,我们会很自然的去打开微信查看,这就是观察者响应变化。
类比一下,adapter就是被观察者,BannerViewpager就是观察者,因为adapter数据变化,BannerViewpager得要响应改变ui。
在代码世界里,这种响应是如何做到的呢,为什么observer会响应observable的变化,说白了就是observable中持有了observer的引用,observable发生变化后,再主动调用observer相关变化的方法即可。
开始上代码,BannerViewPager中:

    private final BannerDataObserver mObserver = new BannerDataObserver();
    static class AdapterDataObservable extends Observable {
        public void notifyChanged() {
            // since onChanged() is implemented by the app, it could do anything, including
            // removing itself from {@link mObservers} - and that could cause problems if
            // an iterator is used on the ArrayList {@link mObservers}.
            // to avoid such problems, just march thru the list in the reverse order.
            for (int i = mObservers.size() - 1; i >= 0; i--) {
                mObservers.get(i).onChanged();
            }
        }
    }

    public static abstract class AdapterDataObserver {
        public void onChanged() {

        }
    }

    private class BannerDataObserver extends AdapterDataObserver{
        @Override
        public void onChanged() {
            mBannerPagerAdapter.notifyDataSetChanged();
        }
    }

BannerAdapter中:

    private BannerViewPager.AdapterDataObservable mObservable = new BannerViewPager.AdapterDataObservable();
    //************************** 观察者设计模式 **************************
    public void registerAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.registerObserver(observer);
    }

    public void unregisterAdapterDataObserver(BannerViewPager.AdapterDataObserver observer) {
        mObservable.unregisterObserver(observer);
    }

    public final void notifyDataSetChanged() {
        mObservable.notifyChanged();
    }

在setAdapter(BannerAdapter adapter)中,将观察者注册给被观察者就完事了,实质就是依赖注入。

public void setAdapter(BannerAdapter adapter) {
        if (mBannerAdapter != null) {
            mBannerAdapter.unregisterAdapterDataObserver(mObserver);
        }
        this.mBannerAdapter = adapter;
        if (mBannerAdapter == null) {
            throw new IllegalArgumentException("BannerAdapter不能为null");
        }
        mBannerAdapter.registerAdapterDataObserver(mObserver);
       ...
    }

这样,调用BannerAdapter的notifyDataSetChanged方法就是调用 mObservable.notifyChanged();->AdapterDataObserver.onChanged()->mBannerPagerAdapter.notifyDataSetChanged();最终ui发生改变。

你可能感兴趣的:(自定义BannerViewPager)