高仿Android「填空題」控件!手擼一個炫酷的View動效

本文轉載自公衆號控件人生

一、寫在前面

本文講解的是如何自定義一個填空題控件,實現的方式其實有很多,最重要的是瞭解其中實現的思路和想法,正所謂條條大路通羅馬嘛。

在Android系統中,我們最常使用的用於展示文字和編輯文字的控件,就是TextView和EditView,這兩個控件基本上已經能夠滿足我們日常大部分開發需求。

但是,凡事都有個但是。程序猿基本都會遇到一些比較特殊的需求,而作爲一個Android開發者,最常見的特殊需求,就是一個特殊的控件,而這個控件剛好是系統沒有提供的。

下面就是一個比較特別的控件,一個可填空的控件。要求可以和普通TextView一樣展示普通的文字,同時又包含可以編輯的部分,類似EditText。如下:

看到這個,第一反應就是,這不合理啊,又是展示,又是可編輯,又是換行,沒辦法實現啊!

結果,被人家甩了一句:那啥,學習強國App裏面不就有可以填空答題的嘛!

我去,這下尷尬了。如果實現不了,豈不是顯得自己很Low B!不行,無論如何都得做出來!(才能咽得下這口氣!

二、尋尋覓覓,不得所需

哼,系統沒有的控件,我找個第三方的輪子還不行嗎?我就不信,世界這麼大,還有別人沒做好的輪子!於是開啓了“常規操作模式”(Google/GitHub/百度,搜索,複製,粘貼)。果不其然,有的是輪子(ヾ(´A`)ノ゚)。

比如這兩個:

Android 使用代碼實現一個填空題

Android 基於TextView實現填空題

他們有一些共同的特點:

1.基於TextView做文字展示
2.基於SpannableString做文字樣式變化,文字點擊等
3.必須要有一個EditText作爲輸入

毫無疑問,這是系統提供的,最簡單方便的定製一個TextView和EditText結合的方法。但是,他們都存在一些問題,比如

1.非嵌入式的輸入,需要在外部提供一個可輸入的EditText
2.雖然是嵌入式的輸入,但是可編輯文字必須要固定長度,不能根據文字長短動態變化

總而言之,就是體驗還是不夠好!無奈之下,萌生了自己造一個輪子的想法。

那麼,我們就仿造學習強國,定製一個填空題控件唄。

三、拆輪子

既然決定自己造輪子,必然要先分析一下這個輪子,把這個輪子拆開,看看它包含些什麼東西。

1.首先,最簡單的功能:顯示文字
2.其次,實現文字點擊,並彈出輸入法
3.再次,接收輸入法輸入
4.最後,光標與文字的輸入和刪除

1. 如何顯示文字?

在定義View中, 顯示文字是一件非常簡單的函數調用,無非就是

canvas.drawText(text, x, y, paint)

但是,如果你想當然的認爲這個是一個簡單的事情,那你就大錯特錯了。

1)文字基線

首先,對於y座標,指的是文字的基線(baseLine),而非文字的top座標,這個座標可以近似認爲是文字的bottom座標,但並沒有那麼簡單。如下圖:

關於文字的繪製,這篇下面這篇文章講得很透徹,建議不熟悉的同學可以看看

自定義控件之繪圖篇( 五):drawText()詳解

2)文字換行

不可避免的問題,文字過長的時候,我們需要對它進行換行顯示,那麼我們怎麼樣才能知道什麼時候需要換行呢?

這裏就涉及到一個文字寬度計算問題

在Android中如何計算文字的寬度呢?如下:

private fun measureTextLength(text: String): Float {
    return mNormalPaint.measureText(text)
}

非常簡單對不對,measureText這個方法,會根據我們設定的文字畫筆中的字體大小,去測量一段文字的寬度,單位是px。

需要注意的是,漢字和數字英文的寬度佔位是不一樣的。 因此在換行的時候,需要特別關注和處理這兩者的關係。

3)區分普通文字和可編輯文字

既然包含特殊的文字部分,那麼我們需要將其標記出來,以便做特殊的處理。這裏,我使用了一個標籤<fill>來編輯,舉個例子:

原文:

大家好,我是<fill>,我來自<fill>。

翻譯過來就是:

大家好,我是【   】,我來自【   】。

這樣,經過 String.split("<fill>") 後,就可以把這段文字拆分爲多個分段。

2.可編輯字段點擊

我們知道,每個View都可以接收onTouch事件,並且可以監聽到觸摸點的x/y座標。

而在繪製文字的過程中,我們可以將可編輯文字段的座標信息記錄下來,那麼在點擊的時候,就可以判斷有沒有觸摸碰撞,如果有,那麼就可以彈出輸入法。

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            if (touchCollision(event)) {//觸摸碰撞檢測
                isFocusableInTouchMode = true
                isFocusable = true
                requestFocus()
                try {
                    val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
                    imm.showSoftInput(this, InputMethodManager.RESULT_SHOWN)
                    imm.restartInput(this)
                } catch (ignore: Exception) {
                }
                return true
            }
        }
    }

    return super.onTouchEvent(event)
}

3.接收輸入法輸入

通常,需要一個可輸入文字的控件時,我們很少自己去定義一個控件,而是直接使用EditText,以至於我們幾乎認爲只有EditText可以接收輸入法輸入。

但是,其實Android每個繼承View的控件都是可以接收輸入的

那麼,如何打開這個功能呢?答案就是以下兩個方法:

override fun onCheckIsTextEditor(): Boolean {
    return true
}

override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
    outAttrs.inputType = InputType.TYPE_CLASS_TEXT
    outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE
    return MyInputConnection(this, false, this)
}

其中,第一個方法返回true表示,這是一個可編輯控件,可以接收輸入法輸入。

第二個方法,則返回一個InputConnection,用於接收輸入。看起來是這樣的:

class MyInputConnection(targetView: View, fullEditor: Boolean, private val mListener: InputListener) : BaseInputConnection(targetView, fullEditor) {
    override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
        mListener.onTextInput(text)
        return super.commitText(text, newCursorPosition)
    }

    override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
        return if (beforeLength == 1 && afterLength == 0) {
            super.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KEYCODE_DEL)) &&
            super.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL))
        } else super.deleteSurroundingText(beforeLength, afterLength)
    }
}

interface InputListener {
    fun onTextInput(text: CharSequence)
}

最主要的方法是commitText,輸入法輸入時,會通過這個方法將文字傳輸給控件

4.光標
1)繪製

普通的EditText在輸入時,都會有一個光標,用於表示輸入或刪除的位置。繪製光標,只需要一句代碼:

canvas.drawLine(startX, startY, stopX, stopY, paint)

沒錯,就是繪製一條線,通過修改paint的alpha值(0/255),控制線條的顯示和隱藏即可。

關鍵在於,如何確定光標的位置。

2)計算純漢字輸入時的光標位置

還記得上面2點,實現可編輯字段的點擊嗎?當我們檢測到觸摸碰撞的時候,我們就可以根據這個時候觸摸點的x座標,以及文字的長度去判斷光標的位置。具體如何實現呢?我們從最簡單的情況來實現。

假設,輸入的文字都是漢字(前面我們就說過,漢字和數字英文佔位是不一樣的)。

那麼,這時,

光標所在漢字的索引 = (觸摸點x座標 - 被觸摸的編輯字段起始位置的x座標)/ 單個漢字寬度

那麼,光標所在實際位置的x座標就是

光標x軸座標 = (0 至 光標所在漢字的索引)這段文字的長度

轉化爲代碼即:

mNormalPaint.measureText(text.substring(0, index))

如下圖:

說明:這裏的index,指的是文字在可編輯字段中的位置,也就是光標的位置

光標起始位置的y座標,就是被觸摸的可編輯字段的y座標。

光標結束位置的x座標和起始位置相同,y座標則爲其實座標加上文字高度

3)考慮多類型輸入時的光標位置

當輸入的文字包含漢字、英文、數字時,由於英文/數字的佔位比漢字小,此時,如果按照漢字的單字來計算光標所在文字的索引,那麼此時的索引比實際的索引小

這裏就需要一個方法來確認:觸摸點x座標到可編輯字段起始位置x座標的這段長度,可以存放多少個文字。

我採用的方法如下:

我們知道,這段長度,可以放置的最少文字個數,就是漢字的個數。

第一步,我們先取最少的漢字個數,並計算文字長度,如果這時,文字的長度沒有超過實際觸摸位置。

第二步,取下一個文字,並計算文字總長度,判斷長度有沒有超過實際觸摸位置。

重複第二步,直到超過實際觸摸位置。

這時,這是實際的文字索引就是:(取到的最後一個文字的索引 - 1)

至此,我們就得到出實際的光標位置,以及文字索引了。

在此基礎上,根據光標的位置和文字索引,就可以對文字進行輸入和刪除了。

具體計算如下圖所示:

四、組裝輪子

經過上面的分解,基本上,我們就已經知道實現輪子的各個步驟,剩下的就是將上面的各個步驟拼接起來就行了。

當然,具體的代碼我就不貼了。大家可以自己去看一下源碼,過程並不複雜。

自定義控件嘛,每個人去實現的時候,都會有不一樣的做法,比如上面計算光標實際位置的方法,肯定會有不同的更好的方法。所以,瞭解實現的思想和可藉助工具方法即可,沒必要太過較真。

最後還一些邊邊角角的小功能,比如自定義一些可配置屬性:文字顏色,字體大小,可編輯字段格式,光標顏色等等;比如根據文字高度,自適應控件高度;比如輸入法的彈出和隱藏......

不再細提,具體可看源碼

五、總結

1.一個複雜的控件往往都可以通過拆解,拆分爲一個個簡單的功能。

2.從最簡單的功能開始實現,你會更有信心。

3.不要放棄,一定有實現的方法。如果沒有,說明你還不夠了解一些基礎屬性,Google之。

好了,以上就是給大家介紹的一種定製“填空控件”的思路,當然還有其他的實現方式。僅供大家參考。

源碼傳送門

文章寫到這裏就結束了,如果你覺得文章寫得不錯就給個讚唄?你的支持是我最大的動力!

閱讀分享

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章