作者:一包纯牛奶
链接:
https://juejin.im/post/5d6bce24f265da03db0790d1
本文由作者授权发布。
这里我把作者两篇文章合体了,主要是为了在项目功能介绍的基础上,也可以直接看到原理实现,故文章略长,下面为文章正文。
最近公司项目在升级AndroidX,由于项目中用到的一些比较老的库都已停止更新维护,因此需要将这些库替换掉,其中就包括自动轮播的横幅库。
恰逢笔者在之前写过一个轮播图,因此就在此基础上重构,打造出了一个全新的支持多种样式的轮播库--- 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接口,并转换相应方法,就可以通过自定义视图为所欲为的打造任意的Indicator了。
如下图【自定义】就是自己实现的指示器样式。
圈:
短跑
自定义
下面通过代码演示如何切换指针:
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,挺有意思)。
关于自定义指标查看将放在后边章节详细讲解。
通过setPageStyle(int)一行代码打开一屏三页模式,一屏三页模式下当前有某种样式,分别如下图所示:
多页
多页比例
多页重叠
代码演示:
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模式下指示器显示到了横幅的下边。这种效果该怎么实现呢?
其实BannerViewPager是支持把指示器放到任意位置的。
之所以能如此强大是因为我们通过自定义指针替换了内置的IndicatorView,而此时的IndicatorView已经脱离了BannerViewPager,也就理所当然的可以放在任意位置了。
接下来通过代码来看下如何实现:
xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent">
"http://schemas.android.com/apk/res/android" "@+id/banner_view" android:layout_width="match_parent" android:layout_height="180dp" android:layout_marginTop="20dp" app:bvp_page_style="multi_page" /> "@+id/indicator_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/banner_view" android:layout_centerHorizontal="true" android:layout_marginTop="10dp" />
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,所以现在还不推荐大家使用这一模式。不过在后续版本中我会尽力修复相关问题。
正常
光滑
代码实现仍然非常简单,使用BannerViewPager你只需要记住一个核心-只有一行!所以演示代码不再贴出你应该不会揍我吧?
关于Transform更好的方式应该是留给开发者自己去实现,因此BannerViewPager中目前唯一内置了其中常用的Transform样式,如果不能满足需求,可以通过BannerViewPager的setPageTransformer(ViewPager.PageTransformer转换器)设置自定义的Transform 。
大致内置的转换样式如下:
堆
手风琴
深度
罗特
当然,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'
"@+id/banner_view"
android:layout_width="match_parent"
android:layout_margin="10dp"
android:layout_height="160dp" />
android:layout_width="match_parent" android:layout_height="wrap_content">
"http://schemas.android.com/apk/res/android" "@+id/banner_image" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" /> "match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:background="#66000000" android:gravity="center_vertical"> "@+id/tv_describe" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="center_vertical" android:layout_marginStart="15dp" android:gravity="center_vertical" android:paddingTop="5dp" android:paddingBottom="5dp" android:textColor="#FFFFFF" android:textSize="16sp" />
public class NetViewHolder implements ViewHolder<BannerData> {
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之后版本无需自行在活动或片段中管理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
如何支持任意的项目布局
产品的需求千变万化,你永远也猜不到下一步产品会给你提一个什么样的需求。
因此对于一个比较人性化的横幅库来说,它也应该支持开发者去自定义任意的项目页面布局。BannerViewPager就是本着这样的思路来做的。
那么究竟其内部是如何实现的呢?
我们先从setHolderCreator(HolderCreatorholderCreator)这个方法说起。
在使用BannerViewPager的时候我们可以为此设置一个HolderCreator,代码如下:
bannerViewPager.setHolderCreator(new HolderCreator() {
@Override
public CustomPageViewHolder createViewHolder() {
return new CustomPageViewHolder();
}
})
而在HolderCreator的createViewHolder方法中返回了一个CustomPageViewHolder,这个CustomPageViewHolder是我们自己实现的。其内部通过createView方法来inflate出来一个我们自定义的itemView,并在绑定方法中为itemView绑定数据。
其代码如下:
public class CustomPageViewHolder implements ViewHolder<CustomBean> {
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。
当holder创建者不为空时,将holder传递到了BannerPagerAdapter中。我们接下来到BannerPagerAdapter中一探究竟:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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<T, VH extends ViewHolder> extends RelativeLayout implements
ViewPager.OnPageChangeListener {
// 轮播数据集合
private List mList;
private HolderCreator holderCreator;
// ...
}
BannerViewPager有两个泛型参数,第一个参数T是对应的数据类型,它用作BannerViewPager中List集合的泛型。
另一个通用型参数VH规定了必须是继承的ViewHolder的类,用作作为HolderCreator的通用型。而ViewHolder和HolderCreator均是一个带有通用型参数的接口,其代码如下:
public interface ViewHolder<T> {
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<VH extends ViewHolder> {
/**
* 创建ViewHolder
*/
VH createViewHolder();
}
另外,T和VH两个泛型也同时作为BannerPagerAdapter的泛型参数:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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的项目计数,然后在第0个项目填充最后一条数据,在最后一个项目填充第0条数据。当右滑到第一个项目的时候将currentItem放置为pageSize -1,当滑动到最后一个项目的时候将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只有三个页面的情况下,页面位置选择的周期是0、1、2、0。很明显,第0个页面被多调用了一次。
虽然在大多数情况下并没有影响,但是当需要在某些第0个页面时做一些逻辑的话,就会产生一定的影响。
2.一屏三页模式下,这一方案在轮播到最后一页时会出现下一页短暂的空白的问题
出现这一问题的原因是因为为了完成循环在切换到最后一页时我们立即将位置切换到了位置为1的页面,而此时位置为2个页面不再加载出来,因此就有了短暂的空白问题。
为了解决这一问题,又不得不在原来循环的基础上再增加两个页面,设置setOffscreenPageLimit设置为2。这样无形中增加了内存消耗,并且使逻辑处理变得更加复杂!
3.需要对位置进行变换
为了实现循环我们将页面计数增加了2,以便解决一屏三页的空白问题我们将页面计数增加了4。但对外暴露的接口需要拿到正确的位置,此时我们就不得不在BannerViewPager内部对位置进行变换,使之能够对应到正确的位置。
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中维护一个列表mViewList集合,用于存放放置出来的itemView。在itemView初始化成功后,由此设置标签并保存到集合中,当在此之后切换页面时我们从集合中取出itemView并进行比较,如果一致则直接使用即可。
这样就避免了重复的创建对象,造成一些性能提升。具体代码如下:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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;
}
}
但是这一优化却又会引发另一个问题,即内存问题!
通常应用程序的轮播控件都是图片,而图片是比较占用内存的。此时我们把所有的itemView都存储在一个集合中这样真的是一个好的方案吗?
在ViewPager页面少的情况下问题可能不会凸显。但是如果ViewPager的页面很多的情况下问题就相当严重了!
于是,后来我灵光一闪,突然奇想!那我就设置一个最大缓存呗?当集合中的个数超过阈值的时候就把最近用过的一个项目视图移除掉不就好了?
妙哉妙哉!可转念一想,这尼玛和设置一个setOffscreenPageLimit有什么区别呢?
当我们在考虑这些问题的时候Google工程师早就替我们想到了!所以关于ViewPager的instantiateItem是否有必要去优化我目前持有的态度。
但是,在BannerViewPager 2.4.3的版本中确实确实做了上述优化,因此前一些版本中可能会存在内存问题。至于2.4.3或之后版本大概会去掉这部分优化。这个问题可能也只能留在未来,待升级到ViewPager2后解决了!关于这个问题欢迎大家在文章下方留言,各抒己见!
这个问题不是太好描述,我们直接通过一张GIF来看
从图中可以很直观的看到,把BannerViewPager向上划出屏幕再很快划回来,此时BannerViewPager页面切换的动画没有了,很生硬的直接跳到了下一页。
这个问题不是BannerViewPager的错误,而是ViewPager内部原因导致的,可以看到很多线上的APP都存在这个问题,例如喜马拉雅(喜马拉雅的轮播图真心做的好看呀,效果也很赞!)。
这个bug虽然不影响使用,但是总感觉效果不太好。因此还是要处理一下。处理之前先分析一下问题原因。
在ViewPager内部有一个私有成员变量mFirstLayout,其默认值是true。这个参数标记为第一次布局的。如果是第一次布局那么滑动就不是平滑的。
代码如下:
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滑动出屏幕的时候其在DetachedFromWindow方法会被调用,而当其再次进入屏幕的时候则调用到AttachedToWindow这个方法。
来看看onAttachedToWindow方法中的代码:
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mFirstLayout = true;
}
只是把mFirstLayout放置为true!而如果此时onLayout没有被触发,则先发生了页面滚动,那么此时的页面滑动就没了的平滑效果了。
了解了原因之后处理起来就简单了,因为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,一切完好!
作者:一包纯牛奶
链接:
https://juejin.im/post/5d6bce24f265da03db0790d1
本文由作者授权发布。
这里我把作者两篇文章合体了,主要是为了在项目功能介绍的基础上,也可以直接看到原理实现,故文章略长,下面为文章正文。
最近公司项目在升级AndroidX,由于项目中用到的一些比较老的库都已停止更新维护,因此需要将这些库替换掉,其中就包括自动轮播的横幅库。
恰逢笔者在之前写过一个轮播图,因此就在此基础上重构,打造出了一个全新的支持多种样式的轮播库--- 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接口,并转换相应方法,就可以通过自定义视图为所欲为的打造任意的Indicator了。
如下图【自定义】就是自己实现的指示器样式。
圈:
短跑
自定义
下面通过代码演示如何切换指针:
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,挺有意思)。
关于自定义指标查看将放在后边章节详细讲解。
通过setPageStyle(int)一行代码打开一屏三页模式,一屏三页模式下当前有某种样式,分别如下图所示:
多页
多页比例
多页重叠
代码演示:
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模式下指示器显示到了横幅的下边。这种效果该怎么实现呢?
其实BannerViewPager是支持把指示器放到任意位置的。
之所以能如此强大是因为我们通过自定义指针替换了内置的IndicatorView,而此时的IndicatorView已经脱离了BannerViewPager,也就理所当然的可以放在任意位置了。
接下来通过代码来看下如何实现:
xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent">
"http://schemas.android.com/apk/res/android" "@+id/banner_view" android:layout_width="match_parent" android:layout_height="180dp" android:layout_marginTop="20dp" app:bvp_page_style="multi_page" /> "@+id/indicator_view" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/banner_view" android:layout_centerHorizontal="true" android:layout_marginTop="10dp" />
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,所以现在还不推荐大家使用这一模式。不过在后续版本中我会尽力修复相关问题。
正常
光滑
代码实现仍然非常简单,使用BannerViewPager你只需要记住一个核心-只有一行!所以演示代码不再贴出你应该不会揍我吧?
关于Transform更好的方式应该是留给开发者自己去实现,因此BannerViewPager中目前唯一内置了其中常用的Transform样式,如果不能满足需求,可以通过BannerViewPager的setPageTransformer(ViewPager.PageTransformer转换器)设置自定义的Transform 。
大致内置的转换样式如下:
堆
手风琴
深度
罗特
当然,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'
"@+id/banner_view"
android:layout_width="match_parent"
android:layout_margin="10dp"
android:layout_height="160dp" />
android:layout_width="match_parent" android:layout_height="wrap_content">
"http://schemas.android.com/apk/res/android" "@+id/banner_image" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" /> "match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:background="#66000000" android:gravity="center_vertical"> "@+id/tv_describe" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="center_vertical" android:layout_marginStart="15dp" android:gravity="center_vertical" android:paddingTop="5dp" android:paddingBottom="5dp" android:textColor="#FFFFFF" android:textSize="16sp" />
public class NetViewHolder implements ViewHolder<BannerData> {
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之后版本无需自行在活动或片段中管理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
如何支持任意的项目布局
产品的需求千变万化,你永远也猜不到下一步产品会给你提一个什么样的需求。
因此对于一个比较人性化的横幅库来说,它也应该支持开发者去自定义任意的项目页面布局。BannerViewPager就是本着这样的思路来做的。
那么究竟其内部是如何实现的呢?
我们先从setHolderCreator(HolderCreatorholderCreator)这个方法说起。
在使用BannerViewPager的时候我们可以为此设置一个HolderCreator,代码如下:
bannerViewPager.setHolderCreator(new HolderCreator() {
@Override
public CustomPageViewHolder createViewHolder() {
return new CustomPageViewHolder();
}
})
而在HolderCreator的createViewHolder方法中返回了一个CustomPageViewHolder,这个CustomPageViewHolder是我们自己实现的。其内部通过createView方法来inflate出来一个我们自定义的itemView,并在绑定方法中为itemView绑定数据。
其代码如下:
public class CustomPageViewHolder implements ViewHolder<CustomBean> {
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。
当holder创建者不为空时,将holder传递到了BannerPagerAdapter中。我们接下来到BannerPagerAdapter中一探究竟:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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<T, VH extends ViewHolder> extends RelativeLayout implements
ViewPager.OnPageChangeListener {
// 轮播数据集合
private List mList;
private HolderCreator holderCreator;
// ...
}
BannerViewPager有两个泛型参数,第一个参数T是对应的数据类型,它用作BannerViewPager中List集合的泛型。
另一个通用型参数VH规定了必须是继承的ViewHolder的类,用作作为HolderCreator的通用型。而ViewHolder和HolderCreator均是一个带有通用型参数的接口,其代码如下:
public interface ViewHolder<T> {
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<VH extends ViewHolder> {
/**
* 创建ViewHolder
*/
VH createViewHolder();
}
另外,T和VH两个泛型也同时作为BannerPagerAdapter的泛型参数:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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的项目计数,然后在第0个项目填充最后一条数据,在最后一个项目填充第0条数据。当右滑到第一个项目的时候将currentItem放置为pageSize -1,当滑动到最后一个项目的时候将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只有三个页面的情况下,页面位置选择的周期是0、1、2、0。很明显,第0个页面被多调用了一次。
虽然在大多数情况下并没有影响,但是当需要在某些第0个页面时做一些逻辑的话,就会产生一定的影响。
2.一屏三页模式下,这一方案在轮播到最后一页时会出现下一页短暂的空白的问题
出现这一问题的原因是因为为了完成循环在切换到最后一页时我们立即将位置切换到了位置为1的页面,而此时位置为2个页面不再加载出来,因此就有了短暂的空白问题。
为了解决这一问题,又不得不在原来循环的基础上再增加两个页面,设置setOffscreenPageLimit设置为2。这样无形中增加了内存消耗,并且使逻辑处理变得更加复杂!
3.需要对位置进行变换
为了实现循环我们将页面计数增加了2,以便解决一屏三页的空白问题我们将页面计数增加了4。但对外暴露的接口需要拿到正确的位置,此时我们就不得不在BannerViewPager内部对位置进行变换,使之能够对应到正确的位置。
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中维护一个列表mViewList集合,用于存放放置出来的itemView。在itemView初始化成功后,由此设置标签并保存到集合中,当在此之后切换页面时我们从集合中取出itemView并进行比较,如果一致则直接使用即可。
这样就避免了重复的创建对象,造成一些性能提升。具体代码如下:
public class BannerPagerAdapter<T, VH extends ViewHolder> 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;
}
}
但是这一优化却又会引发另一个问题,即内存问题!
通常应用程序的轮播控件都是图片,而图片是比较占用内存的。此时我们把所有的itemView都存储在一个集合中这样真的是一个好的方案吗?
在ViewPager页面少的情况下问题可能不会凸显。但是如果ViewPager的页面很多的情况下问题就相当严重了!
于是,后来我灵光一闪,突然奇想!那我就设置一个最大缓存呗?当集合中的个数超过阈值的时候就把最近用过的一个项目视图移除掉不就好了?
妙哉妙哉!可转念一想,这尼玛和设置一个setOffscreenPageLimit有什么区别呢?
当我们在考虑这些问题的时候Google工程师早就替我们想到了!所以关于ViewPager的instantiateItem是否有必要去优化我目前持有的态度。
但是,在BannerViewPager 2.4.3的版本中确实确实做了上述优化,因此前一些版本中可能会存在内存问题。至于2.4.3或之后版本大概会去掉这部分优化。这个问题可能也只能留在未来,待升级到ViewPager2后解决了!关于这个问题欢迎大家在文章下方留言,各抒己见!
这个问题不是太好描述,我们直接通过一张GIF来看
从图中可以很直观的看到,把BannerViewPager向上划出屏幕再很快划回来,此时BannerViewPager页面切换的动画没有了,很生硬的直接跳到了下一页。
这个问题不是BannerViewPager的错误,而是ViewPager内部原因导致的,可以看到很多线上的APP都存在这个问题,例如喜马拉雅(喜马拉雅的轮播图真心做的好看呀,效果也很赞!)。
这个bug虽然不影响使用,但是总感觉效果不太好。因此还是要处理一下。处理之前先分析一下问题原因。
在ViewPager内部有一个私有成员变量mFirstLayout,其默认值是true。这个参数标记为第一次布局的。如果是第一次布局那么滑动就不是平滑的。
代码如下:
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滑动出屏幕的时候其在DetachedFromWindow方法会被调用,而当其再次进入屏幕的时候则调用到AttachedToWindow这个方法。
来看看onAttachedToWindow方法中的代码:
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mFirstLayout = true;
}
只是把mFirstLayout放置为true!而如果此时onLayout没有被触发,则先发生了页面滚动,那么此时的页面滑动就没了的平滑效果了。
了解了原因之后处理起来就简单了,因为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,一切完好!