Android自定義控件進階篇,自定義LayoutManager

前言

「滿足常樂」,不少人不知足現狀,各類折騰,每每捨本逐末,常樂才能少一分浮躁,多一分寧靜。近期在小編身上發生了許多事情,心態也發生了很大的改變,有感於現實的無奈,在離家鄉遙遠城市裏的落寂,追逐名利的浮躁;可能生活就是這樣的,每一個年齡段都有本身的煩惱。java

說道折騰,好久之前就看到了各類自定義LayoutManager作出各類炫酷的動畫,就想本身也要實現。但每次都由於系統自帶的LinearLayoutManager源碼搞得一臉懵逼。正好這段時間不忙,折騰了一天,寫了個簡單的Demo,效果以下:git

效果預覽

在這裏插入圖片描述
在這裏插入圖片描述
RecyclerView的重要性沒必要多說,據過往開發經驗而談,超過一屏可滑動的界面,基本均可以採用 「RecyclerView的多類型」 來作,不只維護仍是擴展都是很是有效率的。RecyclerView相關的面試題也是各大廠常問的問題之一(權重很是高)。

使用

mRecyclerView.setLayoutManager(stackLayoutManager = new StackLayoutManager(this));
複製代碼

跟系統的LinearLayoutManager使用方式一致,文本只是簡單的Demo,功能單一,主要講解流程與步驟,請根據特定的需求修改。github

各屬性意義見圖: 面試

在這裏插入圖片描述
湊合看,因爲ps太爛。注意:由於item隨着滑動會有不一樣的縮放,因此實際normalViewGap會被縮放計算。

自定義LayoutManager基礎知識

有關自定義LayoutManager基礎知識,請查閱如下文章,寫的很是棒:緩存

一、陳小緣的自定義LayoutManager第十一式之飛龍在天(小緣大佬自定義文章邏輯清晰明瞭,堪稱教科書,很是經典)ide

blog.csdn.net/u011387817/…佈局

二、 張旭童的掌握自定義LayoutManager(一) 系列開篇 常見誤區、問題、注意事項,經常使用API動畫

blog.csdn.net/zxt0601/art…this

三、張旭童的掌握自定義LayoutManager(二) 實現流式佈局spa

blog.csdn.net/zxt0601/art…

四、勇朝陳的Android仿豆瓣書影音頻道推薦表單堆疊列表RecyclerView-LayoutManager

blog.csdn.net/ccy0122/art…

這幾篇文章針對自定義LayoutManager的誤區、注意事項,分析的很是到位,來來回回我看了好幾篇,但願對你有所幫助。

自定義LayoutManager基本流程

讓Items顯示出來

咱們在自定義ViewGroup中,想要顯示子View,無非就三件事:

  1. 添加 經過addView方法把子View添加進ViewGroup或直接在xml中直接添加;
  2. 測量 重寫onMeasure方法並在這裏決定自身尺寸以及每個子View大小;
  3. 佈局 重寫onLayout方法,在裏面調用子View的layout方法來肯定它的位置和尺寸;

其實在自定義LayoutManager中,在流程上也是差很少的,咱們須要重寫onLayoutChildren方法,這個方法會在初始化或者Adapter數據集更新時回調,在這方法裏面,須要作如下事情:

  1. 進行佈局以前,咱們須要調用detachAndScrapAttachedViews方法把屏幕中的Items都分離出來,內部調整好位置和數據後,再把它添加回去(若是須要的話);
  2. 分離了以後,咱們就要想辦法把它們再添加回去了,因此須要經過addView方法來添加,那這些View在哪裏獲得呢? 咱們須要調用 Recycler的getViewForPosition(int position) 方法來獲取;
  3. 獲取到Item並從新添加了以後,咱們還須要對它進行測量,這時候能夠調用measureChild或measureChildWithMargins方法,二者的區別咱們已經瞭解過了,相信同窗們都能根據需求選擇更合適的方法;
  4. 在測量完還須要作什麼呢? 沒錯,就是佈局了,咱們也是根據需求來決定使用layoutDecorated仍是layoutDecoratedWithMargins方法;
  5. 在自定義ViewGroup中,layout完就能夠運行看效果了,但在LayoutManager還有一件很是重要的事情,就是回收了,咱們在layout以後,還要把一些再也不須要的Items回收,以保證滑動的流暢度;

以上內容出自陳小緣的自定義LayoutManager第十一式之飛龍在天

佈局實現

再看下相關參數:

在這裏插入圖片描述
若是去掉itemView的縮放,透明度動畫,那麼效果是這樣的:
在這裏插入圖片描述
看到的效果與LinearLayoutManager同樣,但本篇並不使用LinearLayoutManager,而是經過自定義LayoutManager來實現。

索引值爲0的view 一次徹底滑出屏幕所須要的移動距離,定位爲 firstChildCompleteScrollLength ;非索引值爲0的view滑出屏幕所須要移動的距離爲: firstChildCompleteScrollLength + onceCompleteScrollLength ; item 之間的間距爲 normalViewGap

咱們在 scrollHorizontallyBy 方法中記錄偏移量 dx,保存一個累計偏移量 mHorizontalOffset ,而後針對索引值爲0與非0兩種狀況,在 mHorizontalOffset 小於 firstChildCompleteScrollLength 狀況下,用該偏移量除以 firstChildCompleteScrollLength 獲取到已經滾動了的百分比 fraction ;同理索引值非0的狀況下,偏移量須要減去 firstChildCompleteScrollLength 來獲取到滾動的百分比。根據百分比,怎麼佈局childview就很容易了。

接下來開始寫代碼,先取個比較接地氣的名字,就叫 StackLayoutManager ,好普通的名字,哈哈。

StackLayoutManager 繼承 RecyclerView.LayoutManager ,須要重寫 generateDefaultLayoutParams 方法:

@Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
    }
複製代碼

先看當作員變量:

/** * 一次完整的聚焦滑動所須要的移動距離 */
    private float onceCompleteScrollLength = -1;

    /** * 第一個子view的偏移量 */
    private float firstChildCompleteScrollLength = -1;

    /** * 屏幕可見第一個view的position */
    private int mFirstVisiPos;

    /** * 屏幕可見的最後一個view的position */
    private int mLastVisiPos;

    /** * 水平方向累計偏移量 */
    private long mHorizontalOffset;

    /** * view之間的margin */
    private float normalViewGap = 30;

    private int childWidth = 0;

    /** * 是否自動選中 */
    private boolean isAutoSelect = true;
    // 選中動畫
    private ValueAnimator selectAnimator;
複製代碼

接着看看 scrollHorizontallyBy 方法:

@Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 手指從右向左滑動,dx > 0; 手指從左向右滑動,dx < 0;
        // 位移0、沒有子View 固然不移動
        if (dx == 0 || getChildCount() == 0) {
            return 0;
        }

        // 偏差處理
        float realDx = dx / 1.0f;
        if (Math.abs(realDx) < 0.00000001f) {
            return 0;
        }

        mHorizontalOffset += dx;

        dx = fill(recycler, state, dx);

        return dx;
    }

    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
        int resultDelta = dx;
        resultDelta = fillHorizontalLeft(recycler, state, dx);
        recycleChildren(recycler);
        return resultDelta;
    }

    private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
        //----------------一、邊界檢測-----------------
        if (dx < 0) {
            // 已到達左邊界
            if (mHorizontalOffset < 0) {
                mHorizontalOffset = dx = 0;
            }
        }

        if (dx > 0) {
            if (mHorizontalOffset >= getMaxOffset()) {
                // 根據最大偏移量來計算滑動到最右側邊緣
                mHorizontalOffset = (long) getMaxOffset();
                dx = 0;
            }
        }

        // 分離所有的view,加入到臨時緩存
        detachAndScrapAttachedViews(recycler);

        float startX = 0;
        float fraction = 0f;
        boolean isChildLayoutLeft = true;

        View tempView = null;
        int tempPosition = -1;

        if (onceCompleteScrollLength == -1) {
            // 由於mFirstVisiPos在下面可能被改變,因此用tempPosition暫存一下
            tempPosition = mFirstVisiPos;
            tempView = recycler.getViewForPosition(tempPosition);
            measureChildWithMargins(tempView, 0, 0);
            childWidth = getDecoratedMeasurementHorizontal(tempView);
        }

        // 修正第一個可見view mFirstVisiPos 已經滑動了多少個完整的onceCompleteScrollLength就表明滑動了多少個item
        firstChildCompleteScrollLength = getWidth() / 2 + childWidth / 2;
        if (mHorizontalOffset >= firstChildCompleteScrollLength) {
            startX = normalViewGap;
            onceCompleteScrollLength = childWidth + normalViewGap;
            mFirstVisiPos = (int) Math.floor(Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) / onceCompleteScrollLength) + 1;
            fraction = (Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);
        } else {
            mFirstVisiPos = 0;
            startX = getMinOffset();
            onceCompleteScrollLength = firstChildCompleteScrollLength;
            fraction = (Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);
        }

        // 臨時將mLastVisiPos賦值爲getItemCount() - 1,放心,下面遍歷時會判斷view是否已溢出屏幕,並及時修正該值並結束佈局
        mLastVisiPos = getItemCount() - 1;

        float normalViewOffset = onceCompleteScrollLength * fraction;
        boolean isNormalViewOffsetSetted = false;

        //----------------三、開始佈局-----------------
        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
            View item;
            if (i == tempPosition && tempView != null) {
                // 若是初始化數據時已經取了一個臨時view
                item = tempView;
            } else {
                item = recycler.getViewForPosition(i);
            }

            addView(item);
            measureChildWithMargins(item, 0, 0);

            if (!isNormalViewOffsetSetted) {
                startX -= normalViewOffset;
                isNormalViewOffsetSetted = true;
            }

            int l, t, r, b;
            l = (int) startX;
            t = getPaddingTop();
            r = l + getDecoratedMeasurementHorizontal(item);
            b = t + getDecoratedMeasurementVertical(item);

            layoutDecoratedWithMargins(item, l, t, r, b);

            startX += (childWidth + normalViewGap);

            if (startX > getWidth() - getPaddingRight()) {
                mLastVisiPos = i;
                break;
            }
        }
        return dx;
    }
複製代碼

涉及的方法:

/** * 最大偏移量 * * @return */
    private float getMaxOffset() {
        if (childWidth == 0 || getItemCount() == 0) return 0;
        return (childWidth + normalViewGap) * (getItemCount() - 1);
    }
 
    /** * 獲取某個childView在水平方向所佔的空間,將margin考慮進去 * * @param view * @return */
    public int getDecoratedMeasurementHorizontal(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredWidth(view) + params.leftMargin
                + params.rightMargin;
    }

    /** * 獲取某個childView在豎直方向所佔的空間,將margin考慮進去 * * @param view * @return */
    public int getDecoratedMeasurementVertical(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredHeight(view) + params.topMargin
                + params.bottomMargin;
    }
複製代碼

回收複用

這裏使用Android仿豆瓣書影音頻道推薦表單堆疊列表RecyclerView-LayoutManager中使用的回收技巧:

/** * @param recycler * @param state * @param delta */
    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {
        int resultDelta = delta;
        //。。。省略
        
        recycleChildren(recycler);
       log("childCount= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size());
        return resultDelta;
    }
    
	/** * 回收需回收的Item。 */
    private void recycleChildren(RecyclerView.Recycler recycler) {
        List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
        for (int i = 0; i < scrapList.size(); i++) {
            RecyclerView.ViewHolder holder = scrapList.get(i);
            removeAndRecycleView(holder.itemView, recycler);
        }
    }
複製代碼

回收複用這裏就不驗證了,感興趣的小夥伴可自行驗證。

動畫效果

private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
        // 省略 ......
        //----------------三、開始佈局-----------------
        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
            // 省略 ......
            
            // 縮放子view
            final float minScale = 0.6f;
            float currentScale = 0f;
            final int childCenterX = (r + l) / 2;
            final int parentCenterX = getWidth() / 2;
            isChildLayoutLeft = childCenterX <= parentCenterX;
            if (isChildLayoutLeft) {
                final float fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f);
                currentScale = 1.0f - (1.0f - minScale) * fractionScale;
            } else {
                final float fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f);
                currentScale = 1.0f - (1.0f - minScale) * fractionScale;
            }
            item.setScaleX(currentScale);
            item.setScaleY(currentScale);
            item.setAlpha(currentScale);
            
            layoutDecoratedWithMargins(item, l, t, r, b);
           // 省略 ......
        }
        return dx;
    }
複製代碼

childView 越向屏幕中間移動縮放比越大,越向兩邊移動縮放比越小。

自動選中

一、滾動中止後自動選中

監聽 onScrollStateChanged,在滾動中止時計算出應當停留的 position,再計算出停留時的 mHorizontalOffset 值,播放屬性動畫將當前 mHorizontalOffset 不斷更新至最終值便可。相關代碼以下:

@Override
    public void onScrollStateChanged(int state) {
        super.onScrollStateChanged(state);
        switch (state) {
            case RecyclerView.SCROLL_STATE_DRAGGING:
                //當手指按下時,中止當前正在播放的動畫
                cancelAnimator();
                break;
            case RecyclerView.SCROLL_STATE_IDLE:
                //當列表滾動中止後,判斷一下自動選中是否打開
                if (isAutoSelect) {
                    //找到離目標落點最近的item索引
                    smoothScrollToPosition(findShouldSelectPosition());
                }
                break;
            default:
                break;
        }
    }
 
     /** * 平滑滾動到某個位置 * * @param position 目標Item索引 */
    public void smoothScrollToPosition(int position) {
        if (position > -1 && position < getItemCount()) {
            startValueAnimator(position);
        }
    }

    private int findShouldSelectPosition() {
        if (onceCompleteScrollLength == -1 || mFirstVisiPos == -1) {
            return -1;
        }
        int position = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap));
        int remainder = (int) (Math.abs(mHorizontalOffset) % (childWidth + normalViewGap));
        // 超過一半,應當選中下一項
        if (remainder >= (childWidth + normalViewGap) / 2.0f) {
            if (position + 1 <= getItemCount() - 1) {
                return position + 1;
            }
        }
        return position;
    }

    private void startValueAnimator(int position) {
        cancelAnimator();

        final float distance = getScrollToPositionOffset(position);

        long minDuration = 100;
        long maxDuration = 300;
        long duration;

        float distanceFraction = (Math.abs(distance) / (childWidth + normalViewGap));

        if (distance <= (childWidth + normalViewGap)) {
            duration = (long) (minDuration + (maxDuration - minDuration) * distanceFraction);
        } else {
            duration = (long) (maxDuration * distanceFraction);
        }
        selectAnimator = ValueAnimator.ofFloat(0.0f, distance);
        selectAnimator.setDuration(duration);
        selectAnimator.setInterpolator(new LinearInterpolator());
        final float startedOffset = mHorizontalOffset;
        selectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                mHorizontalOffset = (long) (startedOffset + value);
                requestLayout();
            }
        });
        selectAnimator.start();
    }
複製代碼
二、點擊非焦點view自動將其選中爲焦點view

咱們能夠直接拿到 viewposition,直接調用 smoothScrollToPosition 方法,就能夠實現自動選中爲焦點。

中間view覆蓋在兩邊view之上

效果是這樣的:

在這裏插入圖片描述
從效果中能夠看出,索引爲2的view覆蓋在1,3的上面,同時1又覆蓋在0的上面,以此內推。

RecyclerView 繼承於 ViewGroup ,那麼在添加子view addView(View child, int index)index 的索引值越大,越顯示在上層。那麼能夠得出,爲2的綠色卡片被添加是 index 最大,分析能夠得出如下結論:

index 的大小:

0 < 1 < 2 > 3 > 4

中間最大,兩邊逐漸減少的原則。

獲取到中間 view 的索引值,若是小於等於該索引值則調用 addView(item) ,反之調用 addView(item, 0) ;相關代碼以下:

private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
        //省略 ......
        //----------------三、開始佈局-----------------
        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
             //省略 ......
            int focusPosition = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap));
            if (i <= focusPosition) {
                addView(item);
            } else {
                addView(item, 0);
            }
             //省略 ...... 
        }
        return dx;
    }
複製代碼

文章到這裏就差很少要結束了。

源碼地址:

github.com/HpWens/MeiW…

star ~

結語

愛笑的人,運氣通常都不會太差。同時也給本身一個鼓勵,咱們下期見。

相關文章
相關標籤/搜索