應用程式基礎

在 Compose 中打造可靠的文字編輯體驗

喇叭

  • Anastasia Soboleva

    軟體工程師

  • Haoyu Zhang

    資深軟體工程師

關於

講座簡介

為了在 Compose 中提供可靠的文字編輯功能,我們最終決定完全重寫 BasicTextField。改用緩衝區導向架構後,IME 競爭狀況就徹底消失了。本課程將說明為何必須淘汰舊版輸入同步功能,以及 TextFieldState 如何同步處理鍵盤更新。接著,您將深入瞭解格式化編輯,探索 TrackedRange 的機制、編輯區塊的效能成本,以及樣式邊界背後的實際問題,最後再初步瞭解 Markdown 轉譯。

摘要

  • TextFieldState API 超載會取代 Compose 中以回呼為基礎的超載。這項 API 會在使用者輸入時同步更新,避免螢幕鍵盤的時間問題和不必要的重組。
  • 輸入和輸出轉換功能可讓您使用 TextFieldBuffer 操控文字。您可以在提交編輯內容前攔截,或設定顯示文字的格式,而不必手動計算偏移對應。
  • 是否要提升 TextFieldState 取決於架構需求。您可以將其保留在可組合項中,或直接儲存在 ViewModel 中,以便存取商業邏輯。
  • Compose 1.12 在 TextFieldBuffer 中新增了格式化文字編輯 API。您可以使用可自訂的 ExpandPolicy 選項套用樣式,現有的樣式範圍會隨著使用者編輯而自動更新。
  • Jetpack Compose 的新標記文字程式庫目前正在開發中。支援 Markdown 等格式,並可逐步剖析及算繪,方便即時編輯和串流。

資源

Gemini Notebook

主題

  • jetpack-compose
  • textfieldstate
  • 文字編輯
  • basictextfield
  • formatted-text
  • input-transformation

轉錄稿

Compose 文字編輯功能簡介

  • 大家好!

  • 感謝您參與我們的講座。

  • 我是 Anastasia,旁邊是我的同事 Haoyu。

  • 今天,我們將示範如何打造可靠的文字編輯體驗。

  • 無論您是要建構簡單的登入畫面、搜尋即輸入列,還是複雜的訊息應用程式,文字欄位都是使用者與 Android 應用程式互動的核心。

  • 因此,您可能已經使用 Material 文字欄位或外框文字欄位。

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • 如果您要自行建構設計系統,或需要簡單的編輯方塊,也可以使用 BasicTextField。

    BasicTextField(state = rememberTextFieldState())
  • 此外,SecureTextField 專為輸入密碼而設計。

    SecureTextField(state = rememberTextFieldState())
  • BasicTextField 則是無樣式元件,實際上是 Compose 中所有其他文字欄位的基礎。

  • 過去幾年,您可能注意到我們有兩種並行的 API 形式,一種採用值和值變更回呼,另一種則採用 TextFieldState 物件。

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • 您可能會想知道我們推薦哪一種。

  • 強烈建議使用 TextFieldState 超載。

  • 我們也會在本次講座中,討論這項技術在 API、功能和效能方面的優勢。

  • 自 Compose 1.7 起,這個 API 已趨於穩定。

  • 至於回呼超載,我們在 Compose 1.13 中,對 BasicTextField 和 Material 包裝函式進行軟淘汰。

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • 也就是說,除非有重大安全性問題或無障礙功能問題,否則我們不會再新增任何功能或修正錯誤。

  • 值變更超載的無狀態值非常適合用於同步輸入。

  • 但只要與非同步串流 (例如 StateFlow) 配對,就會導致輸入法編輯器 (或螢幕鍵盤) 發生時間問題。

  • 因此,如這段錄音所示,會出現字元遺漏、字元重複、游標跳動等問題。

TextFieldState 簡介

  • 為從根本解決這個問題,我們推出了 TextFieldState。

  • 當使用者輸入內容時,它會同步更新,而不會等待非同步往返,這正是 IME 所需。

  • TextFieldState 除了唯讀文字外,還有其他屬性,可建立有效的編輯工作階段。

  • 也就是選取、復原重做狀態,以及用於編輯格式化文字的 Span、Span 樣式和段落樣式,稍後會詳細說明。

  • TextFieldState 的內部屬性由 Compose 快照系統支援。

  • 也就是說,當您在 Compose 中讀取 TextFieldState 物件的文字或選取範圍時,系統會訂閱讀取來源特定範圍的更新。

  • 這可讓 BasicTextField 僅在版面配置或繪圖階段讀取狀態,並與部分狀態搭配使用,消除使用者輸入時不必要的重組。

  • 這項異動與其他幾項調整,大幅提升了 TextFieldState 超載的效能。

  • 現在,如果 TextFieldState 的文字是唯讀屬性,您要如何透過程式輔助方式變更輸入欄位?

  • 這時 TextFieldBuffer 就能派上用場。

  • 這是一個可變動的物件,可讓您透過 API 進行變更。

  • 因此,如要透過程式輔助方式進行變更,請呼叫 TextFieldState 的編輯函式。

  • 在該 lambda 中,您會取得 TextFieldBuffer。

  • TextFieldBuffer 會公開多個函式,可讓您視需要插入、取代或刪除字元。

  • 編輯時,請避免連續多次呼叫。

    // Often generated by AI agents:
    state.edit { insert(5, ",") }
    state.edit { delete(12, 13) }
    state.edit { replace(0, 5, "goodbye") }
    state.edit { selection = TextRange(9, 16) }
  • 請改為將所有作業批次處理成單一編輯呼叫。

    // Batch all operations in a single atomic edit:
    state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
    }
  • 這樣一來,Compose 只會建立一個 TextFieldBuffer 執行個體,並在原地變更,然後與 IME 和快照系統同步一次。

  • 另一個提示是,對於您需要的大多數常見批次作業,我們實際上在 TextFieldState 上有擴充功能。

    fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
    edit {
    replace(0, length, text)
    placeCursorAtEnd()
    }
    }
  • 因此,您不需要考慮批次處理這些作業。

  • 只要撥打其中一個號碼即可。

  • 例如 clearText 和 setTextAndPlaceCursorAtEnd。

    fun TextFieldState.clearText() {
    edit {
    delete(0, length)
    placeCursorAtEnd()
    }
    }

輸入內容篩選和格式設定

  • 那麼,如何篩選使用者輸入內容?

  • 例如強制執行字元限制,或只接受數字。

  • 在舊版 API 中,onValueChange 的篩選作業會在文字提交後執行。

    onValueChange { newString ->
      // full diffing new String vs old String
    }
  • 因此,拒絕或修改該輸入內容會與鍵盤產生衝突。

  • 此外,onValueChange 回呼只會提供最終字串。

  • 因此,如要瞭解變更內容,您必須進行完整字串差異比較。

  • 而是在新的多載中,我們有輸入轉換。

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • 因此,它會在使用者輸入內容到達文字欄位狀態之前攔截輸入內容,並提供相同的 TextFieldBuffer API。

  • 以上就是如何使用輸入轉換功能,實作僅限數字的篩選條件。基本上,您只需要擷取變更,

    // Intercept and filter input during typing
    inputTransformation = InputTransformation {
        val text = asCharSequence()
        if (!text.all { it.isDigit() }) {
            // Reject non-digit characters
            revertAllChanges()
        }
    }
  • 您會檢查是否有非數字字元,如果有,則在該 TextFieldBuffer 上呼叫 revertAllChanges,拒絕輸入。

  • 這項輸入轉換作業的主要功能是 ChangeList。

  • 因此,您不必比較完整字串,而是可以檢查確切的編輯範圍,然後視需要就地變更或完全拒絕輸入。

  • Compose 會自動同步鍵盤和游標。

  • 最後,如果想對輸入內容進行視覺格式設定,該怎麼做?

  • 例如,您想在信用卡號碼或電話號碼中加入括號或破折號。

  • 真的嗎?

  • 您也不想將這些資料儲存在資料庫中,對吧?

  • 先前您使用的是 Visual Transformation API。

  • 因此,您必須實作從原始輸入到視覺輸出的雙向對應,然後再返回。

  • 而且很難正確執行,往往會導致執行階段例外狀況。

  • 現在我們有輸出轉換,然後在幕後,它是由同一個 TextFieldBuffer 提供支援,因此您會獲得幾乎相同的 API。

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • 因此,在這個電話號碼範例中,如要在特定索引中插入破折號,只要呼叫 TextFieldBuffer 的插入函式,系統就會為您計算雙向偏移對應。

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • 這樣就不必手動計算,當機次數也會大幅減少。

提升及遷移 TextFieldState

  • 現在來到最重要的部分:如何實際遷移,以及在哪裡提升 TextFieldState?

  • 實際上,幾乎所有用途都屬於我接下來要介紹的其中一個類別。

  • 如果不是,通常代表您採用的是非傳統架構,但這些範例仍可引導您完成遷移。

  • 最簡單的情況是將 TextFieldState 保留在文字欄位所在的可組合項中。

    @Composable
    fun PromoCodeInput(onApply: (String) -> Unit) {
        // State hoisted locally inside the composable
        val state = rememberTextFieldState()
    
        OutlinedTextField(
            state = state,
            inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
            trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
        )
    }
  • 當文字欄位是獨立的輸入欄位 (例如優惠碼輸入欄位),且您只需要在使用者提交 (例如按下提交按鈕) 時存取該文字,就適合使用這種方法。

  • 因此,檢視模型不需要在每次變更時攔截使用者輸入內容。

  • 因此,您只需在本機呼叫 rememberTextFieldState,rememberTextFieldState 也會在程序終止後協助您還原狀態。

  • 接著,您可以使用輸入轉換功能進行篩選,並從該狀態 (TextFieldState 物件) 取得文字,當使用者提交時,您會透過 lambda 傳遞文字。

  • 好,如果字元計數器或提交按鈕等同層 UI 元素也需要讀取或變更文字,該怎麼辦?

  • 這會引導我們將 TextFieldState 物件提升至常見的可組合函式,也就是常見的上層可組合函式。

    @Composable
    fun PostCommentBar(onSubmit: (String) -> Unit) {
        // to share between UI components
        val commentState = rememberTextFieldState()
    
        Column {
            CommentTextField(commentState)
            Row {
                Text("${commentState.text.length} / 280")
                SubmitButton(commentState)
            }
        }
  • 由於 TextFieldState 是由快照系統支援,因此透過祖系傳遞不會觸發父項的重組。

  • 只有實際讀取狀態文字的位置才會更新。

  • 好的。

  • 因此,如果複雜的 UI 用途需要 TextFieldState、輸入和輸出轉換,以便在內部協調狀態,請使用狀態容器物件,而不是分別提升狀態,並嘗試在可組合函式中將狀態連結在一起。

    @Composable
    fun PostEditor() {
    
        // Hoisting and wiring related pieces separately in the composable
        val textFieldState = rememberTextFieldState()
        val tagRanges = remember { mutableStateListOf<TextRange>() }
        val inputTransform = remember { TagInputTransformation(tagRanges) }
        val outputTransform = remember { TagOutputTransformation(tagRanges) }
    
        BasicTextField(
            state = textFieldState,
            inputTransformation = inputTransform,
            outputTransformation = outputTransform,
        )
    }
  • 假設您想處理內嵌主題標記。

  • TextFieldState 包含原始文字和游標位置。

  • 輸入轉換功能負責偵測主題標記,您也可以透過輸入轉換功能刪除原子標記。

  • 輸出轉換會加入額外的視覺樣式,也能協助您刪除主題標記。

  • 這些狀態會全部封裝到單一狀態容器物件中,我稱之為 TagEditorState。

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
    
        private val tagRanges = mutableStateListOf<TextRange>()
    
        // Transformations coordinate state internally
        val inputTransformation = TagInputTransformation(tagRanges)
        val outputTransformation = TagOutputTransformation(tagRanges)
    
        fun addTag(tag: String) { ... }
    
    }
  • 然後,他們都可以重複使用中間計算,例如本例中的 tagRanges。

  • 接著,只要將這個狀態容器物件傳遞至可組合函式即可。

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
    
    @Composable
    fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
        BasicTextField(
            state = state.textFieldState,
            inputTransformation = state.inputTransformation,
            outputTransformation = state.outputTransformation,
        )
    }
  • 好,另一個用途是當商業邏輯需要存取該文字,以便在輸入時搜尋或進行複雜驗證,請將該文字欄位提升至 ViewModel。

  • 請務必不要將可變動的文字欄位物件放入不可變動的資料類別 UI 狀態,因為系統會使用複製方法複製該物件,然後在每次變更時將其發送到 StateFlow。

    data class FormUiState(val username: TextFieldState)
  • TextFieldState 是由快照系統支援的長期可變動物件,其參照物件不會變更。

  • 因此,StateFlow 甚至不會知道使用者正在輸入內容,因為參照維持不變。

  • 更糟的是,如果您建立新的 UI 狀態例項,並分配新的 TextFieldState,輸入欄位會失去所有狀態。

  • 例如選取範圍、游標位置或復原/重做記錄。

  • 因此,請改為直接在 ViewModel 上將 TextFieldState 設為屬性。

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • 您也不必擔心 ViewModel 規則中沒有 UI,因為在這種情況下,TextFieldState 不會參照 Android View 或 Context 物件或 Compose 生命週期。

  • 這只是純粹的快照備份狀態容器物件。

  • 因此,在這種情況下,概念上與 StateFlow 沒有差別。

  • 請注意,ViewModel 沒有 rememberSaveable,因此您需要為文字欄位連接 TextFieldState.Saver,才能在程序終止後還原狀態。

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • 現在,如果團隊有嚴格的模組規則,規定 ViewModel 只能存在於純 Kotlin 模組中,且無法帶入 compose-foundation 依附元件,該怎麼辦?

  • 或者,您正在遷移複雜的舊版畫面,但目前無法更新或重構 UI 狀態?

  • 在這種情況下,請使用符合標準的方法。

  • 因此,您會將 TextFieldState 保留在可組合函式中,做為該文字欄位的單一事實來源。

  • 不過,您可以使用 LaunchedEffect 搭配 snapshotFlow,將文字變更推送回現有的 ViewModel。

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1)
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • ViewModel 仍不含 Compose,且 UI 狀態屬性不會在每次變更時回饋至文字欄位,因此您仍可避免非同步往返錯誤。

  • 沒錯,而且要小心常見的陷阱。

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • 在大多數情況下,您會想省略第一個事件。

  • 這樣可以避免不必要的重複搜尋查詢,或避免 ViewModel 遭到初始值覆寫。

  • 好,如果邏輯需要將這些變更回饋給狀態,舉例來說,當使用者按下提交按鈕,而您想清除文字時,不能只將空字串設為 ViewModel 的 ViewModel。

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        ...
    
        // 3. Handle discrete ViewModel events (e.g. clear after login)
        LaunchedEffect(viewModel) {
            viewModel.clearUsernameEvent.collect {
                usernameState.clearText()
            }
        }
        BasicTextField(state = usernameState)
    }
  • 您需要發出清除文字事件,然後保留該 TextFieldState 物件的可組合函式會對該物件呼叫 clearText。

  • 為進一步協助您完成轉換,我們撰寫了這份遷移指南。

  • 您可以透過這個連結存取。

  • 我們也正在開發其他 API,讓遷移和一般 TextFieldState 變更觀察更輕鬆,敬請期待。

格式化和 RTF 格式編輯

  • 到目前為止,我們介紹的所有內容,包括 TextFieldState、TextFieldBuffer、輸入和輸出轉換,都不只是為了純文字輸入而建構。

  • 這就是我們最新一組格式化文字編輯 API 的基礎。

  • 現在請 Haoyu 進一步說明。

  • 現在我們來做個小測驗

  • 感謝您!

  • 大家好。

  • 接下來,我會介紹我們為格式化文字編輯推出的新 API。

  • 因此,在深入探討新 API 之前,我們先回顧一下靜態文字的樣式設定方式。

  • 因此,大多數人應該都對 AnnotatedString 非常熟悉。

    val styledText = buildAnnotatedString {
        append("Jetpack Compose ")
        // 1. Blue & Bold on "Jetpack" [0, 7)
        addStyle(
            style = SpanStyle(
                color = Color.Blue,
                fontWeight = FontWeight.Bold
            ),
            start = 0,
            end = 7
        )
    }
    // Render static formatted text
    Text(text = styledText)
  • 這是設定靜態文字樣式的標準方法。

  • 我們使用 buildAnnotatedString 並呼叫 addStyle,在特定範圍內套用 SpanStyle 或 ParagraphStyle。

  • 這項功能適用於靜態文字。

  • 因此,在考慮格式化文字編輯時,我們最初的想法是,能否直接讓 AnnotatedString 變成可編輯?

  • 但我們很快就發現,這並不容易。

  • AnnotatedString 是不可變動的資料結構。

  • 但在編輯世界中,使用者會不斷輸入內容。

  • 而且每次按鍵時,我們都必須分配新的 AnnotatedString 物件。

  • 每個 AnnotatedString 都有許多 AnnotatedString.Range。

  • 因此也必須重新建立這些物件。

  • 這種做法無法有效擴充,且會造成大量的分配負擔。

  • 請看這個例子。

  • 當我們在中間插入字詞時,其後的每個樣式都會移動範圍並重新分配。

  • 如果樣式很多,這種做法顯然行不通。

  • 因此,我們在 Anastasia 先前介紹的 TextFieldBuffer 中,導入了專屬的樣式 API。

  • 這與 AnnotatedString 中的樣式 API 非常相似。

  • 接著來深入探討這些功能的運作方式。

  • 因此,就像 AnnotatedString.Builder 一樣,TextFieldBuffer 現在也有 addStyle,可將 SpanStyle 或 ParagraphStyle 套用至某個範圍。

    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
    
    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
  • 您也可以傳遞 ExpandPolicy 參數,控制在樣式邊界輸入文字時的樣式行為。

  • 我們稍後會介紹。

  • 首先,我們來看看樣式追蹤的運作方式。

    val state = TextFieldState("The quick fox jumps")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 13
        )
        insert(0, "Look! ")
        insert(16, "brown ")
        delete(16, 22)
        delete(10, 19)
    }
  • 將樣式新增至 TextFieldBuffer 後,系統會在編輯文字時自動更新樣式範圍。

  • 在這個範例中,我們先將部分文字設為粗體。

  • 如果在樣式範圍前插入文字,樣式範圍會向前移動。

  • 在其中插入文字時,文字範圍會擴大。

  • 刪除範圍內的文字時,樣式範圍也會隨之縮小。

  • 如果樣式範圍內的所有文字都遭到刪除,樣式就會收合,並自動從緩衝區中移除。

  • 現在,如果在樣式範圍的邊界插入文字,會發生什麼情況?

  • 新插入的文字是否套用樣式?

  • 因此,我們推出了 ExpandPolicy API,讓您控管這項功能。

  • 我們來看看,逐一介紹。

  • 首先是 ExpandPolicy.InsideOnly。

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.InsideOnly
        )
        insert(4, "quick ")
        insert(length, " jumps")
  • 在這種模式下,在邊界插入文字不會擴大樣式範圍。

  • 首先,我們套用粗體範圍。

  • 如果我們在粗體範圍的開頭插入文字,樣式範圍會移動,但不會擴大。

  • 同樣地,如果我們在粗體範圍結尾插入文字,樣式範圍也不會擴大。

  • 以下示範如何在編輯器中使用這項功能。

  • 接下來是 ExpandPolicy.AtStart。

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtStart
        )
        insert(4, "quick ")
  • 顧名思義,在範圍開頭插入文字會擴大樣式範圍。

  • 因此,當我們在樣式範圍展開前插入文字,就會涵蓋新插入的文字。

  • 以下是另一個示範,說明這項功能的運作方式。

  • 第三名是ExpandPolicy.AtEnd。

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtEnd
        )
        insert(length, " jumps")
  • 因此,我們在範圍結尾插入文字,擴大樣式範圍。

  • 因此,這也是編輯者最常用的設定。

  • 以下示範這項功能的運作方式。

  • 最後是 ExpandPolicy.AtBoth,當文字插入範圍的開頭和結尾時,會展開樣式。

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtBoth
        )
        insert(4, "quick ")
     insert(length, " jumps")
    }
  • 接下來是另一個示範。

  • 我們已介紹所有套用樣式的 API。

  • 此外,我們也推出 getSpanStyles、getParagraphStyles 等新的查詢 API,用於擷取目前套用至指定範圍的樣式。

    // Outside edits. ReadOnly API in TextFieldState.
    val textFieldState.styles: TextFieldTextStyles
    fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
    
    // Inside edits, returned TrackedRange
    fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
  • 以下是兩種版本的查詢 API。

  • 在 TextFieldState 上,樣式查詢 API 僅供讀取。

  • 這些函式會傳回不可變動的 AnnotatedString.Range 物件清單。

  • 但在 TextFieldBuffer 中,getSpanStyles 傳回了新引入的 TrackedRange 物件清單,我稍後會介紹。

  • 因此所有樣式查詢都會考量順序。

  • 系統會按照套用樣式的順序,傳回樣式範圍。

  • 因此,您可以像 AnnotatedString 一樣,在現有樣式上套用其他樣式,覆寫先前的樣式。

  • 接下來是 TrackedRange API。

    var TrackedRange<*>.textRange: TextRange
    
    var TrackedRange<*>.expandPolicy: ExpandPolicy
    
    var TrackedRange<SpanStyle>.spanStyle: SpanStyle
    
    var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
    
    val TrackedRange<*>.isValid: Boolean
    
    fun removeStyle(trackedRange: TrackedRange<*>): Boolean
  • TrackedRange 物件是由 TextFieldBuffer 中的 addStyle 或 getSpanStyles API 傳回。

  • 您可以將其視為緩衝區中樣式範圍的即時控點。

  • 您可以使用這個函式更新文字範圍、ExpandPolicy 或樣式本身。

  • 此外,這個類別也提供 isValid 屬性,可用來檢查樣式範圍是否仍在 TextFieldBuffer 中。

  • 這是因為如果樣式在編輯期間收合,或由 removeStyle 刻意移除,樣式可能會遭到移除。

  • 接下來,我們來看看這些 API 如何在實際應用實例中結合。

建構所見即所得編輯器

  • 第一個用途是建構「所見即所得」(WYSIWYG) 編輯器。

  • 在這個範例中,我們實作了粗體按鈕功能。

  • 按一下粗體按鈕,即可切換所選文字的粗體樣式。

  • 接著來看看程式碼。

  • 因此,當使用者輕觸粗體按鈕時,我們會先對所選內容呼叫 getSpanStyles,找出所有重疊的樣式。

    fun TextFieldBuffer.toggleBoldSelection() {
        if (selection.collapsed) return
        val boldSpans = getSpanStyles(selection)
    
        if (!isFullyBold(boldSpans, selection)) {
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                selection,
                ExpandPolicy.AtEnd
            )
        } else {
            unboldSelection(boldSpans.first())
        }
    }
  • 接著,我們會檢查整個選取範圍是否已設為粗體。

  • 這裡選取的字詞並非粗體。

  • 由於文字並非粗體,因此我們addStyle所選範圍。

  • 我們傳遞 ExpandPolicy.AtEnd,讓使用者可以在這個字詞後繼續輸入粗體。

  • 現在,來看看如果選取的範圍已設為粗體,會發生什麼情況。

    fun TextFieldBuffer.unboldSelection(
        boldSpan: TrackedRange<SpanStyle>
    ) {
        val hasBoldBefore = boldSpan.start < selection.min
        val hasBoldAfter = boldSpan.end > selection.max
    
        when {
            hasBoldBefore ->
                boldSpan.range = TextRange(range.start, selection.min)
            hasBoldAfter ->
                boldSpan.range = TextRange(selection.max, range.end)
            hasBoldBefore && hasBoldAfter -> {
                val oldEnd = boldSpan.end
                boldSpan.range = TextRange(boldSpan.start, selection.min)
                addStyle(
                    boldSpan.spanStyle,
                    start = selection.max,
                    end = oldEnd
                )
            }
            else -> removeStyle(range)
        }
    }
  • 在這種情況下,我們需要清除所選範圍的粗體樣式。

  • 使用者選取的字詞位於較長的粗體詞組中。

  • 由於粗體樣式會延伸到所選文字之前,因此我們需要更新樣式範圍,讓所選文字不套用粗體。

  • 為此,我們可以使用 TrackedRange API。

  • 在這個特定案例中,我們不必刪除並重新建立這個樣式,只要將粗體 Span 範圍調整為在索引 10 的選取範圍之前結束即可。

  • 當然,這是高度簡化的實作方式。

  • 如需完整示範,請參閱 Compose 範例存放區。

  • 接下來,我們來看看以模式為基礎的樣式。

  • 在這個示範中,當使用者在字詞前後輸入 Markdown 星號時,編輯器會自動將其轉換為粗體文字。

  • 現在,我們來看看如何使用 InputTransformation 實作。

    object MarkdownBoldTransformation : InputTransformation {
        val regex = Regex("""**(.*?)**""")
    
        override fun TextFieldBuffer.transformInput() {
            val matches = regex.findAll(asCharSequence()).toList()
    
            for (match in matches) {
                val range = match.range
                val content = match.groupValues[1]
    
                replace(range.first, range.last + 1, content)
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    start = range.first,
                    end = range.first + content.length,
                    policy = ExpandPolicy.atEnd
                )
            }
        }
    }
  • 在轉換輸入篩選器中,我們會找出按鈕上所有相符的模式。

  • 並擷取雙星號內的內容。

  • 在本例中,這個字詞是「fox」。

  • 接著,我們移除星號,並將相符模式替換為其內容。

  • 最後,我們對粗體呼叫 addStyle,將粗體樣式套用至內容文字。

  • 請注意,InputTransformation 會提交至最終文字欄位狀態。

  • 這表示粗體樣式會持續套用至該狀態。

  • 最後,我們來看看搜尋醒目顯示範例。

  • 因此,在這個範例中,當使用者輸入搜尋查詢時,文字欄位中的相符字詞會醒目顯示,但文字欄位狀態不會改變。

  • 現在,我們來看看 OutputTransformation 中的實作方式。

  • 醒目顯示搜尋字詞時,請勿變更文字狀態。

  • OutputTransformation 是實作這個用途的絕佳位置。

  • 我們來看看實際的實作方式。

  • 我們會先搜尋醒目顯示的字詞,並找出所有相符項目。

    class SearchHighlightTransformation(
        val query: MutableState<String>
    ) : OutputTransformation {
        override fun TextFieldBuffer.transformOutput() {
            val search = query.value
            if (search.isEmpty()) return
            val text = asCharSequence().toString()
            var index = text.indexOf(search)
    
            while (index != -1) {
                addStyle(
                    SpanStyle(
                        background = Color.Yellow,
                        color = Color.Black
                    ),
                    start = index,
                    end = index + search.length
                )
                index = text.indexOf(search, index + search.length)
            }
        }
    }
  • 請注意,查詢是可變動狀態。

  • 這是因為 OutputTransformation 具有快照感知功能。

  • 當這個可變動狀態更新時,系統會自動重新套用 OutputTransformation。

  • 接著,我們在每個相符項目中循環,並呼叫 addStyle,在相符範圍內繪製黃色醒目顯示。

  • 同樣地,OutputTransformation 只會變更視覺文字,原始文字狀態則保持不變。

  • 好,以上就是所有應用實例。

  • 提醒您,先前顯示的所有 API 均自 Compose 1.12 起提供。

即將推出的標記文字庫

  • 最後,我想分享我們目前正在進行的新專案。

  • 這是 Jetpack Compose 的標記文字程式庫,支援 Markdown 等 RTF 格式。

  • 我們支援的最令人期待的功能之一,就是增量剖析和增量算繪。

  • 因此非常適合即時編輯或 Markdown 串流。

  • 這是我們工作原型的實際運作記錄,可以看到那些淡出區域是 UI 中更新的部分。

  • 如果您對這項功能感興趣,或有相關應用程式用途,歡迎與我們聯絡。

  • 也感謝各位。

程式碼

程式碼片段

「Introduction to text editing in Compose」影片 00:29 處的 Kotlin 程式碼片段
TextField(state = rememberTextFieldState())
「Compose 文字編輯簡介」影片 00:29 處的 Kotlin 程式碼片段
OutlinedTextField(state = rememberTextFieldState())
「Introduction to text editing in Compose」影片 00:34 處的 Kotlin 程式碼片段
BasicTextField(state = rememberTextFieldState())
「Introduction to text editing in Compose」影片 00:44 處的 Kotlin 程式碼片段
SecureTextField(state = rememberTextFieldState())
「Introduction to text editing in Compose」影片 00:58 處的 Kotlin 程式碼片段
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
「Compose 文字編輯簡介」影片 01:28 處的 Kotlin 程式碼片段
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
「TextFieldState 簡介」影片 04:01 處的 Kotlin 程式碼片段
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }
「TextFieldState 簡介」影片 04:09 處的 Kotlin 程式碼片段
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
「TextFieldState 簡介」影片 04:25 的 Kotlin 程式碼片段
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
「TextFieldState 簡介」影片 04:38 處的 Kotlin 程式碼片段
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
「Input filtering and formatting」影片 04:55 處的 Kotlin 程式碼片段
onValueChange { newString ->
  // full diffing new String vs old String
}
「Input filtering and formatting」影片 05:17 處的 Kotlin 程式碼片段
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
「Input filtering and formatting」影片 05:33 處的 Kotlin 程式碼片段
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
「Input filtering and formatting」影片 06:48 處的 Kotlin 程式碼片段
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
「輸入內容篩選和格式設定」中 06:58 的 Kotlin 程式碼片段
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
「提升及遷移 TextFieldState」影片 07:51 處的 Kotlin 程式碼片段
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
    // State hoisted locally inside the composable
    val state = rememberTextFieldState()

    OutlinedTextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
        trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
    )
}
「Hoisting and migrating TextFieldState」影片 08:58 處的 Kotlin 程式碼片段
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
    // to share between UI components
    val commentState = rememberTextFieldState()

    Column {
        CommentTextField(commentState)
        Row {
            Text("${commentState.text.length} / 280")
            SubmitButton(commentState)
        }
    }
「提升及遷移 TextFieldState」影片 09:27 處的 Kotlin 程式碼片段
@Composable
fun PostEditor() {

    // Hoisting and wiring related pieces separately in the composable
    val textFieldState = rememberTextFieldState()
    val tagRanges = remember { mutableStateListOf<TextRange>() }
    val inputTransform = remember { TagInputTransformation(tagRanges) }
    val outputTransform = remember { TagOutputTransformation(tagRanges) }

    BasicTextField(
        state = textFieldState,
        inputTransformation = inputTransform,
        outputTransformation = outputTransform,
    )
}
「提升及遷移 TextFieldState」影片 10:12 處的 Kotlin 程式碼片段
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {

    private val tagRanges = mutableStateListOf<TextRange>()

    // Transformations coordinate state internally
    val inputTransformation = TagInputTransformation(tagRanges)
    val outputTransformation = TagOutputTransformation(tagRanges)

    fun addTag(tag: String) { ... }

}
「Hoisting and migrating TextFieldState」影片 10:30 處的 Kotlin 程式碼片段
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
「提升及遷移 TextFieldState」影片 10:51 的 Kotlin 程式碼片段
data class FormUiState(val username: TextFieldState)
「Hoisting and migrating TextFieldState」影片 11:38 處的 Kotlin 程式碼片段
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
「提升及遷移 TextFieldState」影片 12:09 處的 Kotlin 程式碼片段
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
「提升及遷移 TextFieldState」影片 12:55 處的 Kotlin 程式碼片段
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1)
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
「Hoisting and migrating TextFieldState」影片 13:21 處的 Kotlin 程式碼片段
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
「提升及遷移 TextFieldState」影片 13:39 處的 Kotlin 程式碼片段
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    ...

    // 3. Handle discrete ViewModel events (e.g. clear after login)
    LaunchedEffect(viewModel) {
        viewModel.clearUsernameEvent.collect {
            usernameState.clearText()
        }
    }
    BasicTextField(state = usernameState)
}
「Formatted and rich text editing」影片 15:09 處的 Kotlin 程式碼片段
val styledText = buildAnnotatedString {
    append("Jetpack Compose ")
    // 1. Blue & Bold on "Jetpack" [0, 7)
    addStyle(
        style = SpanStyle(
            color = Color.Blue,
            fontWeight = FontWeight.Bold
        ),
        start = 0,
        end = 7
    )
}
// Render static formatted text
Text(text = styledText)
「Formatted and rich text editing」影片 16:36 處的 Kotlin 程式碼片段
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)

fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
「格式化和 RTF 格式編輯」影片 16:57 處的 Kotlin 程式碼片段
val state = TextFieldState("The quick fox jumps")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 13
    )
    insert(0, "Look! ")
    insert(16, "brown ")
    delete(16, 22)
    delete(10, 19)
}
「Formatted and rich text editing」影片 17:52 處的 Kotlin 程式碼片段
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.InsideOnly
    )
    insert(4, "quick ")
    insert(length, " jumps")
「格式化和 RTF 格式編輯」中 18:22 的 Kotlin 程式碼片段
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
「Formatted and rich text editing」影片 18:40 處的 Kotlin 程式碼片段
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
「Formatted and rich text editing」影片 18:56 處的 Kotlin 程式碼片段
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtBoth
    )
    insert(4, "quick ")
 insert(length, " jumps")
}
「Formatted and rich text editing」影片 19:14 處的 Kotlin 程式碼片段
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>

// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
「Formatted and rich text editing」影片 20:03 處的 Kotlin 程式碼片段
var TrackedRange<*>.textRange: TextRange

var TrackedRange<*>.expandPolicy: ExpandPolicy

var TrackedRange<SpanStyle>.spanStyle: SpanStyle

var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle

val TrackedRange<*>.isValid: Boolean

fun removeStyle(trackedRange: TrackedRange<*>): Boolean
「建構 WYSIWYG 編輯器」中 21:01 的 Kotlin 程式碼片段
fun TextFieldBuffer.toggleBoldSelection() {
    if (selection.collapsed) return
    val boldSpans = getSpanStyles(selection)

    if (!isFullyBold(boldSpans, selection)) {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            selection,
            ExpandPolicy.AtEnd
        )
    } else {
        unboldSelection(boldSpans.first())
    }
}
「建構 WYSIWYG 編輯器」影片 21:26 處的 Kotlin 程式碼片段
fun TextFieldBuffer.unboldSelection(
    boldSpan: TrackedRange<SpanStyle>
) {
    val hasBoldBefore = boldSpan.start < selection.min
    val hasBoldAfter = boldSpan.end > selection.max

    when {
        hasBoldBefore ->
            boldSpan.range = TextRange(range.start, selection.min)
        hasBoldAfter ->
            boldSpan.range = TextRange(selection.max, range.end)
        hasBoldBefore && hasBoldAfter -> {
            val oldEnd = boldSpan.end
            boldSpan.range = TextRange(boldSpan.start, selection.min)
            addStyle(
                boldSpan.spanStyle,
                start = selection.max,
                end = oldEnd
            )
        }
        else -> removeStyle(range)
    }
}
「Building a WYSIWYG editor」影片 22:27 處的 Kotlin 程式碼片段
object MarkdownBoldTransformation : InputTransformation {
    val regex = Regex("""**(.*?)**""")

    override fun TextFieldBuffer.transformInput() {
        val matches = regex.findAll(asCharSequence()).toList()

        for (match in matches) {
            val range = match.range
            val content = match.groupValues[1]

            replace(range.first, range.last + 1, content)
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                start = range.first,
                end = range.first + content.length,
                policy = ExpandPolicy.atEnd
            )
        }
    }
}
「Building a WYSIWYG editor」影片 23:42 處的 Kotlin 程式碼片段
class SearchHighlightTransformation(
    val query: MutableState<String>
) : OutputTransformation {
    override fun TextFieldBuffer.transformOutput() {
        val search = query.value
        if (search.isEmpty()) return
        val text = asCharSequence().toString()
        var index = text.indexOf(search)

        while (index != -1) {
            addStyle(
                SpanStyle(
                    background = Color.Yellow,
                    color = Color.Black
                ),
                start = index,
                end = index + search.length
            )
            index = text.indexOf(search, index + search.length)
        }
    }
}