Jetpack Compose-এর জন্য Kotlin

Jetpack Compose, Kotlin-এর উপর ভিত্তি করে তৈরি করা হয়েছে। কিছু ক্ষেত্রে, Kotlin বিশেষ ইডিয়াম প্রদান করে যা ভাল Compose কোড লেখা সহজ করে তোলে। আপনি যদি অন্য প্রোগ্রামিং ভাষায় ভাবেন এবং সেই ভাষাকে মনে মনে Kotlin-এ অনুবাদ করেন, তাহলে আপনি Compose-এর কিছু সুবিধা মিস করতে পারেন এবং আপনি হয়ত ইডিওম্যাটিক Kotlin কোড বুঝতে অসুবিধা বোধ করতে পারেন। Kotlin-এর স্টাইল সম্পর্কে আরও জানলে, আপনি এই ধরনের সমস্যা এড়াতে পারবেন।

ডিফল্ট আর্গুমেন্ট

আপনি Kotlin ফাংশন লেখার সময়, ফাংশন আর্গুমেন্টের জন্য ডিফল্ট ভ্যালু নির্দিষ্ট করতে পারেন। কলকারী যদি স্পষ্টভাবে সেইসব ভ্যালু পাস না করে, তাহলে এগুলি ব্যবহার করা হয়। এই ফিচার ওভারলোড করা ফাংশনের প্রয়োজনীয়তা কমায়।

যেমন, ধরুন আপনি এমন একটি ফাংশন লিখতে চান যা একটি বর্গক্ষেত্র আঁকে। সেই ফাংশনে একটি প্রয়োজনীয় প্যারামিটার, sideLength থাকতে পারে, যা প্রতিটি বাহুর দৈর্ঘ্য নির্দিষ্ট করে। এতে thickness, edgeColor ইত্যাদির মতো একাধিক ঐচ্ছিক প্যারামিটার থাকতে পারে; কলকারী এগুলি নির্দিষ্ট না করলে, ফাংশন ডিফল্ট ভ্যালু ব্যবহার করে। অন্যান্য ভাষায়, আপনি হয়ত একাধিক ফাংশন লিখতে চান:

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

Kotlin-এ, আপনি একটি ফাংশন লিখতে এবং আর্গুমেন্টের জন্য ডিফল্ট মান নির্দিষ্ট করতে পারেন:

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

একাধিক অপ্রয়োজনীয় ফাংশন লেখার হাত থেকে বাঁচানোর পাশাপাশি, এই ফিচার আপনার কোডকে আরও স্পষ্ট করে তোলে। কলার কোনও আর্গুমেন্টের ভ্যালু নির্দিষ্ট না করলে, এর অর্থ হল তিনি ডিফল্ট ভ্যালু ব্যবহার করতে চান। এছাড়াও, নামযুক্ত প্যারামিটারগুলি কী ঘটছে তা দেখতে অনেক সহজ করে তোলে । আপনি যদি কোডটি দেখেন এবং এই ধরনের একটি ফাংশন কল দেখতে পান, তাহলে drawSquare() কোড চেক না করে প্যারামিটারগুলির অর্থ কী তা আপনি নাও জানতে পারেন:

drawSquare(30, 5, Color.Red);

অন্যদিকে, এই কোডটি হল সেল্ফ-ডকুমেন্টিং:

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

বেশিরভাগ Compose লাইব্রেরি ডিফল্ট আর্গুমেন্ট ব্যবহার করে এবং আপনি যে কম্পোজ করার উপযুক্ত ফাংশন লেখেন সেগুলির ক্ষেত্রেও একই কাজ করা ভালো। এই প্র্যাক্টিস আপনার কম্পোজেবলকে কাস্টমাইজ করার উপযুক্ত করে তোলে, তবে এখনও ডিফল্ট আচরণকে সহজে ইনভোক করা যায়। যেমন, আপনি এই ধরনের একটি সাধারণ টেক্সট এলিমেন্ট তৈরি করতে পারেন:

Text(text = "Hello, Android!")

নিচে দেওয়া কোডটির প্রভাব একই, তবে এটি অনেক বেশি ভার্বোজ কোড, যেখানে Text প্যারামিটারগুলি স্পষ্টভাবে সেট করা আছে:

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

প্রথম কোড স্নিপেটটি অনেক সহজ এবং পড়তেও সহজ, এছাড়াও এটি স্ব-ডকুমেন্টিং। শুধুমাত্র text প্যারামিটার নির্দিষ্ট করার মাধ্যমে, আপনি এটি ডকুমেন্ট করেন যে অন্যান্য সব প্যারামিটারের জন্য, আপনি ডিফল্ট ভ্যালু ব্যবহার করতে চান। অন্যদিকে, দ্বিতীয় স্নিপেট থেকে বোঝা যায় যে আপনি অন্যান্য প্যারামিটারের ভ্যালু স্পষ্টভাবে সেট করতে চান। যদিও, আপনি যে ভ্যালু সেট করেছেন তা ফাংশনের ডিফল্ট ভ্যালু।

উচ্চ-ক্রমের ফাংশন ও ল্যাম্বডা এক্সপ্রেশন

Kotlin-এ উচ্চ-ক্রমের ফাংশন কাজ করে, এই ফাংশনগুলি অন্যান্য ফাংশনকে প্যারামিটার হিসেবে গ্রহণ করে। কম্পোজ এই পদ্ধতির উপর ভিত্তি করে তৈরি করা হয়েছে। যেমন, Button কম্পোজ করার উপযুক্ত ফাংশন একটি onClick ল্যাম্বডা প্যারামিটার প্রদান করে। সেই প্যারামিটারের ভ্যালু হল একটি ফাংশন, যা ব্যবহারকারী ক্লিক করলে বোতামটি কল করে:

Button(
    // ...
    onClick = myClickFunction
)
// ...

উচ্চ-ক্রমের ফাংশন স্বাভাবিকভাবেই ল্যাম্বডা এক্সপ্রেশন, এক্সপ্রেশনের সাথে পেয়ার করে যা একটি ফাংশনের মান নির্ণয় করে। ফাংশনটি যদি একবারই ব্যবহার করার প্রয়োজন হয়, তাহলে সেটি উচ্চ-ক্রম ফাংশনে পাস করার জন্য অন্য কোথাও সংজ্ঞায়িত করতে হবে না। পরিবর্তে, আপনি সেখানে একটি ল্যাম্বডা এক্সপ্রেশন দিয়ে ফাংশনটি সংজ্ঞায়িত করতে পারেন। আগের উদাহরণে ধরে নেওয়া হয়েছে যে myClickFunction() অন্য কোথাও সংজ্ঞায়িত করা হয়েছে। কিন্তু আপনি যদি এখানে শুধুমাত্র সেই ফাংশনটি ব্যবহার করেন, তাহলে ল্যাম্বডা এক্সপ্রেশন সহ ইনলাইন ফাংশনটি সংজ্ঞায়িত করাই সহজ:

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

ট্রেলিং ল্যাম্বডা

Kotlin-এ উচ্চ-ক্রমের ফাংশন কল করার জন্য বিশেষ সিনট্যাক্স আছে, যার শেষ প্যারামিটার হল ল্যাম্বডা। আপনি যদি সেই প্যারামিটার হিসেবে একটি ল্যাম্বডা এক্সপ্রেশন পাস করতে চান, তাহলে ট্রেলিং ল্যাম্বডা সিনট্যাক্স ব্যবহার করতে পারেন। প্যারেন্থেসিসের মধ্যে ল্যাম্বডা এক্সপ্রেশন না লিখে, আপনি সেটি পরে লিখেছেন। Compose-এ এটি একটি সাধারণ পরিস্থিতি, তাই কোডটি কেমন দেখতে হয় সেই সম্পর্কে আপনাকে পরিচিত হতে হবে।

যেমন, সব লেআউটের শেষ প্যারামিটার, যেমন Column() কম্পোজ করা যায় এমন ফাংশন হল content, একটি ফাংশন যা চাইল্ড UI এলিমেন্ট এমিট করে। ধরা যাক, আপনি এমন একটি কলাম তৈরি করতে চান যাতে তিনটি টেক্সট এলিমেন্ট আছে এবং আপনাকে কিছু ফর্ম্যাটিং প্রয়োগ করতে হবে। এই কোডটি কাজ করবে, তবে এটি খুব জটিল:

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

কারণ, content প্যারামিটারটি ফাংশন সিগনেচারের শেষ প্যারামিটার এবং আমরা এর ভ্যালু ল্যাম্বডা এক্সপ্রেশন হিসেবে পাস করছি, তাই আমরা এটিকে বন্ধনী থেকে বের করে আনতে পারি:

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

দুটি উদাহরণ ঠিক একই অর্থ প্রকাশ করছে। ব্রেসেস lambda এক্সপ্রেশনকে ডিফাইন করে যা content প্যারামিটারে পাস করা হয়।

আসলে, আপনি যদি শুধুমাত্র সেই ট্রেলিং ল্যাম্বডা প্যারামিটারটি পাস করেন—অর্থাৎ, ফাইনাল প্যারামিটারটি একটি ল্যাম্বডা হলে এবং আপনি অন্য কোনও প্যারামিটার পাস না করলে—আপনি সম্পূর্ণভাবে প্যারেন্থেসিস বাদ দিতে পারেন। যেমন, ধরুন, আপনাকে Column-এ কোনও মডিফায়ার পাস করতে হয়নি। আপনি এই ধরনের কোড লিখতে পারেন:

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

Compose-এ এই সিনট্যাক্স খুবই সাধারণ, বিশেষ করে Column-এর মতো লেআউট এলিমেন্টের জন্য। শেষ প্যারামিটারটি হল একটি ল্যাম্বডা এক্সপ্রেশন যা এলিমেন্টের চিলড্রেনকে ডিফাইন করে এবং ফাংশন কল করার পরে ব্রেসে সেই চিলড্রেনকে নির্দিষ্ট করা হয়।

স্কোপ ও রিসিভার

কিছু পদ্ধতি ও প্রপার্টি শুধুমাত্র নির্দিষ্ট স্কোপে উপলভ্য। সীমিত স্কোপ আপনাকে এমন ফাংশনালিটি অফার করতে দেয় যেখানে এটির প্রয়োজন এবং যেখানে এটি উপযুক্ত নয় সেখানে ভুলবশত সেই ফাংশনালিটি ব্যবহার করা এড়াতে দেয়।

কম্পোজ করার সময় ব্যবহৃত একটি উদাহরণ বিবেচনা করুন। আপনি Row লেআউট কম্পোজ করার সময়, আপনার কন্টেন্ট ল্যাম্বডা RowScope-এর মধ্যে অটোমেটিক ইনভোক করা হয়। এটি Row-কে এমন কার্যকারিতা প্রকাশ করতে দেয় যা শুধুমাত্র Row-এর মধ্যেই বৈধ। align মডিফায়ারের জন্য Row কীভাবে সারি-নির্দিষ্ট ভ্যালু এক্সপোজ করেছে তা নিম্নলিখিত উদাহরণে দেখানো হয়েছে:

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

কিছু API ল্যাম্বডা গ্রহণ করে যা রিসিভার স্কোপে কল করা হয়। এইসব ল্যাম্বডার প্রপার্টি ও ফাংশনে অ্যাক্সেস আছে যা প্যারামিটার ঘোষণার উপর ভিত্তি করে অন্য কোথাও সংজ্ঞায়িত করা হয়েছে:

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

আরও তথ্যের জন্য, Kotlin ডকুমেন্টেশনে রিসিভার সহ ফাংশন লিটেরাল দেখুন।

প্রতিনিধিত্ব করা প্রপার্টি

Kotlin-এ প্রতিনিধিত্ব করা প্রপার্টি কাজ করে। এইসব প্রপার্টিকে ফিল্ড হিসেবেই বলা হয়, কিন্তু কোনও এক্সপ্রেশন মূল্যায়ন করে এদের ভ্যালু ডায়নামিক পদ্ধতিতে নির্ধারণ করা হয়। by সিনট্যাক্স ব্যবহার করে আপনি এইসব প্রপার্টি শনাক্ত করতে পারবেন:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

অন্যান্য কোড এই ধরনের কোড ব্যবহার করে প্রপার্টি অ্যাক্সেস করতে পারে:

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

println() এক্সিকিউট করলে, স্ট্রিংয়ের ভ্যালু রিটার্ন করার জন্য nameGetterFunction() কল করা হয়।

আপনি যখন সরকারের মালিকানাধীন প্রপার্টি নিয়ে কাজ করেন, তখন এই ডেলিগেট করা প্রপার্টি বিশেষভাবে উপযোগী হয়:

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

ডেটা ক্লাস ডিসট্রাকচার করা

আপনি ডেটা ক্লাস নির্ধারণ করলে, আপনি ডিস্ট্রাকচারিং ডিক্লারেশন সহ ডেটা অ্যাক্সেস করতে পারবেন। যেমন, ধরুন আপনি Person ক্লাসকে এইভাবে ডিফাইন করেছেন:

data class Person(val name: String, val age: Int)

এই ধরনের কোনও অবজেক্ট থাকলে, আপনি এই ধরনের কোড ব্যবহার করে এর ভ্যালু অ্যাক্সেস করতে পারবেন:

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

আপনি Compose ফাংশনে প্রায়ই এই ধরনের কোড দেখতে পাবেন:

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

ডেটা ক্লাস আরও অনেক দরকারী কার্যকারিতা প্রদান করে। যেমন, আপনি কোনও ডেটা ক্লাস সংজ্ঞায়িত করলে, কম্পাইলার অটোমেটিক equals() ও copy()-এর মতো প্রয়োজনীয় ফাংশন সংজ্ঞায়িত করে। আপনি ডেটা ক্লাস ডকুমেন্টেশনে আরও তথ্য পাবেন।

সিঙ্গেলটন অবজেক্ট

Kotlin আপনাকে সিঙ্গেলটন ঘোষণা করতে দেয়, এমন ক্লাস যার সবসময় একটি এবং শুধুমাত্র একটি ইনস্ট্যান্স থাকে। এইসব সিঙ্গলটন object কীওয়ার্ড দিয়ে ঘোষণা করা হয়। Compose প্রায়ই এই ধরনের অবজেক্ট ব্যবহার করে। যেমন, MaterialTheme-কে সিঙ্গলটন অবজেক্ট হিসেবে সংজ্ঞায়িত করা হয়; MaterialTheme.colors, shapes ও typography প্রপার্টিতে বর্তমান থিমের ভ্যালু থাকে।

টাইপ-সেফ বিল্ডার ও DSL

Kotlin, টাইপ-সেফ বিল্ডার সহ ডোমেন-নির্দিষ্ট ভাষা (DSL) তৈরি করার অনুমতি দেয়। DSL-এর সাহায্যে জটিল হায়ারার্কিক্যাল ডেটা স্ট্রাকচার আরও সহজে ম্যানেজ ও পড়া যায়।

Jetpack Compose, LazyRow এবং LazyColumn-এর মতো কিছু API-এর জন্য DSL ব্যবহার করে।

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin-এ রিসিভার সহ ফাংশন লিটেরাল ব্যবহার করে টাইপ-সেফ বিল্ডার চালু করা হয়। Canvas কম্পোজ করার উপযুক্ত ফাংশনকে উদাহরণ হিসেবে নিলে, এটি প্যারামিটার হিসেবে এমন একটি ফাংশন নেয় যার রিসিভার হিসেবে DrawScope থাকে, onDraw: DrawScope.() -> Unit, যা কোডের ব্লকটিকে DrawScope-এ সংজ্ঞায়িত মেম্বার ফাংশনকে কল করার অনুমতি দেয়।

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

Kotlin-এর ডকুমেন্টেশন থেকে টাইপ-সেফ বিল্ডার ও DSL সম্পর্কে আরও জানুন।

Kotlin coroutines

Coroutine, Kotlin-এ ল্যাঙ্গুয়েজ লেভেলে অ্যাসিঙ্ক্রোনাস প্রোগ্রামিং সহায়তা প্রদান করে। কোরুটিন থ্রেড ব্লক না করেই এক্সিকিউশন সাসপেন্ড করতে পারে। রেসপন্সিভ UI স্বভাবতই অ্যাসিঙ্ক্রোনাস হয় এবং Jetpack Compose কলব্যাক ব্যবহার না করে API লেভেলে কোরাউটিন ব্যবহার করার মাধ্যমে এই সমস্যার সমাধান করে।

Jetpack Compose এমন API অফার করে যা UI লেয়ারের মধ্যে কোরাউটিন ব্যবহার করা নিরাপদ করে তোলে। rememberCoroutineScope ফাংশনটি একটি CoroutineScope রিটার্ন করে যার সাহায্যে আপনি ইভেন্ট হ্যান্ডলারের মধ্যে কোরাউটিন তৈরি করতে এবং Compose সাসপেন্ড API কল করতে পারবেন। ScrollState-এর animateScrollTo API ব্যবহার করে নিচের উদাহরণ দেখুন।

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

Coroutine ডিফল্ট হিসেবে কোডের ব্লক ক্রমিক এক্সিকিউট করে। কোনও রানিং করুটিন কোনও সাসপেন্ড ফাংশনকে কল করলে, সাসপেন্ড ফাংশন রিটার্ন না করা পর্যন্ত সেটির এক্সিকিউশন সাসপেন্ড হয়ে যায়। এমনকি সাসপেন্ড ফাংশন এক্সিকিউশনকে অন্য CoroutineDispatcher-এ সরালেও এটি সত্যি। আগের উদাহরণে, সাসপেন্ড ফাংশন animateScrollTo রিটার্ন না করা পর্যন্ত loadData এক্সিকিউট করা হবে না।

একসাথে কোড এক্সিকিউট করতে, নতুন কোরাউটিন তৈরি করতে হবে। উপরের উদাহরণে, স্ক্রিনের সবচেয়ে উপরে স্ক্রল করা এবং viewModel থেকে ডেটা লোড করার কাজকে প্যারালাইজ করতে, দুটি কোরাউটিন প্রয়োজন।

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

অ্যাসিঙ্ক্রোনাস API একত্রিত করার কাজ কোরাউটিন আরও সহজ করে তোলে। নিচের উদাহরণে, আমরা pointerInput মডিফায়ারকে অ্যানিমেশন API-এর সাথে মিলিয়ে ব্যবহারকারীর স্ক্রিনে ট্যাপ করার সময় কোনও এলিমেন্টের পজিশন অ্যানিমেট করি।

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen and animate
                        // in the same block
                        awaitPointerEventScope {
                            val offset = awaitFirstDown().position

                            // Launch a new coroutine to asynchronously animate to
                            // where the user tapped on the screen
                            launch {
                                // Animate to the pressed position
                                animatedOffset.animateTo(offset)
                            }
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

Coroutines সম্পর্কে আরও জানতে, Android-এ Kotlin coroutines নির্দেশিকা দেখুন।