0. 前言
在Android应用的开发中,有一些产品需求,需要我们获取到输入法的高度。遗憾的是,Android官方并没有提供这样的API。
最近在做的直播项目就有类似的需求,先看一下淘宝的直播页面,当用户点击下方的布局时,弹出输入法的同时,将一个新的EditText置于输入法的正上方,这就需要我们准确的获取到输入法的高度,同时兼顾虚拟按键栏的高度。
同时也看到,在输入法出现时,后面的界面的布局没有受到任何的影响,这显然是android:windowSoftInputMode="adjustNothing"的效果。关于windowSoftInputMode的各种属性含义,可以参考官方文档。
因此,综上所述,我们需求就是:
- 在Activity设置为adjustNothing时获取到输入法的准确高度
- 我们再加一条要求,兼容全面屏手机。不管底部的虚拟操作栏是否存在,都应该准确的将EditText置于输入法的正上方
1. 通常的解决方案
由于Android官方并没有提供API让我们准确的获取到输入法的高度,所以我们只能自己想办法实现。
网上的方案一搜一大把,但是效果并不理想,十个里面有九个都是通过监听布局的变化,即监听ViewTreeObserver.OnGlobalLayoutListener这个接口,在弹出输入法后,用屏幕的总高度减去当前页面窗口的显示范围来得到输入法的高度,通常代码如下:
@Override
public void onGlobalLayout() {
Rect rect = new Rect();
// 获取当前页面窗口的显示范围
((Activity) getContext()).getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
int screenHeight = getScreenHeight();
int keyboardHeight = screenHeight - rect.bottom; // 输入法的高度
if (Math.abs(keyboardHeight) > screenHeight / 5) {
// 超过屏幕五分之一则表示弹出了输入法
}
}
这种方案的劣势很明显:
- 首先“超过屏幕1/5”这种阈值的设置就略为主观,也有的方案是设置阈值为200个像素,都是同理;
- 再者,是否只有弹出输入法才会触发onGlobalLayout且窗口显示范围缩小,这里还是得打个问号,不确定是否会有有其他情况导致误判;
- 最重要的是,这种方案需要在Activity的配置为android:windowSoftInputMode="adjustResize"时没有问题,可以正确获取输入法的高度,因为布局在该属性下确实会动态的调整。但是这种调整并不是我们想要的,在我们的直播场景下,不希望输入法影响到布局的任何调整。但是当Activity配置为android:windowSoftInputMode="adjustNothing"时,布局不会在输入法弹出时进行调整,上面的方案自然也会失效。
2. 基于“透明Window”的解决方案简介
方案原理简介
这种方案的核心原理为,新建一个宽度为0,高度为MATCH_PARENT且支持adjustResize属性的透明PopupWindow,将其盖在当前Activity的content之上,并通过getViewTreeObserver().addOnGlobalLayoutListener的方式,让PopupWindow感知到布局的变化,这样在输入法弹出之后,我们的PopupWindow就会被resize到一个小于屏幕高度的尺寸,用屏幕高度减去该尺寸,便得到了输入法的高度。该方案因为不涉及我们的Activity容器,因此Activity在配置为android:windowSoftInputMode="adjustNothing"时,该方案同样生效。
3. 基于“透明Window”方案的具体实现
3.1 接口设计
首先定义一个接口,onKeyboardHeightChanged()会在输入法的高度发生变化时调用,参数height<=0时,意味着输入法被收起;height>0意味着输入法被打开,同时height值即为输入法的高度。参数orientation为屏幕方向,备用。
因为在我们的应用案例中,不管底部的虚拟操作栏是否存在,都应该准确的将EditText置于输入法的正上方,所以我们同样得关心虚拟操作栏的高度。因此设计另一个方法onVirtualBottomHeight()。参数height即为虚拟操作栏的高度,为0表示全面屏,即虚拟操作栏不存在的情况。
/**
* Created by Calvin on 2020/6/1.
*/
public interface KeyboardHeightObserver {
void onKeyboardHeightChanged(int height, int orientation);
void onVirtualBottomHeight(int height);
}
3.2 PopupWindow的实现
-
在KeyboardHeightProvider()构造方法中配置PopupWindow的各种参数,比如adjustResize等重要参数;
-
PopupWindow的showAtLocation()时机实现在start()方法里,这里调用的时机很重要,必须在Activity的onResume之后调用,因此在Activity中需要手动post一下;
-
监听View树的OnGlobalLayoutListener,将屏幕高度减去PopupWindow的可见高度,得到一个diff的高度diff。
-
1.当diff为负数时,这个数值的相反数刚好等于底部虚拟操作栏的高度,回调给onVirtualBottomHeight()方法使用
-
2.当diff为0时,表示输入法为收起状态,回调给onKeyboardHeightChanged()方法使用
-
3.当diff为正数时,表示输入法为打开状态,回调给onKeyboardHeightChanged()方法使用
-
-
当计算PopupWindow的可见高度时,用到了getWindowVisibleDisplayFrame()这个方法,这个方法比较复杂,使用时有很多注意点,完全可以另写一篇文章介绍,这里简单介绍四点:
- 1.它是View类下的一个方法,使用当前窗口中的任意View执行getWindowVisibleDisplayFrame()返回的结果都是一样的,用来获取当前窗口可视区域大小,View是否可见不影响返回结果。
- 2.只有View对象已经attach到Window上之后,调用此方法才能得到真实的窗口的可视区域大小。值得注意的是,在Activity的onAttachedToWindow()方法和自定义View的onAttachedToWindow()中执行,都不是一个好的时机,前者是因为当前Window被attach到WindowManager中,但是Window中的View仍然没有attach到Window上;后者实际测试结果不稳定。推荐的调用时机可以在onWindowFocusChanged以及onGlobalLayout中。
- 3.如果窗口是全屏的,outRect中的top值始终为0。如果窗口的LayoutParams的height设置为MATCH_PARENT,outRect中的top值等于系统状态栏的高度。如果窗口的LayoutParams的height设置为WRAP_CONTENT或者某个具体的值,且窗口和状态栏存在重叠,outRect中的top值等于重叠区域的高度。同理可应用于输入法/虚拟按键栏与outRect.bottom的关系。比如获取一个高度是MATCH_PARENT的窗口在输入法/虚拟按键栏显示和隐藏两种状态下bottom差值就是输入法/虚拟按键栏的高度。
- 4.getWindowVisibleDisplayFrame()方法是通过IPC方式从WindowManager中获取到这个信息的,相对来说它的开销会比较大,因此不适合放在对性能要求很高的地方调用。
/**
* Created by Calvin on 2020/6/1.
*/
public class KeyboardHeightProvider extends PopupWindow {
private KeyboardHeightObserver observer;
private int keyboardLandscapeHeight;
private int keyboardPortraitHeight;
private View popupView;
private View parentView;
private Activity activity;
public KeyboardHeightProvider(Activity activity) {
super(activity);
this.activity = activity;
LayoutInflater inflater = (LayoutInflater) activity.getSystemService(Activity.LAYOUT_INFLATER_SERVICE);
this.popupView = inflater.inflate(R.layout.keyboard_popup_window, null, false);
setContentView(popupView);
setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE | LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
parentView = activity.findViewById(android.R.id.content);
setWidth(0);
setHeight(LayoutParams.MATCH_PARENT);
popupView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
if (popupView != null) {
handleOnGlobalLayout();
}
});
}
public void start() {
if (!isShowing() && parentView.getWindowToken() != null) {
setBackgroundDrawable(new ColorDrawable(0));
showAtLocation(parentView, Gravity.NO_GRAVITY, 0, 0);
}
}
public void close() {
this.observer = null;
dismiss();
}
public void setKeyboardHeightObserver(KeyboardHeightObserver observer) {
this.observer = observer;
}
private int getScreenOrientation() {
return activity.getResources().getConfiguration().orientation;
}
private void handleOnGlobalLayout() {
Point screenSize = new Point();
activity.getWindowManager().getDefaultDisplay().getSize(screenSize);
Rect rect = new Rect();
popupView.getWindowVisibleDisplayFrame(rect);
int orientation = getScreenOrientation();
int keyboardHeight = screenSize.y - rect.bottom;
if (keyboardHeight < 0 && observer != null) {
observer.onVirtualBottomHeight(-keyboardHeight);
}
if (keyboardHeight == 0) {
notifyKeyboardHeightChanged(0, orientation);
} else if (orientation == Configuration.ORIENTATION_PORTRAIT) {
this.keyboardPortraitHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardPortraitHeight, orientation);
} else {
this.keyboardLandscapeHeight = keyboardHeight;
notifyKeyboardHeightChanged(keyboardLandscapeHeight, orientation);
}
}
private void notifyKeyboardHeightChanged(int height, int orientation) {
if (observer != null) {
observer.onKeyboardHeightChanged(height, orientation);
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/popuplayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:orientation="horizontal">
</LinearLayout>
3.3 Activity中使用
在Activity定义成员变量,并在onCreate()中进行初始化:
private KeyboardHeightProvider mProvider;
//虚拟导航栏的高度, 默认为0
private int mVirtualBottomHeight;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
...
mProvider = new KeyboardHeightProvider(getActivity());
new Handler().post(() -> mProvider.start());
}
为了防止内存泄漏,进行生命周期的相关处理:
@Override
protected void onResume() {
super.onResume();
mProvider.setKeyboardHeightObserver(this);
}
@Override
protected void onPause() {
super.onPause();
mProvider.setKeyboardHeightObserver(null);
}
@Override
protected void onDestroy() {
super.onDestroy();
mProvider.close();
}
最后是在回调方法中,完成数据的使用:
@Override
public void onKeyboardHeightChanged(int height, int orientation) {
if (height > 0) {
//输入法弹出
//输入法之上的布局(包括EditText+发送按钮)在整个屏幕中的位置是沉底的
//这里将布局显示出来, 并设置MarginBottom, 这样就被输入法布局拖起来了
//这里的MarginBottom已经包含了虚拟导航栏的高度mVirtualBottomHeight
mInputLayout.setVisibility(View.VISIBLE);
ViewGroup.MarginLayoutParams params =
(ViewGroup.MarginLayoutParams) mInputLayout.getLayoutParams();
params.setMargins(0, 0, 0, height + mVirtualBottomHeight);
mInputLayout.requestLayout();
} else {
//输入法隐藏, 布局隐藏
mInputLayout.setVisibility(View.GONE);
}
}
@Override
public void onVirtualBottomHeight(int height) {
//虚拟导航栏高度赋值
mVirtualBottomHeight = height;
}
4. 后续彩蛋
-
在小米2S机型上出现了一个奇葩的兼容性问题,在PopupWindow中竟然无法触发onGlobalLayout的回调,后确认为系统bug,更新手机系统后解决。
-
将Activity的adjustNothing属性改成adjustResize后发现功能依然正常,效果同adjustNothing,这就有点奇葩了,我们当初费劲全力使用adjustNothing就是为了不让直播页面Resize,后续经过研究发现:在我们的工程里,为当前直播页面的Activity设置了全屏模式,全屏模式下adjustResize会失效,效果同adjustNothing(但是经过测试弹出输入法后Rect.bottom窗口大小还是变了,adjustNothing模式下是不可能变的)。如果把全面屏代码删掉,adjustResize会让你的界面错乱。这里只能说是歪打正着了,当然全屏模式+adjustNothing肯定也不会有什么问题。
-
上一条会延伸出一个新的问题:当你在全面屏模式下,需要输入框被顶上来时,即便你设置了adjustResize输入框依然会被输入法遮挡。在本文的业务场景里不需要,如果你有这个需求,可以参考以下三篇参考文章中的解决方案: