LoopBanner原理浅析

Java基础

浏览数:121

2019-8-20

AD:资源代下载服务

本文主要是阐述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 .

作者:dreamruner