Kỹ sư phần mềm cấp cao
Hành trình hướng đến tính năng chỉnh sửa văn bản đáng tin cậy trong Compose đã đạt đến đỉnh điểm khi BasicTextField được viết lại hoàn toàn. Sự chuyển đổi sang cấu trúc dựa trên vùng đệm này đã loại bỏ hoàn toàn tình huống tương tranh của IME. Phiên này sẽ giải thích lý do chúng tôi phải loại bỏ tính năng đồng bộ hoá dữ liệu đầu vào cũ và cách TextFieldState đồng bộ hoá nội dung cập nhật trên bàn phím. Sau đó, hãy tìm hiểu sâu về tính năng chỉnh sửa có định dạng để khám phá cơ chế của TrackedRange, chi phí hiệu suất của các khối chỉnh sửa và những điểm cần lưu ý thực tế đằng sau ranh giới kiểu, trước khi xem trước tính năng kết xuất Markdown.
TextFieldState thay thế các quá tải dựa trên lệnh gọi lại trong Compose. Trạng thái này cập nhật đồng bộ với thao tác nhập của người dùng, giúp loại bỏ các vấn đề về thời gian với bàn phím phần mềm và các thành phần kết hợp không cần thiết.TextFieldBuffer. Bạn có thể chặn các nội dung chỉnh sửa trước khi áp dụng hoặc định dạng văn bản hiển thị mà không cần tính toán ánh xạ độ lệch theo cách thủ công.TextFieldState lên phụ thuộc vào nhu cầu về cấu trúc của bạn. Bạn có thể giữ trạng thái này cục bộ trong một composable hoặc lưu trữ trực tiếp trong ViewModel để truy cập logic nghiệp vụ.TextFieldBuffer. Bạn có thể áp dụng kiểu bằng các tuỳ chọn ExpandPolicy có thể tuỳ chỉnh và các dải ô kiểu hiện có sẽ tự động cập nhật khi người dùng chỉnh sửa.Bản chép lời
Xin chào mọi người!
Cảm ơn bạn đã tham gia buổi trò chuyện của chúng tôi.
Tôi là Anastasia, và đồng nghiệp của tôi là Haoyu.
Hôm nay, chúng tôi sẽ hướng dẫn bạn cách xây dựng trải nghiệm chỉnh sửa văn bản đáng tin cậy.
Cho dù bạn đang xây dựng một màn hình đăng nhập đơn giản, một thanh tìm kiếm khi nhập hay một ứng dụng nhắn tin phức tạp, thì trường văn bản vẫn là yếu tố cốt lõi trong cách người dùng tương tác với ứng dụng Android của bạn.
Vì vậy, hầu hết các bạn có thể đã sử dụng trường văn bản Material hoặc trường văn bản có đường viền.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Ngoài ra, còn có một BasicTextField nếu bạn đang xây dựng hệ thống thiết kế của riêng mình hoặc cần một hộp chỉnh sửa đơn giản.
BasicTextField(state = rememberTextFieldState())Ngoài ra, còn có SecureTextField được thiết kế riêng để nhập mật khẩu.
SecureTextField(state = rememberTextFieldState())Và BasicTextField là một thành phần không có kiểu và thực sự hỗ trợ tất cả các trường văn bản khác trong Compose.
Vì vậy, có thể bạn đã nhận thấy trong vài năm qua, chúng tôi có hai hình dạng API song song, một hình dạng nhận giá trị và lệnh gọi lại thay đổi giá trị, còn hình dạng kia nhận đối tượng TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Vì vậy, bạn có thể thắc mắc chúng tôi đề xuất phương pháp nào.
Bạn nên sử dụng phương thức nạp chồng TextFieldState.
Trong buổi trò chuyện này, chúng ta sẽ thảo luận về lý do tại sao nó lại có lợi ích về API, tính năng và hiệu suất.
API này đã ổn định kể từ Compose 1.7.
Đối với các phương thức nạp chồng lệnh gọi lại, chúng tôi sẽ ngừng sử dụng chúng trong Compose 1.13 trên BasicTextField và trình bao bọc Material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitĐiều này có nghĩa là chúng tôi sẽ không thêm bất kỳ tính năng mới nào hoặc sửa lỗi trừ khi bạn biết, các vấn đề bảo mật nghiêm trọng hoặc khả năng hỗ trợ tiếp cận.
Giá trị không có trạng thái khi quá tải thay đổi giá trị thực sự hoạt động rất hiệu quả đối với các dữ liệu đầu vào đồng bộ.
Nhưng ngay khi bạn ghép nối với các luồng không đồng bộ, chẳng hạn như StateFlow, thì việc này sẽ tạo ra các vấn đề về thời gian với IME hoặc theo thuật ngữ kỹ thuật, đối với bàn phím phần mềm.
Do đó, các ký tự bị bỏ sót, ký tự trùng lặp, con trỏ nhảy, như bạn có thể thấy trên bản ghi này.
Để khắc phục vấn đề này một cách cơ bản, chúng tôi đã ra mắt TextFieldState.
Nó cập nhật đồng bộ khi người dùng nhập mà không cần chờ các chuyến khứ hồi không đồng bộ đó, đây chính xác là những gì IME cần.
TextFieldState, ngoài văn bản chỉ có thể đọc, còn có các thuộc tính khác tạo ra một phiên chỉnh sửa đang hoạt động.
Đó là lựa chọn, trạng thái huỷ/làm lại, cũng như kiểu span và kiểu đoạn để chỉnh sửa văn bản được định dạng, chúng ta sẽ tìm hiểu thêm về vấn đề này trong phần sau của bài nói chuyện.
Các thuộc tính nội bộ của TextFieldState được Hệ thống ảnh chụp nhanh của Compose hỗ trợ.
Vì vậy, điều đó có nghĩa là khi bạn đọc văn bản hoặc lựa chọn từ đối tượng TextFieldState đó ở đâu đó trong Compose, thì phạm vi cụ thể mà bạn đọc sẽ được đăng ký nhận thông tin cập nhật.
Hàm này cho phép BasicTextField chỉ đọc trạng thái trong giai đoạn bố cục hoặc vẽ, đồng thời loại bỏ quá trình kết hợp lại không cần thiết khi người dùng nhập.
Cùng với một số thay đổi khác, việc này đã cải thiện đáng kể hiệu suất cho tình trạng quá tải TextFieldState.
Bây giờ, nếu văn bản của TextFieldState là một thuộc tính chỉ có thể đọc, thì làm cách nào để bạn thực sự thay đổi trường nhập dữ liệu theo phương thức lập trình?
Đây là lúc TextFieldBuffer phát huy tác dụng.
Đây là một đối tượng có thể thay đổi, cung cấp cho bạn các API để thực hiện thay đổi.
Vì vậy, khi muốn thực hiện thay đổi theo phương thức lập trình, bạn sẽ gọi hàm chỉnh sửa của TextFieldState.
Và bên trong lambda đó, bạn sẽ nhận được một TextFieldBuffer.
Và TextFieldBuffer hiển thị một số hàm cho phép bạn chèn, thay thế ký tự, xoá ký tự theo yêu cầu.
Một mẹo nhỏ khi chỉnh sửa là tránh gọi các hàm này nhiều lần liên tiếp.
// 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) }Thay vào đó, hãy gộp tất cả các thao tác của bạn vào một lệnh gọi chỉnh sửa duy nhất.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}Bằng cách đó, Compose sẽ chỉ tạo một thực thể duy nhất của TextFieldBuffer, thay đổi thực thể đó tại chỗ, sau đó đồng bộ hoá thực thể đó với IME và Hệ thống tổng quan một lần.
Một mẹo khác là đối với hầu hết các thao tác theo lô phổ biến mà bạn cần, chúng tôi thực sự có các hàm mở rộng trên TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Vì vậy, bạn không cần phải nghĩ đến việc phân lô các thao tác đó.
Bạn chỉ cần gọi một trong những phương thức đó.
Ví dụ: clearText và setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}Vậy còn việc lọc dữ liệu đầu vào của người dùng thì sao?
Chẳng hạn như thực thi giới hạn ký tự hoặc chỉ muốn chấp nhận chữ số.
Trong API cũ, việc lọc trong onValueChange chạy sau khi văn bản đã được xác nhận.
onValueChange { newString ->
// full diffing new String vs old String
}Do đó, việc từ chối hoặc thay đổi kiểu nhập đó sẽ xung đột với bàn phím.
Ngoài ra, lệnh gọi lại onValueChange chỉ cung cấp cho bạn chuỗi cuối cùng.
Vì vậy, để hiểu được những thay đổi, bạn phải thực hiện so sánh toàn bộ chuỗi.
Thay vào đó, trong phương thức nạp chồng mới, chúng ta có phép biến đổi dữ liệu đầu vào.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Vì vậy, thành phần này sẽ chặn dữ liệu đầu vào của người dùng trước khi dữ liệu đó đến trạng thái trường văn bản và cung cấp cho bạn cùng một API TextFieldBuffer.
Đây là cách bạn triển khai tính năng lọc chỉ cho phép nhập chữ số bằng cách biến đổi dữ liệu đầu vào. Về cơ bản, bạn chỉ cần lấy các thay đổi.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Bạn kiểm tra xem có ký tự không phải chữ số hay không, và nếu có, bạn sẽ từ chối dữ liệu đầu vào bằng cách gọi revertAllChanges trên TextFieldBuffer đó.
Và khả năng chính của phép biến đổi dữ liệu đầu vào này là ChangeList.
Vì vậy, thay vì so sánh toàn bộ chuỗi, bạn có thể kiểm tra chính xác phạm vi chỉnh sửa, sau đó thay đổi chúng tại chỗ hoặc từ chối hoàn toàn dữ liệu đầu vào nếu cần.
Compose sẽ tự động đồng bộ hoá bàn phím và con trỏ cho bạn.
Cuối cùng, nếu bạn muốn định dạng hình ảnh cho dữ liệu đầu vào thì sao?
Ví dụ: bạn muốn thêm dấu ngoặc đơn hoặc dấu gạch ngang vào số thẻ tín dụng hoặc số điện thoại.
Ồ, đúng vậy.
Và bạn không muốn lưu trữ những dữ liệu đó trong cơ sở dữ liệu của mình, đúng không?
Trước đây, bạn đã sử dụng API biến đổi hình ảnh.
Vì vậy, trong trường hợp đó, bạn phải triển khai một liên kết hai chiều từ dữ liệu đầu vào thô đến dữ liệu đầu ra trực quan, rồi quay lại.
Và rất khó để làm đúng, thường dẫn đến các ngoại lệ thời gian chạy.
Giờ đây, chúng ta có một phép biến đổi đầu ra và một lần nữa, về bản chất, phép biến đổi này được hỗ trợ bởi cùng một TextFieldBuffer, vì vậy, bạn sẽ nhận được API tương tự.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Vì vậy, trong ví dụ về số điện thoại này, bạn chỉ cần chèn các dấu gạch ngang đó vào một chỉ mục cụ thể, bạn chỉ cần gọi hàm chèn của TextFieldBuffer và hệ thống sẽ tính toán mối liên kết bù trừ hai chiều cho bạn.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Vì vậy, điều này giúp loại bỏ phép toán thủ công và giảm đáng kể số lượng sự cố.
Giờ đến phần quan trọng nhất, làm cách nào để bạn thực sự di chuyển và bạn sẽ kéo TextFieldState đó lên ở đâu?
Trên thực tế, hầu hết mọi trường hợp sử dụng đều thuộc một trong những danh mục mà tôi sắp nói đến.
Và khi không, thì thường là do bạn có một số cấu trúc khác thường, nhưng những ví dụ này vẫn sẽ hướng dẫn bạn thực hiện quá trình di chuyển.
Được rồi, trường hợp đơn giản nhất là giữ TextFieldState ngay bên trong composable nơi trường văn bản đó tồn tại.
@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()) }) { }
)
}Đây là khi trường văn bản của bạn là một trường nhập dữ liệu độc lập, chẳng hạn như trường nhập mã khuyến mãi và bạn chỉ cần truy cập vào văn bản đó khi người dùng nhấn nút gửi, đúng không?
Do đó, mô hình khung hiển thị của bạn không cần phải chặn hoạt động đầu vào của người dùng mỗi khi có thay đổi.
Vì vậy, bạn chỉ cần gọi rememberTextFieldState cục bộ và rememberTextFieldState cũng giúp bạn khôi phục trạng thái sau khi quá trình bị buộc tắt.
Sau đó, bạn sử dụng phép biến đổi dữ liệu đầu vào để lọc và lấy văn bản từ trạng thái đó, đối tượng TextFieldState, và khi người dùng gửi, bạn sẽ truyền văn bản đó qua lambda.
Được rồi, điều gì sẽ xảy ra nếu các phần tử trên giao diện người dùng đồng cấp như bộ đếm ký tự hoặc nút gửi cũng cần đọc văn bản hoặc thay đổi văn bản đó?
Điều này đưa chúng ta đến trường hợp bạn nâng đối tượng TextFieldState lên một hàm composable chung, một hàm composable gốc chung.
@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)
}
}Và vì TextFieldState được hệ thống ảnh chụp nhanh hỗ trợ, nên việc truyền qua đối tượng cấp trên sẽ không kích hoạt quá trình kết hợp lại của đối tượng mẹ.
Chỉ những nơi bạn thực sự đọc văn bản của trạng thái mới cập nhật.
Được rồi.
Vì vậy, đối với các trường hợp sử dụng giao diện người dùng phức tạp, trong đó bạn cần TextFieldState, phép biến đổi đầu vào và đầu ra để điều phối state nội bộ, thay vì nâng riêng và cố gắng kết nối chúng với nhau trong hàm composable, hãy sử dụng đối tượng phần tử giữ trạng thái.
@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,
)
}Vì vậy, hãy tưởng tượng bạn muốn xử lý một hashtag cùng dòng.
TextFieldState giữ văn bản thô và vị trí con trỏ.
Tính năng biến đổi dữ liệu đầu vào chịu trách nhiệm phát hiện hashtag, đồng thời bạn cũng có thể xoá thẻ nguyên tử thông qua tính năng biến đổi dữ liệu đầu vào.
Tính năng biến đổi kết quả sẽ thêm kiểu hình ảnh bổ sung và cũng có thể giúp bạn xoá hashtag.
Và tất cả đều được đóng gói vào một đối tượng phần tử giữ trạng thái duy nhất, tôi gọi đối tượng đó là 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) { ... }
}Sau đó, tất cả đều có thể sử dụng lại các phép tính trung gian như tagRanges trong trường hợp này.
Sau đó, bạn chỉ cần truyền đối tượng phần tử giữ trạng thái này vào thành phần kết hợp.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Được rồi, một trường hợp sử dụng khác là khi logic nghiệp vụ của bạn thực sự cần quyền truy cập vào văn bản đó để tìm kiếm khi bạn nhập hoặc một số quy trình xác thực phức tạp, hãy chuyển trường văn bản đó vào ViewModel của bạn.
Điều quan trọng là bạn không được đặt đối tượng trường văn bản có thể thay đổi vào trạng thái giao diện người dùng của lớp dữ liệu không thể thay đổi, vì đối tượng này sẽ được sao chép bằng phương thức sao chép rồi được phát vào StateFlow mỗi khi có thay đổi.
data class FormUiState(val username: TextFieldState)TextFieldState là một đối tượng có thể thay đổi, tồn tại lâu dài, được Hệ thống tổng quan hỗ trợ và đối tượng tham chiếu của đối tượng này không bao giờ thay đổi.
Vì vậy, StateFlow của bạn thậm chí sẽ không biết rằng người dùng đang nhập vào lúc này vì tham chiếu vẫn giữ nguyên.
Tệ hơn nữa, nếu bạn tạo một thực thể mới của trạng thái giao diện người dùng và phân bổ một TextFieldState mới, thì trường nhập dữ liệu sẽ mất tất cả trạng thái.
Vì vậy, điều đó sẽ giống như lựa chọn, hoặc vị trí con trỏ, hoặc nhật ký huỷ/làm lại.
Vì vậy, thay vào đó, hãy đặt TextFieldState làm thuộc tính trực tiếp trên ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}Bạn cũng không cần lo lắng về quy tắc không có giao diện người dùng trong ViewModel ở đây vì trong trường hợp đó, TextFieldState không tham chiếu đến đối tượng Android View hoặc Context hay vòng đời của Compose.
Đây chỉ là một đối tượng phần tử giữ trạng thái thuần tuý được Snapshot hỗ trợ.
Vì vậy, trong trường hợp đó, về mặt lý thuyết, nó không khác gì StateFlow.
Và một điều cần nhớ là ViewModel không có rememberSaveable, vì vậy, bạn cần kết nối TextFieldState.Saver cho trường văn bản để duy trì trạng thái khi bị buộc tắt và khôi phục trạng thái của trường văn bản.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Bây giờ, điều gì sẽ xảy ra nếu nhóm của bạn có quy tắc nghiêm ngặt về mô-đun, trong đó ViewModel chỉ tồn tại trong các mô-đun Kotlin thuần tuý và bạn không thể đưa phần phụ thuộc compose-foundation đó vào?
Hoặc chẳng hạn như bạn đang di chuyển một màn hình cũ phức tạp và không thể cập nhật hoặc cải tiến trạng thái giao diện người dùng ngay hôm nay?
Trong trường hợp đó, hãy sử dụng phương pháp phù hợp.
Vì vậy, bạn giữ TextFieldState trong composable làm nguồn đáng tin cậy duy nhất cho trường văn bản đó.
Nhưng sau đó, bạn sử dụng LaunchedEffect với snapshotFlow để đẩy các thay đổi về văn bản đó trở lại ViewModel hiện có.
@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)
}Và ViewModel của bạn không có Compose, đồng thời vì các thuộc tính trạng thái giao diện người dùng không phản hồi lại trường văn bản sau mỗi lần thay đổi, nên bạn vẫn tránh được các lỗi khứ hồi không đồng bộ đó.
Đúng vậy, và hãy chú ý đến một bẫy thường gặp ở đây.
@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)
}Trong hầu hết các trường hợp, bạn nên bỏ qua sự kiện đầu tiên.
Điều này sẽ ngăn các cụm từ tìm kiếm trùng lặp không cần thiết hoặc cũng sẽ ngăn việc ghi đè ViewModel của bạn bằng giá trị ban đầu.
Được rồi, vậy nếu logic của bạn cần phản hồi cho bạn biết để phản hồi những thay đổi đó về trạng thái, chẳng hạn như khi người dùng nhấn nút gửi và bạn muốn xoá văn bản, thì bạn không thể chỉ đặt chuỗi trống cho ViewModel của 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)
}Thay vào đó, bạn cần phát ra một sự kiện văn bản rõ ràng, sau đó hàm composable chứa đối tượng TextFieldState đó sẽ gọi clearText trên đối tượng đó.
Để hỗ trợ bạn thêm trong quá trình chuyển đổi, chúng tôi đã viết hướng dẫn di chuyển này.
Bạn có thể truy cập vào công cụ này thông qua đường liên kết này.
Chúng tôi cũng đang phát triển một số API bổ sung để giúp việc di chuyển cũng như quan sát các thay đổi chung về TextFieldState trở nên dễ dàng hơn, vì vậy hãy chú ý theo dõi.
Và mọi nội dung chúng ta đã đề cập cho đến nay, TextFieldState, TextFieldBuffer, phép biến đổi dữ liệu đầu vào và đầu ra, không chỉ được xây dựng để nhập văn bản thuần túy.
Đây là nền tảng hỗ trợ bộ API mới nhất của chúng tôi để chỉnh sửa văn bản có định dạng.
Bây giờ, xin mời Haoyu chia sẻ thêm về vấn đề này.
Được rồi.
Cảm ơn bạn.
Chào các bạn!
Tiếp theo, tôi sẽ nói về các API mới mà chúng tôi đã ra mắt để chỉnh sửa văn bản có định dạng.
Vì vậy, trước khi tìm hiểu các API mới, hãy cùng nhìn lại và xem cách tạo kiểu hoạt động trong văn bản tĩnh.
Hầu hết các bạn đều đã biết rõ về 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)Đây là cách chuẩn để tạo kiểu cho văn bản tĩnh.
Chúng tôi sử dụng buildAnnotatedString và gọi lệnh thêm – gọi addStyle để áp dụng SpanStyle hoặc ParagraphStyle trên một số dải ô nhất định.
Cách này phù hợp với văn bản tĩnh.
Vì vậy, khi xem xét việc chỉnh sửa văn bản được định dạng, suy nghĩ ban đầu của chúng tôi là liệu có thể chỉnh sửa AnnotatedString hay không?
Nhưng sau đó, chúng tôi nhanh chóng nhận ra rằng việc này không hề đơn giản.
AnnotatedString là một cấu trúc dữ liệu không thể thay đổi.
Nhưng trong thế giới chỉnh sửa, người dùng liên tục nhập văn bản.
Và với mỗi phím, mỗi lần nhấn phím, chúng ta phải phân bổ một đối tượng AnnotatedString mới.
Và trên mỗi AnnotatedString, có rất nhiều AnnotatedString.Range.
Vì vậy, các đối tượng đó cũng phải được tạo lại.
Cách này không dễ mở rộng và gây ra mức hao tổn phân bổ đáng kể.
Hãy xem ví dụ này.
Khi chúng ta chèn từ vào giữa, mọi kiểu sau đó sẽ thay đổi phạm vi và được phân bổ lại.
Cách này rõ ràng không hiệu quả khi bạn có nhiều kiểu.
Vì vậy, chúng tôi đã giới thiệu các API định kiểu chuyên dụng bên trong TextFieldBuffer mà Anastasia đã giới thiệu trước đó.
Các API này rất giống với API định kiểu trong AnnotatedString.
Hãy cùng tìm hiểu cách hoạt động của các tính năng này.
Vì vậy, giống như AnnotatedString.Builder, TextFieldBuffer hiện có addStyle để áp dụng SpanStyle hoặc ParagraphStyle cho một dải ô.
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>Bạn cũng có thể truyền tham số ExpandPolicy để kiểm soát cách kiểu hoạt động khi văn bản được nhập ngay tại ranh giới kiểu.
Chúng ta sẽ xem xét vấn đề này sau.
Trước tiên, hãy cùng tìm hiểu cách hoạt động của tính năng theo dõi kiểu.
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)
}Sau khi bạn thêm kiểu vào TextFieldBuffer, phạm vi của kiểu sẽ tự động cập nhật khi văn bản được chỉnh sửa.
Trong ví dụ này, chúng ta bắt đầu bằng cách in đậm một số văn bản.
Khi bạn chèn văn bản trước đó, dải kiểu sẽ dịch chuyển về phía trước.
Và khi bạn chèn văn bản vào bên trong, dải văn bản sẽ mở rộng.
Và khi chúng ta xoá văn bản bên trong phạm vi, phạm vi kiểu sẽ thu hẹp tương ứng.
Nếu tất cả văn bản bên trong phạm vi kiểu đều bị xoá, thì kiểu đó sẽ thu gọn và tự động bị xoá khỏi vùng đệm.
Vậy điều gì sẽ xảy ra khi văn bản được chèn ngay tại ranh giới của một dải kiểu?
Văn bản mới chèn có được áp dụng kiểu hay không?
Vì vậy, để giúp bạn kiểm soát việc này, chúng tôi đã ra mắt các API ExpandPolicy.
Hãy cùng tìm hiểu về những mục này.
Đầu tiên là 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")Ở chế độ này, việc chèn văn bản tại ranh giới sẽ không mở rộng dải ô kiểu.
Ở đây, trước tiên chúng ta áp dụng một dải ô in đậm.
Nếu chúng ta chèn văn bản vào đầu dải ô in đậm, thì dải ô kiểu sẽ dịch chuyển nhưng không mở rộng.
Tương tự, nếu chúng ta chèn văn bản vào cuối dải ô in đậm, thì dải ô kiểu cũng không mở rộng.
Sau đây là bản minh hoạ cho thấy cách hoạt động của tính năng này trong trình chỉnh sửa.
Vậy tiếp theo là ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Đúng như tên gọi, việc chèn văn bản vào đầu dải ô sẽ mở rộng dải ô kiểu.
Vì vậy, khi chúng ta chèn văn bản ngay trước khi phạm vi kiểu mở rộng để bao gồm văn bản mới được chèn.
Sau đây là một bản minh hoạ khác về cách hoạt động của tính năng này.
Thứ ba là ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Vì vậy, chúng ta sẽ chèn văn bản ở cuối dải ô để mở rộng dải ô kiểu.
Vì vậy, đây cũng là chế độ cài đặt phổ biến nhất cho trình chỉnh sửa.
Sau đây là bản minh hoạ về cách hoạt động của tính năng này.
Vì vậy, cuối cùng, đó là ExpandPolicy.AtBoth, giúp mở rộng kiểu khi văn bản được chèn vào đầu và cuối phạm vi.
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")
}Sau đây là một bản minh hoạ khác về vấn đề này.
Chúng ta đã tìm hiểu tất cả các API áp dụng kiểu cho đến nay.
Ngoài ra, chúng tôi cũng ra mắt các API truy vấn mới như getSpanStyles, getParagraphStyles để tìm nạp các kiểu hiện đang được áp dụng trong một dải ô nhất định.
// 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>>Dưới đây là hai phiên bản của API truy vấn.
Trên TextFieldState, các API truy vấn kiểu chỉ có thể đọc.
Các phương thức này trả về danh sách các đối tượng AnnotatedString.Range không thể thay đổi.
Nhưng trong TextFieldBuffer, getSpanStyles trả về danh sách các đối tượng TrackedRange mới được giới thiệu mà tôi sẽ giới thiệu sau.
Vì vậy, tất cả truy vấn kiểu đều nhận biết được thứ tự.
Các hàm này trả về dải kiểu chính xác theo thứ tự khi kiểu được áp dụng.
Vì vậy, điều này cho phép bạn áp dụng, giống như AnnotatedString, để áp dụng một kiểu khác lên trên kiểu hiện có để ghi đè các kiểu trước đó.
Tiếp theo là API TrackedRange.
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Đối tượng TrackedRange được trả về bằng API addStyle hoặc getSpanStyles trong TextFieldBuffer.
Hãy coi đó là một tay điều khiển trực tiếp của dải kiểu trong vùng đệm.
Bạn có thể sử dụng hàm này để cập nhật dải văn bản, ExpandPolicy hoặc chính kiểu đó tại chỗ.
Lớp này cũng có thuộc tính isValid để kiểm tra xem phạm vi kiểu có còn trong TextFieldBuffer hay không.
Điều này là do kiểu có thể bị xoá nếu kiểu đó bị thu gọn trong quá trình chỉnh sửa hoặc bị removeStyle xoá có chủ đích.
Tiếp theo, hãy xem cách các API đó kết hợp với nhau trong các trường hợp sử dụng thực tế.
Trường hợp sử dụng đầu tiên là xây dựng Trình chỉnh sửa WYSIWYG (What You See Is What You Get – Những gì bạn thấy là những gì bạn nhận được).
Trong ví dụ này, chúng tôi đã triển khai tính năng nút in đậm.
Khi bạn nhấp vào nút in đậm, kiểu in đậm sẽ được áp dụng cho văn bản đã chọn.
Tiếp theo, hãy xem mã.
Vì vậy, khi người dùng nhấn vào nút in đậm, trước tiên, chúng ta sẽ gọi getSpanStyles trên một vùng chọn để tìm tất cả các kiểu chồng chéo.
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())
}
}Sau đó, chúng ta kiểm tra xem toàn bộ phần văn bản được chọn đã được in đậm hay chưa.
Ở đây, từ được chọn không được in đậm.
Vì văn bản này không được in đậm, nên chúng ta addStyle cho dải ô đã chọn.
Chúng ta truyền ExpandPolicy.AtEnd để người dùng có thể tiếp tục nhập văn bản in đậm ngay sau từ này.
Bây giờ, hãy xem điều gì sẽ xảy ra nếu dải ô đã chọn đã được in đậm.
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)
}
}Trong trường hợp đó, chúng ta cần xoá kiểu in đậm khỏi dải ô đã chọn.
Ở đây, từ mà người dùng chọn nằm trong cụm từ in đậm dài hơn.
Vì kiểu in đậm mở rộng trước vùng chọn, nên chúng ta cần cập nhật phạm vi kiểu, để từ đã chọn không được in đậm.
Để làm việc này, chúng ta có thể sử dụng API TrackedRange.
Và đối với trường hợp cụ thể này, thay vì xoá kiểu này rồi tạo lại, chúng ta chỉ cần điều chỉnh phạm vi của span in đậm tại chỗ để kết thúc trước lựa chọn tại chỉ mục 10.
Tất nhiên, đây là một cách triển khai rất đơn giản.
Để xem bản minh hoạ đầy đủ, bạn có thể xem kho lưu trữ mẫu Compose của chúng tôi.
Tiếp theo, hãy xem xét một kiểu dựa trên mẫu.
Trong bản minh hoạ này, khi người dùng nhập dấu hoa thị Markdown xung quanh một từ, trình chỉnh sửa sẽ tự động chuyển đổi từ đó thành văn bản in đậm khi họ nhập.
Bây giờ, hãy xem cách triển khai bằng 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
)
}
}
}Trong bộ lọc dữ liệu đầu vào chuyển đổi, chúng ta tìm thấy tất cả các mẫu khớp trên nút.
Và đối với mỗi kết quả khớp, chúng ta sẽ trích xuất nội dung bên trong dấu hoa thị kép.
Trong ví dụ này, đó là từ "fox".
Tiếp theo, chúng tôi xoá dấu hoa thị và thay thế mẫu khớp bằng nội dung của mẫu đó.
Cuối cùng, chúng ta gọi addStyle cho kiểu in đậm để áp dụng kiểu in đậm cho văn bản nội dung.
Chỉ cần nhớ rằng InputTransformation sẽ gửi đến trạng thái trường văn bản cuối cùng.
Điều này có nghĩa là kiểu in đậm được áp dụng cho trạng thái một cách nhất quán.
Cuối cùng, hãy xem ví dụ về tính năng làm nổi bật nội dung tìm kiếm.
Vì vậy, trong ví dụ này, khi người dùng nhập cụm từ tìm kiếm, các từ khớp trong trường văn bản sẽ được làm nổi bật mà không cần sửa đổi trạng thái của trường văn bản.
Bây giờ, hãy xem cách triển khai trong OutputTransformation.
Khi làm nổi bật một từ tìm kiếm, bạn không được thay đổi trạng thái văn bản.
Và OutputTransformation là nơi hoàn hảo để triển khai trường hợp sử dụng này.
Vậy hãy kiểm tra cách triển khai thực tế.
Trước tiên, chúng tôi tìm kiếm các từ được làm nổi bật và tìm tất cả các kết quả phù hợp.
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)
}
}
}Và lưu ý rằng truy vấn là một trạng thái có thể thay đổi.
Điều này là do OutputTransformation nhận biết được ảnh chụp nhanh.
Khi trạng thái có thể thay đổi này được cập nhật, OutputTransformation sẽ tự động được áp dụng lại.
Sau đó, khi lặp lại qua mọi kết quả trùng khớp và gọi addStyle để vẽ các điểm đánh dấu màu vàng trên phạm vi kết quả trùng khớp.
Một lần nữa, OutputTransformation chỉ thay đổi văn bản hiển thị và trạng thái văn bản gốc không bị ảnh hưởng.
Được rồi, đó là tất cả các trường hợp sử dụng mà chúng ta có.
Xin lưu ý rằng tất cả các API được trình bày trước đó đều có sẵn kể từ Compose 1.12.
Để kết thúc, tôi muốn chia sẻ một chút về dự án mới mà chúng tôi đang thực hiện.
Đây là thư viện văn bản đánh dấu cho Jetpack Compose, hỗ trợ các định dạng văn bản đa dạng thức như Markdown.
Một trong những tính năng thú vị nhất mà chúng tôi hỗ trợ là phân tích cú pháp gia tăng và kết xuất gia tăng.
Vì vậy, tính năng này rất phù hợp để chỉnh sửa trực tiếp hoặc truyền trực tuyến Markdown.
Đây là bản ghi lại nguyên mẫu đang hoạt động của chúng tôi. Bạn có thể thấy những vùng mờ dần là các phần đang cập nhật trong giao diện người dùng.
Nếu bạn quan tâm đến tính năng này hoặc có trường hợp sử dụng trong ứng dụng, vui lòng liên hệ với chúng tôi.
Cảm ơn tất cả các bạn.
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)
}
}
}