LoopBanner原理淺析

收藏待读

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的項目,如下所示:

LoopBanner原理淺析

Github搜索結果

但是確實是沒有符合我要求的,要麼項目好久沒人維護了,很多人提issue卻沒人回應,要麼是使用起來太複雜,接入成本過高,還有就是根本不能實現我想要的效果.

其實我要的效果也很簡單,如下:

LoopBanner原理淺析

騰訊視頻首頁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 ,效果如下:

LoopBanner原理淺析

image.png

這裡只是將選中的page占整個 ViewPager 父容器的80%,後面的一個佔20%,顯然是不滿足我們的要求的.

  1. 設置 ViewPager 的左右Margin,並將父布局的 clipChildren 屬性置為false,並且關閉硬件加速.
    

    

效果如下:

LoopBanner原理淺析

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顯示出來,也就達到我們的目的了.

這個效果離我們想要的已經非常接近了,接着設置 ViewPage r的 pageMargin ,

mPager = findViewById(R.id.view_pager);
mPager.setPageMargin(10);

效果如下:

LoopBanner原理淺析

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 extends PagerAdapter {

    private static final String TAG = "LoopAdapter";
    private SparseArray mHolderMap = new SparseArray();
    private List mData;
    private int mLayoutId;
    private boolean mCanLoop = true;
    LoopBanner.OnPageClickListener mClickListener;

    public LoopAdapter(List data, int layoutId) {
        mData = data;
        mLayoutId = layoutId;
    }

    public LoopAdapter(List data) {
        this(data, -1);
    }

    public LoopAdapter(int layoutId) {
        this(new ArrayList(), layoutId);
    }

    public LoopAdapter() {
        this(new ArrayList(), -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()  0) {
        for (int i = 0; i  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 .

原文 : 簡書

相關閱讀

免责声明:本文内容来源于簡書,已注明原文出处和链接,文章观点不代表立场,如若侵犯到您的权益,或涉不实谣言,敬请向我们提出检举。