
全部代碼:github
方式有二
- 組合控件,RecyclerView + View
- 自定義RecyclerView
1中只需要控制View,但是不好封裝。
2中需要重寫RecyclerView中一些東西,最終就是一個CustomRecyclerView。
所以采用的是第二種方法。代碼100來行。
主要步驟
- 添加ItemDecoration使第一個和最后一個Item可以滾動到屏幕中央
- 添加SnapHelper重寫方法
- 重寫OnDrawForeGround繪制高亮區(qū)域
- 添加OnScrollListener當滑動停止可以根據(jù)當前Item高度重繪高亮區(qū)域
1. 添加ItemDecoration使第一個和最后一個Item可以滾動到屏幕中央
很簡單
判斷是第一個/最后一個Item,頂部/底部添加距離
val decoration = object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: State
) {
super.getItemOffsets(outRect, view, parent, state)
this@HighLightRecyclerView.adapter?.let {
if (parent.getChildAdapterPosition(view) == 0)
outRect.top = (parent.measuredHeight - view.layoutParams.height).shr(1)
else if (parent.getChildAdapterPosition(view) == it.itemCount - 1)
outRect.bottom = (parent.measuredHeight - view.layoutParams.height).shr(1)
}
}
}
2. RecyclerView添加SnapHelper并重寫findSnapView()方法
思路就是,根據(jù)停止時各個Item的位置判斷RecyclerView應(yīng)該對齊哪個Item
無非就是一個工具類的兩個比較特別的方法
OrientationHelper的getDecoratedStart(v:View)有兩種情況,字面意思是“獲取Item距離頂部位置”。
- Item設(shè)置了Margin或Decoration這些偏移量,注意是
Start,也就是一般情況下的Left或者Top,視Orientation而定。那么返回值為Item頂部距RecyclerView頂部距離 - 偏移量
- 沒有設(shè)置偏移量,返回值為
Item高度
相應(yīng)地,
OrientationHelper的getDecoratedMeasurement(v:View)也有兩種情況。字面意思是“獲取Item所占空間大小”。
- 有偏移,返回
偏移+高度
- 無偏移,返回
高度
高亮Item的需求因為涉及到第一步中偏移的設(shè)置,主要有三種情況
- 頂部
Start為0,Measurement為頂部偏移+高度
- 中部
Start為頂部距離,Measurement為Item高度
- 底部
Start為頂部距離,Measurement為底部偏移+高度
這地方不弄情況特別亂,我列出表格大概是這個樣子:
| 位置 | getDecoratedStart | getDecoratedMeasurement |
|---|---|---|
| 頂部 | 0 | OffsetTop + Height |
| 中部 | TopDistance | Height |
| 底部 | TopDistance | OffsetBottom + Height |
我們讓Item居中,它應(yīng)該是 頂部距離+ItemHeight/2 == RecyclerView.height/2
偽代碼就是 TopDistance+Height/2 = Parent.Height/2
嘗試用一行代碼寫,發(fā)現(xiàn)沒辦法,只能分情況
也就是
pos為0 helper.getDecoratedStart(it) + helper.getDecoratedMeasurement(it) - it.layoutParams.height
其余情況為 helper.getDecoratedStart(it) + it.layoutParams.height.shr(1)
想明白了也就那么回事。
代碼如下:
override fun findSnapView(layoutManager: RecyclerView.LayoutManager?): View? {
return when {
layoutManager == null -> null
layoutManager.childCount == 0 -> null
else -> {
var closestChild: View? = null
var absClosest = Int.MAX_VALUE
val helper = OrientationHelper.createVerticalHelper(layoutManager)
val center = helper.startAfterPadding + helper.totalSpace.shr(1)
var childCenter: Int
var distance: Int
for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.let {
childCenter = if (i == 0)
helper.getDecoratedStart(it) + helper.getDecoratedMeasurement(it) - it.layoutParams.height
else helper.getDecoratedStart(it) + it.layoutParams.height.shr(1)
distance = abs(center - childCenter)
if (distance < absClosest) {
absClosest = distance
closestChild = it
}
}
}
closestChild
}
}
}
3. 重寫OnDrawForeGround繪制高亮區(qū)域
path.addRect(0f, 0f, width.toFloat(), height.toFloat(), Path.Direction.CW)
path.addRect(
0f,
(height - highLightHeight).shr(1).toFloat(),
width.toFloat(),
(height + highLightHeight).shr(1).toFloat(),
Path.Direction.CCW
)
沒啥好講的,主要就是Path.Direction要講講
這個高亮區(qū)域,有兩個矩形組成,第一個是畫全屏的遮罩,第二個是從第一個矩形中摳出這個區(qū)域,反方向與Android繪制定義有關(guān),記住它能摳出形狀就行。就不用涉及PorterDuff類的相關(guān)概念了。
4. 添加OnScrollListener當滑動停止可以根據(jù)當前Item高度重繪高亮區(qū)域
啊,其實這個如果不要動畫,就很簡單,一行代碼
highLightHeight = height
但是,要動畫也是一行代碼
if (newState == SCROLL_STATE_IDLE) {
snapHelper.findSnapView(layoutManager)?.let {
animator = ValueAnimator.ofInt(highLightHeight, it.layoutParams.height)
.setDuration(300)
animator.removeAllUpdateListeners()
animator.addUpdateListener { va ->
highLightHeight = va.animatedValue as Int
}
animator.start()
}
}