一起擼個簡單粗暴的Tv應用主介面的網格佈局控制元件(下)

請叫我大蘇發表於2018-05-02

上一篇中我們已經一起學了怎麼簡單粗暴的擼個支援動態佈局的網格控制元件出來,但在上一篇的介紹中,並沒有學習實現網格控制元件的滑動效果,所以本篇就來講講,要如何讓我們的網格控制元件可以支援自定義滑動策略。

效果

當貝市場.gif

TvGridLayout示例

圖一是Tv應用:當貝市場的主頁

圖二是我們自己擼的簡單粗暴的 Tv 應用主介面網格控制元件:TvGridLayout 的示例,每個 Tab 下,每一屏的卡位大小、位置都是動態計算出來的。

實現

第一步:定義佈局資料結構

第二步:自定義 TvGridLayout

第三步:自定義 Adapter

第四步:動態佈局

第五步:初步使用

以上內容是在上一篇中講解的內容,所以如果還沒有看過上一篇的,建議先閱讀上一篇一起擼個簡單粗暴的Tv應用主介面的網格佈局控制元件(上)。那麼下面就開始我們今天的內容了:

第六步:內嵌 OverScroller 自定義滑動策略

首先,我們的網格控制元件是繼承自 FrameLayout,那麼它本身就是沒有支援滑動的效果的,但是我們的網格控制元件又需要支援多屏顯示,那麼當焦點滑到當前屏之外時,自然就需要將下一屏的卡位滑動到螢幕內進行顯示。

而實現滑動效果的方式有兩種:

  • 將網格控制元件巢狀在 HorizontalScrollView
  • 自己在網格控制元件內部實現滑動效果

第一種方式實現最簡單,我們只要將自己的網格控制元件 TvGridLayout 巢狀在 HorizontalScrollView 中,就可以實現滑動效果了。

雖然實現最簡單,但缺點也很明顯,就是滑動的策略只能按照 HorizontalScrollView 規則來,我們並沒有辦法進行修改。比如說,滑動的持續時長,滑動的距離,什麼時候觸發滑動等等。

產品的口味可是很刁鑽的,單單使用預設的滑動策略,通常是很難滿足產品的,雖然也可以通過一些反射等手段來修改 HorizontalScrollView 的預設實現,但有點複雜,且容易出問題。

本著不怕瞎折騰的精神,網格控制元件既然都已經自己擼了,那滑動的實現乾脆也來自己擼好了。

6.1 實現滑動的方式

想要讓一個控制元件滑動起來的方式很多很多:

  • 動畫
  • ViewGroup#onLayout()
  • View#scrollTo(), View#scrollBy()
  • OverScroller
  • ...

動畫也行,重新對子 View 佈局,修改子 View 位置也行,呼叫 View 自帶的 scrollTo(), scrollBy() 也行,或者直接用系統提供的滑動輔助類 OverScroller 也行,都行,方式很多,只要能夠讓控制元件動起來就行。所以,讓 View 動起來一直就不是個問題,問題是要怎麼滑,什麼時候滑,滑多長,滑多久,這些問題才是擼個滑動功能的問題所在。

6.2 HorizontalScrollView 滑動原理

既然滑動要自己擼,那當然是要先參考一下 Google 大神的實現思路了,所以首先就先來看看 HorizontalScrollView 的滑動原理是怎樣的?

有一點需要先提一下的是,由於我們是著重分析 Tv 應用的滑動效果,也就是說是由遙控器來觸發的滑動效果,那麼 HorizontalScrollView 內部跟手指觸控相關的滑動原理就不分析了,著重分析跟 Tv 相關的滑動原理即可。

而 Tv 應用由於都是通過遙控器事件即 KeyEvent 來進行 ui 的互動,那麼,理所當然,要檢視 HorizontalScrollView 的滑動原理的話,就需要跟著 dispatchKeyEvent() 走下去應該就可以了。

//HorizontalScrollView#dispatchKeyEvent()
public boolean dispatchKeyEvent(KeyEvent event) {
	// 1. 如果事件沒有被消耗掉,那麼就交由滑動去處理
	return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}

public boolean executeKeyEvent(KeyEvent event) {
	...

	boolean handled = false;
	if (event.getAction() == KeyEvent.ACTION_DOWN) {
	switch (event.getKeyCode()) {
         //2. 事件是方向鍵左鍵時,那麼就向左滑動
		case KeyEvent.KEYCODE_DPAD_LEFT:
			if (!event.isAltPressed()) {
				handled = arrowScroll(View.FOCUS_LEFT);
			}
            ...
		}
	}
	return handled;
}

public boolean arrowScroll(int direction) {
	...
	//3. 先尋找下個焦點的 view
	View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction);
	
	if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump)) {
        //4. 根據下個焦點的位置,去計算是否需要進行滑動,需要的話那麼計算滑動多長的距離
		nextFocused.getDrawingRect(mTempRect);
		offsetDescendantRectToMyCoords(nextFocused, mTempRect);
		int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
        //5. 根據計算出來的滑動距離去處理滑動邏輯
		doScrollX(scrollDelta);
		nextFocused.requestFocus(direction);
	} 
    ...
	return true;
}

private void doScrollX(int delta) {
	if (delta != 0) {
        //6. HorizontalScrollView預設是開啟了平衡滑動,但可也通過介面關掉
		if (mSmoothScrollingEnabled) {
			smoothScrollBy(delta, 0);
		} else {
			scrollBy(delta, 0);
		}
	}
}

public final void smoothScrollBy(int dx, int dy) {
    ...
	if (duration > ANIMATED_SCROLL_GAP) {
        //7. 如果處於邊界情況,那麼需要對計算出來的滑動長度進行修正,確保邊界情況不會出問題
		final int width = getWidth() - mPaddingRight - mPaddingLeft;
		final int right = getChildAt(0).getWidth();
		final int maxX = Math.max(0, right - width);
		final int scrollX = mScrollX;
		dx = Math.max(0, Math.min(scrollX + dx, maxX)) - scrollX;
		//8. 上述步驟均只是用於計算需要滑動的距離值,計算出來後滑動的實現交由mScroller處理
		mScroller.startScroll(scrollX, mScrollY, dx, 0);
		postInvalidateOnAnimation();
	}
    ...
}

//9. mScroller是OverScroller的例項
private OverScroller mScroller;
複製程式碼

HorizontalScrollView 的滑動原理,例如是如何計算滑動距離的以及都有哪些會觸發滑動的場景等等,就不深入去分析了,這不是本篇的目的,以後有時間再抽空來梳理。

所以,只需要跟著遙控器事件 dispatchKeyEvent() 走下去後,就可以找到原來 HorizontalScrollView 內部是通過 OverScroller 來實現的滑動效果。

而且,梳理了 HorizontalScrollView 從接收到遙控器事件到最終實現滑動的一個整體的流程後,我們再自己擼滑動的功能時,也可以參考這個思路、這個流程來寫,所以這也是閱讀原始碼的好處,大夥有時間得多抽抽時間來閱讀原始碼多學習學習。

6.3 OverScroller 原理

既然 HorizontalScrollView 內部是通過 OverScroller 來處理滑動的相關邏輯的,那麼,我們也用 OverScroller 來做好了,向 Google 大佬模仿借鑑。

網上關於 OverScroller 的使用教程很多,本篇就不著重講了,要理解一點的是,OverScroller 只是一個滑動輔助類。

說得白一點也就是,我們只需要告訴 OverScroller 我們想滑動多長的距離,多久時間滑完,那麼,OverScroller 內部就會根據每一幀的時間去計算當前幀時滑動的進度。然後,我們再每一幀通過 OverScroller 計算出的滑動進度,去作用到需要滑動的 View 上面來達到滑動的效果。

如果有看過我前面幾篇關於動畫的部落格分析的話,那麼上面這點就會很清楚了。OverScroller 實現滑動的整個流程原理跟屬性動畫的 ValueAnimator 非常相似,兩個類內部都沒有任何涉及 ui 的操作,兩個類的作用都是用於根據當前幀的時間計算當前幀時的進度值。

唯一有區別的點就是,ValueAnimator 內部會自己通過 Choreographer 去監聽每一幀的螢幕重新整理訊號,然後內部在接收到每一幀訊號時就會自動去根據當前幀時間計算;而 OverScroller 內部並沒有任何監聽螢幕重新整理訊號的邏輯,也就是說,如果要使用 OverScroller 的話,我們需要在接收到每一幀的螢幕重新整理訊號時手動去通知 OverScroller,它才可以正確去工作

這就是為什麼,大夥在網上搜 OverScroller 的使用教程時,基本每一篇都會提到說 OverScroller 需要跟 View 的 computeScroll() 一起使用的原因。

computeScroll() 是 View 中的一個空方法,在 draw() 方法中被呼叫。所以,只要我們能夠讓需要滑動的 View 在滑動的這段時間內,每一幀都通知 View 進行重繪重新整理,那麼它每一幀就都會走到 computeScroll(),這樣我們就可以在 computeScroll() 中手動去通知 OverScroller,它內部就可以根據當前幀時間去計算滑動的工作了。

這也是為什麼,大夥搜 OverScroller 的使用教程時,基本每篇也說了,在呼叫了 startScroll() 之後需要緊接著呼叫 View 的 postInvalidateOnAnimation() ,否則滑動就會失效的原因。因為我們只有通知了 View 需要重繪,computeScroll() 才會被呼叫,才可以再手動去通知 OverScroller 進行工作。

6.4 觸發滑動的時機

搞清了 OverScroller 的原理後,那麼如果要在我們自己的網格控制元件裡擼滑動邏輯的話,也可以大概清楚需要擼哪些程式碼了。

因為 OverScroller 只負責根據我們指定的滑動距離和持續時長,在每一幀裡去計算滑動進度的工作。那麼,到底需要滑動多長的距離,持續多久,什麼時候觸發滑動,這三者就是自定義有滑動效果控制元件需要擼出來的程式碼了。

我們只針對 Tv 應用的話,顯然,滑動的時機就在於遙控器事件了,這是第一點。

HorizontalScorllView 是在 dispatchKeyEvent()中,每次都去檢查是否需要滑動,而滿足滑動的條件則是下個焦點的 View 是否在螢幕上是可見的,而滑動的距離則是將這個不可見的 View 滑動到剛剛好全部可見。當然,它內部還有其它滑動策略,比如整頁滑等等,但這些就需要手動去呼叫相關介面。

僅僅使用 HorizontalScrollView 預設的滑動效果很難滿足產品需求,就像開頭的當貝市場的示例圖,很明顯,它的滑動策略跟 HorizontalScrollView 就是不一樣的,它是焦點快接近邊緣時,就會去觸發滑動了,即使下個焦點的 View 還是全部可見時。

6.5 自定義滑動策略

滑動的時機、滑動的策略、滑動的距離,這些並不是一成不變的,而是取決於業務場景需求;也是因為這樣,才想到要自己擼個滑動的功能出來。

下面我會舉個例子,將程式碼思路講一下,但並不一定適用於你,所以大夥根據自己的需求自己擼一個就行了。

由於 Tv 應用都是通過遙控器控制,因此滑動的時機就在 dispatchKeyEvent()中進行檢測就行了:

@Override
public boolean dispatchKeyEvent(KeyEvent event) {
    //1. 事件如果沒有被消耗掉,那麼就交由滑動去處理
	return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}

//這裡的滑動策略:
//如果下個焦點的 View 屬於另外一屏的話,那麼就觸發滑動
//滑動的距離為下一屏的寬度
//這裡的下一屏是指上一篇提到的 ScreenEntity 資料模型,因為每個 Tab 下可能存在多屏資料,以屏作為單位來進行滑動,兩焦點在兩屏之間切換時,就觸發滑動
private boolean executeKeyEvent(KeyEvent event) {
	int keyCode = event.getKeyCode();
	if (event.getAction() == KeyEvent.ACTION_DOWN) {
		...
		if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
            //2. 檢測下個焦點的 View 是否是屬於另一屏中,是的話,將當前切換的這兩屏的下標儲存在 sTwoInt中
			if (checkIfOnBorder(FOCUS_LEFT, sTwoInt)) {
				...
                  //3. 對外提供屏邊界回撥,當焦點在兩屏之間切換時,觸發回撥
				if (mBorderListener != null && mBorderListener.onLeft(sTwoInt[0], sTwoInt[1])) {
					return true;
				}
                 //4. 如果外部在接收到屏切換回撥時,沒有攔截,那麼就去觸發滑動
                 scrollToPage(sTwoInt[1]);
             }
        } 
    ...
}
複製程式碼

上述是提供了一種滑動策略的思路,滑動策略並不一定需要按照系統預設的來,也不一定要按照上述來,適合自己的業務場景就行,不然幹嘛要瞎折騰來自己擼這個滑動。

上述的滑動策略思路是當焦點在兩屏之間切換時觸發滑動,滑動的距離為下一屏的寬度。這種策略就完全不同於系統預設的策略,因此 HorizontalScrollView 就排不上用場了,那麼就自己擼吧,不就是滑動的時機和滑動的距離計算要自己擼嘛,不難。

6.6 完工

以上,就將需要擼一個滑動的控制元件的思路講完了。

小結一下,如果大夥想要自己擼個滑動的功能的話,很簡單,可以用動畫、scrollTo() 等方式;

如果大夥選擇使用 OverScroller 的話,那麼有幾點需要注意:

  • OverScroller 只負責根據指定的滑動距離,持續時長來計算每一幀內的滑動進度
  • 因此我們需要在每一幀的螢幕重新整理訊號事件中手動去通知 OverScroller 進行工作,並取得經過它計算得到的當前幀的滑動進度來手動應用到 View 上
  • 這就是為什麼使用 OverScroller 需要結合 View#computeScroll()一起使用,並且在呼叫了 startScroll() 之後需要緊接著呼叫 View#postInvalidateOnAnimation()的原因
  • 一個完整的滑動功能需要包括:觸發滑動的時機、滑動策略、滑動距離的計算、OverScroller 輔助計算、應用到 View 上
  • 觸發滑動的時機可以在 dispatchKeyEvent() 中進行檢查是否滿足滑動條件
  • 滿足滑動的條件和滑動策略以及滑動距離的計算基於具體業務需求而實現
  • 整個流程設計可以參考 HorizontalScrollView 的原始碼

QQ圖片20180316094923.jpg
最近剛開通了公眾號,想激勵自己堅持寫作下去,初期主要分享原創的Android或Android-Tv方面的小知識,感興趣的可以點一波關注,謝謝支援~~

相關文章