一步步教你如何定製一個Android「填空題」控件(仿學習強國填空題控件)

1、寫在前面

開始以前,老規矩,絮絮不休。git

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

在Android系統中,咱們最常使用的用於展現文字和編輯文字的控件,就是TextView和EditView,這兩個控件基本上已經可以知足咱們平常大部分開發需求。canvas

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

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

填空控件

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

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

我去,這下尷尬了。若是實現不了,豈不是顯得本身很Low B!不行,不管如何都得作出來!(才能咽得下這口氣!字體

2、尋尋覓覓,不得所需

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

好比這兩個:this

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

Android 基於TextView實現填空題

他們有一些共同的特色:

1.基於TextView作文字展現
2.基於SpannableString作文字樣式變化,文字點擊等
3.必需要有一個EditText做爲輸入
複製代碼

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

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

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

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

3、拆輪子

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

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

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

canvas.drawText(text, x, y, paint)
複製代碼

可是,若是你想固然的認爲這個是一個簡單的事情,那你就大錯特錯了。

1)文字基線

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

文字基線(來源:自定義控件之繪圖篇( 五):drawText()詳解,侵刪)

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

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

2)文字換行

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

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

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

private fun measureTextLength(text: String): Float {
    return mNormalPaint.measureText(text)
}
複製代碼

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

須要注意的是,漢字和數字英文的寬度佔位是不同的。 所以在換行的時候,須要特別關注和處理這二者的關係。

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

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

原文:

你們好,我是<fill>,我來自<fill>。

翻譯過來就是:

你們好,我是【   】,我來自【   】。
複製代碼

這樣,通過 String.split("") 後,就能夠把這段文字拆分爲多個分段。

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

4、組裝輪子

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

固然,具體的代碼我就不貼了。你們能夠本身去看一下源碼,過程並不複雜。

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

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

再也不細提,具體可看源碼

5、總結

1.一個複雜的控件每每均可以經過拆解,拆分爲一個個簡單的功能。

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

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

好了,以上就是給你們介紹的一種定製「填空控件」的思路,固然還有其餘的實現方式。僅供你們參考。

源碼傳送門,喜歡的話,不吝給個star吧😁~

相關文章
相關標籤/搜索