前言
本篇主要有两部分内容
- Compose UI 键盘交互原理解析
- 自定义ViewGroup,模拟AndroidComposeView键盘交互
AndroidComposeView作为ViewGroup的子类,属于Android平台上的Compose UI的核心节点,负责着很多重要的任务,如Layout Node、Modifier Node、Input Node管理,主要涉及事件传递、绘制、坐标测量等任务。同时还负责管理焦点、文本输入。
说到文本输入,这里要说的一点是,Android的事件传递InputManagerService,但很多时候,我们会和 InputMethodService两者搞混。Manager和Method仅一word之差,但是本质上属于两个服务,InputMethodService是InputManagerService下游,负责将KeyEvent和TouchEvent转换为另一种形式的KeyEvent,如KEYCODE_CENTER、KEYCODE_A等,不过,如果创建的键盘类型不是TYPE_NULL,那么会将此事件转换为特定的"文本"返回,如西文、中文、阿拉伯数字等。
键盘弹出需要关联WindowToken的,这样做是展示在app上层,优先接收事件。
也就是说,如果我们点击的区域在键盘区域,那么事件优先发送给InputMethodService,InputMethodService再将事件加工后(加工为事件或者TEXT)转发给有有焦点的Activity,最终接收事件的和View也有关系,因为View必须是可以聚焦的,因为在Android中,KeyEvent可以直接发送给可以聚焦的View。
当然,理论上没有焦点也可以弹出键盘。
Android中,文本输入框EditText大家应该很熟悉了,其实EditText的源码很简单,真正实现键盘调用的是TextView,AndroidComposeView其本身属于ViewGroup,如何做到触发键盘逻辑和文本接收的呢?
原理解析
那么,普通的View如何也能实现呢,主要满足以下条件才可以
- View可以聚焦
- View能够创建InputConnection
- WindowToken
焦点支持
实际上,AndroidCompseView的属性本身就是支持可以聚焦的,我们从init代码块中一探究竟,isFocusable和isFocusableInTouchMode 都是true。
java
init {
setWillNotDraw(false)
isFocusable = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
AndroidComposeViewVerificationHelperMethodsO.focusable(
this,
focusable = View.FOCUSABLE,
defaultFocusHighlightEnabled = false
)
}
isFocusableInTouchMode = true
clipChildren = false
ViewCompat.setAccessibilityDelegate(this, accessibilityDelegate)
ViewRootForTest.onViewCreatedCallback?.invoke(this)
root.attach(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Support for this feature in Compose is tracked here: b/207654434
AndroidComposeViewForceDarkModeQ.disallowForceDark(this)
}
}
当然,上面的代码中还设置了 setWillNotDraw(false) ,主要原因是ViewGroup及其子类默认是不会调用onDraw进行绘制的,当然这里我也不太理解,因为AndroidComposeView最终的onDraw依然是空实现,那这个setWillNotDraw(false)意义其实就不大了。
Compose中的InputConnection
我们知道,键盘属于应用外的弹窗,由InputMethodService进行管理,必然要进行进程通信,InputConnection是为了进程通信么?
实际上InputConnection仅仅是普通的接口,充当InputMethodManager和RemoteInputConnectionImpl的适配器,而真正能实现进程通信的是RemoteInputConnectionImpl,RemoteInputConnectionImpl 负责与InputManagerService进行通信。
java
final class RemoteInputConnectionImpl extends IRemoteInputConnection.Stub {
..省略了一些代码..
}
实际上,Android传统的View都有这个方法,但是只有TextView实现了此方法,那么,在Compose UI中,这里是通过适配器实现的
java
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? =
platformTextInputPluginRegistry.focusedAdapter?.createInputConnection(outAttrs)
但是,最终调用的是PlatformTextInputAdapter子类,在Android平台调用的是AndroidTextInputServicePlugin
java
internal object AndroidTextInputServicePlugin : PlatformTextInputPlugin<Adapter> {
override fun createAdapter(platformTextInput: PlatformTextInput, view: View): Adapter {
val platformService = TextInputServiceAndroid(view, platformTextInput)
return Adapter(TextInputService(platformService), platformService)
}
class Adapter(
val service: TextInputService,
private val androidService: TextInputServiceAndroid
) : PlatformTextInputAdapter {
override fun createInputConnection(outAttrs: EditorInfo): InputConnection =
androidService.createInputConnection(outAttrs)
}
}
注意View的传入,这里的View其实就是AndroidComposeView,而PlatformTextInput仅仅是普通对象,提供模拟焦点的行为。
java
override fun createAdapter(platformTextInput: PlatformTextInput, view: View): Adapter {
val platformService = TextInputServiceAndroid(view, platformTextInput)
return Adapter(TextInputService(platformService), platformService)
}
到这里,最终的落脚点是androidService,其本身是TextInputServiceAndroid的实例,在这里我们会看到其对InputMethodManager和InputConnection的封装,我们的重点是createInputConnection方法,但是呢,这里还有一些重要的接口,比如收起键盘,事件回调需要处理,InputConnection其实仅仅只负责接收事件和文本,那么,弹出键盘和退出键盘必然使用其他方法,这里继承的是下面的接口。
java
interface PlatformTextInputService {
//启动
fun startInput(
value: TextFieldValue, //传入当前的文本
imeOptions: ImeOptions, //键盘配置
onEditCommand: (List<EditCommand>) -> Unit, // callback,负责文本、keyevent接收
onImeActionPerformed: (ImeAction) -> Unit //键盘配置action
)
//停止键盘
fun stopInput()
fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue)
fun notifyFocusedRect(rect: Rect) {
}
// 删除掉一些过期方法,方便你理解
}
为什么要说这个接口呢,实际上在这里,主要是startInput方法的参数,TextFieldValue负责将TextField的值传入InputConnection,事实上运行期间TextField是不存在的,而TextFieldValue实际上来自TextFieldDelegate,其本身是实现运行时的TextField,负责测量、绘制等,不过这里不重要了,因为没有TextFieldValue并不影响键盘调起逻辑,因为我们开头的几个要素其实更笨不涉及TextFieldDeleage和TextFieldValue。
最终我们会看到InputConnection的实际实现
java
fun createInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.update(imeOptions, state) //初始化键盘属性和inputType
outAttrs.updateWithEmojiCompat()
return RecordingInputConnection(
initState = state,
autoCorrect = imeOptions.autoCorrect,
eventCallback = object : InputEventCallback2 {
override fun onEditCommands(editCommands: List<EditCommand>) {
onEditCommand(editCommands)
//inputType不为TYPE_NULL时接收文本
}
override fun onImeAction(imeAction: ImeAction) {
//imeAction事件监听
onImeActionPerformed(imeAction)
}
override fun onKeyEvent(event: KeyEvent) {
//inputType为TYPE_NULL时接收KeyEvent
baseInputConnection.sendKeyEvent(event)
}
override fun onConnectionClosed(ic: RecordingInputConnection) {
//链接关闭
for (i in 0 until ics.size) {
if (ics[i].get() == ic) {
ics.removeAt(i)
return // No duplicated instances should be in the list.
}
}
}
}
).also {
ics.add(WeakReference(it))
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.createInputConnection: $ics")
}
}
}
上面我们可以看到两种互斥行为onKeyEvent和onEditCommands,是根据键盘的InputType决定,而InputType决定是通过 outAttrs.update(imeOptions, state)完成的,主要配置如下,参数太多了,就不一一解析了。
java
internal fun EditorInfo.update(imeOptions: ImeOptions, textFieldValue: TextFieldValue) {
this.imeOptions = when (imeOptions.imeAction) {
ImeAction.Default -> {
if (imeOptions.singleLine) {
// this is the last resort to enable single line
// Android IME still show return key even if multi line is not send
// TextView.java#onCreateInputConnection
EditorInfo.IME_ACTION_DONE
} else {
EditorInfo.IME_ACTION_UNSPECIFIED
}
}
ImeAction.None -> EditorInfo.IME_ACTION_NONE
ImeAction.Go -> EditorInfo.IME_ACTION_GO
ImeAction.Next -> EditorInfo.IME_ACTION_NEXT
ImeAction.Previous -> EditorInfo.IME_ACTION_PREVIOUS
ImeAction.Search -> EditorInfo.IME_ACTION_SEARCH
ImeAction.Send -> EditorInfo.IME_ACTION_SEND
ImeAction.Done -> EditorInfo.IME_ACTION_DONE
else -> error("invalid ImeAction")
}
when (imeOptions.keyboardType) {
KeyboardType.Text -> this.inputType = InputType.TYPE_CLASS_TEXT
KeyboardType.Ascii -> {
this.inputType = InputType.TYPE_CLASS_TEXT
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_FORCE_ASCII
}
KeyboardType.Number -> this.inputType = InputType.TYPE_CLASS_NUMBER
KeyboardType.Phone -> this.inputType = InputType.TYPE_CLASS_PHONE
KeyboardType.Uri ->
this.inputType = InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI
KeyboardType.Email ->
this.inputType =
InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
KeyboardType.Password -> {
this.inputType =
InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
}
KeyboardType.NumberPassword -> {
this.inputType =
InputType.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD
}
KeyboardType.Decimal -> {
this.inputType =
InputType.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_FLAG_DECIMAL
}
else -> error("Invalid Keyboard Type")
}
if (!imeOptions.singleLine) {
if (hasFlag(this.inputType, InputType.TYPE_CLASS_TEXT)) {
// TextView.java#setInputTypeSingleLine
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
if (imeOptions.imeAction == ImeAction.Default) {
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_NO_ENTER_ACTION
}
}
}
if (hasFlag(this.inputType, InputType.TYPE_CLASS_TEXT)) {
when (imeOptions.capitalization) {
KeyboardCapitalization.Characters -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
}
KeyboardCapitalization.Words -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_WORDS
}
KeyboardCapitalization.Sentences -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
}
else -> {
/* do nothing */
}
}
if (imeOptions.autoCorrect) {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
}
}
this.initialSelStart = textFieldValue.selection.start
this.initialSelEnd = textFieldValue.selection.end
//设置初始化文本选中范围
EditorInfoCompat.setInitialSurroundingText(this, textFieldValue.text)
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_NO_FULLSCREEN
}
关于WindowToken
我们知道,一旦DecorView被attachToWindow,那么就会产生Window Token,这个其实我们不需要考虑太多,但为什么强调这个呢?
因为可以依靠这个来确定展示层级,在我们之前的一篇ANR Monitor Dialog相关的文章中,我们可以看到键盘处于Dialog之下的,按道理应该是在Dialog之上,因此大概率和这里有关系。
java
IInputMethodManagerGlobalInvoker.showSoftInput(
mClient,
view.getWindowToken(),
statsToken,
flags,
mCurRootView.getLastClickToolType(),
resultReceiver,
reason);
以上就是Compose UI实现键盘交互的核心逻辑。
模拟实现
上面的内容其实也不少,另外,考虑到一些人也在实现类似Compose UI项目,那么,必然需要处理到键盘问题,本篇实现一个简单的可以接收西文字符的View,为什么是西文呢,主要原因是中文字符的删除是通过KeyEvent + TextKeyListener,这点和ASCII字符还是有区别的。
但是我们最好的方式还是自己实现一个可以与键盘交互的ViewGroup,当然,由于AndroidComposeView涉及的类太多了,代码量太大,不利于我们快速理解和实战。我们这里参考TextView的实现了,即便是TextView相关类也很多,我们只做一个简单的实现。
注意:这里并不是说Compose UI的实现不好,事实上,Compose UI实现方式耦合程度非常低,相比TextView要简单,也非常适合学习,但是限于篇幅因素,我们这里借助耦合程度较高的TextView实现。
SimpleComposeView定义
下面是View的定义,另外在下面的代码继承了ViewGroup,这里我们让ViewGroup作为InputMethod接收事件的主View。
这就类似于Compose UI中的AndroidComposeView。
另外,我们使用了Editable,为什么要使用这个呢,主要原因是Editable可以实现字符串的删减和监听,当然,最终实现是StringBuilder。不过Editable不是必须的。利用InputConnection#setComposingText也可以接收字符串,那这里其实还有个原因是,中文字符的删除Editable没法做到,在Android系统中是依靠TextKeyListener来拦截KeyEvent实现的,而TextKeyListener需要Editable作为参数。
java
class SimpleComposeView : ViewGroup {
private var mDM: DisplayMetrics? = null
private val sizeRect = Rect()
private var mPaint: TextPaint? = null
var editableText: Editable? = null
private val isTextMode = true
private var mIMM: InputMethodManager? = null
private var windowToken: IBinder? = null
val CHANGE_WATCHER_PRIORITY = 100
private var changeWatcher: TextChangeWatcher? = null
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
mDM = resources.displayMetrics
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
setWillNotDraw(false)
initPaint()
changeWatcher = TextChangeWatcher()
//使用Editable方便修改文本,不用Editable也行的,只不过我们需要处理很多逻辑 ,参考 SimpleComposeInputConnection#setComposingText
editableText = Editable.Factory.getInstance().newEditable("")
if (editableText is Spannable) {
//这种方式可以提前通知Watcher
editableText?.setSpan(
changeWatcher, 0, editableText!!.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE
or (CHANGE_WATCHER_PRIORITY shl Spanned.SPAN_PRIORITY_SHIFT)
)
}
setOnClickListener { toggleFocus(isFocused) }
}
private inner class TextChangeWatcher : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
postInvalidate()
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
postInvalidate()
}
override fun afterTextChanged(s: Editable) {
postInvalidate()
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
windowToken = getWindowToken()
if (mIMM == null) {
mIMM = context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as InputMethodManager
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
handleInputMethodLeak(mIMM)
mIMM = null
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
toggleFocus(gainFocus)
}
private fun toggleFocus(gainFocus: Boolean) {
if (gainFocus) {
mIMM?.showSoftInput(this, 0)
} else {
mIMM?.hideSoftInputFromWindow(windowToken, 0)
}
}
private fun handleInputMethodLeak(inputMethodManager: InputMethodManager?) {
//InputMethodManager 很容易泄露,这点一定要注意
// 这里需要修复,不过这仅仅是个demo,不至于要写多好
}
private fun initPaint() {
//否则提供给外部纹理绘制
mPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
isAntiAlias = true
strokeWidth = dp2px(1F)
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
textSize = dp2px(14F)
}
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
if (!enabled) {
mIMM?.hideSoftInputFromWindow(windowToken, 0)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
var widthSize = MeasureSpec.getSize(widthMeasureSpec)
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM?.widthPixels?.div(2) ?: 0
}
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
var heightSize = MeasureSpec.getSize(heightMeasureSpec)
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = dp2px(60f).toInt()
}
setMeasuredDimension(widthSize, heightSize)
}
fun dp2px(dp: Float): Float {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
mPaint?.let { paint ->
editableText?.let {
var strokeWidth = paint.strokeWidth
sizeRect.set(
strokeWidth.toInt(),
strokeWidth.toInt(),
(width - strokeWidth.toInt()),
(height - strokeWidth.toInt())
)
canvas.drawRect(sizeRect, mPaint!!)
val style = paint.style
mPaint?.style = Paint.Style.FILL
canvas.drawText(
it,
0,
it.length,
(sizeRect.left + 10).toFloat(),
getTextPaintBaseline(mPaint) + sizeRect.centerY(),
paint
)
mPaint?.style = style
}
}
}
val keyListener: KeyListener?
get() = null
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
//这里需要TextKeyListener.getInstance()来辅助中文文本删除,参考TextView
return super.onKeyDown(keyCode, event)
}
fun notifyContentCaptureTextChanged() {}
fun setExtracting(request: ExtractedTextRequest?) {}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
if (isEnabled) {
outAttrs.inputType = inputType
if (isTextMode) {
val bundle = Bundle()
outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE
outAttrs.privateImeOptions = "DONE"
outAttrs.actionLabel = "DONE"
outAttrs.actionId = EditorInfo.IME_ACTION_DONE
outAttrs.extras = bundle
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val configuration = context.resources.configuration
val locales = configuration.locales
outAttrs.hintLocales = locales
}
} else {
outAttrs.imeOptions = EditorInfo.IME_NULL
}
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NAVIGATE_NEXT
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NAVIGATE_PREVIOUS
if (outAttrs.imeOptions and EditorInfo.IME_MASK_ACTION
== EditorInfo.IME_ACTION_UNSPECIFIED
) {
if (outAttrs.imeOptions and EditorInfo.IME_FLAG_NAVIGATE_NEXT != 0) {
// An action has not been set, but the enter key will move to
// the next focus, so set the action to that.
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_ACTION_NEXT
} else {
// An action has not been set, and there is no focus to move
// to, so let's just supply a "done" action.
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_ACTION_DONE
}
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_ENTER_ACTION
}
val ic: InputConnection = SimpleComposeInputConnection(this)
outAttrs.initialSelStart = 0
outAttrs.initialSelEnd = 0
outAttrs.initialCapsMode = ic.getCursorCapsMode(inputType)
EditorInfoCompat.setInitialSurroundingText(outAttrs, editableText!!)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
outAttrs.contentMimeTypes = receiveContentMimeTypes
}
return ic
}
return null
}
private val inputType: Int
private get() = EditorInfo.TYPE_CLASS_TEXT
fun extractText(request: ExtractedTextRequest?, res: ExtractedText): Boolean {
res.text = editableText
res.startOffset = 0
res.partialEndOffset = editableText!!.length
res.partialStartOffset = -1 // -1 means full text
res.selectionStart = 0
res.selectionEnd = editableText!!.length
res.flags = ExtractedText.FLAG_SINGLE_LINE
return true
}
fun onEditorAction(actionCode: Int) {}
fun beginBatchEdit() {}
fun endBatchEdit() {}
companion object {
fun getTextPaintBaseline(p: Paint?): Float {
val fontMetrics = p!!.fontMetrics
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent
}
}
}
自定义InputConnection实现
前面说过,InputConnection负责适配RemoteInputConnectionImpl和InputManager,最终用于"连通"View与InputMethodManager,因此,使用键盘的View必然要创建InputConnection。
完整的代码实现如下,过程其实很简单,大部分代码参考TextView实现。其中getSelectedText、sendKeyEvent、setComposingText几个方法是最核心的方法,getSelectedText包含getEditable功能,在Compose UI中使用的是getSelectedText,另外Batch相关的是批量效果(如输入的文本划线效果)辅助手段,可以简单了解下。
java
class SimpleComposeInputConnection(textview: SimpleComposeView) :
BaseInputConnection(textview, true) {
private val mTextInputView: SimpleComposeView?
protected val mIMM: InputMethodManager?
private val TAG = "EditableInputConnection"
private val DEBUG = false
// Keeps track of nested begin/end batch edit to ensure this connection always has a
// balanced impact on its associated TextView.
// A negative value means that this connection has been finished by the InputMethodManager.
private var mBatchEditNesting = 0
init {
mIMM = textview.context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as InputMethodManager
mTextInputView = textview
}
override fun getSelectedText(flags: Int): CharSequence? {
return super.getSelectedText(flags)
}
override fun getEditable(): Editable? {
return mTextInputView?.editableText
}
override fun getExtractedText(request: ExtractedTextRequest, flags: Int): ExtractedText? {
val et = ExtractedText()
if (mTextInputView?.extractText(request, et) == true) {
if (flags and GET_EXTRACTED_TEXT_MONITOR != 0) {
mTextInputView?.setExtracting(request)
}
return et
}
return null
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
return super.sendKeyEvent(event)
}
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
// 这里也可以接收文本
return super.setComposingText(text, newCursorPosition)
}
override fun beginBatchEdit(): Boolean {
synchronized(this) {
if (mBatchEditNesting >= 0) {
mTextInputView?.beginBatchEdit()
mBatchEditNesting++
return true
}
}
return false
}
override fun endBatchEdit(): Boolean {
synchronized(this) {
if (mBatchEditNesting > 0) {
// When the connection is reset by the InputMethodManager and reportFinish
// is called, some endBatchEdit calls may still be asynchronously received from the
// IME. Do not take these into account, thus ensuring that this IC's final
// contribution to mTextView's nested batch edit count is zero.
mTextInputView?.endBatchEdit()
mBatchEditNesting--
return mBatchEditNesting > 0
}
}
return false
}
fun endComposingRegionEditInternal() {
// The ContentCapture service is interested in Composing-state changes.
mTextInputView?.notifyContentCaptureTextChanged()
}
override fun closeConnection() {
super.closeConnection()
synchronized(this) {
while (mBatchEditNesting > 0) {
endBatchEdit()
}
// Will prevent any further calls to begin or endBatchEdit
mBatchEditNesting = -1
}
}
override fun clearMetaKeyStates(states: Int): Boolean {
val content = editable ?: return false
val listener: KeyListener? = mTextInputView?.keyListener
if (listener != null) {
try {
listener.clearMetaKeyState(mTextInputView, content, states)
} catch (e: AbstractMethodError) {
// This is an old listener that doesn't implement the
// new method.
}
}
return true
}
override fun commitCompletion(text: CompletionInfo): Boolean {
if (DEBUG) Log.v(
TAG,
"commitCompletion $text"
)
return false
}
/**
* Calls the [android.widget.TextView.onCommitCorrection] method of the associated TextView.
*/
override fun commitCorrection(correctionInfo: CorrectionInfo): Boolean {
if (DEBUG) Log.v(
TAG,
"commitCorrection$correctionInfo"
)
return true
}
override fun performEditorAction(actionCode: Int): Boolean {
if (DEBUG) Log.v(
TAG,
"performEditorAction $actionCode"
)
mTextInputView?.onEditorAction(actionCode)
return true
}
//键盘呼出菜单行为
override fun performContextMenuAction(id: Int): Boolean {
if (DEBUG) Log.v(
TAG,
"performContextMenuAction $id"
)
return true
}
override fun performSpellCheck(): Boolean {
return false //拼写检查
}
override fun performPrivateCommand(action: String, data: Bundle): Boolean {
return true
}
override fun commitText(
text: CharSequence,
newCursorPosition: Int
): Boolean {
return if (mTextInputView == null) {
super.commitText(text, newCursorPosition)
} else super.commitText(text, newCursorPosition)
}
}
以上,就是实现了与键盘的交互,当然前面说过受限于篇幅因素,以上是参考TextView实现的,耦合程度有些高,但是便于理解,真正实际上比较推荐使用Compose UI的方式。
效果
以上,我们实现了通过ViewGroup呼起键盘、创建InputConnection、接收键盘事件等,另外,理论上还可以模拟Compose的Node节点指定绘制位置,有兴趣的可以自己实现一下。下面我们来看看效果如何。
效果如下:
总结
在本篇我们可以看到,其实键盘的交互并非只有通过TextView来实现,实际上自定义View或者ViewGroup方式也是可以的,Compose UI提供了一个比较好的参考。
考虑到一些团队也在自己实现基于Kotlin的跨平台UI框架,那么,对输入法键盘的交互是必须要考虑的事情。
本篇的重点依然是InputConnection的使用,Compose UI的实现方式比较有参考意义,当然,这里也会涉及到一些面试的内容,希望本篇对大家有所帮助。