欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Android从源码分析ScrollView自动滑动的焦点问题以及解决方案

程序员文章站 2022-05-26 23:33:33
...

大家做项目开放应该都碰到过类似于这种界面
Android从源码分析ScrollView自动滑动的焦点问题以及解决方案
这时候我们做Fragment切换的时候,如果Fragment带有像ListView和RecyclerView之类的列表,在切换的时候ScrollView会自动滑动到列表的顶部。虽然做手机app开发的时候,焦点处理比较少,但是我们可以从源码来分析一下焦点是如何传递的。

源码分析

我们两个Fragment切换的时候,最终其实还是一个View隐藏,一个View显示而已。在两个页面都加载进去了之后,我们做切换的时候一个View会GONE,一个View会VISIBLE。GONE我们后面分析,其实就是会在有焦点的时候会清空焦点。设置VISIBLE之后 。

 void setFlags(int flags, int mask) {
        final boolean accessibilityEnabled =
                AccessibilityManager.getInstance(mContext).isEnabled();
        final boolean oldIncludeForAccessibility = accessibilityEnabled && includeForAccessibility();

        int old = mViewFlags;
        mViewFlags = (mViewFlags & ~mask) | (flags & mask);

        int changed = mViewFlags ^ old;
        if (changed == 0) {
            return;
        }
        int privateFlags = mPrivateFlags;

    .....

        final int newVisibility = flags & VISIBILITY_MASK;
        if (newVisibility == VISIBLE) {
            if ((changed & VISIBILITY_MASK) != 0) {
                /*
                 * If this view is becoming visible, invalidate it in case it changed while
                 * it was not visible. Marking it drawn ensures that the invalidation will
                 * go through.
                 */
                mPrivateFlags |= PFLAG_DRAWN;
                invalidate(true);

                needGlobalAttributesUpdate(true);

                // a view becoming visible is worth notifying the parent
                // about in case nothing has focus.  even if this specific view
                // isn't focusable, it may contain something that is, so let
                // the root view try to give this focus if nothing else does.
                if ((mParent != null) && (mBottom > mTop) && (mRight > mLeft)) {
                    mParent.focusableViewAvailable(this);
                }
            }
        }
        .....
 }

从上面可以看到,我们会执行mParent.focusableViewAvailable(this);我们看看这个方法做了什么

public void focusableViewAvailable(View v) {
        if (mParent != null
                // shortcut: don't report a new focusable view if we block our descendants from
                // getting focus
                && (getDescendantFocusability() != FOCUS_BLOCK_DESCENDANTS)
                && (isFocusableInTouchMode() || !shouldBlockFocusForTouchscreen())
                // shortcut: don't report a new focusable view if we already are focused
                // (and we don't prefer our descendants)
                //
                // note: knowing that mFocused is non-null is not a good enough reason
                // to break the traversal since in that case we'd actually have to find
                // the focused view and make sure it wasn't FOCUS_AFTER_DESCENDANTS and
                // an ancestor of v; this will get checked for at ViewAncestor
                && !(isFocused() && getDescendantFocusability() != FOCUS_AFTER_DESCENDANTS)) {
            mParent.focusableViewAvailable(v);
        }
    }

这里会循环往父布局调用,我们的ViewGroup的默认都是FOCUS_BEFORE_DESCENDANTS,所以会循环调到ViewRootImpl。

 @Override
    public void focusableViewAvailable(View v) {
        checkThread();
        if (mView != null) {
            if (!mView.hasFocus()) {
                v.requestFocus();
            } else {
                // the one case where will transfer focus away from the current one
                // is if the current view is a view group that prefers to give focus
                // to its children first AND the view is a descendant of it.
                View focused = mView.findFocus();
                if (focused instanceof ViewGroup) {
                    ViewGroup group = (ViewGroup) focused;
                    if (group.getDescendantFocusability() == ViewGroup.FOCUS_AFTER_DESCENDANTS
                            && isViewDescendantOf(v, focused)) {
                        v.requestFocus();
                    }
                }
            }
        }
    }

这个方法的参数就是我们Fragment的根布局View了。这里会调用v.requestFocus();经过几层嵌套会来到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);
        }
    }

和明显,会走 case FOCUS_BEFORE_DESCENDANTS。先分析 final boolean took = super.requestFocus(direction, previouslyFocusedRect)。这个是View的方法。

 public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
        return requestFocusNoSearch(direction, previouslyFocusedRect);
    }

    private boolean requestFocusNoSearch(int direction, Rect previouslyFocusedRect) {
        // need to be focusable
        if ((mViewFlags & FOCUSABLE) != FOCUSABLE
                || (mViewFlags & VISIBILITY_MASK) != VISIBLE) {
            return false;
        }

        // need to be focusable in touch mode if in touch mode
        if (isInTouchMode() &&
            (FOCUSABLE_IN_TOUCH_MODE != (mViewFlags & FOCUSABLE_IN_TOUCH_MODE))) {
               return false;
        }

        // need to not have any parents blocking us
        if (hasAncestorThatBlocksDescendantFocus()) {
            return false;
        }

        handleFocusGainInternal(direction, previouslyFocusedRect);
        return true;
    }

因为一般的父布局focusable一般都是false,所以第一个判断直接过不了,返回false。然后就到了
return took ? took : onRequestFocusInDescendants(direction, previouslyFocusedRect);

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

上面这个方法就是循环调用子view的requestFocus了。这里需要注意因为RecyclerView是focusable=true的。所以对于RecyclerView的requestFocus会走这个方法。 handleFocusGainInternal(direction, previouslyFocusedRect);参考刚刚分析的。

 void handleFocusGainInternal(@FocusRealDirection int direction, Rect previouslyFocusedRect) {
        if (DBG) {
            System.out.println(this + " requestFocus()");
        }

        if ((mPrivateFlags & PFLAG_FOCUSED) == 0) {
            mPrivateFlags |= PFLAG_FOCUSED;

            View oldFocus = (mAttachInfo != null) ? getRootView().findFocus() : null;

            if (mParent != null) {
                mParent.requestChildFocus(this, this);
            }

            if (mAttachInfo != null) {
                mAttachInfo.mTreeObserver.dispatchOnGlobalFocusChange(oldFocus, this);
            }

            onFocusChanged(true, direction, previouslyFocusedRect);
            refreshDrawableState();
        }
    }

上面这个方法做的事情也不是特别多,我们也只需要关注 这里mParent.requestChildFocus(this, this);ViewGroup中的代码。

  public void requestChildFocus(View child, View focused) {
        if (DBG) {
            System.out.println(this + " requestChildFocus()");
        }
        if (getDescendantFocusability() == FOCUS_BLOCK_DESCENDANTS) {
            return;
        }

        // Unfocus us, if necessary
        super.unFocus(focused);

        // We had a previous notion of who had focus. Clear it.
        if (mFocused != child) {
            if (mFocused != null) {
                mFocused.unFocus(focused);
            }

            mFocused = child;
        }
        if (mParent != null) {
            mParent.requestChildFocus(this, focused);
        }
    }

看起来没有做什么,但是应该可以猜到ScrollView应该是重写过这个方法的,我们找到ScrollView的requestChildFocus方法。

  @Override
    public void requestChildFocus(View child, View focused) {
        if (!mIsLayoutDirty) {
            scrollToChild(focused);
        } else {
            // The child may not be laid out yet, we can't compute the scroll yet
            mChildToScrollTo = focused;
        }
        super.requestChildFocus(child, focused);
    }

很简单,如果我们请求了requestLayout,那么第一个if判断就不会过,走到else。setVisibility改变了我们肯定会重新绘制,我们直接看ScrollView的onLayout方法。

@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        mIsLayoutDirty = false;
        // Give a child focus if it needs it
        if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) {
            scrollToChild(mChildToScrollTo);
        }
        mChildToScrollTo = null;

      ...
    }

这里我们终于找到了结果,是因为View的显示状态变化,导致焦点View会传到ScrollView中,重新测量的时候导致会自动滑动到焦点的View的位置。

解决方案

方案一

既然我们知道了原因,想要找到解决方案肯定也不难了。之前我google的时候搜索类似问题,有人提出在根布局加上

    android:focusable="true"
    android:focusableInTouchMode="true"

这个东西有没有用呢,有用,然而第一次的切换的时候会出问题,之后就没有问题了。这个到底是为什么呢?源码里也能找到答案。
当我们第一次从第二个Fragment切换会第一个时,走的逻辑和上面分析的类似,所以第一次还是会跳。然后再切换到第二个Fragment,这时候第一个Fragment的根View会设置GONE,这里会执行一个关键的方法clearFocus

 void setFlags(int flags, int mask) {
       ...

        /* Check if the GONE bit has changed */
        if ((changed & GONE) != 0) {
            needGlobalAttributesUpdate(false);
            requestLayout();

            if (((mViewFlags & VISIBILITY_MASK) == GONE)) {
                if (hasFocus()) clearFocus();
                clearAccessibilityFocus();
                destroyDrawingCache();
                if (mParent instanceof View) {
                    // GONE views noop invalidation, so invalidate the parent
                    ((View) mParent).invalidate(true);
                }
                // Mark the view drawn to ensure that it gets invalidated properly the next
                // time it is visible and gets invalidated
                mPrivateFlags |= PFLAG_DRAWN;
            }
            if (mAttachInfo != null) {
                mAttachInfo.mViewVisibilityChanged = true;
            }
        }
        ...
}

注意hasFocus并不代表当前的View有焦点,也可能是子View有焦点。ClearFocus会找到子View带有焦点的View,这里肯定是我们RecyclerView了。

 void clearFocusInternal(View focused, boolean propagate, boolean refocus) {
        if ((mPrivateFlags & PFLAG_FOCUSED) != 0) {
            mPrivateFlags &= ~PFLAG_FOCUSED;

            if (propagate && mParent != null) {
                mParent.clearChildFocus(this);
            }

            onFocusChanged(false, 0, null);
            refreshDrawableState();

            if (propagate && (!refocus || !rootViewRequestFocus())) {
                notifyGlobalFocusCleared(this);
            }
        }
    }

清空了focuse的标志位后,会执行rootViewRequestFocus()。

    boolean rootViewRequestFocus() {
        final View root = getRootView();
        return root != null && root.requestFocus();
    }

那么这里就很明显了,调用DecorView的requestFocus(),而我们在根布局上设置的属性会导致焦点会设置到那个父View上,
这个时候focusableViewAvailable(View v) 同样无法向上传递,因为isFocused()为true。这样RecyclerView就找不到无法获取焦点,ScrollView也就不会自己滑动了。可是第一次切换的时候依然无效,基本上不考虑。

方案二

第二种解决方案就比较简单了,直接在根布局设置android:descendantFocusability=”blocksDescendants”,这样的话同样导致focusableViewAvailable(View v) 这个方法无法向上传递,这样RecyclerView就无法获取焦点,ScrollView同样也不会自动滑动了。

总结

  1. View的setVisibility()很可能会改变焦点的归属,对于某些控件需要考虑清楚使用。
  2. 很多情况下不需要考虑焦点直接上层拦截 android:descendantFocusability=”blocksDescendants”,父布局设置 android:focusable=”true”并不能保证拦截焦点。
  3. hasFocus()和isFocused()区别需要分清楚,一个是有焦点,但是可能焦点在子View,另一个就是当前的View是否有焦点了。
  4. 设置GONE的时候可能会clearFocus(),这时候焦点会从DecorView重新搜索获取。