資深軟體工程師
為了在 Compose 中提供可靠的文字編輯功能,我們最終決定完全重寫 BasicTextField。改用緩衝區導向架構後,IME 競爭狀況就徹底消失了。本課程將說明為何必須淘汰舊版輸入同步功能,以及 TextFieldState 如何同步處理鍵盤更新。接著,您將深入瞭解格式化編輯,探索 TrackedRange 的機制、編輯區塊的效能成本,以及樣式邊界背後的實際問題,最後再初步瞭解 Markdown 轉譯。
TextFieldState API 超載會取代 Compose 中以回呼為基礎的超載。這項 API 會在使用者輸入時同步更新,避免螢幕鍵盤的時間問題和不必要的重組。TextFieldBuffer 操控文字。您可以在提交編輯內容前攔截,或設定顯示文字的格式,而不必手動計算偏移對應。TextFieldState 取決於架構需求。您可以將其保留在可組合項中,或直接儲存在 ViewModel 中,以便存取商業邏輯。TextFieldBuffer 中新增了格式化文字編輯 API。您可以使用可自訂的 ExpandPolicy 選項套用樣式,現有的樣式範圍會隨著使用者編輯而自動更新。轉錄稿
大家好!
感謝您參與我們的講座。
我是 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。
當使用者輸入內容時,它會同步更新,而不會等待非同步往返,這正是 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 保留在文字欄位所在的可組合項中。
@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 變更觀察更輕鬆,敬請期待。
到目前為止,我們介紹的所有內容,包括 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<*>): BooleanTrackedRange 物件是由 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 起提供。
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// 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)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@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()) }) { }
)
}@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)
}
}@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,
)
}@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) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@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)
}@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)
}@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)
}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)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>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)
}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")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")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")
}// 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>>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<*>): Booleanfun 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())
}
}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)
}
}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
)
}
}
}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)
}
}
}