Android Compose软键盘交互原理解析与模拟实现

前言

本篇主要有两部分内容

  • 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的实现方式比较有参考意义,当然,这里也会涉及到一些面试的内容,希望本篇对大家有所帮助。

相关推荐
甜兒.36 分钟前
鸿蒙小技巧
前端·华为·typescript·harmonyos
王中阳Go4 小时前
字节跳动的微服务独家面经
微服务·面试·golang
深海呐4 小时前
Android AlertDialog圆角背景不生效的问题
android
Jiaberrr4 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
ljl_jiaLiang4 小时前
android10 系统定制:增加应用使用数据埋点,应用使用时长统计
android·系统定制
花花鱼4 小时前
android 删除系统原有的debug.keystore,系统运行的时候,重新生成新的debug.keystore,来完成App的运行。
android
everyStudy4 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白4 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、4 小时前
Web Worker 简单使用
前端
web_learning_3215 小时前
信息收集常用指令
前端·搜索引擎