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; } }