Android - 云游戏本地悬浮输入框实现

欢迎关注微信公众号:FSA全栈行动 👋

一、简述

云游戏输入法分两种情况,以云化原神为例,分为 云端输入法本地输入法,运行效果如下:

云端输入法 本地输入法

云端输入法 就是运行在云端设备上的输入法,对于不同客户端来说(Android、iPhone),运行效果一致。 本地输入法 则是运行在用户侧设备上的输入法,对于不同客户端来说(Android、iPhone),运行效果不一致。

从开发角度来说,云端输入法 是最省心的,无须关心输入法事件,只管同步触屏事件即可。但是,从功能性角度来说,本地输入法 可以有更好的体验,能为非触屏设备提供云游戏文字交互能力,例如 TV 端(遥控器操控)。

本地输入法 需要前后端配合开发,后端需提供输入法唤起事件回调,读取和注入文字的能力,这个以后有机会再做详细介绍。本篇主要讲述 前端(Android 客户端)实现,其实仔细分析一下就能看出,这个 本地输入法 功能,无非是在输入法之上显示了一个 悬浮输入框,并且与输入法同步显隐,所以,本篇的核心内容,就是如何来实现这么一个 悬浮输入框

二、方案

在说明具体方案之前,需要先了解下 Android 窗口对于输入法的主要 adjust 策略:

分类 说明
adjustResize 始终调整 activity 主窗口的尺寸,以为屏幕上的软键盘腾出空间。
adjustPan 不通过调整 activity 主窗口的尺寸为软键盘腾出空间。相反,窗口的内容会自动平移,使键盘永远无法遮盖当前焦点,以便用户始终能看到自己输入的内容。这通常不如调整窗口尺寸可取,因为用户可能需关闭软键盘才能进入被遮盖的窗口部分,并与之进行交互。

悬浮输入框 并不是一个新课题,网上有其他 Android 开发者分享过自己实现 悬浮输入框 的方案,他们的做法,大致能分为以下几种:

方案 原理
基于 View 实现 通过监听输入法高度,调整页面底部 View 的 y 值偏移量
基于 Activity 实现 基于 Dialog 实现 利用 adjustResize 特性,将处于窗口底部的输入框,自动被输入法顶上去

那么这几种方案会有什么问题呢?

方案 问题
基于 View 实现 Android SDK 没有提供监听与获取输入法高度的 API,早期主流做法是使用 ViewTreeObserverOnGlobalLayoutListener,监听布局高度变化,进而计算出输入法高度,但是设备兼容性可能存在问题,在不同设备上拿到的输入法高度不一定准确,适配成本较高。
基于 Activity 实现 需要编写一个新的透明 Activity,在清单文件中注册 Activity 和指定 windowSoftInputModeadjustResize,这个方案利用了 adjustResize 特性,非常讨巧,无须计算输入法高度,适配成本较低,但是,实现上比较复杂笨重,特别是输入框监听事件需要在两个 Activity 之间传递时,无法很优雅的实现。

基于 Dialog 实现 方案,可以很好的规避掉上述方案存在的问题。因为 DialogActivity 一样,拥有自己的 Window,而 windowSoftInputMode 实际上是作用于 Window 的,即 Dialog 可以设置自己的 windowSoftInputMode

java 复制代码
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);

另外,Dialog 可以很轻易地把输入框事件回调给外部(Activity、Fragment 等)。

注:Dialog 无须在清单文件中注册,在动态更新上也有一定的优势。

三、实现

经过上面的分析,我最终采用的是 基于 Dialog 实现 方案,来实现 悬浮输入框。要注意的地方大致有这几点:

  • Dialog 样式与布局
  • 监听输入法显隐
  • 兼容横竖屏切换

1、Dialog 样式与布局

系统默认的 Dialog 是有样式的,需要对其进行定制,修改为透明的 Dialog,另外还要修改窗口的 windowSoftInputMode,具体代码如下:

java 复制代码
/**
 * 基于 Dialog 实现的悬浮输入框
 *
 * @author LQR
 * @since 2024/7/6
 */
public class FloatInputDialog extends AbsFloatInputDialog {

    protected View rootView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        rootView = initView();
        initWindow();
    }

    protected View initView() {
        // 自定义布局
        View rootView = View.inflate(getContext(), getLayoutId(), null);
        setContentView(rootView);
        // 因为 window 高度设为 MATCH_PARENT,所以,需要对根布局监听点击事件,用于隐藏弹窗
        rootView.setOnClickListener(v -> {
            EditText inputView = getInputView();
            if (inputView != null) {
                SoftInputUtil.getInstance().hide(inputView);
            }
        });
        return rootView;
    }

    protected void initWindow() {
        Window window = getWindow();
        // 输入法显示模式
        window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
        // 背景透明
        window.setBackgroundDrawableResource(android.R.color.transparent);
        // 去除黑色半透明遮罩
        window.setDimAmount(0);
        // 清除 Dialog 底部背景模糊和黑暗度
        window.clearFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND | WindowManager.LayoutParams.FLAG_DIM_BEHIND);
        // 全屏
        // 注意:窗口高度必须是 MATCH_PARENT,否则 ViewTreeObserver 的 OnGlobalLayoutListener 在输入法隐藏时(个别设备上认为窗口尺寸没变),可能不会回调
        window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
        // 屏幕底部显示
        window.setGravity(Gravity.BOTTOM);
    }
}

代码中的注释很详细,相信不难理解,不过,有一点得注意的,initWindow() 需要在 initView() 之后执行,否则窗口的不会撑满整个屏幕。

2、监听输入法显隐

尽管该方案不需要精确获取输入法高度,但是,有必要监听输入法显隐,以达到输入框同步输入法显隐的目的。为了兼容较旧的 Android 系统(万恶的 4.x),我这里采用 ViewTreeObserverOnGlobalLayoutListener 来实现监听功能,具体代码如下:

java 复制代码
/**
 * 输入法观察者
 *
 * @author LQR
 * @since 2024/7/7
 */
public class SoftInputObserver extends Handler implements ViewTreeObserver.OnGlobalLayoutListener {

    private int lastWindowHeight = 0;
    private boolean isSoftInputShow = false;

    public void onStart(View rootView) {
        ...
        rootView.getViewTreeObserver().addOnGlobalLayoutListener(this);
    }

    public void onStop() {
        ...
        rootView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
    }

    @Override
    public void onGlobalLayout() {
        if (rootView == null) return;
        // 输入法有 显示/隐藏 动画,都会导致窗口高度发生变化
        Rect rect = getWindowDisplayRect(rootView);
        int windowCurrentHeight = rect.height();
        int windowOriHeight = getWindowOriHeight();

        // 屏幕旋转 & 输入法动画 结束
        if (lastWindowHeight == windowCurrentHeight) {
            onGlobalLayoutComplete();
            return;
        }
        lastWindowHeight = windowCurrentHeight;
    }

    private void onGlobalLayoutComplete() {
        if (rootView == null) return;
        Rect rect = getWindowDisplayRect(rootView);
        int windowCurrentHeight = rect.height();
        int windowOriHeight = getWindowOriHeight();

        if (windowCurrentHeight < windowOriHeight) {
            // 窗口高度变小,说明输入法显示
            if (!isSoftInputShow) {
                onSoftInputShow();
            }
        } else if (windowCurrentHeight == windowOriHeight) {
            // 窗口高度 变回 原大小,不一定是 输入法隐藏,有以下几种情况:
            // 1、输入法还没有出现(隐 -> 显)
            // 2、输入法完全收起来(显 -> 隐)
            // 3、输入法显示动画过程中,点击空白处,触发隐藏输入法
            if (isSoftInputShow) {
                onSoftInputHide();
            }
        }
    }
}

因为 Dialog 中的 rootView 是撑满整个窗口的(MATCH_PARENT),而且 Dialog 的 windowSoftInputModeadjustResize,一旦输入法显隐,那么窗口的尺寸就会变化,rootView 的尺寸同步变化,从而触发 onGlobalLayout() 的执行。一旦窗口高度不再变化,说明输入法显隐动画已经执行完毕,再根据窗口高度判定输入法显隐即可。

3、兼容横竖屏切换

在输入法显示的状态下,旋转屏幕,这时需要保证输入法继续显示,同样的,如果是在输入法隐藏状态下,那么旋转屏幕时,得保证输入法继续隐藏。因为上述输入法显隐是根据窗口高度变化来判断的,当旋转屏幕时,所依据的窗口高度就需要改变一下,具体代码如下:

java 复制代码
/**
 * 输入法观察者
 *
 * @author LQR
 * @since 2024/7/7
 */
public class SoftInputObserver extends Handler implements ViewTreeObserver.OnGlobalLayoutListener {

    private int preOrientation = 0;
    private int orientation = 0;
    private int verticalOriHeight = 0;
    private int horizontalOriHeight = 0;

    /**
     * 尽可能晚,但必须在输入法显示之前调用,才能获取到窗口的原始尺寸
     */
    public void onShow() {
        // 获取当前窗口原始尺寸
        getWindowOrientationAndSize();
    }

    private void getWindowOrientationAndSize() {
        if (rootView == null) return;
        orientation = getWindowOrientation(rootView);
        preOrientation = orientation;
        log("getScreenOriAndSize, orientation = " + orientation);
        // 获取窗口原始尺寸
        Rect rect = getWindowDisplayRect(rootView);
        switch (orientation) {
            case Configuration.ORIENTATION_PORTRAIT: // 1
                verticalOriHeight = rect.height();
                horizontalOriHeight = rect.width();
                log("current portrait, verticalOriHeight = " + verticalOriHeight + ", horizontalOriHeight = " + horizontalOriHeight);
                break;
            case Configuration.ORIENTATION_LANDSCAPE: // 2
                horizontalOriHeight = rect.height();
                verticalOriHeight = rect.width();
                log("current landscape, verticalOriHeight = " + verticalOriHeight + ", horizontalOriHeight = " + horizontalOriHeight);
                break;
        }
    }

    private Rect getWindowDisplayRect(View rootView) {
        Rect rect = new Rect();
        rootView.getWindowVisibleDisplayFrame(rect);
        return rect;
    }

    /**
     * 获取当前窗口当前方向上原始高度
     */
    private int getWindowOriHeight() {
        return orientation == Configuration.ORIENTATION_PORTRAIT ? verticalOriHeight : horizontalOriHeight;
    }

    private int getWindowOrientation(View rootView) {
        return rootView.getResources().getConfiguration().orientation;
    }
}

然后,在清单文件中,把 悬浮输入框 所在 Activity 的 configChanges 做如下修改,使其在屏幕旋转时不会被销毁重建:

xml 复制代码
<activity
    android:name=".MainActivity"
    android:configChanges="screenSize|orientation">
    ...
</activity>

经过上面的处理,现在就可以兼容横竖屏切换场景了。但是,有个不起眼的小问题,Android 横屏状态下,EditText 获取焦点时,默认会进入全屏输入模式,像这样:

很显然,这不是 悬浮输入框,而且这种全屏输入模式,不会让我们的 Dialog 窗口高度发生变化,所以输入法显隐监听也就失效了。那么,现在就需要关掉 EditText 的全屏输入模式,具体代码如下:

java 复制代码
/**
 * 基于 Dialog 实现的悬浮输入框
 *
 * @author LQR
 * @since 2024/7/6
 */
public class FloatInputDialog extends AbsFloatInputDialog {

    protected View rootView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        rootView = initView();
        setupInputView();
        initWindow();
    }

    protected void setupInputView() {
        // 强制 输入框 横屏时 不要全屏
        EditText inputView = getInputView();
        if (inputView != null) {
            // IME_FLAG_NO_EXTRACT_UI:使软键盘不全屏显示,只占用一部分屏幕
            inputView.setImeOptions(inputView.getImeOptions() | EditorInfo.IME_FLAG_NO_EXTRACT_UI);
        }
    }
}

至此,支持横竖屏切换的 悬浮输入框 就实现了。因为代码占用篇幅较多,文章中只摘抄了核心代码进行讲解,完整代码请稳步到 GitHub 仓库查看:

注:目前该项目已作为 library 上传到 jitpack 仓库,可快速集成,支持自定义输入框样式。

好了,如果你觉得文章对你有帮助的话,请不吝点个免费的赞~

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有Android技术, 还有iOS, Python等文章, 可能有你想要了解的技能知识点哦~

相关推荐
openinstall全渠道统计3 小时前
免填邀请码工具:赋能六大核心场景,重构App增长新模型
android·ios·harmonyos
双鱼大猫3 小时前
一句话说透Android里面的ServiceManager的注册服务
android
双鱼大猫3 小时前
一句话说透Android里面的查找服务
android
双鱼大猫3 小时前
一句话说透Android里面的SystemServer进程的作用
android
双鱼大猫3 小时前
一句话说透Android里面的View的绘制流程和实现原理
android
双鱼大猫4 小时前
一句话说透Android里面的Window的内部机制
android
双鱼大猫4 小时前
一句话说透Android里面的为什么要设计Window?
android
双鱼大猫4 小时前
一句话说透Android里面的主线程创建时机,frameworks层面分析
android
苏金标5 小时前
android 快速定位当前页面
android
雾里看山8 小时前
【MySQL】内置函数
android·数据库·mysql