সিনিয়র সফ্টওয়্যার ইঞ্জিনিয়ার
Compose-এ নির্ভরযোগ্য টেক্সট এডিটিংয়ের যাত্রা BasicTextField-এর সম্পূর্ণ রিরাইটের মাধ্যমে শেষ হয়েছে। বাফার-চালিত আর্কিটেকচারে এই শিফ্ট IME রেস কন্ডিশন চিরতরে দূর করেছে। এই সেশনে ব্যাখ্যা করা হয়েছে যে কেন পুরনো ইনপুট সিঙ্ক করার পদ্ধতি বন্ধ করতে হয়েছে এবং TextFieldState কীভাবে কীবোর্ড আপডেট সিঙ্ক করে রাখে। সেখান থেকে, Markdown রেন্ডারিংয়ের প্রাথমিক ঝলক দেখার আগে, TrackedRange-এর মেকানিক্স, এডিট ব্লকের পারফর্ম্যান্স খরচ এবং স্টাইল বাউন্ডারির পিছনে রিয়েল-ওয়ার্ল্ড ক্যাচ এক্সপ্লোর করতে, ফর্ম্যাট করা এডিটিংয়ে গভীরভাবে ডুব দিন।
TextFieldState API ওভারলোড কম্পোজে কলব্যাক-ভিত্তিক ওভারলোডকে প্রতিস্থাপন করে। ব্যবহারকারী টাইপ করার সাথে সাথে এটি সিঙ্ক করে আপডেট করে, সফ্টওয়্যার কীবোর্ডের সাথে টাইমিং সমস্যা এবং অপ্রয়োজনীয় রিকম্পোজিশন দূর করে।TextFieldBuffer ব্যবহার করে টেক্সট ম্যানিপুলেট করতে দেয়। আপনি এডিট করার আগে তা ইন্টারসেপ্ট করতে পারবেন অথবা ম্যানুয়ালি অফসেট ম্যাপিং কম্পিউট না করেই ডিসপ্লে টেক্সট ফর্ম্যাট করতে পারবেন।TextFieldState হোস্টিং আপনার আর্কিটেকচার সংক্রান্ত প্রয়োজনের উপর নির্ভর করে। আপনি এটি কম্পোজেবলে লোকাল হিসেবে রাখতে পারেন অথবা বিজনেস লজিক অ্যাক্সেসের জন্য সরাসরি ViewModel-এ স্টোর করতে পারেন।TextFieldBuffer-এ ফর্ম্যাট করা টেক্সট এডিটিং API যোগ করে। আপনি কাস্টমাইজ করা যায় এমন ExpandPolicy বিকল্পের মাধ্যমে স্টাইল প্রয়োগ করতে পারবেন এবং ব্যবহারকারী এডিট করার সাথে সাথে আগে থেকে থাকা স্টাইল রেঞ্জ অটোমেটিক আপডেট হয়ে যায়।ট্রান্সস্ক্রিপ্ট করা
হ্যালো!
আমাদের আলোচনায় যোগ দেওয়ার জন্য ধন্যবাদ।
আমার নাম Anastasia এবং আমার সাথে আমার সহকর্মী Haoyu আছেন।
এবং আমরা আজ আপনাকে দেখাতে চলেছি যে কীভাবে নির্ভরযোগ্য টেক্সট-এডিটিং অভিজ্ঞতা তৈরি করা যায়।
আপনি কোনও সাধারণ লগ-ইন স্ক্রিন, সার্চ অ্যাজ ইউ টাইপ বার বা জটিল মেসেজিং অ্যাপ তৈরি করছেন কিনা তার উপর নির্ভর করে, ব্যবহারকারীরা আপনার Android অ্যাপের সাথে কীভাবে ইন্টার্যাক্ট করবেন তার মূল বিষয় হল টেক্সট ফিল্ড।
তাই, আপনাদের মধ্যে বেশিরভাগই সম্ভবত আগে থেকেই মেটেরিয়াল টেক্সট ফিল্ড বা আউটলাইন করা টেক্সট ফিল্ড ব্যবহার করেন।
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 ওভারলোড ব্যবহার করার জন্য বিশেষভাবে উৎসাহিত করি।
এবং আমরা এই আলোচনায় আলোচনা করব কেন এটি API, ফিচার এবং পারফর্ম্যান্সের ক্ষেত্রে এর সুবিধা।
Compose 1.7 থেকে এটি স্টেবল আছে।
কল ব্যাক ওভারলোডের ক্ষেত্রে, আমরা Compose 1.13-এ BasicTextField এবং মেটেরিয়াল র্যাপার জুড়ে সেগুলিকে সফট-ডেপ্রিকেট করছি।
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unitএর অর্থ হল, আমরা কোনও নতুন ফিচার যোগ করব না বা বাগ ফিক্স করব না, যদি না আপনি গুরুত্বপূর্ণ নিরাপত্তা সংক্রান্ত সমস্যা বা অ্যাক্সেসিবিলিটি সম্পর্কে জানেন।
ভ্যালু পরিবর্তন ওভারলোডে স্টেটলেস ভ্যালু সিঙ্ক্রোনাস ইনপুটের জন্য সত্যিই খুব ভালো কাজ করেছে।
কিন্তু আপনি যখনই এটিকে অ্যাসিঙ্ক্রোনাস স্ট্রিমের সাথে পেয়ার করেছেন, যেমন, StateFlow, এটি IME বা টেকনিক্যাল পরিভাষায়, সফ্টওয়্যার কীবোর্ডের সাথে টাইমিং সংক্রান্ত সমস্যা তৈরি করেছে।
তাই, এর ফলে অক্ষর বাদ পড়ে গেছে, ডুপ্লিকেট অক্ষর দেখা গেছে, কার্সর জাম্প করেছে, যেমন আপনি এই রেকর্ডিংয়ে দেখতে পাচ্ছেন।
এই সমস্যার মূলগত সমাধান করতে, আমরা TextFieldState নিয়ে এসেছি।
ব্যবহারকারী টাইপ করার সময় এটি সিঙ্ক্রোনাসভাবে আপডেট হয়, তাই অ্যাসিঙ্ক রাউন্ড ট্রিপের জন্য অপেক্ষা করতে হয় না। IME-এর জন্য ঠিক এটাই প্রয়োজন।
TextFieldState, শুধুমাত্র পড়া যায় এমন টেক্সট ছাড়াও, অন্যান্য প্রপার্টি আছে যা এক ধরনের অ্যাক্টিভ এডিটিং সেশন তৈরি করে।
অর্থাৎ, বেছে নেওয়া, আগের অবস্থায় ফিরিয়ে আনা, আবার করা, স্প্যান ও স্প্যান স্টাইল এবং ফর্ম্যাট করা টেক্সট এডিট করার জন্য প্যারাগ্রাফ স্টাইল। এই বিষয়ে পরে আরও আলোচনা করা হবে।
TextFieldState-এর ইন্টার্নাল প্রপার্টি Compose Snapshot System-এর মাধ্যমে ব্যাক-আপ নেওয়া হয়।
সুতরাং, এর অর্থ হল আপনি যখন Compose-এর কোথাও সেই TextFieldState অবজেক্ট থেকে সেই টেক্সট বা সিলেকশন পড়েন, তখন আপনি যে নির্দিষ্ট স্কোপ থেকে এটি পড়েন সেটি আপডেটে সাবস্ক্রাইব করা হয়।
এটি BasicTextField-কে লেআউট বা ড্র ফেজে শুধুমাত্র স্টেট রিড করার অনুমতি দেয় এবং কিছু জিনিসের সাথে এটি ব্যবহারকারী টাইপ করার সময় অপ্রয়োজনীয় রিকম্পোজিশন বাদ দেয়।
এবং অন্যান্য কিছু পরিবর্তনের সাথে, এটি TextFieldState ওভারলোডের জন্য একটি উল্লেখযোগ্য পারফর্ম্যান্স উন্নতি প্রদান করেছে।
এখন, TextFieldState-এর টেক্সট যদি শুধুমাত্র পড়ার জন্য হয়, তাহলে আপনি কীভাবে আপনার ইনপুট ফিল্ডে প্রোগ্রাম্যাটিক পরিবর্তন করবেন?
আর এখানেই TextFieldBuffer কাজে আসে।
এটি একটি পরিবর্তনযোগ্য অবজেক্ট যা আপনাকে পরিবর্তন করার জন্য API প্রদান করে।
তাই, আপনি যখন প্রোগ্রাম্যাটিক পরিবর্তন করতে চান, তখন আপনি 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 API প্রদান করে।
সুতরাং, এইভাবে আপনি ইনপুট ট্রান্সফর্মেশনের মাধ্যমে শুধুমাত্র সংখ্যা ফিল্টার করার সুবিধা প্রয়োগ করতে পারবেন এবং মূলত আপনি পরিবর্তনটি গ্রহণ করেন।
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}আপনি চেক করে দেখবেন যে সংখ্যা ছাড়া অন্য কোনও অক্ষর আছে কিনা এবং যদি থাকে, তাহলে আপনি সেই TextFieldBuffer-এ revertAllChanges কল করে ইনপুট বাতিল করবেন।
এই ইনপুট ট্রান্সফর্মেশনের মূল ক্ষমতা হল ChangeList.
তাই সম্পূর্ণ স্ট্রিং ডিফ করার পরিবর্তে, এটি আপনাকে সঠিক এডিট রেঞ্জ পরীক্ষা করতে দেয় এবং তারপরে সেগুলিকে মিউটেট করতে বা প্রয়োজন হলে ইনপুটটি সম্পূর্ণভাবে প্রত্যাখ্যান করতে দেয়।
এবং Compose আপনার জন্য কীবোর্ড এবং কার্সর অটোমেটিক সিঙ্ক করে রাখবে।
সবশেষে, আপনি যদি ইনপুটের ভিজ্যুয়াল ফর্ম্যাটিং করতে চান, তাহলে কী করবেন?
যেমন, আপনি ক্রেডিট কার্ড নম্বর বা ফোন নম্বরে বন্ধনী বা ড্যাশ যোগ করতে চান।
ওহ, হ্যাঁ।
এবং আপনি সেগুলি আপনার ডেটাবেসে স্টোর করতে চান না, তাই তো?
তাই আগে, আপনি ভিজ্যুয়াল ট্রান্সফর্মেশন API ব্যবহার করেছেন।
তাই, সেই ক্ষেত্রে, আপনাকে র ইনপুট থেকে ভিজ্যুয়াল আউটপুট এবং তারপর আবার র ইনপুটে ফিরে যাওয়ার জন্য একটি দ্বি-দিকনির্দেশক ম্যাপিং প্রয়োগ করতে হবে।
এবং এটি ঠিক করা সত্যিই কঠিন ছিল, যার ফলে প্রায়শই রানটাইম এক্সেপশন হত।
এখন আমাদের কাছে একটি আউটপুট ট্রান্সফর্মেশন আছে এবং তারপরে, আবার, আন্ডার দ্য হুড, এটি একই 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, "-")
}
}তাই এটি ম্যানুয়াল গণনার প্রয়োজনীয়তা দূর করে এবং ক্র্যাশের সংখ্যা উল্লেখযোগ্যভাবে কমিয়ে দেয়।
এবার সবচেয়ে গুরুত্বপূর্ণ অংশ, আপনি কীভাবে মাইগ্রেট করবেন এবং কোথায় 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 অবজেক্ট থেকে টেক্সট পান এবং ব্যবহারকারী জমা দিলে, আপনি এটি ল্যাম্বডার মাধ্যমে পাস করেন।
আচ্ছা, ক্যারেক্টার কাউন্টার বা সাবমিট বোতামের মতো একই লেভেলের 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-এর মতো মধ্যবর্তী গণনাগুলি আবার ব্যবহার করতে পারে।
তারপর, আপনি এই স্টেট হোল্ডার অবজেক্টটি আপনার কম্পোজেবলে পাস করেন।
@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 বরাদ্দ করেন, তাহলে আপনার ইনপুট ফিল্ড তার সমস্ত স্টেট হারাবে।
তাই, এটি বেছে নেওয়া, বা কার্সারের পজিশন, বা আগের অবস্থায় ফিরিয়ে আনা/আবার করার ইতিহাসের মতো হবে।
তাই, এর পরিবর্তে, আপনার ViewModel-এ সরাসরি TextFieldState প্রপার্টি তৈরি করুন।
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}আর এখানে ViewModel রুলে UI না থাকার ব্যাপারে চিন্তা করবেন না, কারণ সেক্ষেত্রে 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 মডিউলে থাকে এবং আপনি সেই কম্পোজ-ফাউন্ডেশন নির্ভরতা আনতে না পারেন তাহলে কী হবে?
অথবা, ধরুন, আপনি একটি জটিল লিগ্যাসি স্ক্রিন মাইগ্রেট করছেন এবং আপনি আজ UI স্টেট আপডেট বা রিফ্যাক্টর করতে পারবেন না?
সেই ক্ষেত্রে, কনফর্মিং অ্যাপ্রোচ ব্যবহার করুন।
তাই, আপনি সেই টেক্সট ফিল্ডের জন্য single source of truth হিসেবে কম্পোজেবলে TextFieldState রাখেন।
কিন্তু তারপরে, আপনি snapshotFlow সহ LaunchedEffect ব্যবহার করে সেই টেক্সট পরিবর্তন আপনার আগে থেকে থাকা 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 কম্পোজ-মুক্ত থাকে এবং যেহেতু UI স্টেট প্রপার্টি প্রতিটি পরিবর্তনে টেক্সট ফিল্ডে ফিডব্যাক দেয় না, তাই আপনি এখনও সেই অ্যাসিঙ্ক রাউন্ড ট্রিপ বাগগুলি এড়িয়ে যাচ্ছেন।
হ্যাঁ, আর এখানে একটা সাধারণ ফাঁদ আছে, সেটা থেকে সাবধান।
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}বেশিরভাগ ক্ষেত্রে, আপনি প্রথম ইভেন্টটি বাদ দিতে চান।
এটি অপ্রয়োজনীয় ডুপ্লিকেট সার্চ কোয়েরি প্রতিরোধ করবে অথবা প্রাথমিক ভ্যালু দিয়ে আপনার ViewModel ওভাররাইট করাও প্রতিরোধ করবে।
ঠিক আছে, তাই আপনার লজিক যদি স্টেট পরিবর্তন করার জন্য ফিডব্যাক দিতে চায়, যেমন, ব্যবহারকারী সাবমিট বোতাম প্রেস করলে এবং আপনি টেক্সট মুছে দিতে চাইলে, আপনি ViewModel-এর ViewModel-এ শুধু খালি স্ট্রিং সেট করতে পারবেন না।
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}পরিবর্তে, আপনাকে একটি স্পষ্ট টেক্সট ইভেন্ট এমিট করতে হবে এবং তারপরে কম্পো- কম্পোজেবল ফাংশন যা TextFieldState অবজেক্টকে ধরে রাখে তা সেই অবজেক্টে clearText কল করবে।
ট্রানজিশনের ব্যাপারে আপনাকে আরও সাহায্য করতে, আমরা এই মাইগ্রেশন গাইড লিখেছি।
আপনি এই লিঙ্কের মাধ্যমে এটি অ্যাক্সেস করতে পারবেন।
এছাড়াও, আমরা কিছু অতিরিক্ত API নিয়ে কাজ করছি যাতে মাইগ্রেশন এবং সাধারণ TextFieldState পরিবর্তন পর্যবেক্ষণ করা সহজ হয়, তাই এই বিষয়ে আরও জানতে আমাদের সাথে থাকুন।
আর আমরা এখনও পর্যন্ত যা যা কভার করেছি, TextFieldState, TextFieldBuffer, ইনপুট ও আউটপুট ট্রান্সফর্মেশন, শুধু প্লেন টেক্সট ইনপুটের জন্য তৈরি করা হয়নি।
এগুলি হল এই হল সেই ভিত্তি যা ফর্ম্যাট করা টেক্সট এডিটিংয়ের জন্য আমাদের নতুন API সেটকে শক্তিশালী করে।
এই বিষয়ে আরও জানতে Haoyu-এর কথা শুনুন।
ঠিক আছে।
ধন্যবাদ।
হ্যালো, সবাইকে স্বাগত।
এরপর, ফর্ম্যাট করা টেক্সট এডিটিংয়ের জন্য আমরা যে নতুন API নিয়ে এসেছি, সেই সম্পর্কে বলব।
তাই, নতুন API-তে ডুব দেওয়ার আগে, আসুন এক ধাপ পিছিয়ে গিয়ে দেখে নিই স্ট্যাটিক টেক্সটে স্টাইলিং কীভাবে কাজ করে।
তাই আপনাদের মধ্যে বেশিরভাগই AnnotatedString সম্পর্কে খুব ভালোভাবেই জানেন।
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)এটি স্ট্যাটিক টেক্সট স্টাইল করার স্ট্যান্ডার্ড পদ্ধতি।
আমরা buildAnnotatedString ব্যবহার করি এবং নির্দিষ্ট রেঞ্জে SpanStyle বা ParagraphStyle প্রয়োগ করতে, অ্যাড-- কল addStyle কল করি।
এটি স্ট্যাটিক টেক্সটের জন্য ভাল কাজ করে।
তাই যখন আমরা ফর্ম্যাট করা টেক্সট এডিটিংয়ের দিকে তাকাই, আমাদের প্রাথমিক চিন্তা ছিল, আমরা কি শুধু AnnotatedString এডিটেবল করতে পারি?
কিন্তু তারপরে আমরা দ্রুত বুঝতে পারলাম যে এটি খুব সহজ নয়।
AnnotatedString হল একটি অপরিবর্তনীয় ডেটা স্ট্রাকচার।
কিন্তু এডিটিংয়ের ক্ষেত্রে, ব্যবহারকারী ক্রমাগত টাইপ করতে থাকেন।
এবং প্রতিটি কী-- প্রতিটি কীস্ট্রোকের সাথে, আমাদের একটি নতুন AnnotatedString অবজেক্ট বরাদ্দ করতে হবে।
এবং প্রতিটি AnnotatedString-এ অনেক AnnotatedString.Range আছে।
তাই সেই অবজেক্টগুলিও আবার তৈরি করতে হবে।
এটি খুব ভালভাবে স্কেল করে না এবং উল্লেখযোগ্য অ্যালোকেশন ওভারহেডের কারণ হয়।
তাই, এই উদাহরণটি দেখুন।
আমরা যখন মাঝখানে শব্দটি যোগ করি, তখন এর পরে প্রতিটি স্টাইল তার রেঞ্জ পরিবর্তন করে এবং পুনরায় বরাদ্দ করা হয়।
আপনার কাছে অনেক স্টাইল থাকলে এটি স্পষ্টতই কাজ করে না।
তাই এর পরিবর্তে, আমরা TextFieldBuffer-এর মধ্যে ডেডিকেটেড স্টাইলিং API চালু করেছি যা Anastasia আগে চালু করেছিল।
এগুলি AnnotatedString-এ স্টাইলিং API-এর মতোই।
এগুলি কীভাবে কাজ করে তা বিস্তারিতভাবে জেনে নেওয়া যাক।
তাই AnnotatedString.Builder-এর মতোই, TextFieldBuffer-এ এখন রেঞ্জে SpanStyle বা ParagraphStyle প্রয়োগ করার জন্য addStyle আছে।
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 API নিয়ে এসেছি।
আসুন, আমরা সেগুলি দেখে নিই।
প্রথমটি হল 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")
}তাই এর জন্য আরেকটি ডেমো।
আমরা এখনও পর্যন্ত স্টাইলিং-প্রয়োগকারী সব 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-এর দুটি ভার্সন দেওয়া হল।
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<*>): BooleanTextFieldBuffer-এ addStyle বা getSpanStyles API-এর মাধ্যমে TrackedRangeটি অবজেক্ট রিটার্ন করা হয়েছে।
বাফারে স্টাইল রেঞ্জের লাইভ হ্যান্ডেল হিসেবে এটি বিবেচনা করুন।
আপনি এটি ব্যবহার করে টেক্সট রেঞ্জ, ExpandPolicy বা স্টাইল আপডেট করতে পারবেন।
স্টাইল রেঞ্জ এখনও TextFieldBuffer-এ আছে কিনা তা চেক করার জন্য এতে একটি isValid প্রপার্টিও আছে।
কারণ, এডিট করার সময় স্টাইল সরিয়ে দেওয়া হতে পারে যদি-- যদি এটি এডিট করার সময় বন্ধ হয়ে যায় অথবা removeStyle ইচ্ছাকৃতভাবে এটি সরিয়ে দেয়।
তাহলে, এর পরে দেখা যাক, বাস্তব জীবনের ব্যবহারের ক্ষেত্রে কীভাবে এইসব API একসাথে কাজ করে।
প্রথম ব্যবহারের ক্ষেত্রে, আপনি যা দেখছেন তাই পাবেন, 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 API ব্যবহার করতে পারি।
এবং এই নির্দিষ্ট ক্ষেত্রে, এই স্টাইলটি মুছে ফেলার এবং এটি আবার তৈরি করার পরিবর্তে, আমরা ইনডেক্স ১০-এ বেছে নেওয়ার আগে শেষ করার জন্য বোল্ড স্প্যান রেঞ্জ ইন-প্লেস অ্যাডজাস্ট করতে পারি।
অবশ্যই, এটি অত্যন্ত সহজভাবে প্রয়োগ করা হয়েছে।
সম্পূর্ণ ডেমোর জন্য, আপনি আমাদের 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
)
}
}
}আমাদের ট্রান্সফর্মিং ইনপুট ফিল্টারে, আমরা বোতামের উপরে সব ম্যাচিং প্যাটার্ন খুঁজে পাই।
এবং প্রতিটি মিলের জন্য, আমরা ডবল অ্যাস্টেরিক্সের মধ্যে থাকা কন্টেন্ট এক্সট্র্যাক্ট করি।
এই উদাহরণে, "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-এর জন্য একটি মার্কআপ টেক্সট লাইব্রেরি, যা Markdown-এর মতো রিচ টেক্সট ফর্ম্যাটকে সাপোর্ট করে।
আমরা যেসব সবচেয়ে আকর্ষণীয় ক্ষমতাকে সাপোর্ট করছি তার মধ্যে একটি হল ইনক্রিমেন্টাল পার্সিং ও ইনক্রিমেন্টাল রেন্ডারিং।
তাই এটি লাইভ এডিটিং বা Markdown স্ট্রিমিংয়ের জন্য খুবই উপযুক্ত।
এখানে আমাদের ওয়ার্কিং প্রোটোটাইপের অ্যাকশনের রেকর্ড দেওয়া হল এবং আপনি দেখতে পাবেন যে আবছা হয়ে যাওয়া অংশগুলি হল UI-এর আপডেট হওয়া অংশ।
আপনি যদি এই বিষয়ে আগ্রহী হন অথবা আপনার অ্যাপে এই ধরনের কোনও ব্যবহারিক ক্ষেত্র থাকে, তাহলে আমাদের সাথে যোগাযোগ করতে দ্বিধা করবেন না।
আর সবাইকে ধন্যবাদ।
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)
}
}
}