在android TV端中實現水平滑動效果可以使用HorizontalScrollView來實現, 現在來介紹一下在TV端使用HorizontalScrollView時遇到的問題.
HorizontalScrollView 滑動流程
例如現在在TV端實現類似於手機launcher的功能顯示所有的應用, 並使用HorizontalScrollView來實現水平滑動, 但是有這樣的需求: 當應用滑動到某個子view, 這個子view並沒有全部顯示在屏幕上, 這個時候需要將整個應用按照你滑動的方向滑動整個屏幕的一半的距離. 如下圖所示從圖1到圖2:
圖1
圖2
原生的HorizontalScrollView只能實現當滑動到顯示不全的子view上時, 只是讓子view顯示出來:
查看HorizontalScrollView源碼分析它是怎麼處理這個滑動的, 在TV端對應用的控制都是通過遙控器來進行操作, 說白了就是也就是對焦點的處理(也就是對按鍵消息的處理), 對於在TV端開發來說, 焦點的處理非常重要. 根據android的消息處理機制, 我們查看HorizontalScrollView的dispatchKeyEvent接口:
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// Let the focused view and/or our descendants get the key first
return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}
繼續進入executeKeyEvent(event)方法中查看:
public boolean executeKeyEvent(KeyEvent event) {
mTempRect.setEmpty();
if (!canScroll()) {
if (isFocused()) {
View currentFocused = findFocus();
if (currentFocused == this) currentFocused = null;
View nextFocused = FocusFinder.getInstance().findNextFocus(this,
currentFocused, View.FOCUS_RIGHT);
return nextFocused != null && nextFocused != this &&
nextFocused.requestFocus(View.FOCUS_RIGHT);
}
return false;
}
boolean handled = false;
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_LEFT:
if (!event.isAltPressed()) {
handled = arrowScroll(View.FOCUS_LEFT);
} else {
handled = fullScroll(View.FOCUS_LEFT);
}
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
if (!event.isAltPressed()) {
handled = arrowScroll(View.FOCUS_RIGHT);
} else {
handled = fullScroll(View.FOCUS_RIGHT);
}
break;
case KeyEvent.KEYCODE_SPACE:
pageScroll(event.isShiftPressed() ? View.FOCUS_LEFT : View.FOCUS_RIGHT);
break;
}
}
return handled;
}
通過分析executeKeyEvent的代碼控制滑動的邏輯是arrowScroll(int direction), 繼續查看裏面的代碼:
public boolean arrowScroll(int direction) {
View currentFocused = findFocus();
if (currentFocused == this) currentFocused = null;
View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction);
final int maxJump = getMaxScrollAmount();
if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump)) {
nextFocused.getDrawingRect(mTempRect);
offsetDescendantRectToMyCoords(nextFocused, mTempRect);
int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect);
doScrollX(scrollDelta);
nextFocused.requestFocus(direction);
} else {
// no new focus
int scrollDelta = maxJump;
if (direction == View.FOCUS_LEFT && getScrollX() < scrollDelta) {
scrollDelta = getScrollX();
} else if (direction == View.FOCUS_RIGHT && getChildCount() > 0) {
int daRight = getChildAt(0).getRight();
int screenRight = getScrollX() + getWidth();
if (daRight - screenRight < maxJump) {
scrollDelta = daRight - screenRight;
}
}
if (scrollDelta == 0) {
return false;
}
doScrollX(direction == View.FOCUS_RIGHT ? scrollDelta : -scrollDelta);
}
if (currentFocused != null && currentFocused.isFocused()
&& isOffScreen(currentFocused)) {
// previously focused item still has focus and is off screen, give
// it up (take it back to ourselves)
// (also, need to temporarily force FOCUS_BEFORE_DESCENDANTS so we are
// sure to
// get it)
final int descendantFocusability = getDescendantFocusability(); // save
setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
requestFocus();
setDescendantFocusability(descendantFocusability); // restore
}
return true;
}
拋開代碼細節, 只需要看最關心的地方就可以了, 最終控制滑動的方法在doScrollX(int delta)中, 在此方法中通過scrollBy來滑動, 對於scrollBy, 大家應該不陌生. 滑動距離是通過傳入的delta來確定, 那麼這個delta又是怎麼獲得的? 從arrowScroll(int direction)中可以看到delta是通過computeScrollDeltaToGetChildRectOnScreen(mTempRect)來計算出來, 那麼查看一下此方法是怎麼計算的:
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
if (getChildCount() == 0) return 0;
int width = getWidth();
int screenLeft = getScrollX();
int screenRight = screenLeft + width;
int fadingEdge = getHorizontalFadingEdgeLength();
// leave room for left fading edge as long as rect isn't at very left
if (rect.left > 0) {
screenLeft += fadingEdge;
}
// leave room for right fading edge as long as rect isn't at very right
if (rect.right < getChildAt(0).getWidth()) {
screenRight -= fadingEdge;
}
int scrollXDelta = 0;
if (rect.right > screenRight && rect.left > screenLeft) {
// need to move right to get it in view: move right just enough so
// that the entire rectangle is in view (or at least the first
// screen size chunk).
if (rect.width() > width) {
// just enough to get screen size chunk on
scrollXDelta += (rect.left - screenLeft);
} else {
// get entire rect at right of screen
scrollXDelta += (rect.right - screenRight);
}
// make sure we aren't scrolling beyond the end of our content
int right = getChildAt(0).getRight();
int distanceToRight = right - screenRight;
scrollXDelta = Math.min(scrollXDelta, distanceToRight);
} else if (rect.left < screenLeft && rect.right < screenRight) {
// need to move right to get it in view: move right just enough so that
// entire rectangle is in view (or at least the first screen
// size chunk of it).
if (rect.width() > width) {
// screen size chunk
scrollXDelta -= (screenRight - rect.right);
} else {
// entire rect at left
scrollXDelta -= (screenLeft - rect.left);
}
// make sure we aren't scrolling any further than the left our content
scrollXDelta = Math.max(scrollXDelta, -getScrollX());
}
return scrollXDelta;
}
從註釋 “// get entire rect at right of screen” 來看, scrollXDelta += (rect.right - screenRight); 就是計算向右滑動的距離, 同理 scrollXDelta -= (screenLeft - rect.left); 是計算向左滑的距離.
分析到這我們可以得出結論: 只需要自定義CustomScrollView 繼承 HorizontalScrollView, 重寫computeScrollDeltaToGetChildRectOnScreen, 將計算向右向左的距離改爲屏幕的一般即可實現當滑動到沒有全部顯示的子view時, 滑動距離爲屏幕的一半:
public class CustomScrollView extends HorizontalScrollView {
public CustomScrollView(Context context) {
super(context);
}
public CustomScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
if (getChildCount() == 0) return 0;
int width = getWidth();
int screenLeft = getScrollX();
int screenRight = screenLeft + width;
int fadingEdge = getHorizontalFadingEdgeLength();
// leave room for left fading edge as long as rect isn't at very left
if (rect.left > 0) {
screenLeft += fadingEdge;
}
// leave room for right fading edge as long as rect isn't at very right
if (rect.right < getChildAt(0).getWidth()) {
screenRight -= fadingEdge;
}
int scrollXDelta = 0;
if (rect.right > screenRight && rect.left > screenLeft) {
// need to move right to get it in view: move right just enough so
// that the entire rectangle is in view (or at least the first
// screen size chunk).
if (rect.width() > width) {
// just enough to get screen size chunk on
scrollXDelta += (rect.left - screenLeft);
} else {
// get entire rect at right of screen
scrollXDelta += width / 2; // change here
}
// make sure we aren't scrolling beyond the end of our content
int right = getChildAt(0).getRight();
int distanceToRight = right - screenRight;
scrollXDelta = Math.min(scrollXDelta, distanceToRight);
} else if (rect.left < screenLeft && rect.right < screenRight) {
// need to move right to get it in view: move right just enough so that
// entire rectangle is in view (or at least the first screen
// size chunk of it).
if (rect.width() > width) {
scrollXDelta -= (screenRight - rect.right);
} else {
scrollXDelta -= width / 2; // chang here
}
scrollXDelta = Math.max(scrollXDelta, -getScrollX());
}
return scrollXDelta;
}
}
使用HorizontalScrollView焦點亂竄或失去焦點問題
HorizontalScrollView的長度不能無限的設置, 當長度超過1萬像素後會出現焦點亂竄或失去焦點的問題,
這時因爲在HorizontalScrollView的滑動邏輯中使用:
FocusFinder.getInstance().findNextFocus(this, currentFocused, View.FOCUS_RIGHT);
來確定下一個獲得焦點的子view, 但是當HorizontalScrollView的長度超過1萬像素後, 此方法返回值就不對了, 因爲其內部的獲得下個子view的算法中有一個方法:
/**
* Fudge-factor opportunity: how to calculate distance given major and minor
* axis distances. Warning: this fudge factor is finely tuned, be sure to
* run all focus tests if you dare tweak it.
*/
int getWeightedDistanceFor(int majorAxisDistance, int minorAxisDistance) {
return 13 * majorAxisDistance * majorAxisDistance
+ minorAxisDistance * minorAxisDistance;
}
這個方法值返回類型是int, 當majorAxisDistance過大時, 根據內部的計算方法很容易超過int的最大值,所以當HorizontalScrollView的長度過大時, 此方法的返回值就會溢出, 進而導致焦點亂竄或失去焦點的問題.