Android开发——如何优雅的将布局置于输入法之上

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输入框依然会被输入法遮挡。在本文的业务场景里不需要,如果你有这个需求,可以参考以下三篇参考文章中的解决方案:

    Android全屏状态下弹出输入法adjustResize无效的修复方案

    Android全屏下,各种键盘挡住输入框解决办法

    解决全屏Activity的键盘遮挡输入框

Android 输入法开发是指为Android系统开发一款输入法应用程序的过程。输入法是一种用于输入文字的工具,通过输入法,用户可以在Android设备上输入各种语言的文字。 Android 输入法开发需要掌握一定的编程知识和技巧。首先,开发者需要了解Android系统的架构和输入法的工作原理。其次,需要使用Java等编程语言来编写输入法的代码。开发者还需要使用Android Studio等开发工具来调试和测试输入法的功能。 在Android 输入法开发过程中,需要考虑以下几个关键点。首先,输入法需要正确地处理用户输入的字符,并将其显示在屏幕上。其次,输入法需要提供联想和自动完成的功能,以帮助用户更快地输入文字。此外,输入法还需要支持多种输入方式,例如手写输入、语音输入等。 同时,开发者还应该关注输入法的用户体验。输入法应该具有良好的界面设计和交互方式,方便用户使用。此外,输入法还应该具备一定的智能化功能,例如根据用户的输入习惯进行个性化设置,提供更准确的输入建议等。 最后,输入法开发完成后,开发者还需要将其发布到应用商店供用户下载和使用。在发布前,需要对输入法进行充分的测试和迭代,确保其稳定性和功能完善性。 总的来说,Android 输入法开发是一个复杂而有挑战的过程,但随着技术的不断发展和改进,越来越多的开发者参与到输入法开发中,推动了输入法的创新和提升。
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值