20150210 ViewPager 焦点控制
在TV应用开发中ViewPager是很常用的控件,在ViewPager的页切换时焦点控制是很苦恼的事,有过相关开发经验的同学一定感同身受。废话不多说,我们分析一下ViewPager的相关源码。
对于ViewPager而已,一切按键的响应都是从dispatchKeyEvent开始的。
public boolean dispatchKeyEvent(KeyEvent event) {
// Let the focused view and/or our descendants get the key first
return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}
对于左右键KEYCODE_DPAD_RIGHT和KEYCODE_DPAD_LEFT,围绕Viewpager的view继承体系是不会拦截的,也就是说super.dispatchKeyEvent(event)返回false(注:这部分理解,可以学习相关知识),接着执行executeKeyEvent(event)方法。
/**
* You can call this function yourself to have the scroll view perform
* scrolling from a key event, just as if the event had been dispatched to
* it by the view hierarchy.
*
* @param event The key event to execute.
* @return Return true if the event was handled, else false.
*/
public boolean executeKeyEvent(KeyEvent event) {
boolean handled = false;
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_LEFT:
handled = arrowScroll(FOCUS_LEFT);
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
handled = arrowScroll(FOCUS_RIGHT);
break;
case KeyEvent.KEYCODE_TAB:
if (Build.VERSION.SDK_INT >= 11) {
// The focus finder had a bug handling FOCUS_FORWARD and FOCUS_BACKWARD
// before Android 3.0. Ignore the tab key on those devices.
if (KeyEventCompat.hasNoModifiers(event)) {
handled = arrowScroll(FOCUS_FORWARD);
} else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_SHIFT_ON)) {
handled = arrowScroll(FOCUS_BACKWARD);
}
}
break;
}
}
return handled;
}
左右键都会调用arrowScroll方法。
public boolean arrowScroll(int direction) {
View currentFocused = findFocus();
if (currentFocused == this) {
currentFocused = null;
} else if (currentFocused != null) {
boolean isChild = false;
for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
parent = parent.getParent()) {
if (parent == this) {
isChild = true;
break;
}
}
if (!isChild) {
// This would cause the focus search down below to fail in fun ways.
final StringBuilder sb = new StringBuilder();
sb.append(currentFocused.getClass().getSimpleName());
for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
parent = parent.getParent()) {
sb.append(" => ").append(parent.getClass().getSimpleName());
}
Log.e(TAG, "arrowScroll tried to find focus based on non-child " +
"current focused view " + sb.toString());
currentFocused = null;
}
}
boolean handled = false;
View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused,
direction);
if (nextFocused != null && nextFocused != currentFocused) {
if (direction == View.FOCUS_LEFT) {
// If there is nothing to the left, or this is causing us to
// jump to the right, then what we really want to do is page left.
final int nextLeft = getChildRectInPagerCoordinates(mTempRect, nextFocused).left;
final int currLeft = getChildRectInPagerCoordinates(mTempRect, currentFocused).left;
if (currentFocused != null && nextLeft >= currLeft) {
handled = pageLeft();
} else {
handled = nextFocused.requestFocus();
}
} else if (direction == View.FOCUS_RIGHT) {
// If there is nothing to the right, or this is causing us to
// jump to the left, then what we really want to do is page right.
final int nextLeft = getChildRectInPagerCoordinates(mTempRect, nextFocused).left;
final int currLeft = getChildRectInPagerCoordinates(mTempRect, currentFocused).left;
if (currentFocused != null && nextLeft <= currLeft) {
handled = pageRight();
} else {
handled = nextFocused.requestFocus();
}
}
} else if (direction == FOCUS_LEFT || direction == FOCUS_BACKWARD) {
// Trying to move left and nothing there; try to page.
handled = pageLeft();
} else if (direction == FOCUS_RIGHT || direction == FOCUS_FORWARD) {
// Trying to move right and nothing there; try to page.
handled = pageRight();
}
if (handled) {
playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));
}
return handled;
}
View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction);会根据当前的获得焦点的currentFocused和方向direction来寻找下一个获得焦点的View。
Viewpager的每一页是一个ViewGroup,这个ViewGroup包含多个View,在同一页之间切换焦点没有任何问题,FocusFinder能找到下一个View,最后执行nextFocused.requstFocus()。Lovely,一切很完美。那么,问题来了。切换页时发生了什么?
切换页时,FocusFinder.getInstance().findNextFocus(this, currentFocused, direction)返回的是null,如果是KEYCODE_DPAD_RIGHT,执行pageRight()。
boolean pageRight() {
if (mAdapter != null && mCurItem < (mAdapter.getCount()-1)) {
setCurrentItem(mCurItem+1, true);
return true;
}
return false;
}
我们知道setCurrentItem就是去执行翻页了。
void setCurrentItemInternal(int item, boolean smoothScroll, boolean always) {
setCurrentItemInternal(item, smoothScroll, always, 0);
}
void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
if (mAdapter == null || mAdapter.getCount() <= 0) {
setScrollingCacheEnabled(false);
return;
}
if (!always && mCurItem == item && mItems.size() != 0) {
setScrollingCacheEnabled(false);
return;
}
if (item < 0) {
item = 0;
} else if (item >= mAdapter.getCount()) {
item = mAdapter.getCount() - 1;
}
final int pageLimit = mOffscreenPageLimit;
if (item > (mCurItem + pageLimit) || item < (mCurItem - pageLimit)) {
// We are doing a jump by more than one page. To avoid
// glitches, we want to keep all current pages in the view
// until the scroll ends.
for (int i=0; i<mItems.size(); i++) {
mItems.get(i).scrolling = true;
}
}
final boolean dispatchSelected = mCurItem != item;
if (mFirstLayout) {
// We don't have any idea how big we are yet and shouldn't have any pages either.
// Just set things up and let the pending layout handle things.
mCurItem = item;
if (dispatchSelected && mOnPageChangeListener != null) {
mOnPageChangeListener.onPageSelected(item);
}
if (dispatchSelected && mInternalPageChangeListener != null) {
mInternalPageChangeListener.onPageSelected(item);
}
requestLayout();
} else {
populate(item);
scrollToItem(item, smoothScroll, velocity, dispatchSelected);
}
}
populate和scrollToItem是两个很重要的方法,这两个方法很大,笔者目前没时间也不想去研究。大概是这样的:
populate是构造数据,scrollToItem是滚动到要去的那一页。重点是“要去的那一页”控制焦点的代码是在populate中做的,如下代码:
void populate(int newCurrentItem) {
......
if (hasFocus()) {
View currentFocused = findFocus();
ItemInfo ii = currentFocused != null ? infoForAnyChild(currentFocused) : null;
if (ii == null || ii.position != mCurItem) {
for (int i=0; i<getChildCount(); i++) {
View child = getChildAt(i);
ii = infoForChild(child);
if (ii != null && ii.position == mCurItem) {
//我们修改如下
Rect mRect = new Rect();
currentFocused.getDrawingRect(mRect);
offsetDescendantRectToMyCoords(currentFocused, mRect);
offsetRectIntoDescendantCoords(child, mRect);
if(child.requestFocus(focusDirection, mRect)){
break;
}
//原生代码
//if (child.requestFocus(focusDirection)) {
// break;
//}
}
}
}
}
}
child就是翻页后的控件,一般是个ViewGroup,可以是RelativeLayout也可以是LinearLayout等。
我们对其部分代码进行了修改,如上代码,将前一个焦点区域传给child。这样,我们在child中可操作空间就很大。
再说一下scrollToItem,我们程序员设置的OnPageChangeListener回调都是在scrollToItem执行的,所以在这些回调函数中控制焦点效果不是很到,而且难度很大。
假如child是RelativeLayout,requestFocus(focusDirection, mRect)方法是ViewGroup中的,代码如下
@Override
public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
if (DBG) {
System.out.println(this + " ViewGroup.requestFocus direction="
+ direction);
}
int descendantFocusability = getDescendantFocusability();
switch (descendantFocusability) {
case FOCUS_BLOCK_DESCENDANTS:
return super.requestFocus(direction, previouslyFocusedRect);
case FOCUS_BEFORE_DESCENDANTS: {
final boolean took = super.requestFocus(direction, previouslyFocusedRect);
return took ? took : onRequestFocusInDescendants(direction, previouslyFocusedRect);
}
case FOCUS_AFTER_DESCENDANTS: {
final boolean took = onRequestFocusInDescendants(direction, previouslyFocusedRect);
return took ? took : super.requestFocus(direction, previouslyFocusedRect);
}
default:
throw new IllegalStateException("descendant focusability must be "
+ "one of FOCUS_BEFORE_DESCENDANTS, FOCUS_AFTER_DESCENDANTS, FOCUS_BLOCK_DESCENDANTS "
+ "but is " + descendantFocusability);
}
}
/**
* Look for a descendant to call {@link View#requestFocus} on.
* Called by {@link ViewGroup#requestFocus(int, android.graphics.Rect)}
* when it wants to request focus within its children. Override this to
* customize how your {@link ViewGroup} requests focus within its children.
* @param direction One of FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, and FOCUS_RIGHT
* @param previouslyFocusedRect The rectangle (in this View's coordinate system)
* to give a finer grained hint about where focus is coming from. May be null
* if there is no hint.
* @return Whether focus was taken.
*/
@SuppressWarnings({"ConstantConditions"})
protected boolean onRequestFocusInDescendants(int direction,
Rect previouslyFocusedRect) {
int index;
int increment;
int end;
int count = mChildrenCount;
if ((direction & FOCUS_FORWARD) != 0) {
index = 0;
increment = 1;
end = count;
} else {
index = count - 1;
increment = -1;
end = -1;
}
final View[] children = mChildren;
for (int i = index; i != end; i += increment) {
View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE) {
if (child.requestFocus(direction, previouslyFocusedRect)) {
return true;
}
}
}
return false;
}
onRequestFocusInDescendants是个很重要的方法,这个方法是计算让ViewGroup中哪个View获得焦点。所以,我们要想控制翻页后哪个View获得焦点就要复写这个方法,实现自己寻找VIew的算法。
读者会问,我们为什么要复写这个方法呢?在什么情况下需要复写这样方法呢?
我们在TV应用开发中,通常想实现一种效果:一个View获得焦点后要放大而且要在其他View上面。为了实现这样的效果,我们的ViewGroup会采用RelativeLayout(注:只有RelativeLayout才能实现此效果),同时让获得焦点的View调用bringToFront方法:
public void bringToFront() {
if (mParent != null) {
mParent.bringChildToFront(this);
}
}
public void bringChildToFront(View child) {
int index = indexOfChild(child);
if (index >= 0) {
removeFromArray(index);
addInArray(child, mChildrenCount);
child.mParent = this;
requestLayout();
invalidate();
}
}
上面的代码大家自己研究一下,总的来说bringToFront会让ViewGroup中维护的children数组里面顺序发生变化,children数组放到就是所有的子View,当前获得焦点的那个View会移到children最后位置。大家发现没有这个children就是上面onRequestFocusInDescendants用到的,onRequestFocusInDescendants就是直接取第一个View requstFocus。我们想象一下,第一次翻页,取第一个View获得焦点,没有问题,一切显示正常,注意了bringToFront的作用,会把获得焦点的View移到children数组的末尾,我们第二次翻页的时候,还是去children中的第一个View获得焦点,你会发现页面中获得焦点的View不是你想象中的View,而是别的View。这就是我们为什么要复写onRequestFocusInDescendants了。
至于如何复写,我参考了ListView寻找最近的Item的算法,大家找找学习一下。
我直接贴上代码吧,既然找到了解决方案,就分享给大家。
package com.sohuott.foxpad.launcher.moudle.usercenter.widget;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.widget.RelativeLayout;
/**
*
* @author zhongyili
*
*/
public class CustomRelativeLayout extends RelativeLayout {
public CustomRelativeLayout(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
}
public CustomRelativeLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomRelativeLayout(Context context) {
super(context);
}
/***
* 寻找最近的子View
*/
@Override
protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
int count = getChildCount();
Rect otherRect = new Rect();
int minDistance = Integer.MAX_VALUE;
int closetChildIndex = -1;
for (int i = 0; i < count; i++) {
View other = getChildAt(i);
other.getDrawingRect(otherRect);
offsetDescendantRectToMyCoords(other, otherRect);
int distance = getDistance(previouslyFocusedRect, otherRect, direction);
if (distance < minDistance) {
minDistance = distance;
closetChildIndex = i;
}
}
if (closetChildIndex >= 0) {
View child = getChildAt(closetChildIndex);
child.requestFocus();
return true;
}
return false;
}
public static int getDistance(Rect source, Rect dest, int direction) {
// TODO: implement this
int sX, sY; // source x, y
int dX, dY; // dest x, y
switch (direction) {
case View.FOCUS_RIGHT:
sX = source.right;
sY = source.top + source.height() / 2;
dX = dest.left;
dY = dest.top + dest.height() / 2;
break;
case View.FOCUS_DOWN:
sX = source.left + source.width() / 2;
sY = source.bottom;
dX = dest.left + dest.width() / 2;
dY = dest.top;
break;
case View.FOCUS_LEFT:
sX = source.left;
sY = source.top + source.height() / 2;
dX = dest.right;
dY = dest.top + dest.height() / 2;
break;
case View.FOCUS_UP:
sX = source.left + source.width() / 2;
sY = source.top;
dX = dest.left + dest.width() / 2;
dY = dest.bottom;
break;
case View.FOCUS_FORWARD:
case View.FOCUS_BACKWARD:
sX = source.right + source.width() / 2;
sY = source.top + source.height() / 2;
dX = dest.left + dest.width() / 2;
dY = dest.top + dest.height() / 2;
break;
default:
throw new IllegalArgumentException("direction must be one of "
+ "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT, "
+ "FOCUS_FORWARD, FOCUS_BACKWARD}.");
}
int deltaX = dX - sX;
int deltaY = dY - sY;
return deltaY * deltaY + deltaX * deltaX;
}
}