LoopBanner原理淺析

本文主要是闡述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的項目,如下所示:

Github搜索結(jié)果

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

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

騰訊視頻首頁Banner

這下應(yīng)該很直觀了吧,中間顯示當(dāng)前page的全部,左右顯示前后兩個頁面的一部分,每個page之間有一定的間距.

確實是沒有找到符合條件的輪子,當(dāng)然也可能是我的搜索方式不對,既然如此,那就只有自己動手?jǐn)]一個了.

2.核心問題剖析

2.1 實現(xiàn)方案選擇

基于以上的效果圖,大致能夠想到兩種實現(xiàn)方案:

  1. 基于ViewPager實現(xiàn),需要解決的是如果讓ViewPager在一個屏幕內(nèi)顯示一個以上的子page.

  2. 基于RecyclerView實現(xiàn),需要解決的是如何控制RecyclerView每次滑動到指定位置.

為了實現(xiàn)簡單以及后續(xù)的擴展方便,我選擇的是第一種方案,主要是考慮到后面如果需要控制左右兩個page的大小縮放比例,使用ViewPagerTransformer比自定義RecyclerViewLayoutManager要簡單.

2.2 如何讓ViewPager在一個屏幕內(nèi)顯示多個子頁面?

  1. 繼承PagerAdapter,并重寫getPageWidth函數(shù)
static class MyPagerAdapter extends PagerAdapter {

   ...

    @Override
    public float getPageWidth(int position) {
        return 0.8f;
    }
}

該方法默認(rèn)的返回值是1.0f,這里改成0.8f,效果如下:

image.png

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

  1. 設(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>

效果如下:

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標(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);   

效果如下:

image.png

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;
}

這里貼出的是LoopAdaptergetCount方法, 即需要循環(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ù)的,即我們需要覆蓋 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);
}

如果在無限輪播的情況下也這樣做,會造成大量對象的創(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;
    }

這里貼的是部分代碼,其實也是借鑒了RecyclerViewViewHolder機制,緩存的是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)了相對不錯的擴展效果.

  1. 設(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);


}
  1. 設(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ù)需要定義自己的指示器了.

  1. 實現(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 .

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 內(nèi)容 抽屜菜單 ListView WebView SwitchButton 按鈕 點贊按鈕 進(jìn)度條 TabLayo...
    小狼W閱讀 1,671評論 0 10
  • Android UI相關(guān)開源項目庫匯總OpenDigg 抽屜菜單MaterialDrawer ★7337 - 安卓...
    黃海佳閱讀 8,829評論 3 77
  • 去年,我參加“家庭治療”培訓(xùn),培訓(xùn)中有一個環(huán)節(jié)是討論在原生家庭中的出生順序?qū)θ烁竦挠绊憽?同學(xué)們討論的很熱烈,作為...
    李麗霞閱讀 1,004評論 0 2
  • 2018年,時值改革開放40周年,每一個中國人都能真切地感受到40年來我們的生活發(fā)生了翻天覆地的變化,這...
    藍(lán)君卿閱讀 342評論 0 6
  • 北之蝶 作者\曦蝶藍(lán)瑾 夢亂多少夜 千年的南國 河西為盟 滴血誓約 輪回王朝的逝變 誰在午夜翩翩起舞 又是誰執(zhí)劍踏...
    曦蝶藍(lán)瑾閱讀 145評論 0 2

友情鏈接更多精彩內(nèi)容