Android中自定义键盘和输入法

前言

关于自定义键盘搜索了一下,网上基本上是通过android.inputmethodservice.Keyboard实现的,但是打开Google文档却发现在Android Q中这个类将被废弃。

This class is deprecated because this is just a convenient UI widget class that application developers can re-implement on top of existing public APIs. If you have already depended on this class, consider copying the implementation from AOSP into your project or re-implementing a similar widget by yourselves.

通过Keyboard实现自定义是很容易的,只需要自定义键盘xml即可:

1
2
3
4
5
6
7
8
9
10
11
<Keyboard
android:keyWidth="%10p"
android:keyHeight="50px"
android:horizontalGap="2px"
android:verticalGap="2px" >
<Row android:keyWidth="32px" >
<Key android:keyLabel="A" />
...
</Row>
...
</Keyboard>

这种自定义键盘的方式存在着很多缺点(更准确的说应该是很多不足):

  1. 在Android Pad上面的支持有问题。
  2. 不能够实现动态更改键盘顺序和位置,或者动态控制和隐藏部分键。

实现思路

Activity界面架构

当启动Activity的时候,有一个setContentView()方法,Activity其实不是显示视图,实际上Activity调用了PhoneWindowsetContentView()方法,然后加载视图,将视图放到这个Window上,而Activity其实构造的时候初始化的是Window(PhoneWindow),Activity其实是个控制单元,即可视的人机交互界面(Activity其实不是显示视图,View才是真正的显示视图)。

Activity_PhoneWiondow_DecorView的关系

每个Activity包含一个PhoneWindow对象,PhoneWindow设置DecorView为应用窗口的根视图,所有的UI部件都是放在DecorView中。在里面就是熟悉的TitleView和ContentView,平时使用的setContentView()就是设置的ContentView。

Window类:位于 /frameworks/base/core/java/android/view/Window.java。该类是一个抽象类,提供了绘制窗口的一组通用API。可以将之理解为一个载体,各种View在这个载体上显示。

PhoneWindow类:位于/frameworks/policies/base/phone/com/android/internal/policy/impl/PhoneWindow.java。该类继承于Window类,是Window类的具体实现,即我们可以通过该类具体去绘制窗口。并且,该类内部包含了一个DecorView对象,该DectorView对象是所有应用窗口(Activity界面)的根View。 简而言之,PhoneWindow类是把一个FrameLayout类即DecorView对象进行一定的包装,将它作为应用窗口的根View,并提供一组通用的窗口操作接口。

DecorView类:该类是PhoneWindow类的内部类。该类是一个FrameLayout的子类,并且是PhoneWindow的子类,该类就是对普通的FrameLayout进行功能的扩展,更确切点可以说是修饰(Decor的英文全称是Decoration,即“修饰”的意思),比如说添加TitleBar(标题栏),以及TitleBar上的滚动条等 。最重要的一点是,它是所有应用窗口的根View

给DecorView添加自定义View

Java代码

1
View rootView = window.getDecorView().findViewById(android.R.id.content);

Kotlin代码

1
val rootView = window.decorView.findViewById<View>(android.R.id.content)

接下来给我们自定义的view创建一个id,在/res/values/下面新建ids.xml

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="keyboard_wrapper_id" type="id"/>
</resources>

然后我们在rootView中添加一个文本内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class SecondActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_second)

val rootView = window.decorView.findViewById<View>(android.R.id.content)
var keyboardWrapper = rootView.findViewById<FrameLayout>(R.id.keyboard_wrapper_id)
if(keyboardWrapper == null){
keyboardWrapper = FrameLayout(this).apply{
id = R.id.keyboard_wrapper_id
clipChildren = false
setBackgroundColor(Color.parseColor("#fbbc05"))
addView(TextView(this@SecondActivity).apply {
text="测试一下"
gravity = Gravity.CENTER
setTextColor(Color.WHITE)
setBackgroundColor(Color.parseColor("#ea4335"))
}, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 200, Gravity.BOTTOM))
}
}

if(rootView is FrameLayout){
rootView.addView(keyboardWrapper, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT).apply {
gravity = Gravity.BOTTOM
})
}
}
}

给DecorView添加自定义View

创建键盘布局

在开始布局之前我们先来参考一个GitHub上面的车牌输入键盘停车王车牌键盘

我们会发现实际上键盘是很有规律的,一个键盘有很多行,每一行又有很多个键。

键盘布局示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* 描述:
*
* @author 李小强 (lxq_xsyu@163.com)
* @date 2019/3/26
*/

enum class KeyType{
/**
* 文本类型
*/
GENERAL,

/**
* 功能键:删除
*/
FUNC_DELETE,

/**
* 功能键:确定
*/
FUNC_OK,

/**
* 功能键:更多
*/
FUNC_MORE,

/**
* 功能键:返回
*/
FUNC_BACK
}

/**
* 键盘的每一个键
*/
data class KeyEntry(val text:String, val keyType: KeyType, val isFunKey: Boolean, val enable: Boolean)

//说明:text键盘文本, keyType键盘类型,isFunKey是否是功能键,enable是否可点击

/**
* 键盘的每一行
*/
data class RowEntry(val keys:List<KeyEntry>)

/**
* 键盘所有键
*/
data class LayoutEntry(val rows:List<RowEntry>)

/**
* 键盘上下文环境
*/
data class KeyboardEntry(val selectIndex: Int, val presetNumber: String,
val numberMaxLength: Int, val layoutEntry: LayoutEntry)

//说明:selectIndex当前光标所在位置,presetNumber当前预设的车牌号码,numberMaxLength当前车牌号码的最大长度

键盘逻辑

键盘逻辑控制类关系如下:

键盘控制逻辑

PopupKeyboard是唯一的面向用户的接口,提供给用户基础设置和操作接口。

KeyboardInputController是一个控制容器(相当于IOC的控制器),用来组织键盘和输入框之间的逻辑关系。

KeyboardView实现了键盘的显示和控制逻辑。

InputView实现了输入框的显示逻辑。

KeyboardEngine实现了组织键盘样式和状态的逻辑,用于产生和变更键盘实体数据结构KeyboardEntry.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

class KeyboardInputController(val keyboardView: KeyboardView, val inputView: InputView) {

private var mKeyboardView: KeyboardView = keyboardView
private var mInputView: InputView = inputView
}

class PopupKeyboard {

private val mKeyboardView by lazy {
KeyboardView()
}

private var mKeyboardInputController: KeyboardInputController? = null

fun attach(inputView: InputView, context: Context){
if(mKeyboardInputController == null) {
mKeyboardInputController = KeyboardInputController(mKeyboardView, inputView)
}
}

fun getKeyboardView(): KeyboardView{
return mKeyboardView
}

fun getController(): KeyboardInputController?{
return mKeyboardInputController
}

fun show(){
//TODO mKeyboardView.show()
}

fun dismiss(){
//TODO mKeyboardView.dismiss()
}
}

核心的键盘和输入框的交互逻辑在KeyboardInputController类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 绑定输入框被选中的触发事件:更新键盘
mInputView.addOnFieldViewSelectedListener(new InputView.OnFieldViewSelectedListener() {
@Override
public void onSelectedAt(int index) {
final String number = mInputView.getNumber();
if (mDebugEnabled) {
Log.w(TAG, "点击输入框更新键盘, 号码:" + number + ",序号:" + index);
}
// 除非锁定新能源类型,否则都让引擎自己检测车牌类型
if (mLockedOnNewEnergyType) {
//更改车牌键盘(新能源车键盘)
mKeyboardView.update(number, index, false, NumberType.NEW_ENERGY);
} else {
//更改车牌键盘(其他类型)
mKeyboardView.update(number, index, false, NumberType.AUTO_DETECT);
}
}
});

KeyboardView中调用KeyboardEngineupdate()方法来确定新的键盘样式的KeyboardEntry,然后KeyboardView重新渲染新布局。

系统输入法

还有一种思路比上面的方式更好,但是需要用户自己去更改自己的输入法,这种方式在大多数场景下可能并不合适。

下面是参考自Android自带输入法实例SoftKeyboard的源码。下载源码后导入到Android Studio工程中然后运行。

SoftKeyboard

在手机的系统设置->语言和输入法->键盘和输入法,选择Sample Soft Keyboard输入法。

选择自定义输入法

这样我们就可以在任意APP中的输入框中打开我们自定义的系统输入法了,如下:

自定义输入法

从SDK 1.5版本以后,Android就开放它的IMF(Input Method Framework),让我们能够开发自己的输入法。而开发输入法最好的参考就是Android自带的Sample-SoftKeyboard,虽然这个例子仅包含英文和数字输入,但是它本身还算完整和清楚,对我们研究如何自定义输入法有很大帮助。

IMF简介

IMF(Input Method Frameworks)是Android输入法的Framework框架,其中最主要的是InputMethodService,他继承于AbstractInputMethodService。

一个IMF结构中包含三个主要的部分:

  1. input method manager:管理各部分的交互。它是一个客户端API,存在于各个应用程序的context中,用来沟通管理所有进程间交互的全局系统服务。
  2. input method(IME):实现一个允许用户生成文本的独立交互模块。系统绑定一个当前的输入法。使其创建和生成,决定输入法何时隐藏或者显示它的UI。同一时间只能有一个IME运行。

  3. client application:通过输入法管理器控制输入焦点和IME的状态。一次只能有一个客户端使用IME。

InputManager:由UI控件(View,TextView,EditText等)调用,用来操作输入法。比如,打开,关闭,切换输入法等。它是整个输入法框架(IMF)结构的核心API,处理应用程序和当前输入法的交互。可以通过Context.getSystemService()来获取一个InputMethodManager的实例。

InputMethodService:包括输入法内部逻辑,键盘布局,选词等,最终把选出的字符通过commitText提交出来。实现输入法的基础就是名为InputMethodService的类,比如你要实现一个谷歌输入法,就是要extends本类。

下图是Google官网对IME的生命周期的描述:

IME声明周期

在Mainfest中声明IME组件

在Android系统中IME是一个包含特殊输入法服务的应用程序(例如我们上面的Sample-Soft-Keyboard),mainfest.xml文件中需要声明一个intent-filterandroid.view.InputMethod的系统Service组件。

1
2
3
4
5
6
7
<service android:name=".SoftKeyboard"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data android:name="android.view.im" android:resource="@xml/method" />
</service>

上面service请求了BIND_INPUT_METHOD权限要求系统将IME和Android系统连接,并且在这个Service的meta-data中提供一个用户自定义的设置界面xml/method,这个设置界面可以从系统设置中启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.example.android.softkeyboard.ImePreferences"
android:supportsSwitchingToNextInputMethod="true">
<subtype
android:label="@string/label_subtype_generic"
android:icon="@drawable/icon_en_us"
android:imeSubtypeLocale="en_US"
android:imeSubtypeMode="keyboard" />
<subtype
android:label="@string/label_subtype_en_GB"
android:icon="@drawable/icon_en_gb"
android:imeSubtypeLocale="en_GB"
android:imeSubtypeMode="keyboard" />
</input-method>

接下来声明一个IME的设置Activity。它有一个ACTION_MAIN的intent过滤器,表示此Activity是IME应用程序的主要入口点:

1
2
3
4
5
<activity android:name=".ImePreferences" android:label="@string/settings_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />>
</intent-filter>
</activity>

输入法API

实现自定义输入法的基础就是继承并扩展InputMethodService。InputMethodService是InputMethod的一个完整实现,我们可以再在其基础上扩展和定制。它的主要方法如下:

方法说明
onInitializeInterface它在初始化界面的时候被调用,而一般是由于配置文件的更改导致该函数的执行
onBinndInput它在另外的客户端和该输入法连接时调用
onStartInput非常重要的一个回调,它在编辑框中用户已经开始输入的时候调用
onCreateInputView返回一个层次性的输入视图,而且只是在这个视图第一次显示的时候被调用
onCreateCandidatesView同onCreateInputView(),只不过创建的是候选框的视图
onCreateExtractTextView比较特殊,是在全屏模式下的一个视图
onStartInputView在输入视图被显示并且在一个新的输入框中输入已经开始的时候调用

基本上输入法的定制,都是围绕在这个类来实现的,它主要提供的是一个基本的用户界面框架(包括输入视图,候选词视图和全屏模式),但是这些都是要实现者自己去定制的。这里的实现是让所有的元素都放置在了一个单一的由InputMethodService来管理的窗口中。它提供了很多的回调API,需要我们自己去实现。一些默认的设置包括:

  • 软键盘输入视图,它通常都是被放置在屏幕的下方。
  • 候选词视图,它通常是放置在输入视图的上面。
  • 当我们输入的时候,需要改变应用程序的界面来适应这些视图的放置规则。(比如在Android上面输入,编辑框会自动变形腾出一个软键盘的位置来)。

从InputMethodServiceSample项目可以看出实现一个输入法至少需要CandidateView, LatinKeyboard, LatinKeyboardView,SoftKeyboard这四个文件:

  • CandidateView负责显示软键盘上面的那个候选区域。
  • LatinKeyboard负责解析并保存键盘布局,并提供选词算法,供程序运行当中使用。其中键盘布局是以XML文件存放在资源当中的。比如我们在汉字输入法下,按下b、a两个字母。LatinKeyboard就负责把这两个字母变成爸、把、巴等显示在CandidateView上。
  • LatinKeyboardView负责显示,就是我们看到的按键。它与CandidateView合起来,组成了InputView,就是我们看到的软键盘。
  • SoftKeyboard继承了InputMethodService,启动一个输入法,其实就是启动一个InputMethodService,当SoftKeyboard输入法被使用时,启动就会启动SoftKeyboard这个Service。

本文参考的开源项目

《vehicle-keyboard-android》
《SoftKeyboard》