LoopBanner原理浅析

作者: dreamruner | 来源:发表于2019-01-09 16:28 被阅读5次

    本文主要是阐述LoopBanner项目的原理及重要知识点,不涉及基本用法,对用法不了解的同学,可以访问https://github.com/wenjiangit/LoopBanner或下载demo.

    1. LoopBanner由来

    近来公司项目比较闲,于是便抽空学了一下Kotlin语言,毕竟本人也是一个有追求的Android开发者,对于Google官方力推的Android开发语言怎么可能视而不见,学习Kotlin主要是基于Kotlin实战一书,当然语法特性学习完了,肯定还是动手实战一下,便基于鸿洋大神的Wanandroid 开放API写了个WanAndroid客户端.项目是基于Google的AAC架构,感兴趣的同学可以参考一下.

    项目首页一般会有一个轮播图,当然我的WanAndroid客户端也不例外,其实碰到这种情况,我和大家的想法一样,上Github找个现成的轮子装上就行,于是搜索Banner之类的关键词,倒是出现一大堆上千star的项目,如下所示:

    Github搜索结果

    但是确实是没有符合我要求的,要么项目好久没人维护了,很多人提issue却没人回应,要么是使用起来太复杂,接入成本过高,还有就是根本不能实现我想要的效果.

    其实我要的效果也很简单,如下:

    腾讯视频首页Banner

    这下应该很直观了吧,中间显示当前page的全部,左右显示前后两个页面的一部分,每个page之间有一定的间距.

    确实是没有找到符合条件的轮子,当然也可能是我的搜索方式不对,既然如此,那就只有自己动手撸一个了.

    2.核心问题剖析

    2.1 实现方案选择

    基于以上的效果图,大致能够想到两种实现方案:

    1. 基于ViewPager实现,需要解决的是如果让ViewPager在一个屏幕内显示一个以上的子page.

    2. 基于RecyclerView实现,需要解决的是如何控制RecyclerView每次滑动到指定位置.

    为了实现简单以及后续的扩展方便,我选择的是第一种方案,主要是考虑到后面如果需要控制左右两个page的大小缩放比例,使用ViewPagerTransformer比自定义RecyclerViewLayoutManager要简单.

    2.2 如何让ViewPager在一个屏幕内显示多个子页面?

    1. 继承PagerAdapter,并重写getPageWidth函数
    static class MyPagerAdapter extends PagerAdapter {
    
       ...
    
        @Override
        public float getPageWidth(int position) {
            return 0.8f;
        }
    }
    

    该方法默认的返回值是1.0f,这里改成0.8f,效果如下:

    image.png

    这里只是将选中的page占整个ViewPager父容器的80%,后面的一个占20%,显然是不满足我们的要求的.

    1. 设置ViewPager的左右Margin,并将父布局的clipChildren属性置为false,并且关闭硬件加速.
    <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:clipChildren="false"
        android:orientation="vertical"
        android:layerType="software"
        tools:context="com.wenjian.interview.bugfix.SecondActivity">
    
        <android.support.v4.view.ViewPager
            android:id="@+id/view_pager"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:layout_marginStart="20dp"
            android:layout_marginEnd="20dp">
    
        </android.support.v4.view.ViewPager>
    

    效果如下:

    image.png

    这里有必要了解一下ViewGroupsetClipChildren方法,源码如下:

    /**
     * By default, children are clipped to their bounds before drawing. This
     * allows view groups to override this behavior for animations, etc.
     *
     * @param clipChildren true to clip children to their bounds,
     *        false otherwise
     * @attr ref android.R.styleable#ViewGroup_clipChildren
     */
    public void setClipChildren(boolean clipChildren) {
        boolean previousValue = (mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN;
        if (clipChildren != previousValue) {
            setBooleanFlag(FLAG_CLIP_CHILDREN, clipChildren);
            for (int i = 0; i < mChildrenCount; ++i) {
                View child = getChildAt(i);
                if (child.mRenderNode != null) {
                    child.mRenderNode.setClipToBounds(clipChildren);
                }
            }
            invalidate(true);
        }
    }
    

    这个方法除了更新自己的FLAG_CLIP_CHILDREN标志,也会遍历子view,更新子view的FLAG_CLIP_CHILDREN.

    这个值默认为true,即父view会裁剪超出父view边界的子view,当设置为false,则表示不会裁剪,所以当我们设置ViewPager的左右边距,且父View不对超出边界的进行裁剪,就可以将左右超出ViewPager范围内的page显示出来,也就达到我们的目的了.

    这个效果离我们想要的已经非常接近了,接着设置ViewPager的pageMargin,

    mPager = findViewById(R.id.view_pager);
    mPager.setPageMargin(10);   
    

    效果如下:

    image.png

    page之间也有间隙了,基本符合我们要求了.

    2.3 如何实现ViewPager的无缝循环滚动?

    我们知道ViewPager.setCurrentItem()可以将page滑动到指定的页面,可以开启周期任务来更新item值即可实现滚动,但是当滚动到了最后一个page时,如何回到第一个page页呢?直接设置setCurrentItem(0)可以实现,但是这个过渡动画效果肯定不是我们想要的.

    想要实现无缝滚动,可以将page的个数设置的足够大.

    @Override
    public final int getCount() {
        final int size = mData.size();
        if (size != 0) {
            return mCanLoop ? Integer.MAX_VALUE : size;
        }
        return 0;
    }
    

    这里贴出的是LoopAdaptergetCount方法, 即需要循环滚动时,getCount方法返回Integer的最大值.

       @NonNull
       @Override
       public final Object instantiateItem(@NonNull ViewGroup container, int position) {
           final int dataPosition = computePosition(position);
           ViewHolder holder = mHolderMap.get(dataPosition);
           if (holder == null) {
               View convertView = onCreateView(container);
               holder = new ViewHolder(convertView);
               convertView.setTag(R.id.key_holder, holder);
               onBindView(holder, mData.get(dataPosition), dataPosition);
           }
           return addViewSafely(container, holder.itemView);
       }
    

    然后再初始化page时,对position与数据大小取余,得到真实的数据去填充当前页面.

    2.4 如何消除频繁创建和销毁页面所带来的内存开销?

    我们知道ViewPager是通过PagerAdapter来创建销毁页面并绑定数据的,即我们需要覆盖 instantiateItemdestroyItem来管理page的初始化和销毁,一般的写法如下:

    @NonNull
    @Override
    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        ImageView imageView = new ImageView(container.getContext());
        imageView.setBackgroundColor(Color.rgb(mRandom.nextInt(255), mRandom.nextInt(255), mRandom.nextInt(255)));
        container.addView(imageView);
        return imageView;
    }
    
    @Override
    public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
        container.removeView((View) object);
    }
    

    如果在无限轮播的情况下也这样做,会造成大量对象的创建和销毁,容易造成内存抖动.

    既然我们的page是周期重复的,可以考虑缓存起来,每次有缓存直接拿出来用就好了,缓存的版本如下,也是LoopAdapter采用的方式:

    public abstract class LoopAdapter<T> extends PagerAdapter {
    
        private static final String TAG = "LoopAdapter";
        private SparseArray<ViewHolder> mHolderMap = new SparseArray<>();
        private List<T> mData;
        private int mLayoutId;
        private boolean mCanLoop = true;
        LoopBanner.OnPageClickListener mClickListener;
    
        public LoopAdapter(List<T> data, int layoutId) {
            mData = data;
            mLayoutId = layoutId;
        }
    
        public LoopAdapter(List<T> data) {
            this(data, -1);
        }
    
        public LoopAdapter(int layoutId) {
            this(new ArrayList<T>(), layoutId);
        }
    
        public LoopAdapter() {
            this(new ArrayList<T>(), -1);
        }
    
        @Override
        public final int getCount() {
            final int size = mData.size();
            if (size != 0) {
                return mCanLoop ? Integer.MAX_VALUE : size;
            }
            return 0;
        }
    
        @NonNull
        @Override
        public final Object instantiateItem(@NonNull ViewGroup container, int position) {
            final int dataPosition = computePosition(position);
            ViewHolder holder = mHolderMap.get(dataPosition);
            if (holder == null) {
                View convertView = onCreateView(container);
                holder = new ViewHolder(convertView);
                convertView.setTag(R.id.key_holder, holder);
                onBindView(holder, mData.get(dataPosition), dataPosition);
            }
            return addViewSafely(container, holder.itemView);
        }
    
        @Override
        public final void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
            container.removeView((View) object);
            mHolderMap.put(computePosition(position), (ViewHolder) ((View) object).getTag(R.id.key_holder));
        }
    
        @Override
        public final boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
            return view == object;
        }
    
        private View addViewSafely(ViewGroup container, View itemView) {
            ViewParent parent = itemView.getParent();
            if (parent != null) {
                ((ViewGroup) parent).removeView(itemView);
            }
            container.addView(itemView);
            return itemView;
        }
    
    

    这里贴的是部分代码,其实也是借鉴了RecyclerViewViewHolder机制,缓存的是position与对应的ViewHolder的键值对,数据结构用的是Android独有的SparseArray,也是为了节省内存.

    这样每种page都只需要初始化并绑定数据一次即可,只要不超过20条以上数据,都是完全无压力的,

    不过基本上Banner数据都不会超过10条,所以完全不用担心内存问题了.

    2.5 如何实现手触摸时停止自动滚动,手松开后恢复自动滚动?

    viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        }
    
        @Override
        public void onPageSelected(int position) {
            int lastPosition = mCurrentIndex;
            mCurrentIndex = position;
            notifySelectChange(position);
            updateIndicators(position, lastPosition);
        }
    
        @Override
        public void onPageScrollStateChanged(int state) {
            switch (state) {
                case ViewPager.SCROLL_STATE_IDLE:
                    startInternal(false);
                    break;
                case ViewPager.SCROLL_STATE_DRAGGING:
                    stopInternal();
                    break;
                default:
            }
        }
    });
    
        private void startInternal(boolean force) {
            if (!mCanLoop || !checkAdapterAndDataSize()) {
                return;
            }
            if (force) {
                mHandler.removeCallbacks(mLoopRunnable);
                mHandler.postDelayed(mLoopRunnable, 200);
                inLoop = true;
            } else {
                if (!inLoop) {
                    mHandler.removeCallbacks(mLoopRunnable);
                    mHandler.postDelayed(mLoopRunnable, TOUCH_DELAY);
                    inLoop = true;
                }
            }
        }
    
        private void stopInternal() {
            mHandler.removeCallbacks(mLoopRunnable);
            inLoop = false;
        }
    

    核心代码都在上面,其实就是监听ViewPager的滑动状态,拖动的时候停止定时任务,而在空闲的时候判断是否在滚动,没有滚动时就启动自动滚动.

    2.6 如何兼容不同的指示器样式,并提供良好的扩展?

    这一块当时也考虑挺久的,最后也是基于模板方法和适配器模式实现了相对不错的扩展效果.

    1. 设计适配接口IndicatorAdapter
    public interface IndicatorAdapter {
    
        /**
         * 添加子indicator
         *
         * @param container 父布局
         * @param drawable  配置的Drawable
         * @param size      配置的指示器大小
         * @param margin    配置的指示器margin值
         */
        void addIndicator(LinearLayout container, Drawable drawable, int size, int margin);
    
        /**
         * 应用选中效果
         *
         * @param prev    上一个
         * @param current 当前
         * @param reverse 是否逆向滑动
         */
        void applySelectState(View prev, View current, boolean reverse);
    
        /**
         * 应用为选中效果
         *
         * @param indicator 指示器
         */
        void applyUnSelectState(View indicator);
    
    
        /**
         * 是否需要对某个位置进行特殊处理
         *
         * @param container 指示器容器
         * @param position  第一个或最后一个
         * @return 返回true代表处理好了
         */
        boolean handleSpecial(LinearLayout container, int position);
    
    
    }
    
    1. 设计核心流程:
    private void updateIndicators(int position, int lastPosition) {
        if (mIndicatorContainer == null) {
            return;
        }
        LoopAdapter adapter = getAdapter();
        if (adapter == null || adapter.getDataSize() <= 1) {
            return;
        }
    
        final int dataPosition = adapter.getDataPosition(position);
        if (mIndicatorAdapter.handleSpecial(mIndicatorContainer, dataPosition)) {
            return;
        }
        final int childCount = mIndicatorContainer.getChildCount();
        if (childCount > 0) {
            for (int i = 0; i < childCount; i++) {
                mIndicatorAdapter.applyUnSelectState(mIndicatorContainer.getChildAt(i));
            }
            boolean auto = lastPosition == position;
            int prev;
            if (auto) {
                prev = computePrevPosition(adapter, lastPosition - 1);
            } else {
                prev = computePrevPosition(adapter, lastPosition);
            }
    
            mIndicatorAdapter.applySelectState(mIndicatorContainer.getChildAt(prev),
                    mIndicatorContainer.getChildAt(dataPosition), lastPosition > position);
        }
    }
    

    其实就是每次page被选中的时候会触发updateIndicators方法,只要合理地实现了IndicatorAdapter相关方法就可以根据需要定义自己的指示器了.

    1. 实现自己的IndicatorAdapter

    下面是仿照京东App首页Banner指示器效果所实现的JDIndicatorAdapter:

    public class JDIndicatorAdapter implements IndicatorAdapter {
    
        private final int drawableId;
    
        private boolean initialed = false;
        private float mScale;
    
        public JDIndicatorAdapter(int drawableId) {
            this.drawableId = drawableId;
        }
    
        public JDIndicatorAdapter() {
            this(R.drawable.indicator_jd);
        }
    
        @Override
        public void addIndicator(LinearLayout container, Drawable drawable, int size, int margin) {
            drawable = ContextCompat.getDrawable(container.getContext(), drawableId);
            if (drawable == null) {
                throw new IllegalArgumentException("please provide valid drawableId");
            }
            ImageView image = new ImageView(container.getContext());
            ViewCompat.setBackground(image, drawable);
            LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT);
            params.leftMargin = margin;
            container.addView(image, params);
    
            computeScale(drawable.getMinimumWidth(), margin);
    
        }
    
        @Override
        public void applySelectState(View prev, View current, boolean reverse) {
            prev.setPivotX(0);
            prev.setPivotY(prev.getHeight() / 2);
            if (reverse) {
                current.animate().scaleX(1).setDuration(200).start();
            } else {
                prev.animate().scaleX(mScale).setDuration(200).start();
            }
        }
    
        @Override
        public void applyUnSelectState(View indicator) {
    
        }
    
        @Override
        public boolean handleSpecial(LinearLayout container, int position) {
            int childCount = container.getChildCount();
            //对第一个和最后一个做特殊处理
            if (position == 0 || position == childCount - 1) {
                for (int i = 0; i < childCount; i++) {
                    View childAt = container.getChildAt(i);
                    childAt.setPivotX(0);
                    childAt.setPivotY(childAt.getHeight() / 2);
                    //第一个
                    if (position == 0) {
                        childAt.animate().scaleX(1).setDuration(200).start();
                    }
                    //最后一个
                    else {
                        if (i != childCount - 1) {
                            childAt.animate().scaleX(mScale).setDuration(200).start();
                        }
                    }
                }
                return true;
            }
            return false;
        }
    
        private void computeScale(int width, int margin) {
            if (!initialed) {
                mScale = width == 0 ? 2 : ((width + margin + width / 2) * 1f) / width;
                initialed = true;
            }
        }
    
    }
    

    到此,基本上一些难点都解决了,其次就是一些比较烦人的参数配置了,虽然不难,却也是很费时间,只能说要做一个好点的开源项目确实不容易.

    2.7 如何实现自定义页面内容?

    大多数Banner基本展示都是一张大图,标题,指示器,其实这也能满足大部分的需求,但如何碰到奇葩产品给你加各种各样复杂内容的时候也不要慌,这里也考虑到了,只需要你像使用RecyclerView一样在初始化LoopAdapter的时候传递一个layoutId,然后根据你的需求绑定相应数据即可.当然你也可以不传,默认会给你填充一个ImageView.

    @NonNull
    @Override
    public final Object instantiateItem(@NonNull ViewGroup container, int position) {
        final int dataPosition = computePosition(position);
        ViewHolder holder = mHolderMap.get(dataPosition);
        if (holder == null) {
            View convertView = onCreateView(container);
            holder = new ViewHolder(convertView);
            convertView.setTag(R.id.key_holder, holder);
            onBindView(holder, mData.get(dataPosition), dataPosition);
        }
        return addViewSafely(container, holder.itemView);
    }
    
      @NonNull
        protected View onCreateView(@NonNull ViewGroup container) {
            Tools.logI(TAG, "onCreateView");
            View view;
            if (mLayoutId != -1) {
                view = LayoutInflater.from(container.getContext()).inflate(mLayoutId, container, false);
            } else {
                ImageView imageView = new ImageView(container.getContext());
                imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
                view = imageView;
            }
            return view;
        }
    

    核心代码如上,在onCreateView中使用布局加载器加载对于layoutId对应的布局并返回.子类还可以覆盖该方法返回自己的自定义View,扩展性还是不错的.

    3. 总结

    这是我第一个完整的开源项目,之前虽然也有提交过,但都是一些零零碎碎的东西,不成体系,也没有配置远程仓库地址.总体感觉还是很不错的,至少对自定义View这一块知识有了更加深入的了解,代码虽然不是很漂亮,但确实是用心了的.希望路过的小伙伴觉得不错的可以给个小星星,发现有bug的可以提个issue,对于这个项目我会一直维护的,最后附上仓库地址LoopBanner .

    相关文章

      网友评论

        本文标题:LoopBanner原理浅析

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