จัดรูปแบบการป้อน Rich Text

การป้อนข้อมูล Rich Text ช่วยให้ผู้ใช้จัดรูปแบบสิ่งที่พิมพ์ได้ เช่น การทำตัวหนา การเพิ่มส่วนหัว การจัดแนวพารากราฟ หรือการแทรก @mention ที่ไฮไลต์ ใน Compose คุณจะสร้างฟีเจอร์นี้บนช่องข้อความตามสถานะ (BasicTextField และ TextField) โดยสไตล์จะอยู่ใน TextFieldState ควบคู่ไปกับข้อความ จึงยังคงอยู่แม้จะมีการแก้ไข การเปลี่ยนแปลงการเลือก และการจัดองค์ประกอบใหม่ รวมถึงซิงค์อยู่เสมอเมื่อผู้ใช้พิมพ์ ลบ และวาง

หน้านี้แสดงวิธีใช้ อัปเดต ค้นหา ดึงข้อมูล และนำรูปแบบใน TextFieldState ออกโดยใช้ TextFieldBuffer และวิธีขับเคลื่อนแถบเครื่องมือการจัดรูปแบบจาก TextFieldState.textStyles

ใช้รูปแบบในบรรทัดและย่อหน้า

คุณสามารถใช้การจัดรูปแบบระดับอักขระและระดับย่อหน้าภายในขอบเขตตัวรับ TextFieldBuffer ใดก็ได้ ซึ่งรวมถึง TextFieldState.edit, InputTransformation.transformInput และ OutputTransformation.transformOutput

ควบคุมการขยายขอบเขตด้วย ExpandPolicy

เมื่อเรียกใช้ addStyle คุณสามารถส่ง ExpandPolicy เพื่อกำหนดลักษณะการทำงานของช่วงที่มีการจัดรูปแบบเมื่อผู้ใช้แทรกอักขระใหม่ที่ขอบเขต start หรือ end โดยตรง ดังนี้

  • การแทรกภายใน (start, end): ขยายช่วงที่มีการจัดรูปแบบเสมอ
  • การแทรกก่อน start: เลื่อนทั้งออฟเซ็ต start และ end ไปทางขวาเสมอ
  • การแทรกโดยตรงที่ start หรือ end: ควบคุมโดย ExpandPolicy ที่คุณระบุ

ตารางต่อไปนี้จะอธิบายตัวเลือก ExpandPolicy ที่มีและกรณีการใช้งานทั่วไป

นโยบาย ลักษณะการทำงานของขอบเขต กรณีการใช้งานที่แนะนำ ตัวอย่าง
ExpandPolicy.InsideOnly ข้อความที่พิมพ์ที่ start หรือ end จะอยู่นอกช่วงที่มีการจัดรูปแบบ โทเค็นแบบแยก เช่น @mentions, #hashtags หรือแถบพื้นหลังที่การพิมพ์ข้างแท็กไม่ควรรับค่าสีพื้นหลังของแท็ก ภาพเคลื่อนไหวแสดงข้อความที่พิมพ์ที่จุดเริ่มต้นหรือจุดสิ้นสุดของช่วง ExpandPolicy.InsideOnly ที่ยังไม่มีการจัดรูปแบบ
ExpandPolicy.AtStart ข้อความที่พิมพ์ที่ start จะรับรูปแบบมา และข้อความที่พิมพ์ที่ end จะยังคงไม่มีรูปแบบ ข้อความที่พิมพ์ไว้ด้านหน้าข้อความที่มีการจัดรูปแบบโดยตรงควรใช้การจัดรูปแบบเดียวกันด้วย ภาพเคลื่อนไหวแสดงข้อความที่พิมพ์ที่จุดเริ่มต้นของช่วง ExpandPolicy.AtStart ซึ่งรับค่ารูปแบบมา ขณะที่ข้อความที่พิมพ์ที่จุดสิ้นสุดยังคงไม่มีรูปแบบ
ExpandPolicy.AtEnd ข้อความที่พิมพ์ที่ end จะรับรูปแบบมา และข้อความที่พิมพ์ที่ start จะยังคงไม่มีรูปแบบ การพิมพ์ในเครื่องมือแก้ไข Rich Text มาตรฐาน ซึ่งการพิมพ์ต่อหลังจากคำที่เป็นตัวหนาหรือตัวเอียงจะยังคงใช้การจัดรูปแบบนั้นต่อไป ภาพเคลื่อนไหวแสดงข้อความที่พิมพ์ที่ส่วนท้ายของช่วง ExpandPolicy.AtEnd ซึ่งรับค่ารูปแบบมา ขณะที่ข้อความที่พิมพ์ที่ส่วนต้นยังคงไม่มีรูปแบบ
ExpandPolicy.AtBoth ข้อความที่พิมพ์ที่ start หรือ end จะขยายช่วงและรับรูปแบบมา ไฮไลต์ที่ใช้งานอยู่หรือบล็อกที่จัดรูปแบบต่อเนื่องซึ่งควรขยายจากขอบด้านใดด้านหนึ่ง ภาพเคลื่อนไหวแสดงข้อความที่พิมพ์ที่จุดเริ่มต้นหรือจุดสิ้นสุดของช่วง ExpandPolicy.AtBoth ที่รับช่วงรูปแบบ

ใช้การจัดรูปแบบอักขระด้วย SpanStyle

ใช้ addStyle กับ SpanStyle เพื่อใช้การจัดรูปแบบระดับอักขระ เช่น สีข้อความ ไฮไลต์พื้นหลัง FontWeight FontStyle หรือขีดเส้นใต้ TextRange ที่เฉพาะเจาะจงในบัฟเฟอร์

ลักษณะการทำงานที่สำคัญสำหรับช่วง SpanStyle

  • ลำดับการจัดเลเยอร์: ระบบจะใช้รูปแบบตามลำดับที่คุณเพิ่มลงในบัฟเฟอร์ หากช่วง SpanStyle ที่ทับซ้อนกันหลายช่วงตั้งค่าพร็อพเพอร์ตี้เดียวกันด้วยค่าที่ต่างกัน รูปแบบที่เพิ่มในภายหลังจะแสดงผลที่ด้านบน
  • ข้อกำหนดช่วงที่ไม่ว่างเปล่า: คุณต้องส่งช่วงที่ไม่ยุบซึ่ง start < end การเรียก addStyle ในส่วนที่เลือกแบบยุบที่มีความยาว 0 จะไม่มีผล และการส่งดัชนีที่กลับด้านหรืออยู่นอกขอบเขตจะแสดง 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 ที่ใช้การจัดรูปแบบตัวหนาและตัวเอียงสีแดงแบบไดนามิกขณะที่ผู้ใช้พิมพ์
รูปที่ 1 ใช้การจัดรูปแบบตัวหนาและตัวเอียงสีแดงกับข้อมูลจากผู้ใช้แบบไดนามิก

ใช้การจัดรูปแบบบล็อกด้วย ParagraphStyle

ใช้ addStyle กับ ParagraphStyle เพื่อใช้การจัดรูปแบบโครงสร้างระดับบล็อก เช่น textAlign, lineHeight, textDirection หรือ textIndent กับช่วงย่อหน้า

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
        )
    }
}

TextField ที่สลับการจัดแนวของย่อหน้าระหว่างเริ่มต้น กึ่งกลาง และสิ้นสุดโดยใช้ ParagraphStyle
รูปที่ 2 สลับการจัดข้อความของย่อหน้าระหว่างเริ่มต้น กึ่งกลาง และสิ้นสุดโดยใช้ ParagraphStyle

ข้อผิดพลาดที่ควรหลีกเลี่ยงเมื่อใช้ ParagraphStyle

เนื่องจาก ParagraphStyle มีผลกับบล็อกข้อความทั้งหมด จึงควรหลีกเลี่ยงการใช้ในสถานการณ์ต่อไปนี้

  • การขยายไปยังย่อหน้าที่อยู่ติดกัน: ตั้งค่า ExpandPolicy อย่างระมัดระวังเพื่อให้ช่วง ParagraphStyle ที่อยู่ติดกันไม่ทับซ้อนกันหลังจากการแก้ไขของผู้ใช้ ตัวอย่างเช่น หาก [0, 5) ใช้ ExpandPolicy.AtEnd และ [5, 10) ใช้ ExpandPolicy.AtStart การแทรกข้อความที่ดัชนี 5 จะขยายทั้ง 2 รูปแบบให้ทับซ้อนกันและแสดง IllegalArgumentException
  • การซ้อนช่วง ParagraphStyle: แม้ว่าช่วงที่ซ้อนกันอย่างสมบูรณ์ (เช่น [0, 10) และ [2, 8)) จะใช้ได้ใน AnnotatedString แต่ให้หลีกเลี่ยงการซ้อนช่วง ParagraphStyle ในช่องข้อความ เลย์เอาต์จะแยกช่วงที่ซ้อนกันออกเป็นย่อหน้าแยกต่างหาก และการแก้ไขในภายหลังอาจทำให้ช่วงที่ซ้อนกันกลายเป็นการทับซ้อนกันบางส่วนโดยไม่ตั้งใจ

อัปเดตรูปแบบในตำแหน่งด้วย TrackedRange

การเรียก addStyle ด้วย TextRange และ ExpandPolicy จะแสดงผลแฮนเดิล TrackedRange ซึ่งอาจเป็น TrackedRange<SpanStyle> หรือ TrackedRange<ParagraphStyle> TrackedRange คือการอ้างอิงแบบไดนามิกไปยังช่วงสไตล์ใน TextFieldBuffer ซึ่งจะอัปเดตออฟเซ็ตอักขระ start และ end โดยอัตโนมัติเมื่อมีการแทรก ลบ หรือแทนที่ข้อความรอบๆ

ความสามารถในการเปลี่ยนแปลงได้ในตำแหน่ง

คุณไม่จำเป็นต้องเรียกใช้ removeStyle และ addStyle เพื่ออัปเดตรูปแบบที่มีอยู่หรือปรับขอบเขต ภายในขอบเขต TextFieldBuffer TrackedRange จะมีพร็อพเพอร์ตี้ส่วนขยายที่อัปเดตช่วงเวลาในตำแหน่งเดิมขณะที่ยังคงลำดับการจัดเลเยอร์เดิมที่สัมพันธ์กับสไตล์อื่นๆ ดังนี้

  • TrackedRange<SpanStyle>.spanStyle: อ่านหรือแทนที่เพย์โหลด SpanStyle ที่มีอยู่ใน TrackedRange<SpanStyle>
  • TrackedRange<ParagraphStyle>.paragraphStyle: อ่านหรือแทนที่เพย์โหลด ParagraphStyle ในตำแหน่งบน TrackedRange<ParagraphStyle>
  • TrackedRange<*>.textRange: อ่านหรืออัปเดตขอบเขต TextRange ปัจจุบันในตำแหน่งเดิม การตั้งค่าช่วงที่ยุบ กลับด้าน หรืออยู่นอกขอบเขตจะแสดงข้อผิดพลาด IllegalArgumentException
  • TrackedRange<*>.expandPolicy: อ่านหรืออัปเดต ExpandPolicy ในตำแหน่งเดิม
  • TrackedRange<*>.isValid: แสดงผล true หากช่วงสไตล์ยังคงทำงานอยู่ในบัฟเฟอร์ หรือ false หากการแก้ไขทำให้ความยาวลดลงเหลือ 0 หรือหากถูกนำออก

// 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)
}

การจำกัดขอบเขตและกฎวงจร

โปรดคำนึงถึงขอบเขตและกฎวงจรต่อไปนี้เมื่อทำงานกับอินสแตนซ์ TrackedRange

  • จำกัดเฉพาะ TextFieldBuffer: พร็อพเพอร์ตี้ส่วนขยาย TrackedRange จะมีอยู่ภายในขอบเขต TextFieldBuffer ที่สร้างหรือค้นหาแฮนเดิลเท่านั้น
  • การตัดแต่งอัตโนมัติเมื่อยุบ: หากการแก้ไขของผู้ใช้ลบอักขระทั้งหมดที่ครอบคลุมโดย TrackedRange ความยาวจะลดลงเหลือ 0, isValid จะแสดงผลเป็น false และระบบจะนำสไตล์ออกจากบัฟเฟอร์

ค้นหาและนำรูปแบบออก

Compose มี 2 วิธีในการตรวจสอบสไตล์ที่ใช้งานอยู่ โดยขึ้นอยู่กับว่าคุณกำลังเปลี่ยนแปลงบัฟเฟอร์หรืออ่านสถานะสำหรับ UI ดังนี้

  • ภายในบล็อก TextFieldBuffer: เรียกใช้ getSpanStyles และ getParagraphStyles โดยตรงในตัวรับ TextFieldBuffer เพื่อ รับแฮนเดิล TrackedRange ที่เปลี่ยนแปลงได้
  • นอกบล็อกการแก้ไข: อ่าน TextFieldState.textStyles ซึ่งจะแสดงรายการ AnnotatedString.Range ที่เปลี่ยนแปลงไม่ได้ซึ่งแสดงสถานะที่คอมมิตแล้ว

ค้นหาสไตล์ที่ตัดกันใน TextFieldBuffer

TextFieldBuffer.getSpanStyles และ TextFieldBuffer.getParagraphStyles จะแสดงผลอินสแตนซ์ TrackedRange ทั้งหมดที่ตัดกับ TextRange ที่ระบุ โดยจัดเรียงตามเวลาที่เพิ่มลงในบัฟเฟอร์

ตัวอย่างเช่น คุณสามารถค้นหา getSpanStyles(selection) ภายใน state.edit เพื่อตรวจสอบหรืออัปเดตรูปแบบที่ครอบคลุมการเลือกปัจจุบันของผู้ใช้ ดังนี้

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
    }
    // ...
}

ช่วงสไตล์จะใช้ช่วงทางคณิตศาสตร์แบบกึ่งเปิด [start, end) ระบบจะแสดงผลรูปแบบการแตะหรือไม่นั้นขึ้นอยู่กับว่าคุณค้นหาการเลือกหรือจุดเคอร์เซอร์เดียว

  • สำหรับส่วนที่เลือกซึ่งมีข้อมูล [start, end) ให้ทำดังนี้
    • แสดงผล: รูปแบบใดก็ตามที่ทับซ้อน มี หรืออยู่ภายในช่วงที่เลือก เช่น คำค้นหา [5, 15) จะตรงกับ [0, 20), [8, 12) หรือ [0, 10)
    • ไม่แสดงผล: รูปแบบที่แตะเฉพาะขอบเขตด้านนอกโดยไม่ทับซ้อนกับอักขระ เช่น การค้นหา [5, 15) จะยกเว้น [0, 5) และ [15, 25)
  • สำหรับเคอร์เซอร์ที่ยุบ [index, index] ให้ทำดังนี้
    • แสดงผล: รูปแบบใดก็ตามที่มีเคอร์เซอร์หรือเริ่มต้นที่ตำแหน่งเคอร์เซอร์ ตัวอย่างเช่น เคอร์เซอร์ที่ 10 จะตรงกับ [0, 20) และ [10, 20)
    • ไม่แสดงผล (แตะกฎสิ้นสุด): รูปแบบใดๆ ที่สิ้นสุดที่ตำแหน่งเคอร์เซอร์ เช่น เคอร์เซอร์ที่ 10 จะไม่รวม [0, 10)

นำออกหรือสลับรูปแบบด้วย removeStyle

ส่งแฮนเดิล TrackedRange ไปยัง removeStyle เพื่อลบช่วงสไตล์ที่เฉพาะเจาะจงนั้นออกจาก TextFieldBuffer

removeStyle จะแสดงผล true หากพบและนำ TrackedRange ออก หรือแสดงผล false หากนำออกไปแล้วหรือเป็นของบัฟเฟอร์อื่น โดยจะนำเฉพาะแฮนเดิล TrackedRange ที่กำหนดเป้าหมายออก และปล่อยให้สไตล์อื่นๆ ที่ทับซ้อนกันยังคงอยู่

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
            )
        }
    }
}

TextField ที่เปิดและปิดการจัดรูปแบบตัวหนาสำหรับคำที่เลือก
รูปที่ 3 สลับระหว่างการทำข้อความให้เป็นตัวหนาและไม่เป็นตัวหนา

แยกหรือเรียงลำดับ Rich Text

หากต้องการแสดงอินพุตที่จัดรูปแบบใน Text Composable แบบอ่านอย่างเดียว หรือแปลงเป็นอนุกรมสำหรับบริการแบ็กเอนด์ ให้อ่าน TextFieldState.textStyles นอกบล็อกแก้ไข

state.textStyles.getSpanStyles และ state.textStyles.getParagraphStyles จะแสดงผล List<AnnotatedString.Range<SpanStyle>> และ List<AnnotatedString.Range<ParagraphStyle>> ซึ่งคุณสามารถส่งไปยังตัวสร้าง AnnotatedString ได้โดยตรง ดังนี้

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

ตัวอย่างที่เสร็จสมบูรณ์ใน Jetchat

หากต้องการดูการใช้งานที่สมบูรณ์ซึ่งรวมการจัดรูปแบบอักขระในบรรทัด (ตัวหนา ตัวเอียง ขีดเส้นใต้ ขีดทับ และสี) โปรดดูตัวอย่าง Jetchat ใน GitHub ดังนี้

  • RichTextState.kt: รวมการค้นหาสไตล์ TextFieldState, การเปิด/ปิดช่วง, การตัดหรือแยกขอบเขตการเลือก และอื่นๆ
  • UserInput.kt: เชื่อมต่อ RichTextState กับแถบเครื่องมือการจัดรูปแบบ Rich Text และช่องป้อนข้อความ
แอปตัวอย่าง Jetchat ที่สาธิตการจัดรูปแบบ Rich Text ในแถบป้อนข้อความ
รูปที่ 4 การแก้ไข Rich Text ใน Jetchat