วิศวกรอาวุโสฝ่ายซอฟต์แวร์
เส้นทางการแก้ไขข้อความที่เชื่อถือได้ใน Compose สิ้นสุดลงด้วยการเขียน BasicTextField ใหม่ทั้งหมด การเปลี่ยนไปใช้สถาปัตยกรรมที่ขับเคลื่อนด้วยบัฟเฟอร์นี้ช่วยขจัดภาวะแข่งขันของ IME ได้อย่างถาวร เซสชันนี้จะอธิบายสาเหตุที่ต้องยกเลิกการซิงค์อินพุตแบบเดิม และวิธีที่ TextFieldState ช่วยให้การอัปเดตแป้นพิมพ์ซิงค์กันอยู่เสมอ จากนั้นเจาะลึกข้อมูลเจาะลึกการแก้ไขที่จัดรูปแบบเพื่อสำรวจกลไกของ TrackedRange, ต้นทุนด้านประสิทธิภาพของบล็อกการแก้ไข และข้อจำกัดในชีวิตจริงเบื้องหลังขอบเขตสไตล์ ก่อนที่จะดูตัวอย่างการแสดงผลมาร์กดาวน์ก่อนใคร
TextFieldState API จะแทนที่โอเวอร์โหลดแบบ Callback ใน Compose โดยจะอัปเดตพร้อมกันกับการพิมพ์ของผู้ใช้ ซึ่งช่วยขจัดปัญหาเรื่องเวลาด้วยแป้นพิมพ์เสมือนและการจัดองค์ประกอบใหม่ที่ไม่จำเป็นTextFieldBuffer ได้ คุณสามารถสกัดกั้นการแก้ไขก่อนที่จะคอมมิตหรือจัดรูปแบบข้อความที่แสดงโดยไม่ต้องคำนวณการแมปออฟเซ็ตด้วยตนเองTextFieldState ขึ้นอยู่กับความต้องการด้านสถาปัตยกรรมของคุณ คุณสามารถเก็บไว้ในเครื่องใน Composable หรือจัดเก็บไว้ใน ViewModel โดยตรงเพื่อเข้าถึงตรรกะทางธุรกิจTextFieldBuffer คุณสามารถใช้รูปแบบที่มีตัวเลือก ExpandPolicy ที่ปรับแต่งได้ และช่วงรูปแบบที่มีอยู่จะอัปเดตโดยอัตโนมัติเมื่อผู้ใช้แก้ไขข้อความถอดเสียง
สวัสดีทุกคน
ขอขอบคุณที่เข้าร่วมการพูดคุยของเรา
ฉันชื่อ Anastasia และนี่คือ Haoyu เพื่อนร่วมงานของฉัน
และวันนี้เราจะแสดงวิธีสร้างประสบการณ์การแก้ไขข้อความที่เชื่อถือได้ให้คุณดู
ไม่ว่าคุณจะสร้างหน้าจอเข้าสู่ระบบแบบง่ายๆ แถบค้นหาขณะพิมพ์ หรือแอปรับส่งข้อความที่ซับซ้อน ช่องข้อความก็เป็นหัวใจสำคัญของวิธีที่ผู้ใช้โต้ตอบกับแอป Android ของคุณ
ดังนั้นพวกคุณส่วนใหญ่อาจใช้ช่องข้อความ Material หรือช่องข้อความที่มีเส้นขอบอยู่แล้ว
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())นอกจากนี้ยังมี BasicTextField สำหรับกรณีที่คุณกำลังสร้างระบบการออกแบบของคุณเอง หรือต้องการช่องแก้ไขแบบง่าย
BasicTextField(state = rememberTextFieldState())นอกจากนี้ยังมี SecureTextField ซึ่งออกแบบมาเพื่อป้อนรหัสผ่านโดยเฉพาะ
SecureTextField(state = rememberTextFieldState())และ BasicTextField เป็นคอมโพเนนต์ที่ไม่มีการจัดรูปแบบและเป็นตัวขับเคลื่อนช่องข้อความอื่นๆ ทั้งหมดใน Compose
คุณจึงอาจสังเกตเห็นว่าในช่วง 2-3 ปีที่ผ่านมา เรามี API 2 รูปแบบที่ทำงานควบคู่กัน โดยรูปแบบหนึ่งจะรับค่าและเรียกกลับการเปลี่ยนแปลงค่า ส่วนอีกรูปแบบหนึ่งจะรับออบเจ็กต์ TextFieldState
@Composable
public fun BasicTextField(
state: TextFieldState,
)คุณอาจสงสัยว่าเราแนะนำให้ใช้ตัวเลือกใด
เราขอแนะนำอย่างยิ่งให้ใช้โอเวอร์โหลด TextFieldState
และเราจะพูดคุยกันในการบรรยายนี้ว่าทำไมจึงมีประโยชน์ในแง่ของ API, ฟีเจอร์ และประสิทธิภาพ
ซึ่งมีความเสถียรตั้งแต่ Compose 1.7
สำหรับการโอเวอร์โหลด Callback เราจะเลิกใช้งานแบบซอฟต์ใน Compose 1.13 ใน BasicTextField และ Wrapper ของ Material
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unitซึ่งหมายความว่าเราจะไม่เพิ่มฟีเจอร์ใหม่หรือแก้ไขข้อบกพร่อง เว้นแต่คุณจะทราบถึงปัญหาด้านความปลอดภัยหรือการช่วยเหลือพิเศษที่ร้ายแรง
ค่าแบบไม่เก็บสถานะ (Stateless) ในการโอเวอร์โหลดการเปลี่ยนแปลงค่าทำงานได้ดีมากสำหรับอินพุตแบบซิงโครนัส
แต่ทันทีที่คุณจับคู่กับสตรีมแบบอะซิงโครนัส เช่น StateFlow ก็ทำให้เกิดปัญหาด้านเวลาเกี่ยวกับ IME หรือแป้นพิมพ์เสมือน (ตามศัพท์เทคนิค)
ซึ่งทำให้เกิดอักขระที่หายไป อักขระที่ซ้ำกัน เคอร์เซอร์กระโดด ดังที่คุณเห็นในการบันทึกนี้
เราจึงเปิดตัว TextFieldState เพื่อแก้ไขปัญหานี้อย่างถาวร
โดยจะอัปเดตพร้อมกันเมื่อผู้ใช้พิมพ์โดยไม่ต้องรอการไปกลับแบบไม่พร้อมกัน ซึ่งเป็นสิ่งที่ IME ต้องการ
TextFieldState นอกจากข้อความแบบอ่านอย่างเดียวแล้ว ยังมีพร็อพเพอร์ตี้อื่นๆ ที่สร้างเซสชันการแก้ไขที่ใช้งานอยู่ด้วย
ซึ่งก็คือการเลือก สถานะเลิกทำและทำซ้ำ รวมถึงสแปนและรูปแบบสแปน และรูปแบบย่อหน้าสำหรับการแก้ไขข้อความที่จัดรูปแบบ ซึ่งเราจะพูดถึงเรื่องนี้เพิ่มเติมในภายหลัง
พร็อพเพอร์ตี้ภายในของ TextFieldState ได้รับการสนับสนุนโดยระบบ Compose Snapshot
ซึ่งหมายความว่าเมื่อคุณอ่านข้อความหรือการเลือกจากออบเจ็กต์ TextFieldState นั้นใน Compose ขอบเขตที่เฉพาะเจาะจงที่คุณอ่านจะสมัครรับข้อมูลอัปเดต
ซึ่งช่วยให้ 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
}ดังนั้น การปฏิเสธหรือแก้ไขอินพุตนั้นจึงขัดแย้งกับแป้นพิมพ์
นอกจากนี้ Callback ของ onValueChange จะให้เฉพาะสตริงสุดท้ายเท่านั้น
ดังนั้น หากต้องการทำความเข้าใจว่ามีอะไรเปลี่ยนแปลงไปบ้าง คุณต้องทำการเปรียบเทียบสตริงแบบเต็ม
แต่ในการโอเวอร์โหลดใหม่ เรามีการเปลี่ยนรูปแบบอินพุต
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}ดังนั้นจึงสกัดกั้นอินพุตของผู้ใช้ก่อนที่อินพุตนั้นจะไปถึงสถานะช่องข้อความ และให้ API ของ TextFieldBuffer เดียวกันแก่คุณ
นี่คือวิธีใช้การกรองเฉพาะตัวเลขด้วยการเปลี่ยนรูปแบบอินพุต และโดยพื้นฐานแล้วคุณเพียงแค่ทำการเปลี่ยนแปลงเท่านั้น
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}คุณตรวจสอบว่ามีอักขระที่ไม่ใช่ตัวเลขหรือไม่ และหากมี คุณจะปฏิเสธข้อมูลที่ป้อนโดยเรียกใช้ revertAllChanges ใน TextFieldBuffer นั้น
และความสามารถหลักของการเปลี่ยนรูปแบบอินพุตนี้คือ ChangeList
ดังนั้น แทนที่จะเปรียบเทียบสตริงแบบเต็ม ฟีเจอร์นี้จะช่วยให้คุณตรวจสอบช่วงการแก้ไขที่แน่นอน แล้วเปลี่ยนช่วงเหล่านั้นในตำแหน่งเดิมหรือปฏิเสธข้อมูลที่ป้อนทั้งหมดหากจำเป็น
และ Compose จะซิงค์แป้นพิมพ์และเคอร์เซอร์ให้คุณโดยอัตโนมัติ
สุดท้ายนี้ จะเกิดอะไรขึ้นหากคุณต้องการจัดรูปแบบภาพของอินพุต
เช่น คุณต้องการเพิ่มวงเล็บหรือขีดกลางลงในหมายเลขบัตรเครดิตหรือหมายเลขโทรศัพท์
อ๋อ ใช่
และคุณไม่ต้องการจัดเก็บข้อมูลเหล่านั้นไว้ในฐานข้อมูลใช่ไหม
ก่อนหน้านี้คุณใช้ Visual Transformation API
ในกรณีนั้น คุณต้องใช้การแมปแบบ 2 ทิศทางจากอินพุตดิบไปยังเอาต์พุตภาพ แล้วย้อนกลับ
และเป็นเรื่องยากมากที่จะทำให้ถูกต้อง ซึ่งมักจะนำไปสู่ข้อยกเว้นรันไทม์
ตอนนี้เรามีการเปลี่ยนรูปแบบเอาต์พุต และภายใต้การทำงานนั้นก็ขับเคลื่อนโดย TextFieldBuffer เดียวกัน คุณจึงได้รับ API ที่เหมือนกันมาก
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}ดังนั้นในตัวอย่างหมายเลขโทรศัพท์นี้ หากต้องการแทรกขีดกลางเหล่านั้นในดัชนีที่เฉพาะเจาะจง คุณเพียงแค่เรียกใช้ฟังก์ชันแทรกของ TextFieldBuffer แล้วระบบจะคำนวณการแมปออฟเซ็ตแบบ 2 ทิศทางให้คุณ
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}ซึ่งช่วยลดการคำนวณด้วยตนเองและลดจำนวนข้อขัดข้องได้อย่างมาก
ทีนี้ก็มาถึงส่วนที่สำคัญที่สุด นั่นคือคุณจะย้ายข้อมูลอย่างไร และจะนำTextFieldStateไปไว้ที่ใด
ในทางปฏิบัติ กรณีการใช้งานเกือบทั้งหมดจะอยู่ในหมวดหมู่ใดหมวดหมู่หนึ่งที่ผมจะพูดถึง
และเมื่อไม่เป็นเช่นนั้น โดยปกติแล้วหมายความว่าคุณมีสถาปัตยกรรมที่แหวกแนว แต่ตัวอย่างเหล่านี้จะยังคงแนะนำคุณตลอดการย้ายข้อมูล
กรณีที่ง่ายที่สุดคือการเก็บ TextFieldState ไว้ใน Composable ที่มีช่องข้อความนั้นอยู่
@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()) }) { }
)
}กรณีนี้คือเมื่อช่องข้อความเป็นช่องป้อนข้อมูลแบบสมบูรณ์ในตัว เช่น ช่องป้อนรหัสโปรโมชัน และคุณต้องการเข้าถึงข้อความนั้นเมื่อผู้ใช้กดปุ่มส่งเท่านั้น ใช่ไหม
ดังนั้น View Model จึงไม่จำเป็นต้องสกัดกั้นอินพุตของผู้ใช้ทุกครั้งที่มีการเปลี่ยนแปลง
คุณจึงเพียงแค่เรียกใช้ 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 ในกรณีนี้มาใช้ซ้ำได้
จากนั้นคุณก็เพียงส่งออบเจ็กต์ตัวเก็บสถานะนี้ไปยัง Composable
@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 ใหม่ ช่องป้อนข้อมูลจะสูญเสียสถานะทั้งหมด
ซึ่งจะเป็นเหมือนการเลือก ตำแหน่งเคอร์เซอร์ หรือประวัติการเลิกทำ/ทำซ้ำ
ดังนั้น ให้กำหนดให้ TextFieldState เป็นพร็อพเพอร์ตี้ใน ViewModel โดยตรงแทน
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}และไม่ต้องกังวลเกี่ยวกับกฎที่ว่าไม่มี UI ใน ViewModel เนื่องจากในกรณีนี้ 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 เท่านั้น และคุณไม่สามารถนำทรัพยากร Dependency ของ Compose-Foundation มาใช้ได้
หรือในกรณีที่คุณกำลังย้ายข้อมูลหน้าจอเดิมที่ซับซ้อน และไม่สามารถอัปเดตหรือเปลี่ยนโครงสร้างภายในโค้ดของสถานะ UI ได้ในตอนนี้
ในกรณีนี้ ให้ใช้วิธีการที่สอดคล้องกัน
ดังนั้น คุณจึงเก็บ TextFieldState ไว้ใน Composable เป็นแหล่งข้อมูลที่เชื่อถือได้เพียงแหล่งเดียวสำหรับช่องข้อความนั้น
แต่จากนั้น คุณใช้ 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 และเรียกใช้เมธอด add-- call addStyle เพื่อใช้ SpanStyle หรือ ParagraphStyle กับช่วงที่กำหนด
ซึ่งเหมาะกับข้อความแบบคงที่
ดังนั้นเมื่อเราดูการแก้ไขข้อความที่จัดรูปแบบ ความคิดแรกของเราคือ เราจะทำให้ AnnotatedString แก้ไขได้ไหม
แต่เราก็ตระหนักได้อย่างรวดเร็วว่านั่นไม่ใช่เรื่องง่าย
AnnotatedString เป็นโครงสร้างข้อมูลที่เปลี่ยนแปลงไม่ได้
แต่ในโลกของการแก้ไข ผู้ใช้จะพิมพ์อยู่ตลอดเวลา
และทุกครั้งที่กดแป้นพิมพ์ เราต้องจัดสรรออบเจ็กต์ AnnotatedString ใหม่
และใน AnnotatedString แต่ละรายการจะมี AnnotatedString.Range จำนวนมาก
ดังนั้น คุณจึงต้องสร้างออบเจ็กต์เหล่านั้นขึ้นมาใหม่ด้วย
ซึ่งไม่สามารถปรับขนาดได้ดีนักและทำให้เกิดค่าใช้จ่ายในการจัดสรรที่สูงมาก
ลองดูตัวอย่างนี้
เมื่อเราแทรกคำตรงกลาง ทุกสไตล์หลังจากนั้นจะเปลี่ยนช่วงและได้รับการจัดสรรใหม่
ซึ่งวิธีนี้ใช้ไม่ได้ผลอย่างแน่นอนเมื่อคุณมีสไตล์จำนวนมาก
เราจึงได้เปิดตัว API การจัดรูปแบบเฉพาะภายใน TextFieldBuffer ที่ Anastasia ได้แนะนำไปก่อนหน้านี้แทน
ซึ่งคล้ายกับ API การจัดรูปแบบใน AnnotatedString มาก
มาเจาะลึกวิธีการทำงานของฟีเจอร์เหล่านี้กัน
ดังนั้น TextFieldBuffer จึงมี addStyle เพื่อใช้ SpanStyle หรือ ParagraphStyle กับช่วง เช่นเดียวกับ AnnotatedString.Builder
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 ช่วงของสไตล์จะอัปเดตโดยอัตโนมัติเมื่อมีการแก้ไขข้อความ
ในตัวอย่างนี้ เราจะเริ่มด้วยการทำให้ข้อความบางส่วนเป็นตัวหนา
เมื่อคุณแทรกข้อความก่อนหน้า ช่วงรูปแบบจะเลื่อนไปข้างหน้า
และเมื่อคุณแทรกข้อความลงในนั้น ช่วงข้อความจะขยายออก
และเมื่อเราลบข้อความภายในช่วง ช่วงของสไตล์ก็จะหดลงตามไปด้วย
และหากข้อความทั้งหมดภายในช่วงสไตล์ถูกลบ สไตล์จะยุบและถูกนำออกจากบัฟเฟอร์โดยอัตโนมัติ
แล้วจะเกิดอะไรขึ้นเมื่อแทรกข้อความที่ขอบเขตของช่วงสไตล์
ข้อความที่แทรกใหม่จะครอบคลุมโดยสไตล์หรือไม่
ดังนั้น เราจึงได้เปิดตัว API ExpandPolicy รายการเพื่อให้คุณควบคุมเรื่องนี้ได้
มาดูรายละเอียดกัน
ข้อแรกคือ 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 ")ตามชื่อเลย การแทรกข้อความที่จุดเริ่มต้นของช่วงจะขยายช่วงของสไตล์
ดังนั้นเมื่อเราแทรกข้อความก่อนที่ช่วงรูปแบบจะขยายเพื่อครอบคลุมข้อความที่แทรกใหม่
เราจึงมีวิดีโอสาธิตอีกรายการหนึ่งที่แสดงวิธีการทำงานของฟีเจอร์นี้
อันดับที่ 3 คือ 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 ทั้งหมดที่ใช้จัดสไตล์ไปแล้ว
นอกจากนี้ เรายังได้เปิดตัว API การค้นหาใหม่ เช่น getSpanStyles, getParagraphStyles เพื่อดึงข้อมูลรูปแบบที่ใช้อยู่ในปัจจุบันในช่วงที่กำหนด
// 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 การค้นหามี 2 เวอร์ชัน ดังนี้
ในวันที่ 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 ออบเจ็กต์จะแสดงผลโดย addStyle หรือ getSpanStyles API ใน TextFieldBuffer
ให้คิดว่านี่เป็นแฮนเดิลแบบเรียลไทม์ของช่วงสไตล์ในบัฟเฟอร์
คุณสามารถใช้เพื่ออัปเดตช่วงข้อความ ExpandPolicy หรือรูปแบบในตำแหน่งนั้นได้
นอกจากนี้ยังมีพร็อพเพอร์ตี้ isValid เพื่อตรวจสอบว่าช่วงสไตล์ยังอยู่ใน TextFieldBuffer หรือไม่
เนื่องจากระบบอาจนำสไตล์ออกหากสไตล์ถูกยุบระหว่างการแก้ไข หรือ removeStyle นำออกโดยเจตนา
ต่อไปเรามาดูวิธีที่ API เหล่านั้นทำงานร่วมกันในกรณีการใช้งานจริงกัน
กรณีการใช้งานแรกคือการสร้างเครื่องมือแก้ไข WYSIWYG (What You See Is What You Get)
ในตัวอย่างนี้ เราได้ใช้ฟีเจอร์ปุ่มตัวหนา
การคลิกปุ่มตัวหนาจะเป็นการสลับรูปแบบตัวหนาในข้อความที่เลือก
ต่อไปเรามาดูโค้ดกัน
ดังนั้นเมื่อผู้ใช้แตะปุ่มตัวหนา เราจะเรียก 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
ต่อไป เรามาดูการจัดรูปแบบตามรูปแบบกัน
ในการสาธิตนี้ เมื่อผู้ใช้พิมพ์เครื่องหมายดอกจันมาร์กดาวน์รอบคำ เอดิเตอร์จะแปลงคำนั้นเป็นการทำข้อความเป็นตัวหนาโดยอัตโนมัติขณะที่ผู้ใช้พิมพ์
ตอนนี้เรามาดูวิธีติดตั้งใช้งานโดยใช้ 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
)
}
}
}ในตัวกรองอินพุตการเปลี่ยนรูปแบบ เราจะค้นหารูปแบบที่ตรงกันทั้งหมดเหนือปุ่ม
และสําหรับการจับคู่แต่ละรายการ เราจะดึงเนื้อหาภายในเครื่องหมายดอกจัน 2 ตัว
ในตัวอย่างนี้คือคำว่า "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 เป็นต้นไป
และสุดท้ายนี้ ฉันอยากแชร์ตัวอย่างโปรเจ็กต์ใหม่ที่เรากำลังทำอยู่
ซึ่งเป็นไลบรารีข้อความมาร์กอัปสำหรับ Jetpack Compose ที่รองรับรูปแบบ Rich Text เช่น มาร์กดาวน์
ความสามารถที่น่าตื่นเต้นที่สุดอย่างหนึ่งที่เราสนับสนุนคือการแยกวิเคราะห์และการแสดงผลแบบเพิ่มทีละส่วน
จึงเหมาะอย่างยิ่งสำหรับการแก้ไขแบบสดหรือการสตรีมมาร์กดาวน์
นี่คือบันทึกการทำงานของต้นแบบที่ใช้งานได้จริง และคุณจะเห็นว่าพื้นที่ที่จางลงคือส่วนที่อัปเดตใน UI
หากคุณสนใจหรือมี Use Case ในแอป โปรดติดต่อเรา
และขอขอบคุณทุกคน
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)
}
}
}