一步步教你如何定製一個 Android “填空題”控制元件(仿學習強國填空題控制元件)
一、寫在前面
開始之前,老規矩,絮絮叨叨。
本文講解的是如何自定義一個填空題控制元件,實現的方式其實有很多,最重要的是瞭解其中實現的思路和想法,正所謂條條大路通羅馬嘛。
在Android系統中,我們最常使用的用於展示文字和編輯文字的控制元件,就是TextView和EditView,這兩個控制元件基本上已經能夠滿足我們日常大部分開發需求。
但是,凡事都有個但是。程式猿基本都會遇到一些比較特殊的需求,而作為一個Android開發者,最常見的特殊需求,就是一個特殊的控制元件,而這個控制元件剛好是系統沒有提供的。
下面就是一個比較特別的控制元件,一個可填空的控制元件。要求可以和普通TextView一樣展示普通的文字,同時又包含可以編輯的部分,類似EditText。如下:

填空控制元件
看到這個,第一反應就是,這不合理啊,又是展示,又是可編輯,又是換行,沒辦法實現啊!
結果,被人家甩了一句:那啥,學習強國App裡面不就有可以填空答題的嘛!
我去,這下尷尬了。如果實現不了,豈不是顯得自己很Low B!不行,無論如何都得做出來!(
)
二、尋尋覓覓,不得所需
哼,系統沒有的控制元件,我找個第三方的輪子還不行嗎?我就不信,世界這麼大,還有別人沒做好的輪子!於是開啟了“常規操作模式”(Google/GitHub/百度,搜尋,複製,貼上)。果不其然,有的是輪子(ヾ(´A`)ノ゚)。
比如這兩個:
他們有一些共同的特點:
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))
如下圖:

純漢字游標計算.png
說明:這裡的index,指的是文字在可編輯欄位中的位置,也就是游標的位置
游標起始位置的y座標,就是被觸控的可編輯欄位的y座標。
游標結束位置的x座標和起始位置相同,y座標則為其實座標加上文字高度
3)考慮多型別輸入時的游標位置
當輸入的文字包含漢字、英文、數字時,由於英文/數字的佔位比漢字小,此時,如果按照漢字的單字來計算游標所在文字的索引,那麼此時的索引比實際的索引小 。
這裡就需要一個方法來確認:觸控點x座標到可編輯欄位起始位置x座標的這段長度,可以存放多少個文字。
我採用的方法如下:
我們知道,這段長度,可以放置的最少文字個數,就是漢字的個數。
第一步,我們先取最少的漢字個數,並計算文字長度,如果這時,文字的長度沒有超過實際觸控位置。
第二步,取下一個文字,並計算文字總長度,判斷長度有沒有超過實際觸控位置。
重複第二步,直到超過實際觸控位置。
這時,這是實際的文字索引就是:(取到的最後一個文字的索引 - 1)
至此,我們就得到出實際的游標位置,以及文字索引了。
在此基礎上,根據游標的位置和文字索引,就可以對文字進行輸入和刪除了。
具體計算如下圖所示:

多型別文字游標計算.png
四、組裝輪子
經過上面的分解,基本上,我們就已經知道實現輪子的各個步驟,剩下的就是將上面的各個步驟拼接起來就行了。
當然,具體的程式碼我就不貼了。大家可以自己去看一下原始碼 ,過程並不複雜。
自定義控制元件嘛,每個人去實現的時候,都會有不一樣的做法,比如上面計算游標實際位置的方法,肯定會有不同的更好的方法。所以,瞭解實現的思想和可藉助工具方法即可,沒必要太過較真。
最後還一些邊邊角角的小功能,比如自定義一些可配置屬性:文字顏色,字型大小,可編輯欄位格式,游標顏色等等;比如根據文字高度,自適應控制元件高度;比如輸入法的彈出和隱藏......
不再細提,具體可看原始碼 。
五、總結
1.一個複雜的控制元件往往都可以通過拆解,拆分為一個個簡單的功能。
2.從最簡單的功能開始實現,你會更有信心。
3.不要放棄,一定有實現的方法。如果沒有,說明你還不夠了解一些基礎屬性,Google之。
好了,以上就是給大家介紹的一種定製“填空控制元件”的思路,當然還有其他的實現方式。僅供大家參考。
原始碼傳送門 ,喜歡的話,不吝給個star吧:grin:~