LoopBanner原理浅析
本文主要是阐述LoopBanner项目的原理及重要知识点,不涉及基本用法,对用法不了解的同学,可以访问https://github.com/wenjiangit/LoopBanner或下载demo.
1. LoopBanner由来
近来公司项目比较闲,于是便抽空学了一下Kotlin
语言,毕竟本人也是一个有追求的Android开发者,对于Google
官方力推的Android
开发语言怎么可能视而不见,学习Kotlin主要是基于Kotlin实战一书,当然语法特性学习完了,肯定还是动手实战一下,便基于鸿洋大神的Wanandroid 开放API写了个WanAndroid客户端.项目是基于Google的AAC
架构,感兴趣的同学可以参考一下.
项目首页一般会有一个轮播图,当然我的WanAndroid客户端也不例外,其实碰到这种情况,我和大家的想法一样,上Github
找个现成的轮子装上就行,于是搜索Banner之类的关键词,倒是出现一大堆上千star的项目,如下所示:
但是确实是没有符合我要求的,要么项目好久没人维护了,很多人提issue却没人回应,要么是使用起来太复杂,接入成本过高,还有就是根本不能实现我想要的效果.
其实我要的效果也很简单,如下:
腾讯视频首页Banner这下应该很直观了吧,中间显示当前page的全部,左右显示前后两个页面的一部分,每个page之间有一定的间距.
确实是没有找到符合条件的轮子,当然也可能是我的搜索方式不对,既然如此,那就只有自己动手撸一个了.
2.核心问题剖析
2.1 实现方案选择
基于以上的效果图,大致能够想到两种实现方案:
-
基于
ViewPager
实现,需要解决的是如果让ViewPager
在一个屏幕内显示一个以上的子page. -
基于
RecyclerView
实现,需要解决的是如何控制RecyclerView
每次滑动到指定位置.
为了实现简单以及后续的扩展方便,我选择的是第一种方案,主要是考虑到后面如果需要控制左右两个page的大小缩放比例,使用ViewPager
的Transformer
比自定义RecyclerView
的LayoutManager
要简单.
2.2 如何让ViewPager在一个屏幕内显示多个子页面?
- 继承
PagerAdapter
,并重写getPageWidth
函数
static class MyPagerAdapter extends PagerAdapter {
...
@Override
public float getPageWidth(int position) {
return 0.8f;
}
}
该方法默认的返回值是1.0f
,这里改成0.8f
,效果如下:
这里只是将选中的page占整个ViewPager
父容器的80%,后面的一个占20%,显然是不满足我们的要求的.
- 设置
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这里有必要了解一下ViewGroup
的setClipChildren
方法,源码如下:
/**
* 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显示出来,也就达到我们的目的了.
这个效果离我们想要的已经非常接近了,接着设置ViewPage
r的pageMargin
,
mPager = findViewById(R.id.view_pager);
mPager.setPageMargin(10);
效果如下:
image.pngpage之间也有间隙了,基本符合我们要求了.
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;
}
这里贴出的是LoopAdapter
的getCount
方法, 即需要循环滚动时,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
来创建销毁页面并绑定数据的,即我们需要覆盖 instantiateItem
和destroyItem
来管理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;
}
这里贴的是部分代码,其实也是借鉴了RecyclerView
的ViewHolder
机制,缓存的是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 如何兼容不同的指示器样式,并提供良好的扩展?
这一块当时也考虑挺久的,最后也是基于模板方法和适配器模式实现了相对不错的扩展效果.
- 设计适配接口
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);
}
- 设计核心流程:
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
相关方法就可以根据需要定义自己的指示器了.
- 实现自己的
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 .