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等文章, 可能有你想要了解的技能知识点哦~

相关推荐
杨荧22 分钟前
【JAVA毕业设计】基于Vue和SpringBoot的宠物咖啡馆平台
java·开发语言·jvm·vue.js·spring boot·spring cloud·开源
豆 腐1 小时前
MySQL【四】
android·数据库·笔记·mysql
喔喔咿哈哈2 小时前
【手撕 Spring】 -- Bean 的创建以及获取
java·后端·spring·面试·开源·github
想取一个与众不同的名字好难3 小时前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
luoganttcc4 小时前
能否推荐开源GPU供学习GPU架构
学习·开源
ai产品老杨4 小时前
部署神经网络时计算图的优化方法
人工智能·深度学习·神经网络·安全·机器学习·开源
Jewel1054 小时前
Flutter代码混淆
android·flutter·ios
Yawesh_best5 小时前
MySQL(5)【数据类型 —— 字符串类型】
android·mysql·adb
檀越剑指大厂7 小时前
开源AI大模型工作流神器Flowise本地部署与远程访问
人工智能·开源
weixin_446260857 小时前
开源vs闭源:你更看好哪一方?
开源