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

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

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

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

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

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

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

আপনি হয়ত নিজের কম্পোনেন্টের বিল্ট-ইন কার্যকারিতা বাড়াতে চাইবেন, এর জন্য আপনাকে নিজেই ইন্টার‍্যাকশন ট্র্যাক করতে হবে। যেমন, আপনি হয়ত চান যে কোনও বোতাম প্রেস করলে সেটির রঙ পরিবর্তন হোক। ইন্টার‍্যাকশন ট্র্যাক করার সবচেয়ে সহজ উপায় হল, উপযুক্ত ইন্টার‍্যাকশন স্টেট লক্ষ্য করা। 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 ইমিট করা যায় না। Interactions এমিট করতে, আপনাকে MutableInteractionSource ব্যবহার করতে হবে, যা InteractionSource থেকে এক্সটেন্ড করে।

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

পরিবর্তক ব্যবহার করার উদাহরণ

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

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

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

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

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

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

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

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

Material-এর মতো হাই-লেভেল কম্পোনেন্ট 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 পাস করতে পারেন। কম্পোনেন্টের উপর কার্সর নিয়ে গেলে, এটি HoverInteractions এমিট করে এবং কম্পোনেন্টটি কেমন দেখাবে তা পরিবর্তন করতে আপনি এটি ব্যবহার করতে পারবেন।

// 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 পাস করতে পারেন। এখন, একই MutableInteractionSource-এর মাধ্যমে HoverInteraction.Enter/Exit ও FocusInteraction.Focus/Unfocus, দুটিই নির্গত হয় এবং আপনি একই জায়গায় দুই ধরনের ইন্টার‍্যাকশনের আউটপুট কাস্টমাইজ করতে পারবেন:

// 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")
}

কোনও প্রেস একই ফ্রেমে শুরু ও শেষ হলে, টেক্সটটি কখনই "প্রেস করা হয়েছে!" হিসেবে দেখানো হবে না। বেশিরভাগ ক্ষেত্রে, এটি কোনও সমস্যা নয় — এত কম সময়ের জন্য ভিজ্যুয়াল এফেক্ট দেখালে তা ফ্লিকার করবে এবং ব্যবহারকারীর কাছে খুব বেশি নজরে পড়বে না। কিছু ক্ষেত্রে, যেমন, রিপল এফেক্ট বা এই ধরনের অ্যানিমেশন দেখানোর সময়, বোতাম আর প্রেস করা না হলে সঙ্গে সঙ্গে বন্ধ করে দেওয়ার পরিবর্তে, আপনি হয়ত অন্তত একটি ন্যূনতম সময় ধরে এফেক্ট দেখাতে চাইবেন। এটি করতে, আপনি কোনও স্টেটে না লিখে, সরাসরি collect lambda-র মধ্যে থেকে অ্যানিমেশন শুরু ও বন্ধ করতে পারবেন। অ্যানিমেটেড বর্ডার সহ 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-এর উপরে তৈরি করা হয়েছে, তাই এটি ব্যবহারকারীর ইন্টার‍্যাকশনের সাথে স্বাভাবিকভাবেই প্রতিক্রিয়া দেখায়। ব্যবহারকারী বোতামটি প্রেস করলে, এটি সামান্য অস্বচ্ছ হয়ে যায়, ঠিক সাধারণ Material 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-এর শুরুতে একাধিকবার দ্রুত প্রেস করার জন্য হ্যান্ডেলিং আছে — আগে থেকে থাকা প্রেস বা রেস্টিং অ্যানিমেশনের সময় কোনও প্রেস হলে, আগের অ্যানিমেশন বাতিল হয়ে যায় এবং প্রেস অ্যানিমেশন শুরু থেকে শুরু হয়। একাধিক একসাথে চলা এফেক্ট (যেমন, রিপেল, যেখানে নতুন রিপেল অ্যানিমেশন অন্যান্য রিপেলের উপরে ড্র করা হবে) কাজ করে এমন করতে, আগে থেকে থাকা অ্যানিমেশন বাতিল না করে এবং নতুন অ্যানিমেশন শুরু না করে, আপনি একটি তালিকায় অ্যানিমেশন ট্র্যাক করতে পারেন।