開源庫原始碼學習--DslAdapter的懸停(懸浮)效果

語言: CN / TW / HK

前言

今天說一個我們平時開發中經常用到的一個需求,那就是懸停效果,什麼是懸停效果呢 直接看圖:

吸頂效果圖.gif

就是RecyclerView分組時,當組頭在頂部時需要懸浮,這個比如在選擇地址、通訊錄分組都有用到,在平時開發中大家一般都是直接用輪子,今天我們就來探究一下這個效果是如何實現的。

這個效果的實現,我看了好幾個庫的實現,其實原理都差不多,今天還是使用DslAdapter來說一下,主要是理解其原理,理解原理後改造輪子就很簡單了。

正文

在DslAdapter中,懸停Item的實現類在HoverItemDecoration,首先它是繼承至ItemDecoration,關於ItemDecoration在第一篇文章裡有說:

https://juejin.cn/post/7007624309657042974

這裡還是再提一下,主要是3個方法,分別是設定間隔、在RecyclerView之前繪製和之後繪製,下面這張圖比較好理解

ItemView修飾.png

看到這裡,是不是大概能猜出這個懸停效果是怎麼實現了呢,沒錯,就是利用這個onDrawOver函式,在RecyclerView上面再繪製一個懸停View,把原來的RecyclerView給遮蓋住,達到看起來懸浮的效果。

直接看圖:

懸停效果圖3.jpg

然後向上滾動RecyclerView,這時分組1要被分組2給替換:

懸停效果圖4.png

這裡的需求就是要有一個分組1被頂掉的效果,同時懸停View只繪製一個即可,要了解什麼時候分組2是RecyclerView的,什麼時候是onDrawOver方法繪製的。

看懂原理,離實現就差一點點了,真的就一點點了。

onDrawOver

既然是通過onDrawOver來繪製的懸停View,那還是深入看一下這個函式:

public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) { onDrawOver(c, parent); }

Draw any appropriate decorations into the Canvas supplied to the RecyclerView. Any content drawn by this method will be drawn after the item views are drawn and will thus appear over the views. Params: c – Canvas to draw into parent – RecyclerView this ItemDecoration is drawing into state – The current state of RecyclerView. 其中這個方法的回撥時機特別多,只要itemView發生繪製或者滾動,這個方法都會回撥,所以這就好辦了,可以實時獲取recyclerView的狀態。

具體實現

根據上面思想,其實可以分為2部,第一步找到需要繪製的懸停View,第二步進行繪製懸停View,所以在onDrawOver方法裡也是這2步:

//會呼叫很多次 override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { Log.i(TAG, "onDrawOver: 在RecyclerView上繪製修飾") if (state.isPreLayout || state.willRunSimpleAnimations()) { return } //查詢懸停View checkHoverDecoration(parent) //懸停的ViewHolder不為空,直接繪製 hoverViewHolder?.let { if (!hoverDecorationRect.isEmpty) { hoverCallback?.apply { if (enableTouchEvent && enableDrawableState) { addHoverView(it.itemView) } drawOverDecoration.invoke(canvas, paint, it, hoverDecorationRect) } } } } 但是這個過程十分複雜,容我慢慢說來。

1、獲取RecyclerView顯示出來的第一個ViewHolder。

通過方法: //獲取RecyclerView的第n個位置的ViewHolder private fun firstChildViewHolder(parent: RecyclerView, childIndex: Int): RecyclerView.ViewHolder? { if (parent.childCount > childIndex) { return parent.findContainingViewHolder(parent.getChildAt(childIndex)) } return null } //獲取第一個ViewHolder firstChildViewHolder(parent, 0) 注意,這裡獲取的是顯示出來的第一個ViewHolder,而不是RecyclerView資料集中的第一個ViewHolder,即使在後面在RecyclerView上面繪製了懸浮View,也不影響,比如:

演示獲取第一個可見pos.gif

這裡firstChildViewHolder的position就是0 -> 1 ->2,當然中間會回撥很多次。

//表示第一個可見的ViewHolder的pos firstChildAdapterPosition

2、判斷第一個可見的ViewHolder是否設定了懸停,這裡兵分2路,當它自己就設定了懸停,它自己就是組名或者是標題,這時它就是需要被懸停的ViewHolder:

懸停0.jpg

假如第一個可見的ViewHolder沒有設定懸停,這時需要向前查詢,找到最近的一個懸停ViewHolder,也就是下圖所示:

懸停01.jpg

這2種情況都需要繪製同一個懸停ViewHolder,程式碼就不粘了,具體可以去看源庫的HoverItemDecoration類,這裡先直說思路。

3、通過第二步,就能獲取需要懸停的ViewHolder,這時先不說繪製邏輯,後面再細說,假如分組1已經在懸停了,這裡當繼續向上滾動時會發生一個事,就是分組2會跑到分組1下面,再往上滑動,分組1要被頂上去,動圖如:

頂上去的效果.gif

這裡第一個效果是往上滑動時,分組1要慢慢的消失,注意這裡分組1現在是通過onDrawOver繪製在RecyclerView上的,而不是真正的RecyclerView中的Item,所以要自己處理邏輯和動畫:

先找到下一個需要懸停的ViewHolder,假如這裡每一個ViewHolder的高度都一樣,那下一個需要懸停的View就是當前第一個可見的ViewHolder的下一個:

高度相同被頂.jpg 比如這裡ViewHolder高度一樣,當第一個可見是1時,發現下一個2是分組2,需要懸停,這時分組1就需要根據分組2的上移來向上移動,下一個懸停ViewHolder就是2,但是當ViewHolder高度不一樣時,這就不一定了,比如:

頂出item寬度不一樣.png

這裡1234比較窄,3是分組2,這時需要第一個可見還是1,但是下一個需要懸停的ViewHolder就是3了,而不是2,需要根據3的ViewHOlder的移動來上移分組1,所以這裡的演算法是根據繪製的高度來求出下一個懸停的ViewHolder,程式碼如下:

``` //查詢下一個需要懸停的ViewHolder fun findNextDecoration( parent: RecyclerView, adapter: RecyclerView.Adapter<*>, decorationHeight: Int, offsetIndex: Int = 1 ): RecyclerView.ViewHolder? { var result: RecyclerView.ViewHolder? = null if (hoverCallback != null) { val callback: HoverCallback = hoverCallback!! //offsetIndex預設是1 val childIndex = findNextChildIndex(offsetIndex) if (childIndex != RecyclerView.NO_POSITION) { val childViewHolder = firstChildViewHolder(parent, childIndex) if (childViewHolder != null) {

            if (callback.haveHoverDecoration.invoke(
                    adapter,
                    childViewHolder.adapterPosition
                )
            ) {
                //如果下一個item 具有分割線
                result = childViewHolder
            } else {
                //不具有分割線
                if (childViewHolder.itemView.bottom < decorationHeight) {
                    //item的高度, 沒有分割線那麼高, 繼續往下查詢
                    result = findNextDecoration(
                        parent,
                        adapter,
                        decorationHeight,
                        offsetIndex + 1
                    )
                } else {

                }
            }
        }
    }
}
return result

} ```

4、第三步中的gif圖有上拉會被頂,然後下拉會把上一個分組給帶出來,這裡其實轉一下思維,當下拉時,當前可見的第一個ViewHolder是懸停ViewHolder時,那它下一個懸停ViewHolder就是當前挨著的,所以一套程式碼即可處理。

5、通過上面步驟我們就能獲取到需要懸停的View以及需要展示懸停View的Rect(當被頂時,這個會不斷變化),我們就可以繪製ViewHolder了: hoverViewHolder?.let { if (!hoverDecorationRect.isEmpty) { Log.i(TAG, "onDrawOver: 新增懸停的ViewHolder") hoverCallback?.apply { //是否可以點選 if (enableTouchEvent && enableDrawableState) { addHoverView(it.itemView) } //繪製decoration drawOverDecoration.invoke(canvas, paint, it, hoverDecorationRect) } } }

Touch事件處理

通過上面的步驟,我們就可以繪製出懸浮在RecyclerView上的懸停View了,當然具體實現要考慮的細節比這多的多,還是那句話,這裡只提供思路和方案,具體實現看程式碼。

上面到目前已經可以實現懸停效果了,但是有個問題,就是點選懸停View,因為這個雖然和RecyclerView的ViewHolder一樣,但是它不是RecyclerView的ViewHolder,所以無法響應點選事件,如圖:

事件攔截.gif

會發現分組1是通過繪onDrawOver繪製出來的時候,就無法進行點選了,摺疊和收起將不再起效果,所以要對觸控事件進行處理。

這裡的處理還是通過addItemTouchListener來實現的,關於這個方法在本系列的第一篇文章裡有仔細說明,可以檢視,其實也就是在觸控事件傳遞給RecyclerView前給攔截和處理,程式碼如下: addOnItemTouchListener(itemTouchListener)

//攔截邏輯 override fun onInterceptTouchEvent( recyclerView: RecyclerView, event: MotionEvent ): Boolean { val action = event.actionMasked if (action == MotionEvent.ACTION_DOWN) { //Rect就有contains方法 isDownInHoverItem = hoverDecorationRect.contains(event.x.toInt(), event.y.toInt()) } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { isDownInHoverItem = false } //當點選點在懸浮View上面時,攔截處理觸控事件 if (isDownInHoverItem) { //L.i("onInterceptTouchEvent:$event") Log.i(TAG, "onInterceptTouchEvent: $isDownInHoverItem 攔截Down事件") onTouchEvent(recyclerView, event) } //當在懸浮item上時,處理觸控事件 return isDownInHoverItem }

override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) { Log.i(TAG, "onTouchEvent: eventAction = ${event.actionMasked}") if (isDownInHoverItem) { Log.i(TAG, "onTouchEvent: 處理觸控事件在HoverItem上") hoverViewHolder?.apply { if (hoverCallback?.enableDrawableState == true) { if (event.actionMasked == MotionEvent.ACTION_DOWN) { Log.i(TAG, "onTouchEvent: down之後立馬發出up") recyclerView.postDelayed(cancelEvent, 160L) } else { recyclerView.removeCallbacks(cancelEvent) } //一定要呼叫dispatchTouchEvent, 否則ViewGroup裡面的子View, 不會響應touchEvent Log.i(TAG, "onTouchEvent: itemView自己分發事件") itemView.dispatchTouchEvent(event) if (itemView is ViewGroup) { if ((itemView as ViewGroup).onInterceptTouchEvent(event)) { itemView.onTouchEvent(event) } } else { itemView.onTouchEvent(event) } } else { Log.i(TAG, "onTouchEvent: 沒有啟用drawable點選效果") //沒有Drawable狀態, 需要手動控制touch事件, 因為系統已經管理不了 itemView了 if (event.actionMasked == MotionEvent.ACTION_UP) { Log.i(TAG, "onTouchEvent: itemView自己處理UP事件") itemView.performClick() } } } } }

這裡其實很簡單,就是當觸控事件在懸浮View的Rect範圍內時,攔截並且處理點選事件,由於之前建立懸浮View時是通過建立ViewHolder來實現的,所以這裡直接拿到itemView,進行分發事件即可。

總結

本章內容主要是介紹了懸停效果,平時用時以為蠻簡單,看原理其中的實現還是挺複雜的,對於ItemDecoration的使用要理解到位,後面有其他需求也能快速實現。

這裡只是分析原理,具體實現大家還是去看原始碼,細節地方很多,不再贅述。

還沒有看過前面的文章可以跳轉看一下,基本RecyclerView的幾種常見操作都說了一遍:

# 開源庫原始碼學習--DslAdapter的側滑刪除和拖拽功能

# 開源庫原始碼學習--DslAdapter的側滑功能