Android控件RecyclerView(二)——LayoutManager及其自定義

目錄

前言

1 常用LayoutManager

1.1 LinearLayoutManager

1.2 GridLayoutManager

1.3 StaggeredGridLayoutManager

1.4 FlexboxLayoutManager

2 自定義LayoutManager

2.1 創建自定義LayoutManager類

2.2 繪製RecyclerView子View

2.3 添加滑動功能

2.4 實現橫向循環滑動的LayoutManager

2.5 缺陷

2.6 完善

2.6.1 繪製數量限制

2.6.2 回收子View

2.6.3 最終效果

3. 總結


前言

文章屬於學習總結 ,如有錯漏之處,敬請指正。

同系列文章

Android控件RecyclerView(一)——大家都知道的RecyclerView

Android控件RecyclerView(三)——ItemDecoration的使用與自定義

1 常用LayoutManager

LayoutManager是RecyclerView中子Item的佈局管理器,可控制Item的位置,回收,顯示,大小,滾動等等。下面簡單介紹幾個LayoutManager。

雖然前文寫有Adapter以及模擬數據的設置,但還是把Item佈局和Adapter貼出來。

Item佈局

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scaleType="fitXY"
        app:layout_constraintDimensionRatio="3:2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        android:gravity="center"
        android:textSize="15sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/imageView"
        tools:text="android" />
</android.support.constraint.ConstraintLayout>

Adapter

import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import cn.xhuww.recyclerview.R
import kotlinx.android.synthetic.main.recycle_item_image_text_vertical.view.*

class ImageTextAdapter : RecyclerView.Adapter<ImageTextAdapter.ViewHolder>() {
    var items: List<String> = ArrayList()
        set(value) {
            field = value
            notifyDataSetChanged()
        }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.recycle_item_image_text_vertical, parent, false)
        return ViewHolder(view)
    }

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bindView(items[position])
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        fun bindView(content: String) {
            itemView.imageView.setImageResource(R.mipmap.image)
            itemView.textView.text = content
        }
    }
}

1.1 LinearLayoutManager

LinearLayoutManager爲RecyclerView提供了與ListView類似的功能,單列展示,它有三個構造方法,

LinearLayoutManager(Context context)
LinearLayoutManager(Context context, int orientation, boolean reverseLayout)
LinearLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)

第一個構造方法:默認創建一個 vertical(豎向) 的 LinearLayoutManager
第二個構造方法:可選LinearLayoutManager方向,以及是否反轉佈局位置
第三個構造方法:用於在XML中設置 layoutManager屬性
例子如下:

<android.support.v7.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutManager="android.support.v7.widget.LinearLayoutManager" />

下列構造方法對於的效果圖分別對應 圖 1、2、3

LinearLayoutManager(this)
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true)
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)

圖一與圖二區別爲,數據顯示位置反轉,圖三與圖一二的區別爲,列表展示與滑動方向不同。

1.2 GridLayoutManager

GridLayoutManager爲RecyclerView提供了與GridView類似的功能,網格展示,它有與LinearLayoutManager類似,也有三個構造方法。

GridLayoutManager(Context context, int spanCount)
GridLayoutManager(Context context, int spanCount, int orientation, boolean reverseLayout)
GridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)

第一個構造方法:默認創建一個 vertical(豎向) 的 GridLayoutManager,spanCount爲網格列數
第二個構造方法:可選GridLayoutManager方向,以及是否反轉佈局位置
第三個構造方法:用於在XML中設置 layoutManager屬性

下列構造方法對於的效果圖分別對應 圖 1、2、3

GridLayoutManager(this, 3)
GridLayoutManager(this, 3, GridLayoutManager.VERTICAL, true)
GridLayoutManager(this, 2, GridLayoutManager.HORIZONTAL, false)

 

 第三個因爲手機屏幕顯示3個顯示不完整,就改爲了顯示兩個,橫向滑動。

1.3 StaggeredGridLayoutManager

StaggeredGridLayoutManager交錯的網格佈局,如果子View寬高一致,那效果就和GridLayoutManager一樣,如果子View寬高不一致,就可以實現瀑布流效果。

該類有兩個構造方法,第二個是針對xml設置layoutManager屬性的。

StaggeredGridLayoutManager(int spanCount, int orientation)
StaggeredGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)

修改一下Item佈局的圖片寬高,去掉縱橫比

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:scaleType="fitXY"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

然後Adapter中改變一下顯示圖片

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        fun bindView(content: String, position: Int) {
            if (position % 2 == 0) {
                itemView.imageView.setImageResource(R.mipmap.image_positive)
            } else {
                itemView.imageView.setImageResource(R.mipmap.image)
            }
            itemView.textView.text = content
        }
    }

設置豎向的StaggeredGridLayoutManager

    val imageTextAdapter = ImageTextAdapter().apply {
        //創建含20個字符串的集合 其中 R.string.item_position == 第%1$d個Item
        items = (0..20).map { resources.getString(R.string.item_position, it) }
    }

    val staggeredGridLayoutManager =
        StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)

    recyclerView.apply {
        layoutManager = staggeredGridLayoutManager
        adapter = imageTextAdapter
    }

查看效果

1.4 FlexboxLayoutManager

FlexboxLayoutManager 來自於Google出品的流式佈局 flexbox-layout 支持RecyclerView,畢竟是Google出品,而且很常用,就把他也列舉了出來。

地址:https://github.com/google/flexbox-layout

依賴:

 implementation 'com.google.android:flexbox:1.0.0'

針對於FlexboxLayoutManager 這個類,使用方式與LinearLayoutManager類似,至於FlexBox的其他屬性可查看官方文檔

recyclerView.layoutManager = FlexboxLayoutManager(this)

通過RecyclerView,然後設置 FlexboxLayoutManager之後的效果圖

2 自定義LayoutManager

爲什麼要自定義LayoutManager呢?因爲通過自定義LayoutManager可以實現很多炫酷的功能,也能讓我們更清晰的瞭解RecyclerView。

當然現在我還實現不了比較炫酷的功能,下面簡單的實現一個可無限循環橫向滑動的LayoutManager,爲通過RecyclerViewl來實現無限滑動的Banner做準備。

2.1 創建自定義LayoutManager類

創建HorizontalLayoutManager 繼承於LayoutManager,必須重寫方法generateDefaultLayoutParams(),默認返回RecyclerView.LayoutParams。

class HorizontalLayoutManager : RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
        )
    }
}

然後替換RecyclerView的LayoutManager

recyclerView.layoutManager = HorizontalLayoutManager()

運行後,會界面一片空白,因爲RecyclerView的子View是在其LayoutManager中繪製的,我們並未寫對應的代碼。

2.2 繪製RecyclerView子View

繪製方法命名肯定離不開onLayout這個單詞,在LayoutManager中可以重寫onLayoutChildren方法繪製子View。

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        //分離並且回收當前附加的所有View
        detachAndScrapAttachedViews(recycler)

        if (itemCount == 0) {
            return
        }
        //橫向繪製子View,則需要知道 X軸的偏移量
        var offsetX = 0

        //繪製並添加view
        for (i in 0 until itemCount) {
            val view = recycler.getViewForPosition(i)
            addView(view)

            measureChildWithMargins(view, 0, 0)
            val viewWidth = getDecoratedMeasuredWidth(view)
            val viewHeight = getDecoratedMeasuredHeight(view)
            layoutDecorated(view, offsetX, 0, offsetX + viewWidth, viewHeight)
            offsetX += viewWidth
        }
    }

因爲是橫向列表,所以先把Item佈局文件修改i一下,寬度改爲固定值160dp,然後使用HorizontalLayoutManager看效果

圖中的批註解釋了繪製原理,通過循環以及累加x軸偏移量,橫向繪製完所有子View。

layoutDecorated(view, offsetX, 0, offsetX + viewWidth, viewHeight)
offsetX += viewWidth

此時子View已繪製完成,但還無法滑動,所以需要添加滑動代碼。

2.3 添加滑動功能

因爲要實現的是橫向滑動功能,所以只重寫橫向滑動的對應方法,與之對應的還有豎向的方法

    //是否可橫向滑動
    override fun canScrollHorizontally(): Boolean {
        return true
    }
    
    override fun scrollHorizontallyBy(
        dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State
    ): Int {
        //日誌顯示,左滑dx值爲正數,右滑dx值爲負數
        Log.i("TAG", "----------dx:$dx")
        /**
         * 橫向移動所有子View
         * 爲什麼要 * -1 ? 屏幕xy軸原點在左上角,左移則需要View的座標 x - offset  右移則需要 x + offset
         * 所以需要 dx * -1
         */
        offsetChildrenHorizontal(dx * -1)
        return dx
    }

此時的效果

橫向滑動實現了,顯示完所有子View後在滑動就是空白了,正常情況下還需要判斷是否滑到頭、尾了,但我需要實現的是無限循環橫向滑動,所以只需往右滑滑倒第0個時然後往左邊繪製並添加最後一個子View,往左滑滑到最後一個時,在右邊在添加第0個子View。實現無限循環滑動。

2.4 實現橫向循環滑動的LayoutManager

既然在滑動時還需要繪製,那麼就需要單獨寫一個繪製方法 fill()。

   //爲什麼大多文章都定義方法名爲fill? 我想是因爲Android提供的3個LayoutManager都用的此方法名吧
    private fun fill(dx: Int, recycler: RecyclerView.Recycler) {
        //左滑
        if (dx > 0) {
            //得到當前已添加(可見)的最後一個子View
            val lastVisibleView = getChildAt(childCount - 1) ?: return
            //得到View對應的位置
            val layoutPosition = getPosition(lastVisibleView)
            /**
             * 例如要顯示20個View,當前可見的最後一個View就是第20個,那麼下一個要顯示的就是第一個
             * 如果當前顯示的View不是第20個,那麼就顯示下一個,如當前顯示的是第15個View,那麼下一個顯示第16個
             * 注意區分 childCount 與 itemCount
             */
            val nextView: View = if (layoutPosition == itemCount - 1) {
                recycler.getViewForPosition(0)
            } else {
                recycler.getViewForPosition(layoutPosition + 1)
            }

            addView(nextView)
            measureChildWithMargins(nextView, 0, 0)
            val viewWidth = getDecoratedMeasuredWidth(nextView)
            val viewHeight = getDecoratedMeasuredHeight(nextView)
            val offsetX = lastVisibleView.right
            layoutDecorated(nextView, offsetX, 0, offsetX + viewWidth, viewHeight)
        } else { //右滑
            val firstVisibleView = getChildAt(0) ?: return
            val layoutPosition = getPosition(firstVisibleView)
            /**
             * 如果當前第一個可見View爲第0個,則左側顯示第20個View 如果不是,下一個就顯示前一個
             */
            val nextView = if (layoutPosition == 0) {
                recycler.getViewForPosition(itemCount - 1)
            } else {
                recycler.getViewForPosition(layoutPosition - 1)
            }

            addView(nextView, 0)
            measureChildWithMargins(nextView, 0, 0)
            val viewWidth = getDecoratedMeasuredWidth(nextView)
            val viewHeight = getDecoratedMeasuredHeight(nextView)
            val offsetX = firstVisibleView.left
            layoutDecorated(nextView, offsetX - viewWidth, 0, offsetX, viewHeight)
        }
    }

然後在scrollHorizontallyBy中調用即可,實現滑動中繼續繪製,查看此時的效果,實現了橫向無限循環滑動的效果

2.5 缺陷

我們都知道使用RecyclerView,並不需要在額外做佈局複用緩存處理,因爲RecyclerView已經幫我們做好了,那是不是使用自定義的LayoutManager也具有複用與回收功能呢?

Adapter中添加Log,查看創建的View數量,然後分別使用LinearLayoutManager和剛自定義的 HorizontalLayoutManager

    private var createViewCount = 0

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        ...
        Log.i("TAG", "-------------createViewCount:${++createViewCount}")
        ...
    }

分別查看日誌:

LinearLayoutManager

HorizontalLayoutManager

可以發現 LinearLayoutManager 初次只創建了3個View,而且不管怎樣滑動最多也就7個View,而HorizontalLayoutManager 初始化就創建了20個View,而且後面滑動時,創建了的View數量多出了許多。

2.6 完善

2.6.1 繪製數量限制

在前面的基礎上,繪製子View時,超出RecyclerView範圍則不繪製子View,

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        ...
        var offsetX = 0

        //繪製並添加view
        for (i in 0 until itemCount) {

            ...

            offsetX += viewWidth
            
            if (offsetX > width){
                break
            }
        }
    }

 在滑動時,如果當前兩側的最後一個View滑動後還是未完全展示出來,就不繪製下一個View。

還有個問題就:繪製完下一個View後,RecyclerView偏移 dx,當dx大於子View的寬度時,就會出現子View數量未繪製完,RecyclerView顯白色的問題,前面沒這個問題的原因是沒加繪製條件,滑動時在不斷繪製子View,修改後的代碼如下。 

 private fun fill(dx: Int, recycler: RecyclerView.Recycler) {
        //左滑
        if (dx > 0) {

            while (true) {
                //得到當前已添加(可見)的最後一個子View
                val lastVisibleView = getChildAt(childCount - 1) ?: break

                //如果滑動過後,View還是未完全顯示出來就 不進行繪製下一個View
                if (lastVisibleView.right - dx > width)
                    break

               ...
            }
        } else { //右滑
            while (true) {
                val firstVisibleView = getChildAt(0) ?: break

                if (firstVisibleView.left - dx < 0) break

                ...
            }
        }
    }

2.6.2 回收子View

當子View超出RecyclerView的範圍時,就移除並回收子View

    private fun recycleViews(dx: Int, recycler: RecyclerView.Recycler) {
        for (i in 0 until itemCount) {
            val childView = getChildAt(i) ?: return
            //左滑
            if (dx > 0) {
                //移除並回收 原點 左側的子View
                if (childView.right - dx < 0) {
                    removeAndRecycleViewAt(i, recycler)
                }
            } else { //右滑
                //移除並回收 右側即RecyclerView寬度之以外的子View
                if (childView.left - dx > width) {
                    removeAndRecycleViewAt(i, recycler)
                }
            }
        }
    }

因爲滑動時在不斷添加繪製View,所以對應的也應移除回收View

在 scrollHorizontallyBy 中調用 

    override fun scrollHorizontallyBy(
        dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State
    ): Int {
        recycleViews(dx, recycler)
        fill(dx, recycler)
        offsetChildrenHorizontal(dx * -1)
        return dx
    }

2.6.3 最終效果

完善代碼後,查看效果,基本達到了要求。

完整代碼 

import android.graphics.PointF
import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup

class HorizontalLayoutManager : RecyclerView.LayoutManager(),
    RecyclerView.SmoothScroller.ScrollVectorProvider {

    override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
        if (childCount == 0) {
            return null
        }
        val firstChildPos = getPosition(getChildAt(0)!!)
        val direction = if (targetPosition < firstChildPos) -1 else 1
        return PointF(direction.toFloat(), 0f)
    }

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
        )
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        //分離並且回收當前附加的所有View
        detachAndScrapAttachedViews(recycler)

        if (itemCount == 0) {
            return
        }
        //橫向繪製子View,則需要知道 X軸的偏移量
        var offsetX = 0

        //繪製並添加view
        for (i in 0 until itemCount) {
            val view = recycler.getViewForPosition(i)
            addView(view)

            measureChildWithMargins(view, 0, 0)
            val viewWidth = getDecoratedMeasuredWidth(view)
            val viewHeight = getDecoratedMeasuredHeight(view)
            layoutDecorated(view, offsetX, 0, offsetX + viewWidth, viewHeight)
            offsetX += viewWidth

            if (offsetX > width) {
                break
            }
        }
    }

    //是否可橫向滑動
    override fun canScrollHorizontally(): Boolean {
        return true
    }

    override fun scrollHorizontallyBy(
        dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State
    ): Int {
        recycleViews(dx, recycler)
        fill(dx, recycler)
        offsetChildrenHorizontal(dx * -1)
        return dx
    }

    private fun fill(dx: Int, recycler: RecyclerView.Recycler) {
        //左滑
        if (dx > 0) {

            while (true) {
                //得到當前已添加(可見)的最後一個子View
                val lastVisibleView = getChildAt(childCount - 1) ?: break

                //如果滑動過後,View還是未完全顯示出來就 不進行繪製下一個View
                if (lastVisibleView.right - dx > width)
                    break

                //得到View對應的位置
                val layoutPosition = getPosition(lastVisibleView)
                /**
                 * 例如要顯示20個View,當前可見的最後一個View就是第20個,那麼下一個要顯示的就是第一個
                 * 如果當前顯示的View不是第20個,那麼就顯示下一個,如當前顯示的是第15個View,那麼下一個顯示第16個
                 * 注意區分 childCount 與 itemCount
                 */
                val nextView: View = if (layoutPosition == itemCount - 1) {
                    recycler.getViewForPosition(0)
                } else {
                    recycler.getViewForPosition(layoutPosition + 1)
                }

                addView(nextView)
                measureChildWithMargins(nextView, 0, 0)
                val viewWidth = getDecoratedMeasuredWidth(nextView)
                val viewHeight = getDecoratedMeasuredHeight(nextView)
                val offsetX = lastVisibleView.right
                layoutDecorated(nextView, offsetX, 0, offsetX + viewWidth, viewHeight)
            }
        } else { //右滑
            while (true) {
                val firstVisibleView = getChildAt(0) ?: break

                if (firstVisibleView.left - dx < 0) break

                val layoutPosition = getPosition(firstVisibleView)
                /**
                 * 如果當前第一個可見View爲第0個,則左側顯示第20個View 如果不是,下一個就顯示前一個
                 */
                val nextView = if (layoutPosition == 0) {
                    recycler.getViewForPosition(itemCount - 1)
                } else {
                    recycler.getViewForPosition(layoutPosition - 1)
                }

                addView(nextView, 0)
                measureChildWithMargins(nextView, 0, 0)
                val viewWidth = getDecoratedMeasuredWidth(nextView)
                val viewHeight = getDecoratedMeasuredHeight(nextView)
                val offsetX = firstVisibleView.left
                layoutDecorated(nextView, offsetX - viewWidth, 0, offsetX, viewHeight)
            }
        }
    }

    private fun recycleViews(dx: Int, recycler: RecyclerView.Recycler) {
        for (i in 0 until itemCount) {
            val childView = getChildAt(i) ?: return
            //左滑
            if (dx > 0) {
                //移除並回收 原點 左側的子View
                if (childView.right - dx < 0) {
                    removeAndRecycleViewAt(i, recycler)
                }
            } else { //右滑
                //移除並回收 右側即RecyclerView寬度之以外的子View
                if (childView.left - dx > width) {
                    removeAndRecycleViewAt(i, recycler)
                }
            }
        }
    }
}

3. 總結

  • RecyclerView自己具有繪製、回收、緩存複用子View的方法,但需要在LayoutManager調用
  • 回收View是根據RecyclerView的寬或高來判斷的,所以想要具有緩存複用功能,RecyclerView一定要有確定的寬或高。
  • getChildCount() 是得到RecyclerView中顯示的Item個數
  • getItemCount() 是得到Adapter中設置的需要顯示的item個數
  • getChildAt(int position) 是從當前屏幕顯示的View中的到對應位置的View
  • getPosition(View view) 得到View對應Adapter中的索引位置
  • recycler.getViewForPosition(position) 是複用View的關鍵

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章