最近公司项目在升级AndroidX,由于项目中用到的一些比较老的库都已停止更新维护,因此需要将这些库替换掉,其中就包括自动轮播的Banner库。
恰逢笔者在之前写过一个轮播图,因此就在此基础上重构,打造出了一个全新的支持多种样式的轮播库---BannerViewPager。
https://github.com/zhpanvip/BannerViewPager/
BannerViewPager拥有简洁高效的代码,更是因为它高度的可定制性。BannerViewPager不仅支持任意的页面布局,而且可以支持任意的Indicator样式。
甚至连Indicator的位置都可以做到任意摆放。是的,就是这么随心所欲。无图言叼,还是先通过图片和代码一览BannerViewPager的功能吧(多图预警)。
1
BannerViewPager效果预览及API介绍
由于GIF图片质量问题,下面的预览图并不清晰,大家可以点击下面链接或者扫描二维码下载Apk体验。Apk存放在github上,下载速度可能会比较慢。
https://github.com/zhpanvip/BannerViewPager/raw/master/download/app.apk
1. setIndicatorStyle(开局就放王炸?)
BannerViewPager目前内置了CIRCLE和DASH两种样式的指示器,通过setIndicatorStyle(int)一行代码就可以切换指示器的样式。当然,如果内置样式不满足你的需求。
BannerViewPager还提供了自定义指示器的功能。只要继承BaseIndicatorView或者实现IIndicator接口,并重写相应方法,就可以通过自定义View为所欲为的打造任意的Indicator了。
如下图【自定义】就是自己实现的指示器样式。
Circle:
Dash
自定义
下面通过代码演示如何切换指示器:
mViewPager.setIndicatorStyle(IndicatorStyle.DASH)
.setIndicatorHeight(BannerUtils.dp2px(3f))
.setIndicatorWidth(BannerUtils.dp2px(3), BannerUtils.dp2px(10))
.setHolderCreator(() -> new ImageResourceViewHolder(0))
.create(mDrawableList)
通过5行代码就轻松的实现了上图【Dash】仿支付宝的Indicator样式(大家可以留意一下支付宝的轮播Indicator,挺有意思)。
关于自定义IndicatorView将会放在后边章节详细讲解。
通过setPageStyle(int)一行代码开启一屏三页模式,一屏三页模式下目前有三种样式,分别如下图所示:
Multi Page
Multi Page Scale
Multi Page Overlap
代码演示:
mViewPager.setPageStyle(PageStyle.MULTI_PAGE)
.setPageMargin(BannerUtils.dp2px(10))
.setRevealWidth(BannerUtils.dp2px(10))
.setHolderCreator(() -> new ImageResourceViewHolder(BannerUtils.dp2px(5)))
.create(mDrawableList);
同样通过短短5行代码就实现了上图【MULTI_PAGE】的效果,简单好用!
我们看到上面图表中MULTI_PAGE_OVERLAP模式下指示器显示到了Banner的下边。这种效果该怎么实现呢?
其实BannerViewPager是支持把Indicator摆放在任意位置的。
之所以能如此强大是因为我们通过自定义指示器替换了内置的IndicatorView,也就是说此时的IndicatorView已经脱离了BannerViewPager,也就理所当然的可以放在任意位置了。
接下来通过代码来看下如何实现:
CircleIndicatorView indicatorView = findViewById(R.id.indicator_view);
mViewPager.setIndicatorView(indicatorView)
.setIndicatorColor(Color.parseColor("#888888"),
Color.parseColor("#118EEA"))
.setHolderCreator(() -> new ImageResourceViewHolder(BannerUtils.dp2px(5)))
.create(mDrawableList);
CircleIndicatorView是什么?
其实他就是内置在BannerViewPager中的指示器,现在你只需要把它同BannerViewPager放在同一个布局文件中就可以了。又是仅仅通过一行代码就完成了对内部指示器的替换,不知道你看完之后是否会拍案叫绝,竟然如此简单!
注:2.5.0版本中CircleIndicatorView与DashIndicatorView已被弃用,可以用IndicatorView来替代这两个指示器。IndicatorView承载了CIRCLE与DASH两种样式
我们应该见过很多App轮播图的指示器都会跟随页面一起滑动。BannerViewPager自然也不会少了这个功能。通过setIndicatorSlideMode(int)一行代码就可以轻松切换到下图(SMOOTH)的效果。
当然,由于目前SMOOTH模式还存在一些BUG,所以现在还不推荐大家使用这一模式。不过在后续版本中我会尽力修复相关问题。
Normal
Smooth
代码实现仍然非常简单,使用BannerViewPager你只需要记住一个核心--Only One Line!所以演示代码不再贴出你应该不会揍我吧?
关于Transform更好的方式应该是留给开发者自己去实现,因此BannerViewPager中目前仅内置了四种常用Transform样式,如果不能满足需求,可以通过BannerViewPager的setPageTransformer(ViewPager.PageTransformer transformer)设置自定义的Transform。
四种内置Transform样式如下:
Stack
Accordion
Depth
Roate
当然,BannerViewPager的功能并不仅仅局限于此,更多功能就不再演示,可以看下面所有开放的API接口。
2
如何使用BannerViewPager
1. gradle中添加依赖
如果您已迁移到AndroidX请使用latestVersion(>=2.4.3.1)
implementation 'com.zhpan.library:bannerview:latestVersion'
如果未迁移到AndroidX请使用(非Androidx的包托管在JCenter上):
implementation 'com.zhpan.library:bannerview:2.4.3.1'
public class NetViewHolder implements ViewHolder {
private ImageView mImageView;
private TextView mTextView;
@Override
public View createView(ViewGroup viewGroup, Context context, int position) {
View view = LayoutInflater.from(context).inflate(R.layout.item_net, viewGroup, false);
mImageView = view.findViewById(R.id.banner_image);
mTextView = view.findViewById(R.id.tv_describe);
return view;
}
@Override
public void onBind(Context context, BannerData data, int position, int size) {
ImageLoaderOptions options = new ImageLoaderOptions.Builder().into(mImageView).load(data.getImagePath()).placeHolder(R.drawable.placeholder).build();
ImageLoaderManager.getInstance().loadImage(options);
mTextView.setText(data.getTitle());
}
}
private BannerViewPager mBannerViewPager;
private void initViewPager() {
mBannerViewPager = findViewById(R.id.banner_view);
mBannerViewPager.showIndicator(true)
.setInterval(3000)
.setCanLoop(false)
.setAutoPlay(true)
.setRoundCorner(DpUtils.dp2px(7))
.setIndicatorColor(Color.parseColor("#935656"), Color.parseColor("#FF4C39"))
.setIndicatorGravity(BannerViewPager.END)
.setScrollDuration(1000).setHolderCreator(NetViewHolder::new)
.setOnPageClickListener(position -> {
BannerData bannerData = mBannerViewPager.getList().get(position);
Toast.makeText(NetworkBannerActivity.this,
"点击了图片" + position + " " + bannerData.getDesc(), Toast.LENGTH_SHORT).show();
}).create(mList);
}
2.5.0之后版本无需自行在Activity或Fragment中管理stopLoop和startLoop方法,但这两个方法依旧保留对外开发
如果开启了自动轮播功能,请务必在onDestroy中停止轮播,以免出现内存泄漏。
@Override
protected void onDestroy() {
super.onDestroy();
if (mBannerViewPager != null)
mViewpager.stopLoop();
}
为了节省性能也可以在onStop中停止轮播,在onResume中开启轮播:
@Override
protected void onStop() {
super.onStop();
if (mBannerViewPager != null)
mBannerViewPager.stopLoop();
}
@Override
protected void onResume() {
super.onResume();
if (mBannerViewPager != null)
mBannerViewPager.startLoop();
}
3
如何支持任意的Item布局
产品的需求千变万化,你永远也猜不到下一步产品会给你提一个什么样的需求。
因此对于一个比较人性化的Banner库来说,它也应该支持开发者去自定义任意的Item页面布局。BannerViewPager就是本着这样的思路来做的。
那么究竟其内部是如何实现的呢?
我们先从setHolderCreator(HolderCreator holderCreator)这个方法说起。
在使用BannerViewPager的时候我们可以为其设置一个HolderCreator,代码如下:
bannerViewPager.setHolderCreator(new HolderCreator() {
@Override
public CustomPageViewHolder createViewHolder() {
return new CustomPageViewHolder();
}
})
而在HolderCreator的createViewHolder方法中返回了一个CustomPageViewHolder,这个CustomPageViewHolder是我们自己实现的。其内部通过createView方法来inflate出来一个我们自定义的itemView,并在onBind方法中为itemView绑定数据。
其代码如下:
public class CustomPageViewHolder implements ViewHolder {
private ImageView mImageView;
private TextView mTextView;
@Override
public View createView(ViewGroup viewGroup, Context context, int position) {
View view = LayoutInflater.from(context).inflate(R.layout.item_custom_view, viewGroup, false);
mImageView = view.findViewById(R.id.banner_image);
mTextView = view.findViewById(R.id.tv_describe);
return view;
}
@Override
public void onBind(Context context, CustomBean data, int position, int size) {
mImageView.setImageResource(data.getImageRes());
mTextView.setText(data.getImageDescription());
}
...
}
setHolderCreator之后再BannerViewPager内部是如何处理的呢?我们接下来继续看:
/**
* 必须为BannerViewPager设置HolderCreator,HolderCreator中创建ViewHolder,
* 在ViewHolder中管理BannerViewPager的ItemView.
*
* @param holderCreator HolderCreator
*/
public BannerViewPager setHolderCreator(HolderCreator holderCreator) {
this.holderCreator = holderCreator;
return this;
}
private void setupViewPager() {
if (holderCreator != null) {
BannerPagerAdapter bannerPagerAdapter =
new BannerPagerAdapter<>(mList, holderCreator);
...
} else {
throw new NullPointerException("You must set HolderCreator for BannerViewPager");
}
}
上述代码中判断如果holderCreator为null时就抛出了一个NullPointerException,这也解释了为什么必须要为BannenrViewPager设置holderCreator。
当holderCreator不为null时,将holder传递到了BannerPagerAdapter中。我们接下来到BannerPagerAdapter中一探究竟:
public class BannerPagerAdapter extends PagerAdapter {
@Override
public @NonNull
Object instantiateItem(@NonNull final ViewGroup container, final int position) {
// 为了方便理解,此处与源码并不一致,详情请参看BannerPagerAdapter源码
View itemView =getView(position, container);
container.addView(itemView);
return itemView;
}
...
@SuppressWarnings("unchecked")
private View getView(final int position, ViewGroup container) {
ViewHolder holder = holderCreator.createViewHolder();
if (holder == null) {
throw new RuntimeException("can not return a null holder");
}
return createView(holder, position, container);
}
private View createView(ViewHolder holder, int position, ViewGroup container) {
View view = null;
if (list != null && list.size() > 0) {
view = holder.createView(container, container.getContext(), position);
holder.onBind(container.getContext(), list.get(position), position, list.size());
return view;
}
}
在BannerPagerAdapter的getView方法中通过holderCreator.createViewHolder()拿到了我们自定义的ViewHolder,此时即为上边的CustomPageViewHolder 。
接下来在createView方法中调用CustomPageViewHolder的createView方法拿到我们自定义的itemView,并通过holder.onBind方法将集合中的数据传递给了CustomPageViewHolder。
到这里我们就完成了自定义item布局以及item数据的绑定。
4
BannerViewPager的泛型设计
上一节中讲解了如何通过HolderCreator来支持任意的页面布局,那么此时我们应该会面临一个难点,既然可以支持任意的页面布局那么BannerViewPager中接收的数据也应该时任意类型的。面对此问题我们可以引入泛型来实现。
首先看BannerViewPager的泛型:
public class BannerViewPager extends RelativeLayout implements
ViewPager.OnPageChangeListener {
// 轮播数据集合
private List mList;
private HolderCreator holderCreator;
// ...
}
BannerViewPager有两个泛型参数,第一个参数T是对应的数据类型,它用来作为BannerViewPager中List集合的泛型。
另一个泛型参数VH规定了必须是继承ViewHolder的类,用来作为HolderCreator的泛型。而ViewHolder和HolderCreator均是一个带有泛型参数的接口,其代码如下:
public interface ViewHolder {
View createView(ViewGroup viewGroup,Context context, int position);
/**
* @param context context
* @param data 实体类对象
* @param position 当前位置
* @param size 页面个数
*/
void onBind(Context context,T data,int position,int size);
}
public interface HolderCreator {
/**
* 创建ViewHolder
*/
VH createViewHolder();
}
另外,T和VH两个泛型也同时作为BannerPagerAdapter的泛型参数:
public class BannerPagerAdapter extends PagerAdapter {
private List list;
public BannerPagerAdapter(List list, HolderCreator holderCreator) {
this.list = list;
this.holderCreator = holderCreator;
}
}
可以看到,我们通过泛型约束,使得涉及到的相关类中的参数数据类型保持了同步,从而实现了BannerViewPager可以接收并处理任意的数据类型。
5
如何实现无限循环轮播
关于ViewPager的无限循环无外乎两种方案。
第一种方案是在PagerAdapter的getCount中返回一个Integer.MAX_VALUE,即一个最大的Integer整数。然后将setCurrentItem的值设置为 Integer.MAX_VALUE / 2,在滑动过程中不断取余以此来达到一个无限循环轮播的假象。
另外一种方案是额外增加两个ViewPager的item count,然后在第0个Item填充最后一条数据,在最后一个Item填充第0条数据。当右滑到第一个Item的时候将currentItem置为pageSize-1,当滑动到最后一个Item的时候将currentItem置为1,以此来达到一个无限循环的目的,此方案的示意图如下:
BannerViewPager 也从方案二转向了方案一。
这个方案的优点虽然我苦思冥想也只想出来了两条,但是关于它的缺点我却能罗列出来很多。正所谓谁(sei)用谁(sei)知道!
1.onPageSelected(int)方法重复调用问题
我们为BannerViewPager开启自动轮播,并为其设置页面改变的监听事件,如下:
mBannerViewPager.setAutoPlay(true)
.setOnPageChangeListener(new OnPageChangeListenerAdapter() {
@Override
public void onPageSelected(int position) {
BannerUtils.e("position " + position);
}
})
然后可以看到打印的Log:
在BannerViewPager只有三个页面的情况下,页面position selected的周期是0、1、2、0。很明显,第0个页面被多调用了一次。
虽然在大多数情况下并没有影响,但是当需要在选中第0个页面时做一些逻辑的话,就会产生一定影响。
2.在一屏三页模式下,这一方案在轮播到最后一页时会出现下一页短暂空白的问题
出现这一问题的原因是因为为了完成循环在切换到最后一页时我们立即将position切换到了position为1的页面,而此时position为2个页面还未加载出来,因此就有了短暂的空白问题。
为了解决这一问题,又不得不在原来循环的基础上再增加两个页面,并将setOffscreenPageLimit设置为2。这样无形中增加了内存开销,并且使得逻辑处理变得更为复杂!
3.需要对position进行变换
为了实现循环我们将page count增加了2,为了解决一屏三页的空白问题我们将page count增加了4。但对外暴露的接口需要拿到正确的position,此时我们就不得不在BannerViewPager内部对position进行变换,使之能够对应到正确的position。
6
遇到的其他问题及解决方案
在BannerViewPager的开发过避免不了的会碰到一些问题,虽然有些已经解决了,但有些可能还悬而未决。但是不管解决没解决以供大家参考或讨论。
自动轮播的功能是通过Handler来实现的。通过postDelayed开启轮播,通过removeCallbacks停止轮播。
代码如下:
/**
* 开启轮播
*/
public void startLoop() {
if (!isLooping && isAutoPlay && mList.size() > 1) {
mHandler.postDelayed(mRunnable, interval);
isLooping = true;
}
}
/**
* 停止轮播
*/
public void stopLoop() {
if (isLooping) {
mHandler.removeCallbacks(mRunnable);
isLooping = false;
}
}
如果在手指滑动的过程中没有停止轮播,体验上来说非常不好。因此,需要处理这种情况。解决方案是重写ViewPager的setOnTouchListener方法,监听手指滑动的时候停止轮播,抬起手指的时候开启轮播。
代码如下:
private void setTouchListener() {
mViewPager.setOnTouchListener((v, event) -> {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
isLooping = true;
stopLoop();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isLooping = false;
startLoop();
default:
break;
}
return false;
});
}
我们知道,在ViewPager每次切换页面的时候都会调用instantiateItem去实例化ItemView,也就意味着我们在这个方法中通过ViewHolder的createView方法每次切换页面都会被调用重新初始化绑定数据。这样对程序来说是一种性能上的浪费。
针对这种情况,在2.4.3之前的版本中做了些优化。在BannerPagerAdapter中维护一个ListmViewList集合,用来存放创建出来的itemView.在itemView初始化成功后,为其设置tag并保存到集合中,当在此切换页面时我们从集合中取出itemView并对比tag,如果一致则直接使用即可。
这样就避免了重复的创建对象,造成一些性能开销。具体代码如下:
public class BannerPagerAdapter extends PagerAdapter {
private List mViewList = new ArrayList<>();
private View findViewByPosition(ViewGroup container, int position) {
for (View view : mViewList) {
if (((int) view.getTag()) == position && view.getParent() == null) {
return view;
}
}
View view = getView(position, container);
view.setTag(position);
mViewList.add(view);
return view;
}
}
但是这一优化却又会引发另一个问题,即内存问题!
通常App的轮播控件都是图片,而图片是比较占用内存的。此时我们把所有的itemView都存储在一个集合中这样真的是一个好的方案吗?
在ViewPager页面少的情况下问题可能不会凸显。但是如果ViewPager的页面很多的情况下问题就相当严重了!
于是,后来我灵光一闪,突发奇想!那我就设置一个最大缓存呗?当集合中的个数超过阈值的时候就把最近用过的一个itemView移除掉不就好了?
妙哉妙哉!可转念一想,这尼玛和设置一个setOffscreenPageLimit有什么区别呢?
当我们在考虑这些问题的时候Google工程师早就替我们想到了!所以关于ViewPager的instantiateItem是否有必要去优化我目前持保留态度。
但是,在BannerViewPager 2.4.3之前的版本中确实做了上述优化,因此前些版本中可能会存在内存问题。至于2.4.3或之后版本大概会去掉这部分优化。这个问题可能也只能留在未来,待升级到ViewPager2后解决了!关于这个问题欢迎大家在文章下方留言,各抒己见!
这个问题不是太好描述,我们直接通过一张GIF来看
从图中可以很直观的看到,把BannerViewPager向上划出屏幕再很快划回来,此时BannerViewPager页面切换的动画没有了,很生硬的直接跳到了下一页。
这个问题不是BannerViewPager的bug,而是ViewPager内部原因导致的,可以看到很多线上的APP都存在这个问题,例如喜马拉雅(喜马拉雅的轮播图真心做的好看呀,效果也很赞!)。
这个bug虽然不影响使用,但是总感觉效果不太好。因此还是要处理一下。处理之前先来分析一下问题原因。
在ViewPager内部有一个私有成员变量mFirstLayout,其默认值为true。这个参数用来标记是否是第一次layout的。如果是第一次layout那么滑动就不是smooth的。
代码如下:
public void setCurrentItem(int item) {
mPopulatePending = false;
setCurrentItemInternal(item, !mFirstLayout, false);
}
void setCurrentItemInternal(int item, boolean smoothScroll, boolean always) {
setCurrentItemInternal(item, smoothScroll, always, 0);
}
这个参数在onLayout方法中会被置为false.代码如下:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 在onLayout的最后一行
mFirstLayout = false;
}
因此,在正常情况下,onLayout执行之后页面滑动都应该时smooth的。然后,当ViewPager滑动出屏幕的时候其onDetachedFromWindow方法会被调用,而当其再次进入屏幕的时候则会调用onAttachedToWindow这个方法。
来看看onAttachedToWindow方法中的代码:
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mFirstLayout = true;
}
仅仅把mFirstLayout置为了true!而如果此时onLayout没有被触发,而是先发生了页面滚动,那么此时的页面滑动就没了的smooth效果了。
了解了原因之后处理起来就简单了,因为mFirstLayout是私有属性,我们无法访问,所以只有通过反射来修改其值。我们在CatchViewPager(继承自ViewPager的一个类)中做如下操作:
private boolean firstLayout = true;
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
hookFirstLayout();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
firstLayout = false;
}
private void hookFirstLayout() {
try {
Field mFirstLayout = ViewPager.class.getDeclaredField("mFirstLayout");
mFirstLayout.setAccessible(true);
mFirstLayout.set(this, firstLayout);
setCurrentItem(getCurrentItem());
} catch (IllegalAccessException | NoSuchFieldException e) {
e.printStackTrace();
}
}
这样问题就迎刃而解了,再次滑动RecyclerView,一切完好!