การป้อนข้อมูล 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.AtStart |
ข้อความที่พิมพ์ที่ start จะรับรูปแบบมา และข้อความที่พิมพ์ที่ end จะยังคงไม่มีรูปแบบ |
ข้อความที่พิมพ์ไว้ด้านหน้าข้อความที่มีการจัดรูปแบบโดยตรงควรใช้การจัดรูปแบบเดียวกันด้วย | ![]() |
ExpandPolicy.AtEnd |
ข้อความที่พิมพ์ที่ end จะรับรูปแบบมา และข้อความที่พิมพ์ที่ start จะยังคงไม่มีรูปแบบ |
การพิมพ์ในเครื่องมือแก้ไข Rich Text มาตรฐาน ซึ่งการพิมพ์ต่อหลังจากคำที่เป็นตัวหนาหรือตัวเอียงจะยังคงใช้การจัดรูปแบบนั้นต่อไป | ![]() |
ExpandPolicy.AtBoth |
ข้อความที่พิมพ์ที่ start หรือ end จะขยายช่วงและรับรูปแบบมา |
ไฮไลต์ที่ใช้งานอยู่หรือบล็อกที่จัดรูปแบบต่อเนื่องซึ่งควรขยายจากขอบด้านใดด้านหนึ่ง | ![]() |
ใช้การจัดรูปแบบอักขระด้วย 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 ) } } } )
ใช้การจัดรูปแบบบล็อกด้วย 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 ) } }
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ปัจจุบันในตำแหน่งเดิม การตั้งค่าช่วงที่ยุบ กลับด้าน หรืออยู่นอกขอบเขตจะแสดงข้อผิดพลาดIllegalArgumentExceptionTrackedRange<*>.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 ) } } }
แยกหรือเรียงลำดับ 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 และช่องป้อนข้อความ



