Android—RecyclerView進(jìn)階(5)—自定義LayoutManager

我的CSDN: ListerCi
我的簡(jiǎn)書: 東方未曦

一、簡(jiǎn)介&示例

雖然官方提供的LinearLayoutManager和GridLayoutManager等已經(jīng)可以滿足絕大部分需求了,但是當(dāng)我們對(duì)Item的布局有特殊的需求時(shí)就需要我們自定義LayoutManager。自定義LayoutManager作為RecyclerView的一大難點(diǎn),對(duì)自定義View和RecyclerView復(fù)用機(jī)制相關(guān)的知識(shí)有一定的要求,建議各位同學(xué)打好基礎(chǔ)再學(xué)習(xí)。

首先強(qiáng)推啟艦大神的自定義LayoutManager系列博客,可以說把自定義LayoutManager講透了
RecyclerView系列之三自定義LayoutManager
RecyclerView系列之四實(shí)現(xiàn)回收復(fù)用
RecyclerView系列之五回收復(fù)用實(shí)現(xiàn)方式二
RecyclerView系列之六實(shí)現(xiàn)滾動(dòng)畫廊控件

當(dāng)然網(wǎng)上還有很多優(yōu)秀的自定義LayoutManager樣例,例如......馬蜂窩:把RecyclerView擼成馬蜂窩,當(dāng)年我第一次看到這篇博客時(shí)可謂虎軀一震。

眼瞅著各路大神都實(shí)現(xiàn)了這么優(yōu)秀的自定義LayoutManager,我琢磨許久,把RecyclerView擼成了一個(gè)轉(zhuǎn)盤,效果如下所示,今天就來講講怎么實(shí)現(xiàn)它。

gif-轉(zhuǎn)盤效果.gif

二、布局與移動(dòng)

2.1 初始化時(shí)的Item布局

首先來看如何對(duì)item進(jìn)行布局,我們知道轉(zhuǎn)盤的整體是一個(gè)圓,只不過這個(gè)圓很大,只有一部分的Item可以顯示在屏幕上,因此可以設(shè)計(jì)布局如下。其中紅色框代表屏幕,在初始化時(shí),第0個(gè)Item會(huì)被布局到屏幕中央,我們稱它的角度為0。

那么怎么判斷其他Item要不要被布局到屏幕上呢?如果先計(jì)算一個(gè)Item的坐標(biāo)再去判斷是否與屏幕相交會(huì)有些麻煩,不過由于各個(gè)Item之間的角度相等,我們?nèi)菀椎玫矫總€(gè)Item與中間虛線的角度差,當(dāng)這個(gè)角度差小于某個(gè)值的時(shí)候,我們認(rèn)為它會(huì)顯示在可視區(qū)域,就可以將這個(gè)Item布局到屏幕上。

布局設(shè)計(jì).png

假設(shè)這個(gè)圓的半徑為Radius,Item之間的角度為Angle,初始化時(shí)我們需要將前面的幾個(gè)Item布局到屏幕上。由于Item坐標(biāo)的計(jì)算依賴于圓心,因此我們首先要得到圓心的坐標(biāo): (circleX=screenWidth/2, circleY=screenHeight/2 + Radius),之后可以通過圓心坐標(biāo)和三角函數(shù)計(jì)算每個(gè)Item中心的坐標(biāo)。

初始化時(shí)第i個(gè)Item的角度為i*Angle,因此該Item的x坐標(biāo)為circleX+sin(i*Angle)*Radius,y坐標(biāo)為circleY-cos(i*Angle)*Radius,不過Java計(jì)算三角函數(shù)時(shí)傳入的參數(shù)不是角度而是弧度,因此需要將角度轉(zhuǎn)化為弧度,最終計(jì)算坐標(biāo)的代碼如下:

float curAngle = index * mEachAngle;
int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
int x = mCircleMidX + xToAdd;
int y = mCircleMidY - yToMinus;

2.2 移動(dòng)時(shí)的Item布局

剛剛我們計(jì)算了初始化時(shí)Item的坐標(biāo),那么Item移動(dòng)時(shí)的坐標(biāo)該如何計(jì)算呢?
由于坐標(biāo)的計(jì)算依賴于當(dāng)前Item的角度,當(dāng)轉(zhuǎn)盤移動(dòng)時(shí),所有Item的角度都會(huì)變化。因此可以通過一個(gè)值mMovedAngle保存所有Item(也就是轉(zhuǎn)盤整體)向后移動(dòng)的角度,可得第i個(gè)Item此時(shí)的角度為i * Angle - mMovedAngle,接下來看如何計(jì)算mMovedAngle的值。

由于轉(zhuǎn)盤只支持橫向移動(dòng),當(dāng)RecyclerView移動(dòng)時(shí)會(huì)傳入一個(gè)dx表示本次橫向移動(dòng)的距離。當(dāng)Radius的值很大時(shí),dx基本等于圓弧的長(zhǎng)度,所以可以通過周長(zhǎng)公式計(jì)算單次移動(dòng)的角度值。

    private float convertDxToAngle(int dx) {
        return (float) (360 * dx / (2 * Math.PI * mRadius));
    }

將角度轉(zhuǎn)化為dx的方法如下,在邊界處理時(shí)會(huì)使用到。

    private int convertAngleToDx(float angle) {
        return (int) (2 * Math.PI * mRadius * angle / 360);
    }

我們?cè)?code>scrollHorizontallyBy(int dx, ...)方法中更新mMovedAngle,首先進(jìn)行邊界處理。mMovedAngle的范圍是[0, (getItemCount() - 1) * Angle],假設(shè)本次移動(dòng)的角度為moveAngle ,當(dāng)mMovedAngle + moveAngle > (getItemCount() - 1) * Angle時(shí),表示滑動(dòng)到了右邊界;當(dāng)mMovedAngle + moveAngle < 0時(shí),表示滑動(dòng)到了左邊界,此時(shí)需要將多余的角度去掉并計(jì)算真正的dx,代碼如下所示。

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        float moveAngle = convertDxToAngle(dx);
        int actualDx = dx;
        if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
            moveAngle = getMaxScrollAngle() - mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        } else if (mMovedAngle + moveAngle < 0) {
            moveAngle = -mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        }
        mMovedAngle += moveAngle;
        // 根據(jù)mMovedAngle對(duì)Item布局......
        return actualDx;
    }

    private int getMaxScrollAngle() {
        return (getItemCount() - 1) * mEachAngle;
    }

三、具體實(shí)現(xiàn)

對(duì)于LayoutManager來說,不關(guān)心轉(zhuǎn)盤的半徑和Item的角度差,這兩個(gè)值由構(gòu)造函數(shù)傳入,讓用戶去自定義。LayoutManager的成員變量和構(gòu)造函數(shù)如下。

public class TurntableLayoutManager extends RecyclerView.LayoutManager {

    private int mRadius; // 轉(zhuǎn)盤半徑
    private int mEachAngle; // Item間的角度差

    private int mItemWidth;
    private int mItemHeight;
    private int mCircleMidX; // 圓心X坐標(biāo)
    private int mCircleMidY; // 圓心Y坐標(biāo)
    private float mMovedAngle = 0;

    public TurntableLayoutManager(int radius, int eachAngle) {
        this.mRadius = radius;
        this.mEachAngle = eachAngle;
    }

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }

    // ......
}

接下來通過onLayoutChildren(...)方法對(duì)初始化時(shí)的Item進(jìn)行布局,主要為4步:
① 由于每個(gè)Item的大小相等,先將Item的大小計(jì)算出來。
② 計(jì)算轉(zhuǎn)盤的圓心坐標(biāo),用于之后根據(jù)角度計(jì)算Item的坐標(biāo)
③ 回收當(dāng)前屏幕上的ItemView
④ 對(duì)要顯示在屏幕上的Item布局

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 1. 假設(shè)每個(gè)Item大小相等, 得到item的大小
        initItemSize(recycler);
        // 2. 根據(jù)屏幕中心得到轉(zhuǎn)盤的圓心
        int screenMidX = getWidth() / 2;
        int screenMidY = getHeight() / 2;
        mCircleMidX = screenMidX;
        mCircleMidY = screenMidY + mRadius;
        // 3. 回收屏幕上的ItemView
        detachAndScrapAttachedViews(recycler);
        // 4. 當(dāng)Item角度的絕對(duì)值小于50°時(shí),將其布局到屏幕上
        // 這個(gè)值可以自己調(diào)整,只要顯示效果沒問題即可
        for (int i = 0; i < getItemCount(); i++) {
            if (Math.abs(i * mEachAngle) < 50) {
                layoutViewByIndex(recycler, i);
            }
        }
    }

    private void initItemSize(RecyclerView.Recycler recycler) {
        View view = recycler.getViewForPosition(0);
        addView(view);
        measureChildWithMargins(view, 0, 0);
        mItemWidth = getDecoratedMeasuredWidth(view);
        mItemHeight = getDecoratedMeasuredHeight(view);
        removeAndRecycleView(view, recycler);
    }

    private void layoutViewByIndex(RecyclerView.Recycler recycler, int index) {
        float curAngle = index * mEachAngle - mMovedAngle;
        int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
        int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
        int x = mCircleMidX + xToAdd;
        int y = mCircleMidY - yToMinus;

        View viewForPosition = recycler.getViewForPosition(index);
        addView(viewForPosition);
        measureChildWithMargins(viewForPosition, 0, 0);
        // 將item布局
        layoutDecorated(viewForPosition, x - mItemWidth / 2, y - mItemHeight / 2,
                x + mItemWidth / 2, y + mItemHeight / 2);
        // 調(diào)整Item自身的旋轉(zhuǎn)角度
        viewForPosition.setRotation(curAngle);
    }

最后就要考慮滑動(dòng)了,其實(shí)在第2節(jié)已經(jīng)講的差不多了,只要通過已經(jīng)移動(dòng)的角度來計(jì)算當(dāng)前Item的實(shí)際角度并布局就可以了,這里先使能橫向滑動(dòng)。

    @Override
    public boolean canScrollHorizontally() {
        return true;
    }

    @Override
    public boolean canScrollVertically() {
        return false;
    }

滑動(dòng)事件在scrollHorizontallyBy(...)中處理。首先對(duì)滑動(dòng)距離dx進(jìn)行邊界處理,并轉(zhuǎn)化為角度;隨后將角度絕對(duì)值>=50的Item回收;最后對(duì)角度絕對(duì)值<50的Item進(jìn)行布局。

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        float moveAngle = convertDxToAngle(dx);
        int actualDx = dx;
        if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
            moveAngle = getMaxScrollAngle() - mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        } else if (mMovedAngle + moveAngle < 0) {
            moveAngle = -mMovedAngle;
            actualDx = convertAngleToDx(moveAngle);
        }
        mMovedAngle += moveAngle;
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            if (view != null) {
                int position = getPosition(view);
                float curAngle = position * mEachAngle + mMovedAngle;
                if (Math.abs(curAngle) >= 50) {
                    removeAndRecycleView(view, recycler);
                }
            }
        }
        // 回收當(dāng)前屏幕上的所有ItemView
        detachAndScrapAttachedViews(recycler);
        for (int i = 0; i < getItemCount(); i++) {
            float curAngle = i * mEachAngle - mMovedAngle;
            if (Math.abs(curAngle) < 50) {
                layoutViewByIndex(recycler, i);
            }
        }
        return actualDx;
    }

    private int getMaxScrollAngle() {
        return (getItemCount() - 1) * mEachAngle;
    }

    private float convertDxToAngle(int dx) {
        return (float) (360 * dx / (2 * Math.PI * mRadius));
    }

    private int convertAngleToDx(float angle) {
        return (int) (2 * Math.PI * mRadius * angle / 360);
    }

寫完之后檢查一下ViewHolder的復(fù)用情況,通過在onCreateViewHolder()中打Log的方式計(jì)算,發(fā)現(xiàn)一共create了7個(gè)ViewHolder,復(fù)用情況良好。
到這里這次的自定義LayoutManager就結(jié)束了,源碼可以去github下載:RecyclerViewDemo

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

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

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