پایه برنامه‌ها

ایجاد تجربه‌های ویرایش نوشتار قابل‌اعتماد در Compose

بلندگو

  • آناستازیا سوبولوا

    مهندس نرم‌افزار

  • Haoyu Zhang

    مهندس نرم‌افزار ارشد

درباره

درباره سخنرانی

مسیر رسیدن به ویرایش نوشتار قابل‌اعتماد در Compose با بازنویسی کامل BasicTextField به اوج خود رسید. این تغییر به معماری مبتنی بر میان‌گیر، شرایط رقابت IME را برای همیشه ازبین برد. این جلسه توضیح می‌دهد که چرا همگام‌سازی ورودی قدیمی باید حذف می‌شد و چگونه TextFieldState به‌روزرسانی‌های صفحه‌کلید را همگام نگه می‌دارد. از آنجا، به ویرایش قالب‌بندی‌شده عمیقاً بپردازید تا مکانیک TrackedRange، هزینه‌های عملکرد بلوک‌های ویرایش، و مشکلات واقعی پشت مرزهای سبک را کاوش کنید، قبل‌از اینکه نگاهی اولیه به رندر Markdown داشته باشید.

خلاصه

  • سرریز API TextFieldState جایگزین سرریزهای مبتنی بر فراخوانی برگشتی در Compose می‌شود. این ویژگی به‌طور هم‌زمان با تایپ کاربر به‌روز می‌شود و مشکلات زمان‌بندی با صفحه‌کلیدهای نرم‌افزاری و بازترکیب‌های غیرضروری را برطرف می‌کند.
  • تبدیل‌های ورودی و خروجی به شما امکان می‌دهند بااستفاده از TextFieldBuffer نوشتار را دستکاری کنید. می‌توانید ویرایش‌ها را قبل‌از اعمال کردن رهگیری کنید یا نوشتار نمایشی را بدون محاسبه دستی نگاشت‌های جابه‌جایی قالب‌بندی کنید.
  • بالا بردن TextFieldState به نیازهای معماری شما بستگی دارد. می‌توانید آن را به‌صورت محلی در یک composable نگه دارید یا مستقیماً در یک ViewModel برای دسترسی به منطق کسب‌وکار ذخیره کنید.
  • ‫Compose نسخه ۱.۱۲ میاناهای برنامه‌سازی کاربردی ویرایش نوشتار قالب‌بندی‌شده را به TextFieldBuffer اضافه می‌کند. می‌توانید سبک‌ها را با گزینه‌های ExpandPolicy قابل‌سفارشی‌سازی اعمال کنید و با ویرایش کاربران، محدوده‌های سبک موجود به‌طور خودکار به‌روزرسانی می‌شوند.
  • کتابخانه نوشتار نشانه‌گذاری جدیدی برای Jetpack Compose درحال توسعه است. این مدل از قالب‌هایی مانند Markdown با تجزیه و پردازش افزایشی برای ویرایش و جاری‌سازی زنده پشتیبانی می‌کند.

منابع

Gemini Notebook

موضوع

  • jetpack-compose
  • textfieldstate
  • ویرایش نوشتار
  • basictextfield
  • نوشتار قالب‌بندی‌شده
  • تبدیل ورودی

آوانویسی

مقدمه‌ای بر ویرایش نوشتار در Compose

  • سلام به همه!

  • از اینکه به گفتگوی ما پیوستید سپاس‌گزاریم.

  • نام من آناستازیا است و همکارم هایو نیز با من است.

  • و امروز می‌خواهیم به شما نشان دهیم که چگونه تجربه ویرایش نوشتار قابل‌اعتمادی بسازید.

  • چه درحال ساختن صفحه ورود ساده باشید، چه نوار جستجو درحین تایپ، یا برنامه پیام‌رسانی پیچیده، فیلدهای نوشتاری در مرکز نحوه تعامل کاربران با برنامه‌های Android شما قرار دارند.

  • بنابراین، احتمالاً اکثر شما از فیلدهای نوشتاری Material یا فیلدهای نوشتاری حاشیه‌دار استفاده می‌کنید.

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • همچنین یک BasicTextField وجود دارد برای زمانی که درحال ساختن سیستم طراحی خودتان هستید یا به چارگوش ویرایش ساده نیاز دارید.

    BasicTextField(state = rememberTextFieldState())
  • همچنین SecureTextField وجود دارد که به‌طور خاص برای وارد کردن گذرواژه‌ها طراحی شده است.

    SecureTextField(state = rememberTextFieldState())
  • و BasicTextField یک مؤلفه بدون سبک است و درواقع همه فیلدهای نوشتاری دیگر را در Compose تقویت می‌کند.

  • بنابراین، ممکن است در چند سال گذشته متوجه شده باشید که ما دو شکل API موازی داریم، یکی مقدار و مقدار تغییر تماس برگشتی را می‌گیرد و دیگری شیء TextFieldState را می‌گیرد.

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • بنابراین، ممکن است بپرسید کدام‌یک را توصیه می‌کنیم.

  • اکیداً توصیه می‌کنیم از سربارگذاری TextFieldState استفاده کنید.

  • و در این گفتگو درباره مزایای آن ازنظر میانای برنامه‌سازی کاربردی، ویژگی‌ها، و عملکرد صحبت خواهیم کرد.

  • این ویژگی از Compose 1.7 پایدار بوده است.

  • As for the call back overloads, we are soft-deprecating them in Compose 1.13 across BasicTextField and material wrappers.

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • یعنی قرار نیست ویژگی جدیدی اضافه کنیم یا اشکال‌ها را برطرف کنیم، مگر اینکه، می‌دانید، مشکلات امنیتی یا دسترسی‌پذیری بحرانی وجود داشته باشد.

  • مقدار بدون حالت در اضافه‌بار تغییر مقدار واقعاً برای ورودی‌های همگام خوب کار می‌کرد.

  • اما به‌محض اینکه آن را با جاری‌سازی‌های ناهمگام، مثلاً StateFlow، مرتبط کردید، مشکلات زمان‌بندی با IME یا به‌اصطلاح فنی، برای صفحه‌کلید نرم‌افزاری ایجاد کرد.

  • بنابراین، این امر باعث حذف نویسه‌ها، تکرار نویسه‌ها، و پرش مکان‌نما می‌شد، همان‌طور که در این ضبط می‌بینید.

معرفی TextFieldState

  • برای رفع اساسی این مشکل، TextFieldState را معرفی کردیم.

  • این میانای برنامه‌سازی کاربردی وقتی کاربر تایپ می‌کند به‌طور هم‌زمان به‌روز می‌شود و منتظر آن رفت‌وبرگشت‌های ناهمگام نمی‌ماند، که دقیقاً همان چیزی است که IME نیاز داشت.

  • ‫TextFieldState، علاوه‌بر نوشتار فقط خواندنی، ویژگی‌های دیگری نیز دارد که به‌نوعی یک جلسه ویرایش فعال ایجاد می‌کند.

  • That is selection, undo redo state, and also span and span styles and paragraph styles for formatted text editing, more on that later in this talk.

  • ویژگی‌های داخلی TextFieldState توسط «سیستم عکس آنی Compose» پشتیبانی می‌شود.

  • بنابراین، یعنی وقتی آن نوشتار یا انتخاب را از آن شیء TextFieldState در جایی در Compose می‌خوانید، محدوده خاصی که آن را از آنجا می‌خوانید در به‌روزرسانی‌ها مشترک می‌شود.

  • این ویژگی به BasicTextField اجازه می‌دهد فقط در فازهای چیدمان یا ترسیم وضعیت را بخواند و همراه با برخی دیگر، بازترکیب غیرضروری را هنگام تایپ کردن کاربر حذف می‌کند.

  • و همراه با برخی تغییرات دیگر، بهبود عملکرد قابل‌توجهی را برای اضافه‌بار TextFieldState ارائه کرد.

  • اکنون، اگر نوشتار TextFieldState یک ویژگی فقط خواندنی باشد، چگونه می‌توانید تغییرات برنامه‌نویسی را در فیلد ورودی خود اعمال کنید؟

  • و اینجا است که TextFieldBuffer مفید واقع می‌شود.

  • این یک شیء تغییرپذیر است که میاناهای برنامه‌سازی کاربردی را برای ایجاد تغییرات دراختیار شما قرار می‌دهد.

  • بنابراین، وقتی می‌خواهید تغییر برنامه‌ای ایجاد کنید، تابع ویرایش TextFieldState را فراخوانی می‌کنید.

  • و در داخل آن لامبدا، یک 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 و Snapshot System همگام‌سازی می‌کند.

  • نکته دیگر این است که برای اکثر عملیات دسته‌ای رایجی که نیاز دارید، درواقع توابع افزونه‌ای در 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
    }
  • بنابراین، رد کردن یا تغییر دادن آن ورودی به‌نوعی با صفحه‌کلید تداخل داشت.

  • همچنین، فراخوانی برگشتی onValueChange فقط رشته نهایی را به شما می‌دهد.

  • بنابراین برای درک آنچه تغییر کرده است، باید تفاضل کامل رشته را انجام می‌دادید.

  • درعوض، در سربارگذاری جدید، تبدیل ورودی داریم.

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • بنابراین ورودی کاربر را قبل‌از رسیدن به حالت فیلد نوشتاری رهگیری می‌کند و همان میاناهای برنامه‌سازی کاربردی 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 به‌طور خودکار صفحه‌کلید و مکان‌نما را برای شما همگام‌سازی می‌کند.

  • درنهایت، اگر بخواهید قالب‌بندی دیداری ورودی‌تان را انجام دهید چه؟

  • برای مثال، می‌خواهید پرانتز یا خط تیره به شماره کارت اعتباری یا شماره تلفن اضافه کنید.

  • آه، بله.

  • و نمی‌خواهید آن‌ها را در پایگاه داده‌تان ذخیره کنید، درست است؟

  • بنابراین قبلاً از میانای برنامه‌سازی کاربردی تبدیل دیداری استفاده می‌کردید.

  • بنابراین در آن مورد، باید یک نگاشت دوطرفه از ورودی خام به خروجی بصری و سپس برعکس را پیاده‌سازی می‌کردید.

  • و درست کردن آن واقعاً دشوار بود، که اغلب منجر به استثناهای زمان اجرا می‌شد.

  • اکنون یک تبدیل خروجی داریم و سپس، دوباره، در پشت صحنه، توسط همان TextFieldBuffer پشتیبانی می‌شود، بنابراین تقریباً همان API را دریافت می‌کنید.

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • بنابراین در این مثال شماره تلفن، برای درج آن خط تیره‌ها در یک نمایه خاص، به‌سادگی تابع درج TextFieldBuffer را فراخوانی می‌کنید و سیستم نگاشت آفست دوطرفه را برای شما محاسبه می‌کند.

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • بنابراین این کار محاسبات ریاضی دستی را حذف می‌کند و تعداد خرابی‌ها را به‌طور قابل‌توجهی کاهش می‌دهد.

Hoisting and migrating TextFieldState

  • اکنون به مهم‌ترین بخش می‌رسیم، چگونه واقعاً انتقال می‌دهید و آن TextFieldState را کجا برافراشته می‌کنید؟

  • در عمل، تقریباً هر مورد استفاده‌ای در یکی از آن دسته‌هایی قرار می‌گیرد که می‌خواهم درباره‌شان صحبت کنم.

  • And when it is not, it Well, usually it means that you have some unconventional architecture, but these examples are still will still g- guide you through your migration.

  • بسیارخوب، ساده‌ترین حالت این است که 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()) }) { }
        )
    }
  • این زمانی است که فیلد نوشتاری شما یک فیلد ورودی مستقل است، بنابراین، برای مثال، یک فیلد ورودی کد تبلیغاتی، و شما فقط زمانی نیاز به دسترسی به آن نوشتار دارید که کاربر، برای مثال، دکمه ارسال را فشار دهد، درست است؟

  • بنابراین مدل نمای شما نیازی به رهگیری ورودی کاربر در هر تغییر ندارد.

  • بنابراین شما فقط rememberTextFieldState را به‌صورت محلی فراخوانی می‌کنید و rememberTextFieldState همچنین به شما در بازیابی وضعیت پس‌از مرگ پردازش کمک می‌کند.

  • سپس از تبدیل ورودی برای فیلتر کردن استفاده می‌کنید و نوشتار را از آن حالت، شیء TextFieldState، دریافت می‌کنید و وقتی کاربر ارسال می‌کند، آن را ازطریق لامبدا ارسال می‌کنید.

  • بسیار خوب، اگر عناصر میانای کاربر هم‌نیا مثل شمارنده نویسه یا دکمه ارسال هم نیاز به خواندن نوشتار یا تغییر آن داشته باشند، چه؟

  • این ما را به موردی می‌رساند که در آن شیء 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 توسط سیستم عکس آنی پشتیبانی می‌شود، عبور دادن آن از جد باعث بازترکیب والدین نمی‌شود.

  • فقط جاهایی که واقعاً نوشتار وضعیت را خوانده‌اید به‌روزرسانی می‌شود.

  • بسیارخوب.

  • بنابراین، برای موارد استفاده پیچیده از میانای کاربر که در آن‌ها به 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 حاوی نوشتار خام و جایگاه مکان‌نما است.

  • تبدیل ورودی مسئول شناسایی هشتگ است و همچنین می‌توانید حذف برچسب اتمی را ازطریق تبدیل ورودی انجام دهید.

  • تبدیل خروجی سبک بصری بیشتری اضافه می‌کند و همچنین می‌تواند به شما کمک کند هشتگ را حذف کنید.

  • And they are all together packed into a single state holder object, how do I call it, 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 خود منتقل کنید.

  • مهم است که شیء فیلد نوشتاری تغییرپذیر را در وضعیت واسط کاربر کلاس داده تغییرناپذیر قرار ندهید زیرا با روش کپی کردن کپی می‌شود و سپس با هر تغییر در StateFlow منتشر می‌شود.

    data class FormUiState(val username: TextFieldState)
  • ‫TextFieldState یک شیء تغییرپذیر با عمر طولانی است که توسط «سیستم عکس آنی» پشتیبانی می‌شود و شیء مرجع آن هرگز تغییر نمی‌کند.

  • بنابراین، StateFlow شما حتی متوجه نمی‌شود که کاربر درحال تایپ کردن است زیرا مرجع ثابت می‌ماند.

  • And even worse, if you create a new instance of the UI state and you are allocating a fresh TextFieldState, your input field will lose all of its state.

  • بنابراین، این شامل مواردی مانند انتخاب، موقعیت مکان‌نما، یا سابقه واگرد/ازنو انجام دادن خواهد بود.

  • بنابراین به‌جای آن، TextFieldState را مستقیماً در ViewModel خودتان به دارایی تبدیل کنید.

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • و نگران نبودن میانای کاربر در قانون ViewModel در اینجا نباشید زیرا در آن صورت، TextFieldState به شیء «زمینه» یا «نمای Android» یا چرخه حیات Compose ارجاع نمی‌دهد.

  • این فقط یک شیء نگه‌دارنده حالت خالص با پشتیبانی Snapshot است.

  • بنابراین، در این مورد، ازنظر مفهومی تفاوتی با StateFlow ندارد.

  • And one thing to remember that ViewModel doesn't have rememberSaveable, so you need to wire TextFieldState.Saver for text field to survive process death and restore its state.

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • اکنون، اگر تیم شما قوانین سخت‌گیرانه‌ای برای ماژول داشته باشد که ViewModels فقط در ماژول‌های Kotlin خالص وجود داشته باشد و نتوانید آن وابستگی compose-foundation را بیاورید، چه؟

  • یا مثلاً، درحال انتقال یک صفحه قدیمی پیچیده هستید و نمی‌توانید امروز وضعیت واسط کاربر را به‌روزرسانی یا بازسازی کنید؟

  • در این صورت، از رویکرد منطبق استفاده کنید.

  • بنابراین، 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 باقی می‌ماند، و چون ویژگی‌های وضعیت واسط کاربر در هر تغییر به فیلد نوشتاری بازخورد نمی‌دهند، همچنان از آن اشکال‌های رفت‌وبرگشت ناهمگام اجتناب می‌کنید.

  • بله، و مراقب یک تله رایج در اینجا باشید.

    @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 را در آن شیء فراخوانی خواهد کرد.

  • برای کمک بیشتر به شما در این انتقال، این راهنمای انتقال را نوشتیم.

  • ازطریق این پیوند می‌توانید به آن دسترسی داشته باشید.

  • همچنین درحال کار روی چند میانای برنامه‌سازی کاربردی دیگر هستیم تا هم انتقال و هم مشاهده تغییرات کلی TextFieldState را آسان‌تر کنیم، پس منتظر باشید.

ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده

  • و هر چیزی که تاکنون پوشش داده‌ایم، TextFieldState، TextFieldBuffer، تبدیل ورودی و خروجی، فقط برای ورودی نوشتار ساده ساخته نشده‌اند.

  • این‌ها پایه‌ای هستند که جدیدترین مجموعه میاناهای برنامه‌سازی کاربردی ما را برای ویرایش نوشتار قالب‌بندی‌شده تقویت می‌کنند.

  • اکنون نوبت Haoyu است تا اطلاعات بیشتری درباره این موضوع به شما بدهد.

  • بسیار خوب.

  • متشکریم.

  • سلام به همه.

  • سپس درباره میاناهای برنامه‌سازی کاربردی جدیدی که برای ویرایش نوشتار قالب‌بندی‌شده معرفی کرده‌ایم صحبت خواهم کرد.

  • بنابراین، قبل‌از اینکه به 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 جدید اختصاص دهیم.

  • And on each AnnotatedString, there are a lot of AnnotatedString.Range.

  • بنابراین آن اشیا نیز باید دوباره ایجاد شوند.

  • این روش به‌خوبی مقیاس‌بندی نمی‌شود و باعث سربار تخصیص قابل‌توجهی می‌شود.

  • بنابراین، این مثال را بررسی کنید.

  • وقتی کلمه را در وسط درج می‌کنیم، هر سبک بعداز آن محدوده خود را تغییر می‌دهد و دوباره تخصیص می‌یابد.

  • وقتی سبک‌های زیادی داشته باشید، این روش قطعاً کار نمی‌کند.

  • بنابراین، به‌جای آن، میاناهای برنامه‌سازی کاربردی سبک‌بندی اختصاصی را در TextFieldBuffer که آناستازیا قبلاً معرفی کرده بود، معرفی کرده‌ایم.

  • این میاناهای برنامه‌سازی کاربردی بسیار شبیه به میاناهای برنامه‌سازی کاربردی سبک‌بندی در AnnotatedString هستند.

  • بیایید نحوه کار آن‌ها را بررسی کنیم.

  • بنابراین درست مانند AnnotatedString.Builder، اکنون TextFieldBuffer دارای addStyle برای اعمال SpanStyle یا ParagraphStyle به یک محدوده است.

    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 اضافه می‌کنید، محدوده آن به‌طور خودکار با ویرایش نوشتار به‌روز می‌شود.

  • در این مثال، ابتدا بخشی از نوشتار را پررنگ می‌کنیم.

  • وقتی نوشتاری را قبل‌از آن درج می‌کنید، محدوده سبک به جلو منتقل می‌شود.

  • و وقتی نوشتاری را در آن درج می‌کنید، محدوده نوشتار گسترش می‌یابد.

  • و وقتی نوشتار داخل محدوده را حذف می‌کنیم، محدوده سبک به‌طور متناسب کوچک می‌شود.

  • و اگر همه نوشتار داخل محدوده سبک حذف شود، سبک جمع می‌شود و به‌طور خودکار از میان‌گیر برداشته می‌شود.

  • بنابراین، وقتی نوشتار دقیقاً در مرزهای محدوده سبک درج می‌شود چه اتفاقی می‌افتد؟

  • آیا نوشتار جدید درج‌شده تحت پوشش سبک قرار می‌گیرد یا نه؟

  • بنابراین، برای اینکه بتوانید این موضوع را کنترل کنید، میاناهای برنامه‌سازی کاربردی 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 ")
  • همان‌طور که از نام آن مشخص است، درج نوشتار در ابتدای محدوده، محدوده سبک را گسترش می‌دهد.

  • بنابراین وقتی نوشتاری را درست قبل‌از گسترش محدوده سبک درج می‌کنیم تا نوشتار جدیداً درج‌شده را پوشش دهد.

  • بنابراین، در اینجا نسخه نمایشی دیگری وجود دارد که نحوه عملکرد آن را نشان می‌دهد.

  • سومی 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")
    }
  • پس یک نسخه نمایشی دیگر برای این.

  • تا اینجا همه میاناهای برنامه‌سازی کاربردی اعمال سبک را پوشش داده‌ایم.

  • علاوه‌براین، میاناهای برنامه‌سازی کاربردی پُرسمان جدیدی مثل 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>>
  • در اینجا دو نسخه از میاناهای برنامه‌سازی کاربردی پرسمان ارائه شده است.

  • در TextFieldState، میاناهای برنامه‌سازی کاربردی پُرسمان سبک فقط خواندنی هستند.

  • آن‌ها فهرستی از اشیاء تغییرناپذیر AnnotatedString.Range را برمی‌گردانند.

  • And but in the TextFieldBuffer, getSpanStyles returned a list of newly introduced TrackedRange objects, which I will introduce later.

  • بنابراین همه پرسمان‌های سبک از ترتیب آگاه هستند.

  • آن‌ها محدوده‌های سبک را دقیقاً به همان ترتیبی که سبک اعمال شده است برمی‌گردانند.

  • بنابراین این به شما امکان می‌دهد، درست مانند AnnotatedString، سبک دیگری را روی سبک موجود اعمال کنید تا سبک‌های قبلی را بازنویسی کنید.

  • بنابراین، مورد بعدی میانای برنامه‌سازی کاربردی TrackedRange است.

    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 در TextFieldBuffer برگردانده می‌شود.

  • آن را به‌عنوان یک دسته زنده از محدوده سبک در میان‌گیر درنظر بگیرید.

  • می‌توانید از آن برای به‌روزرسانی محدوده نوشتار، ExpandPolicy یا خود سبک در محل استفاده کنید.

  • همچنین دارای ویژگی isValid است تا بررسی کند که آیا محدوده سبک هنوز در TextFieldBuffer وجود دارد یا نه.

  • دلیلش این است که اگر سبک درطول ویرایش جمع شود، یا اگر removeStyle آن را عمداً بردارد، ممکن است سبک برداشته شود.

  • بنابراین، در ادامه بیایید ببینیم که چگونه این میاناهای برنامه‌سازی کاربردی در موارد استفاده واقعی باهم ترکیب می‌شوند.

ساختن ویرایشگر WYSIWYG

  • اولین مورد استفاده، ساختن «ویرایشگر WYSIWYG» (آنچه می‌بینید همان چیزی است که دریافت می‌کنید) است.

  • در این مثال، ویژگی دکمه پررنگ را پیاده‌سازی کرده‌ایم.

  • با کلیک کردن روی دکمه پررنگ، سبک پررنگ روی نوشتار انتخاب‌شده فعال یا غیرفعال می‌شود.

  • بنابراین، بیایید به کد نگاه کنیم.

  • بنابراین وقتی کاربر روی دکمه پررنگ تک‌ضرب می‌زند، ابتدا 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 استفاده کنیم.

  • و برای این مورد خاص، به‌جای حذف این سبک و ایجاد مجدد آن، می‌توانیم فقط محدوده گستره پررنگ را در محل تنظیم کنیم تا قبل‌از انتخاب در نمایه ۱۰ پایان یابد.

  • البته این پیاده‌سازی بسیار ساده‌شده است.

  • برای مشاهده نسخه نمایشی کامل، می‌توانید مخزن نمونه‌های Compose ما را بررسی کنید.

  • و در ادامه، بیایید به سبک‌بندی مبتنی بر الگو نگاه کنیم.

  • در این نسخه نمایشی، وقتی کاربر ستاره‌های Markdown را در اطراف کلمه‌ای تایپ می‌کند، ویرایشگر به‌طور خودکار آن را به نوشتار پررنگ تبدیل می‌کند.

  • اکنون بیایید ببینیم چگونه بااستفاده از 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
                )
            }
        }
    }
  • در فیلتر ورودی تبدیل‌کننده، همه الگوهای منطبق را روی دکمه پیدا می‌کنیم.

  • و برای هر مطابقت، محتوای داخل ستاره‌های دوتایی را استخراج می‌کنیم.

  • در این مثال، کلمه «fox» (روباه) است.

  • و سپس، ستاره‌ها را برمی‌داریم و الگوی منطبق را با محتوای آن جایگزین می‌کنیم.

  • و درنهایت، addStyle را به bold فراخوانی می‌کنیم تا سبک پررنگ را به نوشتار محتوا اعمال کنیم.

  • فقط به‌خاطر داشته باشید که 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 به‌طور خودکار دوباره اعمال می‌شود.

  • And then when we loops through every matches and call addStyle to draw the yellow highlights over the match range.

  • دوباره، OutputTransformation فقط نوشتار دیداری را تغییر می‌دهد و حالت‌های نوشتار اصلی دست‌نخورده باقی می‌ماند.

  • بسیارخوب، این‌ها همه موارد استفاده‌ای است که داریم.

  • فقط برای یادآوری، همه میاناهای برنامه‌سازی کاربردی که قبلاً نشان داده شد از Compose نسخه ۱.۱۲ دردسترس هستند.

کتابخانه نوشتار نشانه‌گذاری آینده

  • و برای جمع‌بندی، می‌خواهم پیش‌نمایشی از پروژه جدیدی که درحال‌حاضر روی آن کار می‌کنیم هم‌رسانی کنم.

  • این کتابخانه نوشتار نشانه‌گذاری برای Jetpack Compose است که از قالب‌های متن غنی مانند Markdown پشتیبانی می‌کند.

  • یکی از هیجان‌انگیزترین قابلیت‌هایی که از آن پشتیبانی می‌کنیم، تجزیه افزایشی و پردازاندن افزایشی است.

  • بنابراین برای ویرایش زنده یا جاری‌سازی Markdown بسیار مناسب است.

  • اگر در اینجا سابقه نمونه اولیه کاری ما درحال اجرا است، و می‌توانید ببینید که آن نواحی محوشونده بخش‌های به‌روزرسانی در واسط کاربر هستند.

  • اگر به این موضوع علاقه‌مند هستید یا مورد استفاده‌ای در برنامه‌تان دارید، لطفاً با ما تماس بگیرید.

  • و از همه شما سپاس‌گزاریم.

کد

تکه‌کدها

قطعه Kotlin در ۰۰:۲۹ در «مقدمه‌ای بر ویرایش نوشتار در Compose»
TextField(state = rememberTextFieldState())
قطعه Kotlin در 00:29 در «مقدمه‌ای بر ویرایش نوشتار در Compose»
OutlinedTextField(state = rememberTextFieldState())
قطعه Kotlin در ۰۰:۳۴ در «مقدمه‌ای بر ویرایش نوشتار در Compose»
BasicTextField(state = rememberTextFieldState())
قطعه Kotlin در ۰۰:۴۴ در «مقدمه‌ای بر ویرایش نوشتار در Compose»
SecureTextField(state = rememberTextFieldState())
قطعه Kotlin در ۰۰:۵۸ در «مقدمه‌ای بر ویرایش نوشتار در Compose»
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
قطعه Kotlin در ۰۱:۲۸ در «مقدمه‌ای بر ویرایش نوشتار در Compose»
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
قطعه Kotlin در ۰۴:۰۱ در «معرفی 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 در ۰۴:۰۹ در «معرفی 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 در ۰۴:۲۵ در «معرفی TextFieldState»
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
قطعه Kotlin در ۰۴:۳۸ در «معرفی TextFieldState»
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
قطعه Kotlin در ۰۴:۵۵ در «فیلتر کردن و قالب‌بندی ورودی»
onValueChange { newString ->
  // full diffing new String vs old String
}
قطعه Kotlin در ۰۵:۱۷ در «فیلتر کردن و قالب‌بندی ورودی»
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
قطعه Kotlin در ۰۵:۳۳ در «فیلتر کردن و قالب‌بندی ورودی»
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
قطعه Kotlin در ۰۶:۴۸ در «فیلتر کردن و قالب‌بندی ورودی»
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
قطعه Kotlin در ۰۶:۵۸ در «فیلتر کردن و قالب‌بندی ورودی»
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
قطعه Kotlin در ۰۷:۵۱ در «بالا بردن و انتقال 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 در ۰۹:۲۷ در «Hoisting and migrating 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 در دقیقه ۱۰:۱۲ در «بالا بردن و انتقال 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 در ۱۰:۳۰ در «بالا بردن و انتقال 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 در «Hoisting and migrating TextFieldState»
data class FormUiState(val username: TextFieldState)
قطعه Kotlin در ۱۱:۳۸ در «بالا بردن و انتقال TextFieldState»
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
قطعه Kotlin در ۱۲:۰۹ در «بالا بردن و انتقال TextFieldState»
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
قطعه Kotlin در ۱۲:۵۵ در «Hoisting and migrating 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 در ۱۳:۳۹ در «بالا بردن و انتقال 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 در ۱۵:۰۹ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در ۱۶:۳۶ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در ۱۶:۵۷ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در ۱۷:۵۲ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در ۱۸:۲۲ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
قطعه Kotlin در ۱۸:۴۰ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
قطعه Kotlin در ۱۸:۵۶ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در دقیقه ۱۹:۱۴ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
// 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 در دقیقه ۲۰:۰۳ در «ویرایش نوشتار غنی‌شده و قالب‌بندی‌شده»
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 در دقیقه ۲۱:۰۱ در «ساخت ویرایشگر 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 در ۲۱:۲۶ در «ساخت ویرایشگر 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 در ۲۲:۲۷ در «ساخت ویرایشگر 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 در ۲۳:۴۲ در «ساخت ویرایشگر 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)
        }
    }
}