ব্যবহারকারীর ইন্টার‍্যাকশন ম্যানেজ করা

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

ছবি ১. বোতামগুলি সর্বদা চালু থাকে, প্রেস করলে কোনও রিপল এফেক্ট হয় না।
ছবি ২. বোতামে প্রেস রিপল আছে যা সেগুলির চালু থাকা অবস্থা অনুযায়ী দেখায়।

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

ইন্টার‍্যাকশন

অনেক ক্ষেত্রে, আপনার কম্পোজ কম্পোনেন্ট কীভাবে ব্যবহারকারীর ইন্টারঅ্যাকশন ব্যাখ্যা করছে তা জানার প্রয়োজন নেই। যেমন, ব্যবহারকারী বোতামে ক্লিক করেছেন কিনা তা বুঝতে Button Modifier.clickable-এর উপর নির্ভর করে। আপনি যদি নিজের অ্যাপে একটি সাধারণ বোতাম যোগ করেন, তাহলে আপনি বোতামের onClick কোড নির্ধারণ করতে পারেন এবং উপযুক্ত হলে Modifier.clickable সেই কোড রান করে। এর অর্থ হল, ব্যবহারকারী স্ক্রিনে ট্যাপ করেছেন নাকি কীবোর্ড দিয়ে বোতামটি বেছে নিয়েছেন তা আপনাকে জানতে হবে না; Modifier.clickable বুঝে নেয় যে ব্যবহারকারী ক্লিক করেছেন এবং আপনার onClick কোড রান করে উত্তর দেয়।

তবে, ব্যবহারকারীর আচরণ অনুযায়ী আপনার UI কম্পোনেন্টের প্রতিক্রিয়া কাস্টমাইজ করতে চাইলে, আপনাকে আরও জানতে হবে যে ব্যাকগ্রাউন্ডে কী চলছে। এই বিভাগে সেই তথ্যের কিছু অংশ দেওয়া হয়েছে।

ব্যবহারকারী UI কম্পোনেন্টের সাথে ইন্টার‍্যাক্ট করলে, সিস্টেম তার আচরণকে Interaction ইভেন্টের সংখ্যা জেনারেট করার মাধ্যমে দেখায়। যেমন, ব্যবহারকারী কোনও বোতামে টাচ করলে, বোতামটি PressInteraction.Press তৈরি করে। ব্যবহারকারী বোতামের মধ্যে থেকে আঙুল সরিয়ে নিলে, এটি একটি PressInteraction.Release জেনারেট করে, যা বোতামটিকে জানায় যে ক্লিক করা হয়ে গেছে। অন্যদিকে, ব্যবহারকারী বোতামের বাইরে আঙুল টেনে নিয়ে গিয়ে আঙুল তুলে নিলে, বোতামটি PressInteraction.Cancel জেনারেট করে, যা থেকে বোঝা যায় যে বোতামে প্রেস করার অ্যাকশন বাতিল করা হয়েছে, সম্পূর্ণ করা হয়নি।

এইসব ইন্টার‍্যাকশন নিরপেক্ষ। অর্থাৎ, এই লো-লেভেল ইন্টার‍্যাকশন ইভেন্ট ব্যবহারকারীর অ্যাকশনের অর্থ বা তাদের ক্রম ব্যাখ্যা করার উদ্দেশ্যে নয়। এছাড়াও, ব্যবহারকারীর কোন অ্যাকশনটি অন্য অ্যাকশনের চেয়ে বেশি গুরুত্বপূর্ণ তা এটি ব্যাখ্যা করে না।

এইসব ইন্টার‍্যাকশন সাধারণত জোড়ায় জোড়ায় হয়, যার একটি শুরু ও একটি শেষ থাকে। দ্বিতীয় ইন্টার‍্যাকশনে প্রথমটির রেফারেন্স থাকে। যেমন, কোনও ব্যবহারকারী কোনও বোতামে টাচ করে আঙুল তুলে নিলে, টাচ করার ফলে একটি PressInteraction.Press ইন্টার‍্যাকশন তৈরি হয় এবং আঙুল তুলে নেওয়ার ফলে একটি PressInteraction.Release তৈরি হয়; Release-এর একটি press প্রপার্টি থাকে যা প্রাথমিক PressInteraction.Press শনাক্ত করে।

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

ইন্টার‍্যাকশন স্টেট

আপনি হয়ত নিজের কম্পোনেন্টের বিল্ট-ইন কার্যকারিতা বাড়াতে চাইবেন, এর জন্য ইন্টার‍্যাকশনও ট্র্যাক করতে হবে। যেমন, আপনি হয়ত চান যে কোনও বোতাম প্রেস করা হলে তার রঙ পরিবর্তন হোক। ইন্ট্যার‍্যাকশন ট্র্যাক করার সবচেয়ে সহজ উপায় হল উপযুক্ত ইন্ট্যার‍্যাকশন স্টেট লক্ষ্য করা। InteractionSource-এ এমন অনেক পদ্ধতি আছে যার মাধ্যমে বিভিন্ন ইন্টার‍্যাকশন স্ট্যাটাসকে স্টেট হিসেবে দেখানো যায়। যেমন, আপনি যদি কোনও নির্দিষ্ট বোতাম প্রেস করা হয়েছে কিনা তা দেখতে চান, তাহলে আপনি এটির InteractionSource.collectIsPressedAsState() মেথড কল করতে পারেন:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

collectIsPressedAsState() ছাড়াও, Compose-এ collectIsFocusedAsState(), collectIsDraggedAsState() এবং collectIsHoveredAsState()-এর সুবিধাও রয়েছে। এই পদ্ধতিগুলি আসলে লোয়ার-লেভেল InteractionSource API-এর উপরে তৈরি করা সুবিধাজনক পদ্ধতি। কিছু ক্ষেত্রে, আপনি হয়ত সেইসব নিম্ন-স্তরের ফাংশন সরাসরি ব্যবহার করতে চাইবেন।

যেমন, ধরুন আপনাকে জানতে হবে যে কোনও বোতাম প্রেস করা হচ্ছে কিনা এবং এছাড়াও এটি টেনে আনা হচ্ছে কিনা। আপনি collectIsPressedAsState() এবং collectIsDraggedAsState(), দুটিই ব্যবহার করলে, Compose অনেক ডুপ্লিকেট কাজ করে এবং আপনি সব ইন্টার‍্যাকশন সঠিক ক্রমে পাবেন কিনা তার কোনও গ্যারান্টি নেই। এই ধরনের পরিস্থিতিতে, আপনি হয়ত সরাসরি InteractionSource-এর সাথে কাজ করতে চাইবেন। InteractionSource-এর সাথে আপনার ইন্টার‍্যাকশন ট্র্যাক করার বিষয়ে আরও তথ্যের জন্য, InteractionSource-এর সাথে কাজ করুন দেখুন।

নিম্নলিখিত বিভাগ থেকে আপনি জানতে পারবেন যে কীভাবে InteractionSource এবং MutableInteractionSource-এর সাথে ইন্টার‍্যাকশন কনজিউম এবং এমিট করতে হয়।

Interaction ব্যবহার ও নির্গমন করুন

InteractionSource হল Interactions-এর শুধু-পড়ার স্ট্রিম — InteractionSource-এ Interaction এমিট করা সম্ভব নয়। Interaction এমিট করতে, আপনাকে MutableInteractionSource ব্যবহার করতে হবে, যা InteractionSource থেকে এক্সটেন্ড করে।

মডিফায়ার ও কম্পোনেন্ট Interactions ব্যবহার, নির্গমন অথবা ব্যবহার ও নির্গমন করতে পারে। নিম্নলিখিত বিভাগগুলি বর্ণনা করে যে কীভাবে মডিফায়ার এবং কম্পোনেন্ট, উভয় থেকেই ইন্টার‍্যাকশন গ্রহণ এবং নির্গত করা যায়।

কনজিউমিং মডিফায়ার সংক্রান্ত উদাহরণ

ফোকাসড মোডের জন্য বর্ডার আঁকে এমন মডিফায়ারের জন্য, আপনাকে শুধু Interactions দেখতে হবে, যাতে আপনি InteractionSource গ্রহণ করতে পারেন:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

ফাংশন সিগনেচার থেকে এটি স্পষ্ট যে এই মডিফায়ার হল একটি কনজিউমার — এটি Interaction কনজিউম করতে পারে, কিন্তু এমিট করতে পারে না।

প্রোডিউসিং মডিফায়ার সংক্রান্ত উদাহরণ

Modifier.hoverable-এর মতো হোভার ইভেন্ট ম্যানেজ করে এমন মডিফায়ারের জন্য, আপনাকে Interactions এমিট করতে হবে এবং পরিবর্তে প্যারামিটার হিসেবে MutableInteractionSource গ্রহণ করতে হবে:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

এই মডিফায়ার হল একটি প্রডিউসার — এটি হোভার করা বা হোভার না করা হলে HoverInteractions নির্গত করার জন্য প্রদত্ত MutableInteractionSource ব্যবহার করতে পারে।

এমন কম্পোনেন্ট তৈরি করুন যা ব্যবহার ও উৎপাদন করে

মেটেরিয়াল Button-এর মতো হাই-লেভেল কম্পোনেন্ট, প্রোডিউসার ও কনজিউমার, উভয় হিসেবেই কাজ করে। এগুলি ইনপুট ও ফোকাস ইভেন্ট ম্যানেজ করে এবং এইসব ইভেন্টের উত্তরে তাদের চেহারাও পরিবর্তন করে, যেমন রিপল দেখানো বা তাদের এলিভেশন অ্যানিমেট করা। ফলস্বরূপ, তারা সরাসরি MutableInteractionSource-কে একটি প্যারামিটার হিসেবে প্রকাশ করে, যাতে আপনি নিজের মনে রাখা ইনস্ট্যান্স প্রদান করতে পারেন:

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

এটি কম্পোনেন্ট থেকে MutableInteractionSource হোইস্ট করতে এবং কম্পোনেন্ট দ্বারা তৈরি করা সমস্ত Interaction পর্যবেক্ষণ করতে দেয়। আপনি এই কম্পোনেন্ট বা আপনার UI-এর অন্য কোনও কম্পোনেন্টের উপস্থিতি নিয়ন্ত্রণ করতে এটি ব্যবহার করতে পারেন।

আপনি যদি নিজের ইন্টার‍্যাক্টিভ হাই লেভেল কম্পোনেন্ট তৈরি করেন, তাহলে আমরা সাজেস্ট করি যে আপনি এইভাবে MutableInteractionSource-কে প্যারামিটার হিসেবে এক্সপোজ করুন। স্টেট হোয়েস্টিং সংক্রান্ত পেশাদার পদ্ধতি অনুসরণ করার পাশাপাশি, এটি কোনও কম্পোনেন্টের ভিজ্যুয়াল স্টেটকে এমনভাবে সহজে পড়তে ও কন্ট্রোল করতে সাহায্য করে, যেভাবে অন্য যেকোনও ধরনের স্টেট (যেমন, চালু করা স্টেট) পড়া ও কন্ট্রোল করা যায়।

Compose লেয়ার্ড আর্কিটেকচারাল অ্যাপ্রোচ অনুসরণ করে, তাই হাই-লেভেল মেটেরিয়াল কম্পোনেন্টগুলি ফাউন্ডেশনাল বিল্ডিং ব্লকের উপরে তৈরি করা হয় যা রিপল এবং অন্যান্য ভিজ্যুয়াল এফেক্ট নিয়ন্ত্রণ করার জন্য প্রয়োজনীয় Interaction তৈরি করে। ফাউন্ডেশন লাইব্রেরি হাই-লেভেল ইন্টার‍্যাকশন মডিফায়ার প্রদান করে যেমন Modifier.hoverable, Modifier.focusable এবং Modifier.draggable।

হোভার ইভেন্টে রেসপন্স করে এমন কম্পোনেন্ট তৈরি করতে, আপনি শুধু Modifier.hoverable ব্যবহার করতে এবং প্যারামিটার হিসেবে MutableInteractionSource পাস করতে পারবেন। যখনই কম্পোনেন্টের উপর হোভার করা হয়, এটি HoverInteraction এমিট করে এবং কম্পোনেন্টটি কীভাবে দেখা যাবে তা পরিবর্তন করতে আপনি এটি ব্যবহার করতে পারেন।

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

এছাড়াও, এই কম্পোনেন্ট ফোকাসযোগ্য করতে, আপনি Modifier.focusable যোগ করতে এবং প্যারামিটার হিসেবে একই MutableInteractionSource পাস করতে পারবেন। এখন, HoverInteraction.Enter/Exit এবং FocusInteraction.Focus/Unfocus, দুটিই একই MutableInteractionSource-এর মাধ্যমে নির্গত হয় এবং আপনি একই জায়গায় দুই ধরনের ইন্টার‍্যাকশনের জন্যই চেহারা কাস্টমাইজ করতে পারবেন:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable হল hoverable এবং focusable-এর চেয়েও উচ্চ লেভেলের অ্যাবস্ট্রাকশন — কোনও কম্পোনেন্ট ক্লিকযোগ্য হতে হলে, সেটি অন্তর্নিহিতভাবে হোভারযোগ্য হতে হবে এবং যে কম্পোনেন্ট ক্লিক করা যায়, সেটি ফোকাসযোগ্যও হতে হবে। আপনি Modifier.clickable ব্যবহার করে এমন কম্পোনেন্ট তৈরি করতে পারেন যা নিম্ন স্তরের API একত্রিত না করেই হোভার, ফোকাস এবং প্রেস ইন্টার‍্যাকশন ম্যানেজ করে। আপনি যদি নিজের কম্পোনেন্টকে ক্লিক করার উপযুক্ত করতে চান, তাহলে hoverable এবং focusable-এর পরিবর্তে clickable ব্যবহার করতে পারেন:

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

InteractionSource-এর সাথে কাজ করুন

কোনও কম্পোনেন্টের সাথে ইন্টার‍্যাকশন সম্পর্কে আপনার লো-লেভেল তথ্যের প্রয়োজন হলে, আপনি সেই কম্পোনেন্টের InteractionSource-এর জন্য স্ট্যান্ডার্ড ফ্লো API ব্যবহার করতে পারেন। যেমন, ধরুন আপনি কোনও InteractionSource-এর জন্য প্রেস ও ড্র্যাগ ইন্টার‍্যাকশনের তালিকা বজায় রাখতে চান। এই কোডটি অর্ধেক কাজ করে, নতুন প্রেস তালিকায় যোগ করে, সেগুলি আসার সাথে সাথে:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

তবে নতুন ইন্টার‍্যাকশন যোগ করার পাশাপাশি, ইন্টার‍্যাকশন শেষ হয়ে গেলে (যেমন, ব্যবহারকারী কম্পোনেন্ট থেকে আঙুল সরিয়ে নিলে) আপনাকে ইন্টার‍্যাকশন সরিয়েও দিতে হবে। এটি করা সহজ, কারণ শেষ ইন্টারঅ্যাকশনে সবসময় সংশ্লিষ্ট শুরু ইন্টারঅ্যাকশনের রেফারেন্স থাকে। এই কোড থেকে বোঝা যায় যে শেষ হয়ে যাওয়া ইন্টার‍্যাকশন কীভাবে সরাবেন:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

এখন, আপনি যদি জানতে চান যে কম্পোনেন্টটি বর্তমানে প্রেস বা ড্র্যাগ করা হচ্ছে কিনা, আপনাকে শুধু চেক করতে হবে যে interactions খালি আছে কিনা:

val isPressedOrDragged = interactions.isNotEmpty()

সবচেয়ে সাম্প্রতিক ইন্টার‍্যাকশন কী ছিল তা জানতে চাইলে, তালিকার শেষ আইটেমটি দেখুন। যেমন, Compose রিপল ইমপ্লিমেন্টেশন কীভাবে সবচেয়ে সাম্প্রতিক ইন্টার‌্যাকশনের জন্য উপযুক্ত স্টেট ওভারলে খুঁজে বের করে তা এখানে দেখানো হয়েছে:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

যেহেতু সব Interaction একই স্ট্রাকচার ফলো করে, তাই বিভিন্ন ধরনের ব্যবহারকারীর ইন্টার‍্যাকশন নিয়ে কাজ করার সময় কোডে খুব বেশি পার্থক্য থাকে না — সামগ্রিক প্যাটার্ন একই থাকে।

মনে রাখবেন যে এই বিভাগে আগের উদাহরণগুলি State ব্যবহার করে ইন্টার‍্যাকশনের Flow দেখায় — এটি আপডেট করা ভ্যালু পর্যবেক্ষণ করা সহজ করে তোলে, কারণ স্টেট ভ্যালু রিড করলে অটোমেটিক রিকম্পোজিশন হবে। তবে, কম্পোজিশন হল ব্যাচ করা প্রি-ফ্রেম। এর অর্থ হল, যদি স্টেট পরিবর্তন হয় এবং তারপরে একই ফ্রেমের মধ্যে আবার পরিবর্তন হয়, তাহলে স্টেট পর্যবেক্ষণকারী কম্পোনেন্টগুলি পরিবর্তন দেখতে পাবে না।

ইন্টার‍্যাকশনের জন্য এটি গুরুত্বপূর্ণ, কারণ ইন্টার‍্যাকশন নিয়মিতভাবে একই ফ্রেমের মধ্যে শুরু ও শেষ হতে পারে। যেমন, Button-এর সাথে আগের উদাহরণ ব্যবহার করা:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

একই ফ্রেমে প্রেস করা শুরু ও শেষ হলে, টেক্সট কখনই "প্রেস করা হয়েছে!" হিসেবে দেখানো হবে না। বেশিরভাগ ক্ষেত্রে, এটি কোনও সমস্যা নয় — এত কম সময়ের জন্য কোনও ভিজ্যুয়াল এফেক্ট দেখালে, সেটি ফ্লিকার করবে এবং ব্যবহারকারীর কাছে খুব একটা লক্ষণীয় হবে না। কিছু ক্ষেত্রে, যেমন রিপল এফেক্ট বা একই ধরনের অ্যানিমেশন দেখানোর সময়, আপনি হয়ত চাইবেন যে বোতামটি আর প্রেস করা না হলেও, অবিলম্বে বন্ধ না করে, অন্তত ন্যূনতম সময়ের জন্য এফেক্টটি দেখাতে। এটি করতে, আপনি কোনও স্টেটে লেখার পরিবর্তে, সরাসরি ল্যাম্বডা কালেকশনের ভিতর থেকে অ্যানিমেশন শুরু ও বন্ধ করতে পারেন। এই প্যাটার্নের একটি উদাহরণ অ্যানিমেটেড বর্ডার সহ উন্নত Indication তৈরি করুন বিভাগে রয়েছে।

উদাহরণ: কাস্টম ইন্টার‍্যাকশন হ্যান্ডলিং সহ কম্পোনেন্ট তৈরি করা

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

একটি বোতামের অ্যানিমেশন যা ক্লিক করা হলে ডাইনামিকভাবে একটি মুদিখানার কার্ট আইকন যোগ করে
ছবি ৩. ক্লিক করা হলে, একটি বোতাম যা ডায়নামিকভাবে একটি আইকন যোগ করে।

এটি করতে, Button-এর উপর ভিত্তি করে একটি কাস্টম কম্পোজেবল তৈরি করুন এবং আইকনটি আঁকার জন্য (এই ক্ষেত্রে, একটি শপিং কার্ট) এতে একটি অতিরিক্ত icon প্যারামিটার যোগ করুন। ব্যবহারকারী বোতামের উপরে মাউস নিয়ে যাচ্ছেন কিনা তা ট্র্যাক করতে আপনি collectIsPressedAsState() কল করেন; যখন তারা তা করেন, আপনি আইকন যোগ করেন। কোডটি দেখতে কেমন তা এখানে দেওয়া হল:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

এবং সেই নতুন কম্পোজেবল ব্যবহার করলে কেমন দেখাবে তা এখানে দেওয়া হল:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

কারণ এই নতুন PressIconButton আগে থেকে থাকা Material Button-এর উপরে তৈরি করা হয়েছে, এটি সব সাধারণ উপায়ে ব্যবহারকারীর ইন্টার‍্যাকশনে প্রতিক্রিয়া জানায়। ব্যবহারকারী বোতামটি প্রেস করলে, এটি সাধারণ মেটেরিয়াল Button-এর মতো এর অস্বচ্ছতা সামান্য পরিবর্তন করে।

Indication-এর সাহায্যে আবার ব্যবহার করা যায় এমন কাস্টম এফেক্ট তৈরি ও প্রয়োগ করা

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

আপনি যদি এই ধরনের ডিজাইন সিস্টেম তৈরি করেন, তাহলে একটি কম্পোনেন্ট কাস্টমাইজ করা এবং অন্যান্য কম্পোনেন্টের জন্য এই কাস্টমাইজেশন আবার ব্যবহার করা নিম্নলিখিত কারণে কঠিন হতে পারে:

  • ডিজাইন সিস্টেমের প্রতিটি কম্পোনেন্টের জন্য একই বয়লারপ্লেট প্রয়োজন
  • নতুন তৈরি করা কম্পোনেন্ট এবং কাস্টম ক্লিক করা যায় এমন কম্পোনেন্টে এই এফেক্ট প্রয়োগ করতে ভুলে যাওয়া সহজ
  • অন্যান্য এফেক্টের সাথে কাস্টম এফেক্ট যোগ করা কঠিন হতে পারে

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

  • IndicationNodeFactory: একটি ফ্যাক্টরি যা Modifier.Node ইনস্ট্যান্স তৈরি করে যা একটি কম্পোনেন্টের জন্য ভিজ্যুয়াল এফেক্ট রেন্ডার করে। সহজ ইমপ্লিমেন্টেশনের জন্য যা কম্পোনেন্ট জুড়ে পরিবর্তন হয় না, এটি একটি সিঙ্গলটন (অবজেক্ট) হতে পারে এবং সম্পূর্ণ অ্যাপ্লিকেশন জুড়ে পুনরায় ব্যবহার করা যেতে পারে।

    এইসব ইনস্ট্যান্স স্টেটফুল বা স্টেটলেস হতে পারে। যেহেতু এগুলি প্রতিটি কম্পোনেন্টের জন্য তৈরি করা হয়, তাই এগুলি একটি CompositionLocal থেকে ভ্যালু রিট্রিভ করতে পারে যাতে একটি নির্দিষ্ট কম্পোনেন্টের মধ্যে এগুলি কীভাবে দেখা যায় বা আচরণ করে তা পরিবর্তন করা যায়, যেমন অন্য যেকোনও Modifier.Node-এর ক্ষেত্রে হয়।

  • Modifier.indication: একটি মডিফায়ার যা একটি কম্পোনেন্টের জন্য Indication আঁকে। Modifier.clickable এবং অন্যান্য উচ্চ লেভেলের ইন্টার‍্যাকশন মডিফায়ার সরাসরি একটি ইঙ্গিত প্যারামিটার গ্রহণ করে, তাই তারা শুধু Interaction নির্গত করে না, বরং তারা যে Interaction নির্গত করে তার জন্য ভিজ্যুয়াল এফেক্টও আঁকতে পারে। তাই, সহজ কেসের জন্য, আপনি Modifier.indication-এর প্রয়োজন ছাড়াই Modifier.clickable ব্যবহার করতে পারবেন।

Indication দিয়ে এফেক্ট পরিবর্তন করুন

এই বিভাগে বর্ণনা করা হয়েছে যে কীভাবে একটি নির্দিষ্ট বোতামে প্রয়োগ করা ম্যানুয়াল স্কেল এফেক্টকে একটি সমতুল্য ইঙ্গিত দিয়ে প্রতিস্থাপন করা যায় যা একাধিক কম্পোনেন্ট জুড়ে পুনরায় ব্যবহার করা যেতে পারে।

নিম্নলিখিত কোডটি এমন একটি বোতাম তৈরি করে যা প্রেস করলে নিচের দিকে স্কেল করে:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

উপরের স্নিপেটে স্কেল এফেক্টকে Indication-এ কনভার্ট করতে, এই ধাপগুলি অনুসরণ করুন:

  1. স্কেল এফেক্ট প্রয়োগ করার জন্য দায়ী Modifier.Node তৈরি করুন। অ্যাটাচ করা হলে, নোডটি ইন্টার‍্যাকশন সোর্স পর্যবেক্ষণ করে, যা আগের উদাহরণের মতোই। এখানে একমাত্র পার্থক্য হল যে এটি ইনকামিং ইন্টার‍্যাকশনকে স্টেটে কনভার্ট করার পরিবর্তে সরাসরি অ্যানিমেশন লঞ্চ করে।

    নোডকে DrawModifierNode প্রয়োগ করতে হবে যাতে এটি ContentDrawScope#draw() ওভাররাইড করতে পারে এবং Compose-এ অন্য যেকোনও গ্রাফিক্স API-এর মতো একই ড্রয়িং কমান্ড ব্যবহার করে স্কেল এফেক্ট রেন্ডার করতে পারে।

    ContentDrawScope রিসিভার থেকে উপলভ্য drawContent() কল করলে, আসল কম্পোনেন্ট ড্র করবে যার উপর Indication প্রয়োগ করা উচিত, তাই আপনাকে শুধু স্কেল ট্রান্সফর্মেশনের মধ্যে এই ফাংশন কল করতে হবে। আপনার Indication ইমপ্লিমেন্টেশন যাতে সবসময় কোনও না কোনও সময় drawContent() কল করে তা নিশ্চিত করুন; অন্যথায়, আপনি যে কম্পোনেন্টে Indication প্রয়োগ করছেন সেটি ড্র করা হবে না।

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. IndicationNodeFactory তৈরি করুন। এর একমাত্র কাজ হল প্রদত্ত ইন্টার‍্যাকশন সোর্সের জন্য নতুন নোড ইনস্ট্যান্স তৈরি করা। যেহেতু ইঙ্গিত কনফিগার করার জন্য কোনও প্যারামিটার নেই, তাই ফ্যাক্টরি একটি অবজেক্ট হতে পারে:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable ইন্টার্নালি Modifier.indication ব্যবহার করে, তাই ScaleIndication-এর সাথে ক্লিক করা যায় এমন কম্পোনেন্ট তৈরি করতে, আপনাকে শুধু clickable-এর প্যারামিটার হিসেবে Indication প্রদান করতে হবে:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    এটি কাস্টম ব্যবহার করে উচ্চ স্তরের, পুনর্ব্যবহারযোগ্য উপাদান তৈরি করা সহজ করে তোলে Indication — একটি বোতাম দেখতে কেমন হতে পারে:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

আপনি নিম্নলিখিত উপায়ে বোতামটি ব্যবহার করতে পারবেন:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

গ্রোসারি কার্ট আইকন সহ একটি বোতামের অ্যানিমেশন যা প্রেস করলে ছোট হয়ে যায়
ছবি ৪. কাস্টম Indication দিয়ে তৈরি করা বোতাম।

অ্যানিমেটেড বর্ডার সহ একটি উন্নত Indication তৈরি করুন

Indication শুধু ট্রান্সফর্মেশন এফেক্টের মধ্যেই সীমাবদ্ধ নয়, যেমন কোনও কম্পোনেন্ট স্কেল করা। যেহেতু IndicationNodeFactory একটি Modifier.Node রিটার্ন করে, তাই আপনি অন্যান্য ড্রয়িং API-এর মতো কন্টেন্টের উপরে বা নিচে যেকোনও ধরনের এফেক্ট আঁকতে পারেন। যেমন, আপনি কম্পোনেন্টের চারপাশে অ্যানিমেটেড বর্ডার এবং কম্পোনেন্ট প্রেস করা হলে তার উপরে ওভারলে আঁকতে পারেন:

প্রেস করলে রামধনু এফেক্ট সহ একটি বোতাম
ছবি ৫. Indication দিয়ে আঁকা অ্যানিমেটেড বর্ডার এফেক্ট।

এখানে Indication প্রয়োগ করার পদ্ধতি আগের উদাহরণের মতোই — এটি শুধু কিছু প্যারামিটার সহ একটি নোড তৈরি করে। যেহেতু অ্যানিমেটেড বর্ডার, Indication-এর জন্য ব্যবহৃত কম্পোনেন্টের আকার ও বর্ডারের উপর নির্ভর করে, তাই Indication প্রয়োগ করার জন্য প্যারামিটার হিসেবে আকার ও বর্ডারের প্রস্থও প্রদান করতে হয়:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

Modifier.Node প্রয়োগের পদ্ধতিও ধারণাগতভাবে একই, এমনকি ড্রয়িং কোড আরও জটিল হলেও। আগের মতোই, এটি InteractionSource অ্যাটাচ করা হলে লক্ষ্য করে, অ্যানিমেশন লঞ্চ করে এবং কন্টেন্টের উপরে এফেক্ট ড্র করার জন্য DrawModifierNode প্রয়োগ করে:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

এখানে মূল পার্থক্য হল যে animateToResting() ফাংশনের সাথে অ্যানিমেশনের জন্য এখন একটি ন্যূনতম সময়কাল রয়েছে, তাই প্রেসটি অবিলম্বে রিলিজ করা হলেও, প্রেস অ্যানিমেশন চলতে থাকবে। এছাড়াও, animateToPressed-এর শুরুতে একাধিকবার দ্রুত প্রেস করার হ্যান্ডলিং আছে — যদি আগে থেকে থাকা প্রেস বা রেস্টিং অ্যানিমেশনের সময় প্রেস করা হয়, তাহলে আগের অ্যানিমেশন বাতিল হয়ে যায় এবং প্রেস অ্যানিমেশন শুরু থেকে শুরু হয়। একাধিক সমবর্তী এফেক্ট (যেমন, রিপল, যেখানে একটি নতুন রিপল অ্যানিমেশন অন্যান্য রিপলের উপরে আঁকা হবে) সাপোর্ট করতে, আপনি আগে থেকে থাকা অ্যানিমেশন বাতিল করে নতুন অ্যানিমেশন শুরু করার পরিবর্তে, একটি তালিকায় অ্যানিমেশন ট্র্যাক করতে পারেন।