Tạo kiểu cho dữ liệu nhập văn bản đa dạng thức

Tính năng nhập văn bản đa dạng thức cho phép người dùng định dạng nội dung họ nhập, bao gồm cả việc in đậm một từ, thêm tiêu đề, căn chỉnh đoạn văn hoặc chèn một @mention được làm nổi bật. Trong Compose, bạn xây dựng tính năng này dựa trên các trường văn bản dựa trên trạng thái (BasicTextField và TextField). Kiểu nằm trong TextFieldState cùng với văn bản, vì vậy, kiểu sẽ tồn tại sau khi chỉnh sửa, thay đổi lựa chọn và kết hợp lại, đồng thời luôn đồng bộ hoá khi người dùng nhập, xoá và dán.

Trang này trình bày cách áp dụng, cập nhật, truy vấn, trích xuất và xoá kiểu trong TextFieldState bằng TextFieldBuffer, cũng như cách điều khiển thanh công cụ định dạng từ TextFieldState.textStyles.

Áp dụng kiểu cùng dòng và kiểu đoạn văn

Bạn có thể áp dụng định dạng ở cấp ký tự và cấp đoạn văn trong bất kỳ phạm vi trình thu nhận TextFieldBuffer nào, bao gồm cả TextFieldState.edit, InputTransformation.transformInput và OutputTransformation.transformOutput.

Kiểm soát việc mở rộng ranh giới bằng ExpandPolicy

Khi gọi addStyle, bạn có thể truyền một ExpandPolicy để xác định cách phạm vi được tạo kiểu hoạt động khi người dùng chèn các ký tự mới ngay tại ranh giới start hoặc end:

  • Chèn vào bên trong (start, end): Luôn mở rộng phạm vi được tạo kiểu.
  • Chèn trước start: Luôn dịch chuyển cả độ lệch start và end sang bên phải.
  • Chèn trực tiếp tại start hoặc end: Do ExpandPolicy mà bạn chỉ định kiểm soát.

Bảng sau đây mô tả các tuỳ chọn ExpandPolicy hiện có và các trường hợp sử dụng thông thường:

Chính sách Hành vi ranh giới Trường hợp nên sử dụng Ví dụ
ExpandPolicy.InsideOnly Văn bản được nhập tại start hoặc end nằm ngoài phạm vi được tạo kiểu. Các mã thông báo riêng biệt như @mentions, #hashtags hoặc thẻ nền mà khi nhập bên cạnh thẻ thì không được kế thừa màu nền của thẻ. Ảnh động cho thấy văn bản được nhập ở đầu hoặc cuối dải ô ExpandPolicy.InsideOnly vẫn chưa được định kiểu.
ExpandPolicy.AtStart Văn bản được nhập tại start sẽ kế thừa kiểu, còn văn bản được nhập tại end sẽ không được định kiểu. Văn bản được nhập ngay trước văn bản được định kiểu cũng phải áp dụng cùng một định dạng. Ảnh động cho thấy văn bản được nhập ở đầu dải ô ExpandPolicy.AtStart kế thừa kiểu, trong khi văn bản được nhập ở cuối vẫn không có kiểu.
ExpandPolicy.AtEnd Văn bản được nhập tại end sẽ kế thừa kiểu, còn văn bản được nhập tại start sẽ không được định kiểu. Nhập văn bản đa dạng thức theo cách thông thường, trong đó việc tiếp tục nhập sau một từ in đậm hoặc in nghiêng sẽ tiếp tục định dạng đó. Ảnh động cho thấy văn bản được nhập ở cuối dải ô ExpandPolicy.AtEnd kế thừa kiểu, trong khi văn bản được nhập ở đầu dải ô vẫn không có kiểu.
ExpandPolicy.AtBoth Văn bản được nhập tại start hoặc end sẽ mở rộng phạm vi và kế thừa kiểu. Các khối được định dạng liên tục hoặc được làm nổi bật đang hoạt động sẽ mở rộng từ một trong hai cạnh. Ảnh động cho thấy văn bản được nhập ở đầu hoặc cuối của dải ô ExpandPolicy.AtBoth kế thừa kiểu.

Áp dụng định dạng ký tự bằng SpanStyle

Sử dụng addStyle với SpanStyle để áp dụng định dạng ở cấp ký tự, chẳng hạn như màu văn bản, màu nền, FontWeight, FontStyle hoặc gạch dưới cho một TextRange cụ thể trong vùng đệm.

Hành vi chính trong phạm vi SpanStyle

  • Thứ tự phân lớp: Kiểu được áp dụng theo thứ tự bạn thêm vào vùng đệm. Nếu nhiều dải ô SpanStyle chồng chéo đặt cùng một thuộc tính với các giá trị khác nhau, thì kiểu được thêm sau sẽ hiển thị ở trên cùng.
  • Yêu cầu về dải ô không trống: Bạn phải truyền một dải ô không thu gọn, trong đó start < end. Việc gọi addStyle trên một vùng chọn thu gọn có độ dài 0 sẽ không có hiệu lực và việc truyền các chỉ mục đảo ngược hoặc nằm ngoài giới hạn sẽ gửi một IllegalArgumentException.

TextField(
    state = rememberTextFieldState(),
    inputTransformation = InputTransformation {
        for (i in 0 until changes.changeCount) {
            val range = changes.getRange(i)
            if (!range.collapsed) {
                addStyle(
                    spanStyle = SpanStyle(
                        fontWeight = FontWeight.ExtraBold,
                        fontStyle = FontStyle.Italic,
                        color = Color.Red,
                    ),
                    range = range,
                    expandPolicy = ExpandPolicy.AtEnd
                )
            }
        }
    }
)

TextField tự động áp dụng định dạng màu đỏ in đậm và in nghiêng khi người dùng nhập.
Hình 1. Áp dụng định dạng màu đỏ, in đậm và in nghiêng cho dữ liệu đầu vào của người dùng một cách linh động.

Áp dụng định dạng khối bằng ParagraphStyle

Sử dụng addStyle với ParagraphStyle để áp dụng định dạng cấu trúc cấp khối như textAlign, lineHeight, textDirection hoặc textIndent cho một dải ô đoạn văn.

fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) {
    val targetRange = if (state.selection.collapsed) {
        TextRange(0, state.text.length)
    } else {
        state.selection
    }
    if (targetRange.collapsed) return
    state.edit {
        // 1. Remove existing paragraph styles in this range
        getParagraphStyles(targetRange).forEach { removeStyle(it) }
        // 2. Apply the new paragraph alignment
        addStyle(
            paragraphStyle = ParagraphStyle(textAlign = alignment),
            range = targetRange,
            expandPolicy = ExpandPolicy.InsideOnly
        )
    }
}

Một TextField chuyển đổi giữa các chế độ căn chỉnh đoạn văn: đầu, giữa và cuối bằng ParagraphStyle.
Hình 2. Chuyển đổi cách căn chỉnh đoạn văn giữa đầu, giữa và cuối bằng ParagraphStyle.

Những sai lầm cần tránh khi sử dụng ParagraphStyle

Vì ParagraphStyle áp dụng cho toàn bộ khối văn bản, nên hãy tránh sử dụng thuộc tính này trong các trường hợp sau:

  • Mở rộng sang các đoạn văn liền kề: Đặt ExpandPolicy cẩn thận để các dải ô ParagraphStyle liền kề không chồng chéo sau khi người dùng chỉnh sửa. Ví dụ: nếu [0, 5) sử dụng ExpandPolicy.AtEnd và [5, 10) sử dụng ExpandPolicy.AtStart, thì việc chèn văn bản tại chỉ mục 5 sẽ mở rộng cả hai kiểu thành một kiểu chồng chéo và gửi một IllegalArgumentException.
  • Lồng các dải ô ParagraphStyle: Mặc dù các dải ô được lồng hoàn toàn (chẳng hạn như [0, 10) và [2, 8)) là hợp lệ trong AnnotatedString, nhưng hãy tránh lồng các dải ô ParagraphStyle trong một trường văn bản. Bố cục chia các dải ô lồng nhau thành các đoạn riêng biệt và các nội dung chỉnh sửa tiếp theo có thể vô tình biến các dải ô lồng nhau thành một phần chồng chéo.

Cập nhật kiểu tại chỗ bằng TrackedRange

Việc gọi addStyle bằng TextRange và ExpandPolicy sẽ trả về một tay điều khiển TrackedRange, có thể là TrackedRange<SpanStyle> hoặc TrackedRange<ParagraphStyle>. TrackedRange là một tham chiếu động đến một khoảng kiểu trong TextFieldBuffer, tự động cập nhật các độ lệch ký tự start và end khi văn bản được chèn, xoá hoặc thay thế xung quanh nó.

Khả năng biến đổi tại chỗ

Bạn không cần gọi removeStyle và addStyle để cập nhật kiểu hiện có hoặc điều chỉnh ranh giới của kiểu đó. Trong phạm vi TextFieldBuffer, TrackedRange cung cấp các thuộc tính tiện ích giúp cập nhật khoảng thời gian tại chỗ trong khi vẫn giữ nguyên thứ tự phân lớp ban đầu so với các kiểu khác:

  • TrackedRange<SpanStyle>.spanStyle: Đọc hoặc thay thế tải trọng SpanStyle tại chỗ trên TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: Đọc hoặc thay thế tải trọng ParagraphStyle tại chỗ trên TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: Đọc hoặc cập nhật các giới hạn TextRange hiện tại. Việc đặt một dải ô thu gọn, đảo ngược hoặc nằm ngoài giới hạn sẽ gửi một IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: Đọc hoặc cập nhật ExpandPolicy tại chỗ.
  • TrackedRange<*>.isValid: Trả về true nếu khoảng thời gian của kiểu vẫn đang hoạt động trong vùng đệm, hoặc false nếu các nội dung chỉnh sửa khiến độ dài của khoảng thời gian đó giảm xuống 0 hoặc nếu khoảng thời gian đó đã bị xoá.

// Trim an existing bold span in place when the selection overlaps one edge
val range = style.textRange
if (range.start < selection.min && range.end <= selection.max) {
    // Selection covers the end of the span - shrink end boundary in place
    style.textRange = TextRange(range.start, selection.min)
} else if (range.start >= selection.min && range.end > selection.max) {
    // Selection covers the start of the span - shrink start boundary in place
    style.textRange = TextRange(selection.max, range.end)
}

Quy tắc về giới hạn phạm vi và vòng đời

Hãy lưu ý các quy tắc về phạm vi và vòng đời sau đây khi làm việc với các thực thể TrackedRange:

  • Giới hạn trong TextFieldBuffer: Các thuộc tính tiện ích TrackedRange chỉ tồn tại trong phạm vi TextFieldBuffer nơi tay điều khiển được tạo hoặc truy vấn.
  • Tự động cắt bớt khi thu gọn: Nếu người dùng chỉnh sửa xoá tất cả ký tự thuộc phạm vi của một TrackedRange, thì độ dài của ký tự đó sẽ thu nhỏ thành 0, isValid trả về false và kiểu sẽ bị xoá khỏi vùng đệm.

Truy vấn và xoá kiểu

Compose cung cấp hai cách để kiểm tra kiểu đang hoạt động, tuỳ thuộc vào việc bạn đang thay đổi vùng đệm hay đọc trạng thái cho giao diện người dùng:

  • Bên trong khối TextFieldBuffer: Gọi getSpanStyles và getParagraphStyles trực tiếp trên trình nhận TextFieldBuffer để lấy các handle TrackedRange có thể thay đổi.
  • Bên ngoài khối chỉnh sửa: Đọc TextFieldState.textStyles, thao tác này sẽ trả về danh sách AnnotatedString.Range không thể thay đổi, phản ánh trạng thái đã cam kết.

Kiểu giao nhau của truy vấn trong TextFieldBuffer

TextFieldBuffer.getSpanStyles và TextFieldBuffer.getParagraphStyles trả về tất cả các thực thể TrackedRange giao nhau với TextRange đã cho, được sắp xếp theo thời điểm chúng được thêm vào vùng đệm.

Ví dụ: bạn có thể truy vấn getSpanStyles(selection) bên trong state.edit để kiểm tra hoặc cập nhật các kiểu áp dụng cho lựa chọn hiện tại của người dùng:

state.edit {
    if (selection.collapsed) return@edit
    // Query all bold spans that overlap with the current selection
    val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
        it.spanStyle.fontWeight == FontWeight.Bold
    }
    // ...
}

Khoảng kiểu sử dụng phạm vi toán học nửa mở [start, end). Việc kiểu chạm có được trả về hay không phụ thuộc vào việc bạn truy vấn một vùng chọn hay một điểm con trỏ:

  • Đối với lựa chọn không trống [start, end):
    • Được trả về: Mọi kiểu trùng lặp, chứa hoặc nằm trong dải ô lựa chọn. Ví dụ: cụm từ tìm kiếm [5, 15) khớp với [0, 20), [8, 12) hoặc [0, 10).
    • Không được trả về: Kiểu chỉ chạm vào các cạnh ranh giới bên ngoài mà không chồng chéo các ký tự. Ví dụ: truy vấn [5, 15) loại trừ [0, 5) và [15, 25).
  • Đối với con trỏ thu gọn [index, index]:
    • Trả về: Mọi kiểu chứa con trỏ hoặc bắt đầu tại vị trí con trỏ. Ví dụ: một con trỏ tại 10 khớp với [0, 20) và [10, 20).
    • Không được trả về (quy tắc chạm vào điểm cuối): Mọi kiểu kết thúc tại vị trí con trỏ. Ví dụ: con trỏ tại 10 sẽ loại trừ [0, 10).

Xoá hoặc bật/tắt kiểu bằng removeStyle

Truyền một tay điều khiển TrackedRange đến removeStyle để xoá khoảng kiểu cụ thể đó khỏi TextFieldBuffer.

removeStyle trả về true nếu tìm thấy và xoá TrackedRange, hoặc false nếu đã xoá hoặc thuộc về một vùng đệm khác. Thao tác này chỉ xoá tay điều khiển TrackedRange được nhắm mục tiêu, giữ nguyên các kiểu chồng chéo khác.

fun toggleBold(state: TextFieldState) {
    state.edit {
        if (selection.collapsed) return@edit
        // Query all bold spans that overlap with the current selection
        val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
        if (boldStyles.isNotEmpty()) {
            // Remove or trim existing bold spans covering the selection
            for (style in boldStyles) {
                val range = style.textRange
                if (range.start >= selection.min && range.end <= selection.max) {
                    removeStyle(style)
                } else if (range.start < selection.min && range.end > selection.max) {
                    val oldEnd = range.end
                    style.textRange = TextRange(range.start, selection.min)
                    addStyle(
                        spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                        range = TextRange(selection.max, oldEnd),
                        expandPolicy = ExpandPolicy.AtEnd
                    )
                } else if (range.start < selection.min) {
                    style.textRange = TextRange(range.start, selection.min)
                } else {
                    style.textRange = TextRange(selection.max, range.end)
                }
            }
        } else {
            // No bold spans intersect the selection - apply bold
            addStyle(
                spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                range = selection,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}

Một TextField bật và tắt định dạng in đậm cho các từ đã chọn.
Hình 3. Chuyển đổi giữa chế độ in đậm và không in đậm văn bản.

Trích xuất hoặc chuyển đổi tuần tự văn bản đa dạng thức

Để hiển thị thông tin đầu vào được định dạng trong một composable Text chỉ có thể đọc hoặc chuyển đổi tuần tự dữ liệu đó thành một dịch vụ phụ trợ, hãy đọc TextFieldState.textStyles bên ngoài một khối chỉnh sửa.

state.textStyles.getSpanStyles và state.textStyles.getParagraphStyles trả về List<AnnotatedString.Range<SpanStyle>> và List<AnnotatedString.Range<ParagraphStyle>>, bạn có thể chuyển trực tiếp đến hàm khởi tạo AnnotatedString:

fun TextFieldState.toAnnotatedString(): AnnotatedString {
    val fullRange = TextRange(0, text.length)
    return AnnotatedString(
        text = text.toString(),
        spanStyles = textStyles.getSpanStyles(fullRange),
        paragraphStyles = textStyles.getParagraphStyles(fullRange)
    )
}

Ví dụ hoàn chỉnh trong Jetchat

Để xem cách triển khai hoàn chỉnh kết hợp kiểu ký tự cùng dòng (đậm, nghiêng, gạch chân, gạch ngang chữ và màu), hãy xem mẫu Jetchat trên GitHub:

  • RichTextState.kt: Đóng gói các truy vấn kiểu TextFieldState, bật/tắt span, cắt bớt hoặc phân tách trên các ranh giới lựa chọn, v.v.
  • UserInput.kt: Kết nối RichTextState với thanh công cụ định dạng văn bản đa dạng thức và trường nhập dữ liệu tin nhắn.
Ứng dụng mẫu Jetchat minh hoạ định dạng văn bản đa dạng thức trong thanh nhập tin nhắn.
Hình 4. Chỉnh sửa văn bản đa dạng thức trong Jetchat.