كبير مهندسي البرامج
The journey to reliable text editing in Compose culminated in the complete rewrite of BasicTextField. وقد أدى هذا التحوّل إلى بنية مستندة إلى المخزن المؤقت إلى القضاء على حالات السباق في IME نهائيًا. توضّح هذه الجلسة سبب إيقاف مزامنة الإدخالات القديمة وكيفية حفاظ TextFieldState على مزامنة تحديثات لوحة المفاتيح. بعد ذلك، سنتعمّق في التعديل المنسَّق لاستكشاف آليات TrackedRange، وتكاليف أداء مجموعات التعديل، والمشاكل الواقعية وراء حدود الأنماط، قبل إلقاء نظرة مبكرة على عرض Markdown.
TextFieldState محلّ التحميل الزائد المستند إلى عمليات الاستدعاء في Compose. ويتم تحديثه بشكل متزامن مع كتابة المستخدم، ما يزيل مشاكل التوقيت في لوحات المفاتيح الافتراضية وعمليات إعادة الإنشاء غير الضرورية.TextFieldBuffer. يمكنك اعتراض التعديلات قبل تنفيذها أو تنسيق النص المعروض بدون احتساب عمليات ربط الإزاحة يدويًا.TextFieldState على احتياجات البنية. يمكنك الاحتفاظ بها محليًا في دالة مركّبة أو تخزينها مباشرةً في ViewModel للوصول إلى منطق النشاط التجاري.TextFieldBuffer. يمكنك تطبيق الأنماط باستخدام خيارات ExpandPolicy قابلة للتخصيص، ويتم تعديل نطاقات الأنماط الحالية تلقائيًا أثناء تعديل المستخدمين.النص
مرحبًا بالجميع!
شكرًا على انضمامك إلى جلستنا.
اسمي أناستازيا، ومعي زميلي هاويو.
وسنوضّح لكم اليوم كيفية إنشاء تجربة موثوقة لتعديل النصوص.
سواء كنت بصدد إنشاء شاشة تسجيل دخول بسيطة أو شريط بحث أثناء الكتابة أو تطبيق مراسلة معقّد، فإنّ الحقول النصية هي أساس تفاعل المستخدمين مع تطبيقات Android.
لذا، من المحتمل أن معظمكم يستخدمون حقول النص في Material Design أو حقول النص المحددة.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())هناك أيضًا BasicTextField إذا كنت تنشئ نظام تصميم خاصًا بك، أو تحتاج إلى مربّع تعديل بسيط.
BasicTextField(state = rememberTextFieldState())هناك أيضًا SecureTextField المصمَّم خصيصًا لإدخال كلمات المرور.
SecureTextField(state = rememberTextFieldState())وBasicTextField هو مكوّن غير منسَّق، وهو في الواقع يشغّل جميع حقول النص الأخرى في Compose.
لذا، ربما لاحظتم على مدار السنوات القليلة الماضية أنّ لدينا شكلَين من واجهة برمجة التطبيقات بالتوازي، أحدهما يأخذ القيمة وعملية الاستدعاء لتغيير القيمة، والآخر يأخذ عنصر TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)لذا، قد تتساءل عن الخيار الذي ننصح به.
ننصحك بشدة باستخدام التحميل الزائد TextFieldState.
وسنناقش في هذه الجلسة مزاياها من حيث واجهة برمجة التطبيقات والميزات والأداء.
وقد أصبح ثابتًا منذ الإصدار 1.7 من Compose.
بالنسبة إلى عمليات التحميل الزائد لعمليات الاستدعاء، سنوقفها تدريجيًا في الإصدار 1.13 من Compose في BasicTextField وأغلفة Material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unitهذا يعني أنّنا لن نضيف أي ميزات جديدة أو نصلح الأخطاء إلا إذا كانت هناك مشاكل أمنية خطيرة أو مشاكل في تسهيل الاستخدام.
كانت قيمة stateless في التحميل الزائد لتغيير القيمة تعمل بشكل جيد حقًا مع المدخلات المتزامنة.
ولكن بمجرد إقرانها مع أحداث غير متزامنة، مثل StateFlow، تسبّبت في حدوث مشاكل في التوقيت مع محرّر أسلوب الإدخال (IME) أو لوحة المفاتيح الافتراضية، حسب المصطلح الفني.
وقد تسبّب ذلك في حذف أحرف وتكرارها وانتقال المؤشر إلى أماكن أخرى، كما يظهر في هذا التسجيل.
لحلّ هذه المشكلة بشكل جذري، طرحنا TextFieldState.
ويتم تحديثه بشكل متزامن عندما يكتب المستخدم بدون انتظار عمليات الذهاب والعودة غير المتزامنة، وهو بالضبط ما يحتاجه محرِّر أسلوب الإدخال.
TextFieldState، بالإضافة إلى النص المخصّص للقراءة فقط، يتضمّن أيضًا سمات أخرى تنشئ نوعًا من جلسات التعديل النشطة.
وهي التحديد، وحالة التراجع والإعادة، وأيضًا أنماط الامتداد وأنماط الفقرات لتعديل النص المنسق، وسنتحدث عن ذلك بمزيد من التفصيل لاحقًا في هذه المحاضرة.
تعتمد السمات الداخلية لـ TextFieldState على نظام اللقطات في Compose.
هذا يعني أنّه عند قراءة هذا النص أو الاختيار من عنصر TextFieldState في مكان ما في Compose، يتم الاشتراك في التحديثات في النطاق المحدّد الذي تقرأ منه.
تسمح هذه الطريقة لـ BasicTextField بقراءة الحالة فقط في مرحلتَي التنسيق أو الرسم، وتؤدي مع بعض الطرق الأخرى إلى إلغاء إعادة الإنشاء غير الضرورية عندما يكتب المستخدم.
وبالإضافة إلى بعض التغييرات الأخرى، حقّق هذا التغيير تحسّنًا كبيرًا في الأداء في ما يتعلّق بالحمل الزائد على TextFieldState.
الآن، إذا كان نص TextFieldState هو خاصية للقراءة فقط، كيف يمكنك إجراء تغييرات برمجية على حقل الإدخال؟
وهنا يأتي دور TextFieldBuffer.
وهو عنصر قابل للتغيير يوفّر لك واجهات برمجة تطبيقات لإجراء التغييرات.
لذا، عندما تريد إجراء التغيير الآلي، يمكنك استدعاء دالة التعديل في 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()
}
}والآن، ماذا عن فلترة إدخالات المستخدم؟
مثلاً، فرض حدّ أقصى لعدد الأحرف المسموح به أو قبول الأرقام فقط.
في واجهة برمجة التطبيقات القديمة، كانت الفلترة في 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 على مزامنة لوحة المفاتيح والمؤشر تلقائيًا.
أخيرًا، ماذا لو أردت إجراء تنسيق مرئي لإدخالك؟
على سبيل المثال، تريد إضافة أقواس أو شرطات إلى رقم بطاقة ائتمان أو رقم هاتف.
يا للهول
ولا تريد تخزينها في قاعدة بياناتك، أليس كذلك؟
في السابق، استخدمت واجهة برمجة التطبيقات Visual Transformation API.
في هذه الحالة، كان عليك تنفيذ عملية ربط ثنائية الاتجاه من الإدخال الأولي إلى الناتج المرئي، ثم العكس.
وكان من الصعب جدًا تنفيذها بشكل صحيح، ما يؤدي غالبًا إلى حدوث استثناءات في وقت التشغيل.
لدينا الآن تحويل للإخراج، ومرة أخرى، يتم تشغيله من خلال TextFieldBuffer نفسه، لذا ستحصل على واجهة برمجة التطبيقات نفسها إلى حد كبير.
@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, "-")
}
}وبالتالي، يغني ذلك عن العمليات الحسابية اليدوية ويقلّل من عدد الأعطال بشكل كبير.
والآن نصل إلى الجزء الأهم، وهو كيفية نقل البيانات فعليًا، وأين يمكنك رفع TextFieldState؟
من الناحية العملية، تندرج كل حالة استخدام تقريبًا ضمن إحدى الفئات التي سأتحدث عنها.
وعندما لا يكون الأمر كذلك، فهذا يعني عادةً أنّ لديك بعض البُنى غير التقليدية، ولكن هذه الأمثلة ستظلّ ترشدك خلال عملية نقل البيانات.
حسنًا، أبسط حالة هي الاحتفاظ بـ 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، وعندما يرسل المستخدم النص، يتم تمريره من خلال دالة Lambda.
حسنًا، ماذا لو كانت عناصر واجهة المستخدم الشقيقة مثل عداد الأحرف أو زر الإرسال تحتاج أيضًا إلى قراءة النص أو تغييره، أو إجراء تغييرات عليه؟
وهذا ينقلنا إلى حالة يتم فيها رفع كائن 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 على نص أولي وموضع المؤشر.
تكون ميزة "تحويل الإدخال" مسؤولة عن رصد الهاشتاغ، ويمكنك أيضًا حذف العلامات الذرية من خلالها.
سيؤدي تحويل الناتج إلى إضافة نمط مرئي إضافي، ويمكن أن يساعدك أيضًا في حذف الهاشتاغ.
ويتم تجميعها معًا في عنصر واحد للاحتفاظ بالحالة، وهو 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 في هذه الحالة.
بعد ذلك، ما عليك سوى تمرير عنصر الاحتفاظ بالحالة هذا إلى الدالة المركّبة.
@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 أنّ المستخدم يكتب في الوقت الحالي لأنّ المرجع يبقى كما هو.
والأسوأ من ذلك، إذا أنشأت مثيلاً جديدًا لحالة واجهة المستخدم وكنت تخصص TextFieldState جديدًا، سيفقد حقل الإدخال كل حالته.
لذا، سيكون ذلك مثل التحديد، أو موضع المؤشر، أو سجل التراجع/الإعادة.
لذا، اجعل TextFieldState سمة مباشرةً في ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}ولا داعي للقلق بشأن قاعدة "عدم استخدام واجهة المستخدم" في ViewModel هنا، لأنّ TextFieldState في هذه الحالة لا يشير إلى Android View أو عنصر Context أو مراحل نشاط Compose.
وهو مجرد عنصر احتفاظ بالحالة يستند إلى Snapshot.
في هذه الحالة، لا يختلف الأمر من الناحية النظرية عن 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> = ...
}ماذا لو كان فريقك يتبع قواعد صارمة للوحدات، حيث توجد ViewModels فقط في وحدات Kotlin الخالصة، ولا يمكنك استخدام تبعية compose-foundation؟
أو على سبيل المثال، إذا كنت تنقل شاشة قديمة معقّدة، ولا يمكنك تحديث حالة واجهة المستخدم أو إعادة تصميمها اليوم؟
في هذه الحالة، استخدِم النهج المتوافق.
لذلك، يمكنك الاحتفاظ بـ TextFieldState في الدالة المركّبة كمصدر وحيد للحقيقة لحقل النص هذا.
بعد ذلك، يمكنك استخدام 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 وتحويل الإدخال والإخراج، لم يتم إنشاؤه فقط لإدخال النص العادي.
هذه هي الأساس الذي تستند إليه أحدث مجموعة من واجهات برمجة التطبيقات لتعديل النصوص المنسَّقة.
والآن، سأترككم مع "هاويو" ليخبركم بالمزيد عن هذا الموضوع.
حسنًا.
شكرًا لك.
مرحبًا،
بعد ذلك، سأتحدث عن واجهات برمجة التطبيقات الجديدة التي قدّمناها لتعديل النصوص المنسَّقة.
قبل أن نتعمّق في واجهات برمجة التطبيقات الجديدة، لنلقِ نظرة على كيفية عمل التنسيق في النص الثابت.
يعرف معظمكم 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.
لذلك يجب إعادة إنشاء هذه العناصر أيضًا.
لا يمكن توسيع نطاقها بشكل جيد وتتسبب في زيادة كبيرة في تخصيص الموارد.
لذا، اطّلِع على هذا المثال.
عندما نُدرج الكلمة في المنتصف، يتغيّر نطاق كل نمط بعدها ويُعاد تخصيصه.
من الواضح أنّ هذا لا ينجح عندما يكون لديك الكثير من الأنماط.
لذلك، قدّمنا واجهات برمجة تطبيقات مخصّصة للتصميم داخل 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 غير قابلة للتغيير.
ولكن في TextFieldBuffer، عرضت الدالة getSpanStyles قائمة بكائنات TrackedRange التي تم تقديمها حديثًا، والتي سأقدمها لاحقًا.
لذلك، تكون جميع طلبات البحث عن الأنماط على دراية بالترتيب.
وتعرض نطاقات الأنماط بالترتيب نفسه عند تطبيق النمط.
يتيح لك ذلك تطبيق نمط آخر فوق نمط حالي لاستبدال الأنماط السابقة، تمامًا مثل 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 (ما تراه هو ما تحصل عليه).
في هذا المثال، نفّذنا ميزة الزر الغامق.
يؤدي النقر على زر النص الغامق إلى تفعيل نمط النص الغامق أو إيقافه على النص المحدّد.
بعد ذلك، لنلقِ نظرة على الرمز.
لذلك، عندما ينقر المستخدم على زر الخط الغامق، نستدعي أولاً 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.
وفي هذه الحالة تحديدًا، بدلاً من حذف هذا النمط وإعادة إنشائه، يمكننا فقط تعديل نطاق امتداد الخط الغامق في مكانه لينتهي قبل الاختيار عند الفهرس 10.
بالطبع، هذا تنفيذ مبسط للغاية.
للحصول على عرض توضيحي كامل، يمكنك الاطّلاع على مستودع نماذج 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
)
}
}
}في فلتر الإدخال التحويلي، نجد جميع الأنماط المطابقة فوق الزر.
ولكل تطابق، نستخرج المحتوى داخل العلامات النجمية المزدوجة.
في هذا المثال، هي كلمة "ثعلب".
بعد ذلك، نزيل علامات النجمة ونستبدل النمط المطابق بمحتواه.
وأخيرًا، نستدعي 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 تلقائيًا.
بعد ذلك، ننتقل إلى كل النتائج المطابقة ونستدعي addStyle لرسم علامات التمييز الصفراء على نطاق النتائج المطابقة.
مرة أخرى، لا يغيّر OutputTransformation سوى النص المرئي، ولا يتم تغيير حالات النص الأصلي.
حسنًا، هذه هي جميع حالات الاستخدام المتاحة لدينا.
للتذكير، جميع واجهات برمجة التطبيقات التي تم عرضها سابقًا متاحة بدءًا من الإصدار 1.12 من Compose.
وفي الختام، أريد أن أشارككم لمحة سريعة عن المشروع الجديد الذي نعمل عليه حاليًا.
وهي مكتبة نصوص ترميزية لـ Jetpack Compose تتيح استخدام تنسيقات النصوص المنسّقة، مثل Markdown.
من أهم الإمكانات التي نتيحها هي التحليل التدريجي والعرض التدريجي.
لذلك، فهو مناسب جدًا للتعديل المباشر أو بث Markdown.
إليك سجلّ لنموذجنا الأوّلي أثناء العمل، ويمكنك ملاحظة أنّ المناطق التي تتلاشى هي الأجزاء التي يتم تعديلها في واجهة المستخدم.
إذا كنت مهتمًا بهذا الأمر أو لديك حالة استخدام في تطبيقك، يُرجى التواصل معنا.
شكرًا لكم جميعًا.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}