Apps foundation

การสร้างประสบการณ์การแก้ไขข้อความที่เชื่อถือได้ใน Compose

ลำโพง

  • Anastasia Soboleva

    วิศวกรซอฟต์แวร์

  • Haoyu Zhang

    วิศวกรอาวุโสฝ่ายซอฟต์แวร์

เกี่ยวกับ

เกี่ยวกับการเสวนา

เส้นทางการแก้ไขข้อความที่เชื่อถือได้ใน Compose สิ้นสุดลงด้วยการเขียน BasicTextField ใหม่ทั้งหมด การเปลี่ยนไปใช้สถาปัตยกรรมที่ขับเคลื่อนด้วยบัฟเฟอร์นี้ช่วยขจัดภาวะแข่งขันของ IME ได้อย่างถาวร เซสชันนี้จะอธิบายสาเหตุที่ต้องยกเลิกการซิงค์อินพุตแบบเดิม และวิธีที่ TextFieldState ช่วยให้การอัปเดตแป้นพิมพ์ซิงค์กันอยู่เสมอ จากนั้นเจาะลึกข้อมูลเจาะลึกการแก้ไขที่จัดรูปแบบเพื่อสำรวจกลไกของ TrackedRange, ต้นทุนด้านประสิทธิภาพของบล็อกการแก้ไข และข้อจำกัดในชีวิตจริงเบื้องหลังขอบเขตสไตล์ ก่อนที่จะดูตัวอย่างการแสดงผลมาร์กดาวน์ก่อนใคร

สรุป

  • โอเวอร์โหลด TextFieldState API จะแทนที่โอเวอร์โหลดแบบ Callback ใน Compose โดยจะอัปเดตพร้อมกันกับการพิมพ์ของผู้ใช้ ซึ่งช่วยขจัดปัญหาเรื่องเวลาด้วยแป้นพิมพ์เสมือนและการจัดองค์ประกอบใหม่ที่ไม่จำเป็น
  • การเปลี่ยนรูปแบบอินพุตและเอาต์พุตช่วยให้คุณจัดการข้อความโดยใช้ TextFieldBuffer ได้ คุณสามารถสกัดกั้นการแก้ไขก่อนที่จะคอมมิตหรือจัดรูปแบบข้อความที่แสดงโดยไม่ต้องคำนวณการแมปออฟเซ็ตด้วยตนเอง
  • การโฮสต์ TextFieldState ขึ้นอยู่กับความต้องการด้านสถาปัตยกรรมของคุณ คุณสามารถเก็บไว้ในเครื่องใน Composable หรือจัดเก็บไว้ใน ViewModel โดยตรงเพื่อเข้าถึงตรรกะทางธุรกิจ
  • Compose 1.12 เพิ่ม API การแก้ไขข้อความที่จัดรูปแบบลงใน TextFieldBuffer คุณสามารถใช้รูปแบบที่มีตัวเลือก ExpandPolicy ที่ปรับแต่งได้ และช่วงรูปแบบที่มีอยู่จะอัปเดตโดยอัตโนมัติเมื่อผู้ใช้แก้ไข
  • ขณะนี้เรากำลังพัฒนาไลบรารีข้อความมาร์กอัปใหม่สำหรับ Jetpack Compose โดยรองรับรูปแบบต่างๆ เช่น มาร์กดาวน์ที่มีการแยกวิเคราะห์และการแสดงผลแบบเพิ่มทีละส่วนสำหรับการแก้ไขและการสตรีมแบบเรียลไทม์

แหล่งข้อมูล

Gemini Notebook

หัวข้อ

  • jetpack-compose
  • textfieldstate
  • การแก้ไขข้อความ
  • basictextfield
  • formatted-text
  • input-transformation

ข้อความถอดเสียง

ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose

  • สวัสดีทุกคน

  • ขอขอบคุณที่เข้าร่วมการพูดคุยของเรา

  • ฉันชื่อ 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

  • เราจึงเปิดตัว 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ไปไว้ที่ใด

  • ในทางปฏิบัติ กรณีการใช้งานเกือบทั้งหมดจะอยู่ในหมวดหมู่ใดหมวดหมู่หนึ่งที่ผมจะพูดถึง

  • และเมื่อไม่เป็นเช่นนั้น โดยปกติแล้วหมายความว่าคุณมีสถาปัตยกรรมที่แหวกแนว แต่ตัวอย่างเหล่านี้จะยังคงแนะนำคุณตลอดการย้ายข้อมูล

  • กรณีที่ง่ายที่สุดคือการเก็บ 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 ทำได้ง่ายขึ้น โปรดติดตามข้อมูลเพิ่มเติม

เครื่องมือแก้ไขข้อความที่จัดรูปแบบและ Rich Text

  • และทุกอย่างที่เราพูดถึงไปแล้ว ไม่ว่าจะเป็น 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<*>): Boolean
  • TrackedRange ออบเจ็กต์จะแสดงผลโดย addStyle หรือ getSpanStyles API ใน TextFieldBuffer

  • ให้คิดว่านี่เป็นแฮนเดิลแบบเรียลไทม์ของช่วงสไตล์ในบัฟเฟอร์

  • คุณสามารถใช้เพื่ออัปเดตช่วงข้อความ ExpandPolicy หรือรูปแบบในตำแหน่งนั้นได้

  • นอกจากนี้ยังมีพร็อพเพอร์ตี้ isValid เพื่อตรวจสอบว่าช่วงสไตล์ยังอยู่ใน TextFieldBuffer หรือไม่

  • เนื่องจากระบบอาจนำสไตล์ออกหากสไตล์ถูกยุบระหว่างการแก้ไข หรือ removeStyle นำออกโดยเจตนา

  • ต่อไปเรามาดูวิธีที่ API เหล่านั้นทำงานร่วมกันในกรณีการใช้งานจริงกัน

การสร้างเครื่องมือแก้ไข WYSIWYG

  • กรณีการใช้งานแรกคือการสร้างเครื่องมือแก้ไข 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 ในแอป โปรดติดต่อเรา

  • และขอขอบคุณทุกคน

รหัส

ข้อมูลโค้ด

ข้อมูลโค้ด Kotlin ที่เวลา 00:29 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose"
TextField(state = rememberTextFieldState())
ข้อมูลโค้ด Kotlin ที่นาที 00:29 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose"
OutlinedTextField(state = rememberTextFieldState())
ข้อมูลโค้ด Kotlin ที่ 00:34 ใน "ข้อมูลเบื้องต้นเกี่ยวกับเครื่องมือแก้ไขข้อความใน Compose"
BasicTextField(state = rememberTextFieldState())
ข้อมูลโค้ด Kotlin ที่นาที 00:44 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose"
SecureTextField(state = rememberTextFieldState())
ข้อมูลโค้ด Kotlin ที่นาที 00:58 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
ข้อมูลโค้ด Kotlin ที่ 01:28 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการแก้ไขข้อความใน Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
ข้อมูลโค้ด Kotlin ที่ 04:01 ใน "Introducing TextFieldState"
// 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) }
ข้อมูลโค้ด Kotlin ที่ 04:09 ใน "Introducing TextFieldState"
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
ข้อมูลโค้ด Kotlin ที่นาที 04:25 ใน "Introducing TextFieldState"
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
ข้อมูลโค้ด Kotlin ที่นาที 04:38 ใน "Introducing TextFieldState"
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
ข้อมูลโค้ด Kotlin ที่ 04:55 ใน "การกรองและการจัดรูปแบบอินพุต"
onValueChange { newString ->
  // full diffing new String vs old String
}
ข้อมูลโค้ด Kotlin ที่ 05:17 ใน "การกรองและการจัดรูปแบบอินพุต"
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
ข้อมูลโค้ด Kotlin ที่นาที 05:33 ใน "การกรองและการจัดรูปแบบอินพุต"
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 06:48 ใน "การกรองและการจัดรูปแบบอินพุต"
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
ข้อมูลโค้ด Kotlin ที่ 06:58 ใน "การกรองและการจัดรูปแบบอินพุต"
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 07:51 ใน "การยกและการย้ายข้อมูล TextFieldState"
@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()) }) { }
    )
}
ข้อมูลโค้ด Kotlin ที่นาที 08:58 ใน "การยกและการย้ายข้อมูล 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)
        }
    }
ข้อมูลโค้ด Kotlin ที่นาที 09:27 ใน "การยกและการย้ายข้อมูล 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,
    )
}
ข้อมูลโค้ด Kotlin ที่ 10:12 ใน "การยกและการย้ายข้อมูล TextFieldState"
@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) { ... }

}
ข้อมูลโค้ด Kotlin ที่นาที 10:30 ใน "การยกและการย้ายข้อมูล TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
ข้อมูลโค้ด Kotlin ที่นาที 10:51 ใน "การยกและการย้ายข้อมูล TextFieldState"
data class FormUiState(val username: TextFieldState)
ข้อมูลโค้ด Kotlin ที่นาที 11:38 ใน "การยกและการย้ายข้อมูล TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
ข้อมูลโค้ด Kotlin ที่นาที 12:09 ใน "การยกและการย้ายข้อมูล TextFieldState"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
ข้อมูลโค้ด Kotlin ที่ 12:55 ใน "การยกและการย้ายข้อมูล TextFieldState"
@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)
}
ข้อมูลโค้ด Kotlin ที่นาที 13:21 ใน "การยกและการย้ายข้อมูล TextFieldState"
@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)
}
ข้อมูลโค้ด Kotlin ที่นาที 13:39 ใน "การยกและการย้ายข้อมูล TextFieldState"
@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)
}
ข้อมูลโค้ด Kotlin ที่ 15:09 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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)
ข้อมูลโค้ด Kotlin ที่ 16:36 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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>
ข้อมูลโค้ด Kotlin ที่นาที 16:57 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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)
}
ข้อมูลโค้ด Kotlin ที่นาที 17:52 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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")
ข้อมูลโค้ด Kotlin ที่ 18:22 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
ข้อมูลโค้ด Kotlin ที่นาที 18:40 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
ข้อมูลโค้ด Kotlin ที่ 18:56 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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")
}
ข้อมูลโค้ด Kotlin ที่นาที 19:14 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
// 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>>
ข้อมูลโค้ด Kotlin ที่นาที 20:03 ใน "การแก้ไขข้อความที่จัดรูปแบบและ Rich Text"
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
ข้อมูลโค้ด Kotlin ที่ 21:01 ใน "การสร้างเครื่องมือแก้ไข WYSIWYG"
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())
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 21:26 ใน "การสร้างเครื่องมือแก้ไข WYSIWYG"
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)
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 22:27 ใน "การสร้างเครื่องมือแก้ไข WYSIWYG"
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
            )
        }
    }
}
ข้อมูลโค้ด Kotlin ที่ 23:42 ใน "การสร้างเครื่องมือแก้ไข WYSIWYG"
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)
        }
    }
}