本文主要是闡述LoopBanner項目的原理及重要知識點,不涉及基本用法,對用法不了解的同學(xué),可以訪問https://github.com/wenjiangit/LoopBanner或下載demo.
1. LoopBanner由來
近來公司項目比較閑,于是便抽空學(xué)了一下Kotlin語言,畢竟本人也是一個有追求的Android開發(fā)者,對于Google官方力推的Android開發(fā)語言怎么可能視而不見,學(xué)習(xí)Kotlin主要是基于Kotlin實戰(zhàn)一書,當(dāng)然語法特性學(xué)習(xí)完了,肯定還是動手實戰(zhàn)一下,便基于鴻洋大神的Wanandroid 開放API寫了個WanAndroid客戶端.項目是基于Google的AAC架構(gòu),感興趣的同學(xué)可以參考一下.
項目首頁一般會有一個輪播圖,當(dāng)然我的WanAndroid客戶端也不例外,其實碰到這種情況,我和大家的想法一樣,上Github找個現(xiàn)成的輪子裝上就行,于是搜索Banner之類的關(guān)鍵詞,倒是出現(xiàn)一大堆上千star的項目,如下所示:

但是確實是沒有符合我要求的,要么項目好久沒人維護了,很多人提issue卻沒人回應(yīng),要么是使用起來太復(fù)雜,接入成本過高,還有就是根本不能實現(xiàn)我想要的效果.
其實我要的效果也很簡單,如下:

這下應(yīng)該很直觀了吧,中間顯示當(dāng)前page的全部,左右顯示前后兩個頁面的一部分,每個page之間有一定的間距.
確實是沒有找到符合條件的輪子,當(dāng)然也可能是我的搜索方式不對,既然如此,那就只有自己動手?jǐn)]一個了.
2.核心問題剖析
2.1 實現(xiàn)方案選擇
基于以上的效果圖,大致能夠想到兩種實現(xiàn)方案:
基于
ViewPager實現(xiàn),需要解決的是如果讓ViewPager在一個屏幕內(nèi)顯示一個以上的子page.基于
RecyclerView實現(xiàn),需要解決的是如何控制RecyclerView每次滑動到指定位置.
為了實現(xiàn)簡單以及后續(xù)的擴展方便,我選擇的是第一種方案,主要是考慮到后面如果需要控制左右兩個page的大小縮放比例,使用ViewPager的Transformer比自定義RecyclerView的LayoutManager要簡單.
2.2 如何讓ViewPager在一個屏幕內(nèi)顯示多個子頁面?
- 繼承
PagerAdapter,并重寫getPageWidth函數(shù)
static class MyPagerAdapter extends PagerAdapter {
...
@Override
public float getPageWidth(int position) {
return 0.8f;
}
}
該方法默認(rèn)的返回值是1.0f,這里改成0.8f,效果如下:

這里只是將選中的page占整個ViewPager父容器的80%,后面的一個占20%,顯然是不滿足我們的要求的.
- 設(shè)置
ViewPager的左右Margin,并將父布局的clipChildren屬性置為false,并且關(guān)閉硬件加速.
<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>
效果如下:

這里有必要了解一下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標(biāo)志,也會遍歷子view,更新子view的FLAG_CLIP_CHILDREN.
這個值默認(rèn)為true,即父view會裁剪超出父view邊界的子view,當(dāng)設(shè)置為false,則表示不會裁剪,所以當(dāng)我們設(shè)置ViewPager的左右邊距,且父View不對超出邊界的進(jìn)行裁剪,就可以將左右超出ViewPager范圍內(nèi)的page顯示出來,也就達(dá)到我們的目的了.
這個效果離我們想要的已經(jīng)非常接近了,接著設(shè)置ViewPager的pageMargin,
mPager = findViewById(R.id.view_pager);
mPager.setPageMargin(10);
效果如下:

page之間也有間隙了,基本符合我們要求了.
2.3 如何實現(xiàn)ViewPager的無縫循環(huán)滾動?
我們知道ViewPager.setCurrentItem()可以將page滑動到指定的頁面,可以開啟周期任務(wù)來更新item值即可實現(xiàn)滾動,但是當(dāng)滾動到了最后一個page時,如何回到第一個page頁呢?直接設(shè)置setCurrentItem(0)可以實現(xiàn),但是這個過渡動畫效果肯定不是我們想要的.
想要實現(xiàn)無縫滾動,可以將page的個數(shù)設(shè)置的足夠大.
@Override
public final int getCount() {
final int size = mData.size();
if (size != 0) {
return mCanLoop ? Integer.MAX_VALUE : size;
}
return 0;
}
這里貼出的是LoopAdapter的getCount方法, 即需要循環(huán)滾動時,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與數(shù)據(jù)大小取余,得到真實的數(shù)據(jù)去填充當(dāng)前頁面.
2.4 如何消除頻繁創(chuàng)建和銷毀頁面所帶來的內(nèi)存開銷?
我們知道ViewPager是通過PagerAdapter來創(chuàng)建銷毀頁面并綁定數(shù)據(jù)的,即我們需要覆蓋 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);
}
如果在無限輪播的情況下也這樣做,會造成大量對象的創(chuàng)建和銷毀,容易造成內(nèi)存抖動.
既然我們的page是周期重復(fù)的,可以考慮緩存起來,每次有緩存直接拿出來用就好了,緩存的版本如下,也是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與對應(yīng)的ViewHolder的鍵值對,數(shù)據(jù)結(jié)構(gòu)用的是Android獨有的SparseArray,也是為了節(jié)省內(nèi)存.
這樣每種page都只需要初始化并綁定數(shù)據(jù)一次即可,只要不超過20條以上數(shù)據(jù),都是完全無壓力的,
不過基本上Banner數(shù)據(jù)都不會超過10條,所以完全不用擔(dān)心內(nèi)存問題了.
2.5 如何實現(xiàn)手觸摸時停止自動滾動,手松開后恢復(fù)自動滾動?
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;
}
核心代碼都在上面,其實就是監(jiān)聽ViewPager的滑動狀態(tài),拖動的時候停止定時任務(wù),而在空閑的時候判斷是否在滾動,沒有滾動時就啟動自動滾動.
2.6 如何兼容不同的指示器樣式,并提供良好的擴展?
這一塊當(dāng)時也考慮挺久的,最后也是基于模板方法和適配器模式實現(xiàn)了相對不錯的擴展效果.
- 設(shè)計適配接口
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);
/**
* 應(yīng)用選中效果
*
* @param prev 上一個
* @param current 當(dāng)前
* @param reverse 是否逆向滑動
*/
void applySelectState(View prev, View current, boolean reverse);
/**
* 應(yīng)用為選中效果
*
* @param indicator 指示器
*/
void applyUnSelectState(View indicator);
/**
* 是否需要對某個位置進(jìn)行特殊處理
*
* @param container 指示器容器
* @param position 第一個或最后一個
* @return 返回true代表處理好了
*/
boolean handleSpecial(LinearLayout container, int position);
}
- 設(shè)計核心流程:
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被選中的時候會觸發(fā)updateIndicators方法,只要合理地實現(xiàn)了IndicatorAdapter相關(guān)方法就可以根據(jù)需要定義自己的指示器了.
- 實現(xiàn)自己的
IndicatorAdapter
下面是仿照京東App首頁Banner指示器效果所實現(xiàn)的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;
}
}
}
到此,基本上一些難點都解決了,其次就是一些比較煩人的參數(shù)配置了,雖然不難,卻也是很費時間,只能說要做一個好點的開源項目確實不容易.
2.7 如何實現(xiàn)自定義頁面內(nèi)容?
大多數(shù)Banner基本展示都是一張大圖,標(biāo)題,指示器,其實這也能滿足大部分的需求,但如何碰到奇葩產(chǎn)品給你加各種各樣復(fù)雜內(nèi)容的時候也不要慌,這里也考慮到了,只需要你像使用RecyclerView一樣在初始化LoopAdapter的時候傳遞一個layoutId,然后根據(jù)你的需求綁定相應(yīng)數(shù)據(jù)即可.當(dāng)然你也可以不傳,默認(rèn)會給你填充一個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對應(yīng)的布局并返回.子類還可以覆蓋該方法返回自己的自定義View,擴展性還是不錯的.
3. 總結(jié)
這是我第一個完整的開源項目,之前雖然也有提交過,但都是一些零零碎碎的東西,不成體系,也沒有配置遠(yuǎn)程倉庫地址.總體感覺還是很不錯的,至少對自定義View這一塊知識有了更加深入的了解,代碼雖然不是很漂亮,但確實是用心了的.希望路過的小伙伴覺得不錯的可以給個小星星,發(fā)現(xiàn)有bug的可以提個issue,對于這個項目我會一直維護的,最后附上倉庫地址LoopBanner .