欢迎关注微信公众号:FSA全栈行动 👋
一、简述
云游戏输入法分两种情况,以云化原神为例,分为 云端输入法
和 本地输入法
,运行效果如下:
云端输入法 | 本地输入法 |
---|---|
云端输入法
就是运行在云端设备上的输入法,对于不同客户端来说(Android、iPhone),运行效果一致。 本地输入法
则是运行在用户侧设备上的输入法,对于不同客户端来说(Android、iPhone),运行效果不一致。
从开发角度来说,云端输入法
是最省心的,无须关心输入法事件,只管同步触屏事件即可。但是,从功能性角度来说,本地输入法
可以有更好的体验,能为非触屏设备提供云游戏文字交互能力,例如 TV 端(遥控器操控)。
本地输入法
需要前后端配合开发,后端需提供输入法唤起事件回调,读取和注入文字的能力,这个以后有机会再做详细介绍。本篇主要讲述 前端(Android 客户端)实现,其实仔细分析一下就能看出,这个 本地输入法
功能,无非是在输入法之上显示了一个 悬浮输入框
,并且与输入法同步显隐,所以,本篇的核心内容,就是如何来实现这么一个 悬浮输入框
。
二、方案
在说明具体方案之前,需要先了解下 Android 窗口对于输入法的主要 adjust
策略:
分类 | 说明 |
---|---|
adjustResize | 始终调整 activity 主窗口的尺寸,以为屏幕上的软键盘腾出空间。 |
adjustPan | 不通过调整 activity 主窗口的尺寸为软键盘腾出空间。相反,窗口的内容会自动平移,使键盘永远无法遮盖当前焦点,以便用户始终能看到自己输入的内容。这通常不如调整窗口尺寸可取,因为用户可能需关闭软键盘才能进入被遮盖的窗口部分,并与之进行交互。 |
- 图片出处:code.luasoftware.com/tutorials/a...
adjust
官方说明:developer.android.com/guide/topic...
悬浮输入框
并不是一个新课题,网上有其他 Android 开发者分享过自己实现 悬浮输入框
的方案,他们的做法,大致能分为以下几种:
方案 | 原理 |
---|---|
基于 View 实现 | 通过监听输入法高度,调整页面底部 View 的 y 值偏移量 |
基于 Activity 实现 基于 Dialog 实现 | 利用 adjustResize 特性,将处于窗口底部的输入框,自动被输入法顶上去 |
那么这几种方案会有什么问题呢?
方案 | 问题 |
---|---|
基于 View 实现 | Android SDK 没有提供监听与获取输入法高度的 API,早期主流做法是使用 ViewTreeObserver 的 OnGlobalLayoutListener ,监听布局高度变化,进而计算出输入法高度,但是设备兼容性可能存在问题,在不同设备上拿到的输入法高度不一定准确,适配成本较高。 |
基于 Activity 实现 | 需要编写一个新的透明 Activity,在清单文件中注册 Activity 和指定 windowSoftInputMode 为 adjustResize ,这个方案利用了 adjustResize 特性,非常讨巧,无须计算输入法高度,适配成本较低,但是,实现上比较复杂笨重,特别是输入框监听事件需要在两个 Activity 之间传递时,无法很优雅的实现。 |
而 基于 Dialog 实现
方案,可以很好的规避掉上述方案存在的问题。因为 Dialog
和 Activity
一样,拥有自己的 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),我这里采用 ViewTreeObserver
的 OnGlobalLayoutListener
来实现监听功能,具体代码如下:
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 的 windowSoftInputMode
为 adjustResize
,一旦输入法显隐,那么窗口的尺寸就会变化,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等文章, 可能有你想要了解的技能知识点哦~