Android 软键盘分析和最佳实践


Project: https://github.com/weikeet/SoftKeyboardWatcher

前言

  • 软键盘是 Android 进行用户交互的重要途径之一,Android 应用开发基本无法避免不使用它。

  • 然而官方没有提供一套明确的 API 来获取软键盘相关信息:

    • 软键盘是否正在展示
    • 软键盘高度等
  • 本次分享将从以下内容来分析软键盘

    • 软键盘开启与关闭
    • 软键盘示例分析
    • softInputMode 使用及原理
    • 如何获取可见区域
    • WindowInsets API
    • 软键盘适配最佳实践

软键盘弹出和关闭

平时和软键盘交互最多的就是 EditText, 当点击 EditText 时键盘就会弹出,当点击返回按钮时键盘收起:

既然已经有弹出、收起键盘的例子,那么找找其如何控制键盘的。

从 TextView (EditText extends TextView) 源码看看如何点击时弹出软键盘

弹出软键盘

@Override  
public boolean onTouchEvent(MotionEvent event) {  
  final int action = event.getActionMasked();  
  //...  
  if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()  
      && mText instanceof Spannable && mLayout != null) {  
    //...  
    if (touchIsFinished && (isTextEditable() || textIsSelectable)) {  
      // Show the IME, except when selecting in read-only text.  
      final InputMethodManager imm = getInputMethodManager();  
      viewClicked(imm);
      if (isTextEditable() && mEditor.mShowSoftInputOnFocus && imm != null && !showAutofillDialog()) {
        imm.showSoftInput(this, 0);  
      }  
      //...  
    }  
    //...  
  }  
  //...  
}

可以看出弹出键盘需要两个步骤:

1、获取 InputMethodManager 实例
2、调用 showSoftInput()

关闭软键盘

public void onEditorAction(int actionCode) {
	//...
	if (actionCode == EditorInfo.IME_ACTION_DONE) {  
	    InputMethodManager imm = getInputMethodManager();  
	    if (imm != null && imm.isActive(this)) {  
	        imm.hideSoftInputFromWindow(getWindowToken(), 0);  
	    }
	    return;
	}
	//...
}

可以得出关闭键盘也只需要两步:

1、获取 InputMethodManager 实例
2、调用 hideSoftInputFromWindow()

注意 📢

  1. imm.showSoftInput(view, code) 通常 view 传入的是 EditText 类型。
    • 如果传入其它 View,需要设置 setFocusableInTouchMode(true) 才能弹出键盘。
    • 比较完善的实现还需在 onTouchEvent() 里弹出键盘、将 Button 与键盘关联,实际上就是模仿 EditText 的实现。
  2. imm.showSoftInput(view, code), imm.hideSoftInputFromWindow(windowToken, code) 两个方法的最后一个参数用来匹配关闭键盘时判断当初弹出键盘时传入的类型,一般填 0 即可。
  3. imm.hideSoftInputFromWindow(windowToken, code) 第一个参数传入的 IBinder windowToken 类型。
    • 每个 Activity 创建时候会生成 windowToken,该值存储在 AttachInfo 里。
    • 因此对于同一个 Activity 里的 ViewTree,每个 View 持有的 windowToken 都是指向相同对象。

软键盘示例分析

  • 当键盘弹起的时,当前能看到的是两个 Window: Activity#WindowIME#Window
  • IME#Window 展示遮住 Activity#Window 的部分区域,为了使 EditText 能够被看到,Activity 布局会向上偏移

Window 区域构成和变化情况

H0CsIU

软键盘弹出时 Window 状态:Window {d 855900 InputMethod} 正好在 ImeTestActivity 之上

{all|6,7}
$ adb shell dumpsys window|grep WindowStateAnimator Window #0: WindowStateAnimator{e30fe7 com.android.systemui.ImageWallpaper} Window #1: WindowStateAnimator{3305794 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity} Window #2: WindowStateAnimator{6bc923d com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity} Window #3: WindowStateAnimator{b070a32 com.weiwei.keyboard.watcher.sample/com.weiwei.keyboard.watcher.sample.MainActivity} Window #4: WindowStateAnimator{bc6c383 com.weiwei.keyboard.watcher.sample/com.weiwei.keyboard.watcher.sample.test.ImeTestActivity} Window #5: WindowStateAnimator{d855900 InputMethod} Window #6: WindowStateAnimator{8e51539 ShellDropTarget} Window #7: WindowStateAnimator{917977e StatusBar} Window #8: WindowStateAnimator{8a814df NotificationShade} Window #9: WindowStateAnimator{967052c NavigationBar0} Window ...

是谁控制了 Window 向上偏移呢?

  • Window 中恰好就有控制软键盘的参数 WindowManager.LayoutParams.softInputMode

试想以下问题如何解决:
1、当键盘弹出时,底部 Button 恰好保持在键盘之上。
2、当键盘弹出时,任何 View 都不需要顶上去。

softInputMode 文档说明

softInputMode 顾名思义:软键盘模式,控制软键盘是否可见、关联的 EditText 是否跟随键盘移动等,重点关注以下属性:

// WindowManager.java
public static final int SOFT_INPUT_ADJUST_UNSPECIFIED = 0x00;
public static final int SOFT_INPUT_ADJUST_RESIZE = 0x10;
public static final int SOFT_INPUT_ADJUST_PAN = 0x20;
public static final int SOFT_INPUT_ADJUST_NOTHING = 0x30;

从注释的文档上看:

  • ADJUST_UNSPECIFIED: 不指定调整方式,系统自行决定使用哪种调整方式
  • ADJUST_RESIZE: 显示软键盘时调整窗口大小,使其内容不被输入法覆盖
  • ADJUST_PAN: 显示软键盘时,窗口将回平移来保证输入焦点可见
  • ADJUST_NOTHING: 不做任何操作

softInputMode 设置方法

softInputMode 默认是 ADJUST_UNSPECIFIED 模式,其他模式设置方法:

方法1 Activity#onCreate  
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)

方法2 AndroidManifest.xml activity tag
android:windowSoftInputMode="adjustNothing"

方法3 Application/Activity theme  
<item name="android:windowSoftInputMode">adjustNothing</item>

softInputMode 示例对比

以这三种布局约束排列结构为示例:分别点击两个 EditText 来测试弹出和收起软键盘


SOFT_INPUT_ADJUST_RESIZE

从三个示例 gif 可以看出:软键盘弹出/关闭时,布局高度会随着改变,布局中的控件会重新布局。

所以与底部没有约束关系的 View 在布局高度变化的时候,不会跟随移动。

  • 那么思考一下 ADJUST_RESIZE 模式下的几个问题:
    • 如何改变布局高度?
    • 改变了哪个布局的高度?
    • 是否一定会改变布局高度?

原理分析

由以上的示例可知一定是 ViewTree 里的某个 ViewGroup 高度改变了。

键盘本身是一个 Window,键盘弹出影响了 ActivityWindow 大小,从而导致 ViewTree 变化,而 Window 和 ViewTree 的联系则是通过 ViewRootImpl.java 实现的

ViewRootImpl 接收 WMS 事件 的处理过程如下:

// ViewRootImpl.java
final class ViewRootHandler extends Handler {
  private void handleMessageImpl(Message msg) {
    switch (msg.what) {
      //接收窗口变化事件
      case MSG_RESIZED: {
        //args 记录了各个区域大大小
        SomeArgs args = (SomeArgs) msg.obj;
        //------------------->Note 1
        //arg 1---->Window 的尺寸
        //arg 2---->内容区域限定边界
        //arg 3----->可见区域的限定边界
        //arg 6----->固定区域的限定边界
        if (mWinFrame.equals(args.arg 1)
            && mPendingOverscanInsets.equals(args.arg 5)
            && mPendingContentInsets.equals(args.arg 2)
            && mPendingStableInsets.equals(args.arg 6)
            && mPendingDisplayCutout.get().equals(args.arg 9)
            && mPendingVisibleInsets.equals(args.arg 3)
            && mPendingOutsets.equals(args.arg 7)
            && mPendingBackDropFrame.equals(args.arg 8)
            && args.arg 4 == null
            && args.argi 1 == 0
            && mDisplay.getDisplayId() == args.argi 3) {
          //各个区域大小都没变化,则不作任何操作
          break;
        }
      }
      case MSG_RESIZED_REPORT:
        if (mAdded) {
          SomeArgs args = (SomeArgs) msg.obj;
          //...

          final boolean framesChanged = !mWinFrame.equals(args.arg 1)
            || !mPendingOverscanInsets.equals(args.arg 5)
            || !mPendingContentInsets.equals(args.arg 2)
            || !mPendingStableInsets.equals(args.arg 6)
            || !mPendingDisplayCutout.get().equals(args.arg 9)
            || !mPendingVisibleInsets.equals(args.arg 3)
            || !mPendingOutsets.equals(args.arg 7);

          //重新设置 Window 尺寸
          setFrame((Rect) args.arg 1);
          //将值记录到各个成员变量里
          mPendingOverscanInsets.set((Rect) args.arg 5);
          mPendingContentInsets.set((Rect) args.arg 2);
          mPendingStableInsets.set((Rect) args.arg 6);
          mPendingDisplayCutout.set((DisplayCutout) args.arg 9);
          mPendingVisibleInsets.set((Rect) args.arg 3);
          mPendingOutsets.set((Rect) args.arg 7);
          mPendingBackDropFrame.set((Rect) args.arg 8);
          mForceNextWindowRelayout = args.argi 1 != 0;
          mPendingAlwaysConsumeSystemBars = args.argi 2 != 0;

          args.recycle();

          if (msg.what == MSG_RESIZED_REPORT) {
            reportNextDraw();
          }

          if (mView != null && (framesChanged || configChanged)) {
            //尺寸发生变化,强制走 layout+draw 过程----------->Note2
            forceLayout(mView);
          }
          //重新 layout-------------->Note3
          requestLayout();
        }
        break;
        //...
    }
  }
}

上面代码列出了三个注意点,分别来看看:

Note 1

//arg 1—->Window 的尺寸
//arg 2—->内容区域限定边界
//arg 3—–>可见区域的限定边界
//arg 6—–>固定区域的限定边界
arg 是 Rect 类型

“限定边界”是什么意思呢?以小米 6 测试机为例 (屏幕尺寸 1920 x 1080):可以看出,所谓的”限定边界”实际上就是上面矩形区域。

图片

当键盘弹出时:

  • arg 1—->Rect(0, 0 - 1080, 1920)
  • arg 2—->Rect(0, 63 - 0, 972)
  • arg 3—->Rect(0, 63 - 0, 972)
  • arg 6—->Rect(0, 63 - 0, 126)

当键盘收起后:

  • arg 1—->Rect(0, 0 - 1080, 1920)
  • arg 2—->Rect(0, 63 - 0, 126)
  • arg 3—->Rect(0, 63 - 0, 126)
  • arg 6—->Rect(0, 63 - 0, 126)

看到此,大家都明白了:

  • arg 1 表示的屏幕尺寸。
  • arg 6 表示的是状态栏和导航栏的高度。
  • arg 6 赋值给了 mPendingStableInsets,从名字可以看出,这值是不变的。

无论键盘弹出还是关闭,这两个值都不变,变的是 arg 2 和 arg 3,而 arg 2 赋值给了 mPendingContentInsets,arg 3 赋值给了 mPendingVisibleInsets。好了,现在 arg 2、arg 3、arg 6 都记录到成员变量里了。

Note 2 & 3

尺寸发生了变化后调用:

forceLayout(mView)—>ViewTree 里每个 View/ViewGroup 打上 layout, draw 标记,也就是说每个 View/ViewGroup 最后都会执行三大流程。

requestLayout()—> 触发执行三大流程

既然记录了尺寸的变化,继续跟踪这些值怎么使用。调用 requestLayout() 将会触发执行 performTraversals() 方法:

// ViewRootImpl.java
private void performTraversals() {
  if (mFirst || windowShouldResize || insetsChanged ||
      viewVisibilityChanged || params != null || mForceNextWindowRelayout) {
    //...
    boolean hwInitialized = false;
    //内容边界是否发生变化
    boolean contentInsetsChanged = false;
    try {
      //...
      //内容区域变化----------->Note 1
      contentInsetsChanged = !mPendingContentInsets.equals(
        mAttachInfo.mContentInsets);

      if (contentInsetsChanged || mLastSystemUiVisibility !=
          mAttachInfo.mSystemUiVisibility || mApplyInsetsRequested
          || mLastOverscanRequested != mAttachInfo.mOverscanRequested
          || outsetsChanged) {
        //...
        //分发 Inset----------->Note2
        dispatchApplyInsets(host);
        contentInsetsChanged = true;
      }
      //...
    } catch (RemoteException e) {
    }
    //...
  }
  //...
}

主要看两个点:

Note 1: 内容区域发生变化

  • 当设置 SOFT_INPUT_ADJUST_RESIZE,键盘弹起时内容区域发生变化,因此会执行 dispatchApplyInsets()
  • 当设置 SOFT_INPUT_ADJUST_PAN,键盘弹起时内容部区域不变,因此不会执行 dispatchApplyInsets()

Note 2: 分发 Inset

这些记录的值会存储在 AttachInfo 对应的变量里。该方法调用栈如下:

图片

dispatchApplyWindowInsets(WindowInsets insets)里的 insets 构成是通过计算之前记录在 mPendingXx 里的边界值。

最终调用 fitSystemWindowsInt():

// View.java
private boolean fitSystemWindowsInt(Rect insets) {
  //对于 DecorView 的子布局 LinearLayout 来说,默认 fitsSystemWindows=true
  if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
    //...
    //设置 padding
    internalSetPadding(localInsets.left, localInsets.top, localInsets.right, localInsets.bottom);
    return res;
  }
  return false;
}

看到这答案就呼之欲出了,DecorView 的子布局 LinearLayout 设置 padding,从而影响 LinearLayout 子布局的高度,最终会影响到 Activity 布局文件的高度。


SOFT_INPUT_ADJUST_PAN

软键盘弹出/关闭时,整个布局会上移,布局高度不会改变,布局中的控件不会重新布局。

  • ADJUST_PAN 如何移动整个布局?

当点击输入框 1 的时候,界面没有移动,当点击输入框 2 的时候,界面向上移动了。接下来将分析为啥会有这样的表现。

原理分析

ADJUST_PANADJUST_RESIZE 流程差不多,也是在 ViewRootImpl 里接收窗口变化的通知,不同的是:

当键盘弹起时:

- arg 1---->Rect(0, 0 - 1080, 1920)
- arg 2---->Rect(0, 63 - 0, 126)
- arg 3---->Rect(0, 63 - 0, 972)
- arg 6---->Rect(0, 63 - 0, 126)

可以看出 arg 2 没有变化,也就是内容区域没有变,最终不会执行 ViewRootImp-> dispatchApplyInsets(xx) ,当然布局的高度就不会变。

先来分析为什么点击输入框 2 能往上移动。我们知道布局移动无非就是坐标发生改变,或者内容滚动了,不管是何种形式最终都需要通过对 Canvas 进行位移才能实现移动的效果。

当窗口事件到来之后,发起 View 的三大绘制流程,并且将限定边界存储到 AttachInfo 的成员变量里,有如下关系:

  • mPendingContentInsets–>mAttachInfo.mContentInsets;
  • mPendingVisibleInsets–>mAttachInfo.mVisibleInsets;

依旧是从三大流程开启的方法开始分析。

// ViewRootImpl.java
private void performTraversals() {
  //...
  //在执行 Draw 过程之前执行
  boolean cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw() || !isViewVisible;

  if (!cancelDraw) {
    //...
    //开启 Draw 过程
    performDraw();
  } else {
    //...
  }
}

performDraw() 最终执行了 scrollToRectOrFocus() 方法:

// View.java
boolean scrollToRectOrFocus(Rect rectangle, boolean immediate) {
  final Rect ci = mAttachInfo.mContentInsets; //窗口内容区域
  final Rect vi = mAttachInfo.mVisibleInsets; //窗口可见区域
  int scrollY = 0; //滚动距离
  boolean handled = false;

  if (vi.left > ci.left || vi.top > ci.top || vi.right > ci.right || vi.bottom > ci.bottom) {
    scrollY = mScrollY;
    //找到当前有焦点的 View------------>Note1
    final View focus = mView.findFocus();
    //...
    if (focus == lastScrolledFocus && !mScrollMayChange && rectangle == null) {
      //焦点没有发生切换,不做操作
    } else {
      mLastScrolledFocus = new WeakReference<View>(focus);
      mScrollMayChange = false;

      // Try to find the rectangle from the focus view.
      if (focus.getGlobalVisibleRect(mVisRect, null)) {
        //...
        //找到当前焦点与可见区域的相交部分
        //mVisRect 为当前焦点在 Window 里的可见部分
        if (mTempRect.intersect(mVisRect)) {
          if (mTempRect.height() > (mView.getHeight()-vi.top-vi.bottom)) {
            //...
          }
          else if (mTempRect.top < vi.top) {
            //如果当前焦点位置在窗口可见区域上边,说明焦点 View 应该往下移动到可见区域里边
            scrollY = mTempRect.top - vi.top;
          } else if (mTempRect.bottom > (mView.getHeight()-vi.bottom)) {
            //如果当前焦点位置在窗口可见区域之下,说明其应该往上移动到可见区域里边------->Note 2
            scrollY = mTempRect.bottom - (mView.getHeight()-vi.bottom);
          } else {
            //无需滚动------->Note 3
            scrollY = 0;
          }
          handled = true;
        }
      }
    }
  }

  if (scrollY != mScrollY) {
    //滚动距离发生变化
    if (!immediate) {
      if (mScroller == null) {
        mScroller = new Scroller(mView.getContext());
      }
      //开始设置滚动----------->Note 4
      mScroller.startScroll(0, mScrollY, 0, scrollY-mScrollY);
    } else if (mScroller != null) {
      mScroller.abortAnimation();
    }
    //赋值给成员变量
    mScrollY = scrollY;
  }
  return handled;
}
  • Note 1: 对于上面的 Demo 来说,当前的焦点 View 就是 EditText,点击哪个 EditText,哪个就获得焦点。
  • Note 2: 对于输入框 2 来说,因为键盘弹出会遮住它,通过计算满足”当前焦点位置在窗口可见区域之下,说明其应该往上移动到可见区域里边” 条件,因此 srolly > 0
  • Note 3: 而对于输入框 1 来说,当键盘弹出时,它没有被键盘遮挡,走到 else 分支,因此 scrollY = 0
  • Note 4: 滚动是借助 Scoller 类完成的。

上面的操作实际上就是为了确认滚动值,并记录在成员变量 mScrollY 里,继续来看如何使用滚动值呢?

//ViewRootImpl.java
private boolean draw(boolean fullRedrawNeeded) {
  //...
  boolean animating = mScroller != null && mScroller.computeScrollOffset();
  final int curScrollY;
  //获取当前需要滚动的 scroll 值
  if (animating) {
    curScrollY = mScroller.getCurrY();
  } else {
    curScrollY = mScrollY;
  }
  //...

  int xOffset = -mCanvasOffsetX;
  //记录在 yOffset 里
  int yOffset = -mCanvasOffsetY + curScrollY;

  boolean useAsyncReport = false;
  if (!dirty.isEmpty() || mIsAnimating || accessibilityFocusDirty) {
    if (mAttachInfo.mThreadedRenderer != null && mAttachInfo.mThreadedRenderer.isEnabled()) {
      //...
      //对于走硬件加速绘制
      if (mHardwareYOffset != yOffset || mHardwareXOffset != xOffset) {
        //记录偏移量到 mHardwareYOffset 里
        mHardwareYOffset = yOffset;
        mHardwareXOffset = xOffset;
        invalidateRoot = true;
      }
      //...
      mAttachInfo.mThreadedRenderer.draw(mView, mAttachInfo, this);
    } else {
      //软件绘制, 传入 yOffset
      if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty, surfaceInsets)) {
        return false;
      }
    }
  }
  //...
  return useAsyncReport;
}

滚动值分别传递给了硬件加速绘制分支和软件绘制分支,在各自的分支里对 Canvas 进行平移。

小结

当设置 SOFT_INPUT_ADJUST_PAN 时,如果发现键盘遮住了当前有焦点的 View,那么会对 RootView (此处 Demo 里 DecorView 作为 RootView) 的 Canvas 进行平移,直至有焦点的 View 显示到可见区域为止。所以点击输入框 2 的时候布局会整体向上移动。

同样的最后用图展示这种移动效果:

图片

SOFT_INPUT_ADJUST_NOTHING

软键盘弹出/关闭时,整个布局不会发生任何变化(不放 gif 图了)没有事件发出,自然不会产生任何效果了。


SOFT_INPUT_ADJUST_UNSPECIFIED

默认的效果与 SOFT_INPUT_ADJUST_PAN 一致。

在 View 里增加 isScrollContainer 属性

android:isScrollContainer="true"

重新运行后效果如下:

可以看到 ADJUST_UNSPECIFIED 模式下产生的效果可能与 ADJUST_PAN 相同,也可能与 ADJUST_RESIZE 相同。

接下来就来分析 选择的标准 是什么?

原理分析

ViewRootImpl #performTraversals () 方法开始分析:

private void performTraversals() {
  //...
  if (mFirst || mAttachInfo.mViewVisibilityChanged) {
    mAttachInfo.mViewVisibilityChanged = false;
    int resizeMode = mSoftInputMode & WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST;
    //如果没有设置,那么默认为 0,即 SOFT_INPUT_ADJUST_UNSPECIFIED
    if (resizeMode == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED) {
      //查看 mScrollContainers 数组中的元素 --> Note 1
      final int N = mAttachInfo.mScrollContainers.size();
      for (int i=0; i<N; i++) {
        if (mAttachInfo.mScrollContainers.get(i).isShown()) {
          //如果有且 ScrollContainer=true 则设为 SOFT_INPUT_ADJUST_RESIZE 模式
          resizeMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
        }
      }
      if (resizeMode == 0) {
        //如果没有设置为 resize 模式,则设置 pan 模式
        resizeMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN;
      }
      if ((lp.softInputMode & WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST) != resizeMode) {
        lp.softInputMode = (lp.softInputMode & ~WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST) | resizeMode;
        //最后赋值给 params,让 Window 属性生效
        params = lp;
      }
    }
  }
  //...
}

Note 1: mAttachInfo.mScrollContainers: ArrayList<View> 即可滚动的 View list,什么时候添加元素进去的呢?

调用 View #setScrollContainer 方法时会把 View 添加到 mAttachInfo.mScrollContainers

public void setScrollContainer(boolean isScrollContainer) {
  if (isScrollContainer) {
    if (mAttachInfo != null && (mPrivateFlags&PFLAG_SCROLL_CONTAINER_ADDED) == 0) {
      mAttachInfo.mScrollContainers.add(this); // --> add
      mPrivateFlags |= PFLAG_SCROLL_CONTAINER_ADDED;
    }
    mPrivateFlags |= PFLAG_SCROLL_CONTAINER;
  } else {
    if ((mPrivateFlags&PFLAG_SCROLL_CONTAINER_ADDED) != 0) {
      mAttachInfo.mScrollContainers.remove(this); // --> remove
    }
    mPrivateFlags &= ~(PFLAG_SCROLL_CONTAINER|PFLAG_SCROLL_CONTAINER_ADDED);
  }
}

我们常用的 RecyclerView 就在构造函数里默认设置了该值。

public RecyclerView(Context context, AttributeSet attrs, int defStyle) {
    setScrollContainer(true);
    //...
}

所以容器可以滚动的话,那么它的高度可以伸缩的,既然可以伸缩,那么刚好符合 SOFT_INPUT_ADJUST_RESIZE 模式,因此此种情况下会设置为 SOFT_INPUT_ADJUST_RESIZE 模式。

如何获取可见区域

从前面的分析可以知道:状态栏、导航栏、屏幕可见区域、内容区域限定边界都是存储在如下变量里:

AttachInfo.mStableInsets 状态栏导航栏
AttachInfo.mContentInsets 内容区域限定边界
AttachInfo.mVisibleInsets 可见区域限定边界

fixme-能获取到上面的值,什么状态栏、导航栏、键盘高度获取不在话下。发现这些字段的访问权限是”default”,当然你想到了反射,没错反射是可以获取这些值,但是在 Android 10.0 之后不能反射了。

反射行不通,还好 Android 还开了个口子:getWindowVisibleDisplayFrame()

public void getWindowVisibleDisplayFrame(Rect outRect) {
  if (mAttachInfo != null) {
    try {
      //获取 Window 尺寸,注意此处的尺寸是包含状态栏、导航栏
      //与 getWindowManager().getDefaultDisplay().getRealSize()尺寸一致;
      mAttachInfo.mSession.getDisplayFrame(mAttachInfo.mWindow, outRect);
    } catch (RemoteException e) {
      return;
    }
    //...
    final Rect insets = mAttachInfo.mVisibleInsets;
    //拿到可见区域(限定边界) 运算一下,将可见区域记录在 outRect(相对于屏幕)
    outRect.left += insets.left;
    outRect.top += insets.top;
    outRect.right -= insets.right;
    outRect.bottom -= insets.bottom;
    return;
  }
  //...
}

拿到的 outRect 就是可见区域的位置坐标。

到此我们明白了,屏幕尺寸我们是知道的,outRect 我们也知道了,反推 mAttachInfo.mVisibleInsets 也可以算出来了,这个值有了,键盘高度也就有了。曲线救国之路至此完成了。

值得注意的是 getWindowVisibleDisplayFrame() 的计算依赖于 mAttachInfo.mVisibleInsets,而 mAttachInfo.mVisibleInsets 值发生变化的条件是设置了 SOFT_INPUT_ADJUST_RESIZE or SOFT_INPUT_ADJUST_PAN 模式。

最佳实践

通过前面的示例可以看出 adjustPan 体验不太友好,adjustResize 在某些情况下又会失效,且不能完全满足需求。

adjustPan 和 adjustResize 都无法达到我们的高要求和兼容性,正如问题所说的,它们存在各种问题。

而在 adjustNothing 模式下,软键盘弹出时,页面是完全不偏不移的,我们只要能在这种模式下监听到软键盘弹出、收起事件和高度,就能自行控制页面的内容偏移或者 resize。那么怎么才能监听到弹出、收起甚至得到准确高度?

如果想做到完全可空,那么只有 adjustNothing 可选择,

adjustNothing 是一个可怕的模式,它的意思是,当键盘弹出时,Activity 不做任何变形、偏移响应。

那么在 adjustNothing 模式下,我们如何接管软键盘逻辑?我们为什么要选择 adjustNothing?

纯纯写作采用的方式是:在 Activity 层上加入一层完全看不见的 Window,由这个 Window 来监听键盘变化,每一个 Window 都可以设置 softInputMode,因此它可以单独设置为 adjustResize,这样当这个 Window 本身被挤压时,我们就能判断和计算出键盘的状态了,进而再通知到编辑器 Activity。

弹出/关闭

判断是否展示

优化补充

现如果用户使用 FooView 浮窗组件,在 Android 9.0 之上将监听不到键盘状态。这是个坑 - 可以通过提供 Window type 解决

WindowInsets Api

Android 11 的一些 Window Insets 相关内容提供了向下兼容: 附图是 Chris Banes 提供的示例,从图中可见,之后我们将不再需要调用一堆的 flags 和 setSystemUiVisibility(int) 了,新的接口 setDecorFitsSystemWindows 能够更方便的配置 DoctorView 是否 fit,这应该影响状态栏和导航栏区域 UI 的延伸。
另外,我们可以通过 ViewCompat.getRootWindowInsets(view) 来快捷获取 root insets 了,而且,ime 软键盘 insets API 也能同时获得兼容,终于,到 Android 11 时代,我们终于可以方便地获取键盘高度了?!
拭目以待。🌚 附:这是该 PR 的描述:

  • Backport WindowInsetsCompat.getInsets(), getInsetsIgnoringVisibility and isVisible() APIs.

  • Works with both adjustResize and adjustPan

  • Backport WindowInsetsCompat.Builder APIs.

  • Also backports WindowCompat.setDecorFitsSystemWindows().

  • 支持 WindowInsetsCompat.getInsets()、getInsetsIgnoringVisibility 和 isVisible()API。

  • 与 adjustResize 和 adjustPan 一起工作。

  • 回传 WindowInsetsCompat.Builder APIs。

  • 同时备份 WindowCompat.setDecorFitsSystemWindows()。

H4cOzP

setWindowInsetsAnimationCallback

onPrepare -> onStart-> onProgress… -> onEnd

弹出软键盘过程中 imeVisible 状态
onPrepare: false
onStart: true
onProgress: false -> false -> true… -> true
onEnd: true

15:16:46.726 D/SoftKeyboardWatcher: onPrepare: imeVisible=false, imeHeight=0, navigationBarsHeight=48  
15:16:46.744 D/SoftKeyboardWatcher: onStart: imeVisible=true, imeHeight=962, navigationBarsHeight=48  
15:16:46.747 D/SoftKeyboardWatcher: onProgress: imeVisible=false, imeHeight=0, navigationBarsHeight=48  
15:16:46.760 D/SoftKeyboardWatcher: onProgress: imeVisible=false, imeHeight=0, navigationBarsHeight=48  
15:16:46.764 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=14, navigationBarsHeight=48  
15:16:46.779 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=79, navigationBarsHeight=48  
....
15:16:47.154 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=960, navigationBarsHeight=48  
15:16:47.171 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=961, navigationBarsHeight=48  
15:16:47.187 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=962, navigationBarsHeight=48  
15:16:47.188 D/SoftKeyboardWatcher: onEnd: imeVisible=true, imeHeight=962, navigationBarsHeight=48

收起软键盘过程中 imeVisible 状态
onPrepare: true
onStart: false
onProgress: true -> true… -> false
onEnd: false

15:19:39.994 D/SoftKeyboardWatcher: onPrepare: imeVisible=true, imeHeight=962, navigationBarsHeight=48  
15:19:40.026 D/SoftKeyboardWatcher: onStart: imeVisible=false, imeHeight=0, navigationBarsHeight=48  
15:19:40.028 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=962, navigationBarsHeight=48  
15:19:40.045 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=962, navigationBarsHeight=48  
15:19:40.060 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=947, navigationBarsHeight=48  
15:19:40.077 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=882, navigationBarsHeight=48  
....
15:19:40.439 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=2, navigationBarsHeight=48  
15:19:40.456 D/SoftKeyboardWatcher: onProgress: imeVisible=true, imeHeight=1, navigationBarsHeight=48  
15:19:40.478 D/SoftKeyboardWatcher: onProgress: imeVisible=false, imeHeight=0, navigationBarsHeight=48  
15:19:40.479 D/SoftKeyboardWatcher: onEnd: imeVisible=false, imeHeight=0, navigationBarsHeight=48

错误纠正

可以看出,EditText 随着键盘顶上去了,ImageView 随着键盘顶上去了。

(实际上以上布局仅仅是扩大了 ImageView 展示范围 - 表述有误,并不是单纯扩大范围,而是让 ImageView 和 EditText 占满父控件)

# Android 监听软键盘的高度并解决其覆盖输入框的问题
# 这交互炸了系列: 仿微信键盘弹出体验
# Android 软键盘的那些坑,原理篇来了!
# Android 软键盘的那些坑,一招搞定!
liangjingkanji/soft-input-event
android/user-interface-samples/WindowInsetsAnimation


文章作者: Weikeet
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Weikeet !
评论
  目录