उपयोगकर्ता के इंटरैक्शन को मैनेज करना

यूज़र इंटरफ़ेस कॉम्पोनेंट, डिवाइस के उपयोगकर्ता को इस तरह से सुझाव देते हैं कि वे उपयोगकर्ता के इंटरैक्शन का जवाब दे सकें. हर कॉम्पोनेंट, इंटरैक्शन का जवाब अपने तरीके से देता है. इससे उपयोगकर्ता को यह पता चलता है कि उसके इंटरैक्शन क्या कर रहे हैं. उदाहरण के लिए, अगर कोई व्यक्ति डिवाइस की टचस्क्रीन पर किसी बटन को छूता है, तो बटन में कुछ बदलाव हो सकता है. जैसे, हाइलाइट करने के लिए रंग जोड़ा जा सकता है. इस बदलाव से उपयोगकर्ता को पता चलता है कि उसने बटन को छुआ है. अगर उपयोगकर्ता को ऐसा नहीं करना है, तो उसे पता होगा कि बटन को छोड़ने से पहले, अपनी उंगली को बटन से दूर ले जाना है. ऐसा न करने पर, बटन चालू हो जाएगा.

पहली इमेज. ऐसे बटन जो हमेशा चालू दिखते हैं और दबाने पर कोई इफ़ेक्ट नहीं दिखाते.
दूसरी इमेज. ऐसे बटन जिनमें दबाने पर रिपल इफ़ेक्ट दिखता है. साथ ही, ये बटन अपनी चालू स्थिति के हिसाब से दिखते हैं.

Compose Gestures के दस्तावेज़ में बताया गया है कि Compose कॉम्पोनेंट, पॉइंटर मूव और क्लिक जैसे लो-लेवल पॉइंटर इवेंट को कैसे हैंडल करते हैं. Compose, डिफ़ॉल्ट रूप से उन छोटे-मोटे इवेंट को बड़े इंटरैक्शन में बदल देता है. उदाहरण के लिए, पॉइंटर इवेंट की सीरीज़ को बटन दबाने और छोड़ने के तौर पर गिना जा सकता है. इन ऐब्स्ट्रैक्शन को समझने से, आपको यह तय करने में मदद मिल सकती है कि आपका यूज़र इंटरफ़ेस (यूआई), उपयोगकर्ता को कैसे जवाब देगा. उदाहरण के लिए, ऐसा हो सकता है कि आपको यह पसंद न हो कि जब कोई उपयोगकर्ता किसी कॉम्पोनेंट से इंटरैक्ट करे, तो उसकी उपस्थिति में बदलाव हो. इसके अलावा, ऐसा भी हो सकता है कि आपको सिर्फ़ उपयोगकर्ता की उन कार्रवाइयों का लॉग बनाए रखना हो. इस दस्तावेज़ में, आपको स्टैंडर्ड यूज़र इंटरफ़ेस (यूआई) एलिमेंट में बदलाव करने या अपने यूज़र इंटरफ़ेस (यूआई) को डिज़ाइन करने के लिए ज़रूरी जानकारी मिलेगी.

इंटरैक्शन

कई मामलों में, आपको यह जानने की ज़रूरत नहीं होती कि आपका Compose कॉम्पोनेंट, उपयोगकर्ता के इंटरैक्शन को कैसे समझ रहा है. उदाहरण के लिए, Button इस बात का पता लगाने के लिए Modifier.clickable पर निर्भर करता है कि उपयोगकर्ता ने बटन पर क्लिक किया है या नहीं. अगर आपको अपने ऐप्लिकेशन में कोई सामान्य बटन जोड़ना है, तो बटन का onClick कोड तय किया जा सकता है. इसके बाद, Modifier.clickable उस कोड को सही समय पर चलाता है. इसका मतलब है कि आपको यह जानने की ज़रूरत नहीं है कि उपयोगकर्ता ने स्क्रीन पर टैप किया है या कीबोर्ड से बटन चुना है. Modifier.clickable यह पता लगाता है कि उपयोगकर्ता ने क्लिक किया है और आपके onClick कोड को चलाकर जवाब देता है.

हालांकि, अगर आपको उपयोगकर्ता के व्यवहार के हिसाब से, अपने यूज़र इंटरफ़ेस (यूआई) कॉम्पोनेंट के जवाब को पसंद के मुताबिक बनाना है, तो आपको यह जानना होगा कि बैकग्राउंड में क्या हो रहा है. इस सेक्शन में, आपको इस बारे में कुछ जानकारी मिलेगी.

जब कोई उपयोगकर्ता किसी यूज़र इंटरफ़ेस (यूआई) कॉम्पोनेंट के साथ इंटरैक्ट करता है, तो सिस्टम उसके व्यवहार को कई Interaction इवेंट जनरेट करके दिखाता है. उदाहरण के लिए, अगर कोई उपयोगकर्ता किसी बटन को छूता है, तो बटन PressInteraction.Press जनरेट करता है. अगर उपयोगकर्ता बटन के अंदर अपनी उंगली उठाता है, तो इससे PressInteraction.Release जनरेट होता है. इससे बटन को पता चलता है कि क्लिक पूरा हो गया है. वहीं दूसरी ओर, अगर उपयोगकर्ता अपनी उंगली को बटन से बाहर की ओर खींचता है और फिर उंगली हटाता है, तो बटन PressInteraction.Cancel जनरेट करता है. इससे पता चलता है कि बटन को दबाने की कार्रवाई पूरी नहीं हुई, बल्कि रद्द कर दी गई है.

इन इंटरैक्शन में कोई राय नहीं दी जाती. इसका मतलब है कि ये लो-लेवल इंटरैक्शन इवेंट, उपयोगकर्ता की कार्रवाइयों या उनके क्रम का मतलब नहीं बताते. ये यह भी नहीं बताते कि उपयोगकर्ता की किन कार्रवाइयों को अन्य कार्रवाइयों की तुलना में प्राथमिकता दी जा सकती है.

आम तौर पर, ये इंटरैक्शन जोड़े में होते हैं. इनमें एक शुरुआत होती है और एक अंत होता है. दूसरे इंटरैक्शन में, पहले इंटरैक्शन का रेफ़रंस शामिल है. उदाहरण के लिए, अगर कोई उपयोगकर्ता किसी बटन को छूता है और फिर अपनी उंगली हटा लेता है, तो छूने से PressInteraction.Press इंटरैक्शन जनरेट होता है और उंगली हटाने से PressInteraction.Release जनरेट होता है. Release में press प्रॉपर्टी होती है, जो शुरुआती PressInteraction.Press की पहचान करती है.

किसी कॉम्पोनेंट के इंटरैक्शन देखने के लिए, उसके InteractionSource को देखें. InteractionSource को Kotlin फ़्लो के आधार पर बनाया गया है. इसलिए, इससे इंटरैक्शन उसी तरह से इकट्ठा किए जा सकते हैं जिस तरह से किसी अन्य फ़्लो के साथ काम किया जाता है. इस डिज़ाइन के बारे में ज़्यादा जानने के लिए, 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() के अलावा, 'लिखें' सुविधा में collectIsFocusedAsState(), collectIsDraggedAsState(), और collectIsHoveredAsState() भी मिलती हैं. ये तरीके, असल में सुविधा देने वाले तरीके हैं. इन्हें निचले लेवल के InteractionSource एपीआई के आधार पर बनाया गया है. कुछ मामलों में, आपको सीधे तौर पर उन लोवर-लेवल फ़ंक्शन का इस्तेमाल करना पड़ सकता है.

उदाहरण के लिए, मान लें कि आपको यह जानना है कि कोई बटन दबाया जा रहा है या नहीं. साथ ही, आपको यह भी जानना है कि उसे खींचा जा रहा है या नहीं. collectIsPressedAsState() और collectIsDraggedAsState(), दोनों का इस्तेमाल करने पर, कंपोज़ फ़ंक्शन कई बार एक ही काम करता है. साथ ही, इस बात की कोई गारंटी नहीं होती कि आपको सभी इंटरैक्शन सही क्रम में मिलेंगे. ऐसी स्थितियों में, सीधे 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 को तब चालू कर सकता है, जब उस पर कर्सर घुमाया जाता है या हटाया जाता है.

ऐसे कॉम्पोनेंट बनाना जो डेटा का इस्तेमाल करते हैं और डेटा जनरेट करते हैं

मटेरियल 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 को देखा जा सकता है. इसका इस्तेमाल, उस कॉम्पोनेंट या आपके यूज़र इंटरफ़ेस (यूआई) में मौजूद किसी अन्य कॉम्पोनेंट के दिखने के तरीके को कंट्रोल करने के लिए किया जा सकता है.

अगर आपको इंटरैक्टिव हाई लेवल कॉम्पोनेंट बनाने हैं, तो हमारा सुझाव है कि आप MutableInteractionSource को इस तरह से पैरामीटर के तौर पर दिखाएं. स्टेट को ऊपर ले जाने के सबसे सही तरीके अपनाने के अलावा, इससे किसी कॉम्पोनेंट की विज़ुअल स्टेट को पढ़ना और कंट्रोल करना भी आसान हो जाता है. ठीक उसी तरह जैसे किसी अन्य तरह की स्टेट (जैसे कि चालू की गई स्टेट) को पढ़ा और कंट्रोल किया जा सकता है.

Compose, लेयर वाले आर्किटेक्चर के सिद्धांत का पालन करता है. इसलिए, ज़्यादा लेवल वाले Material कॉम्पोनेंट, बुनियादी बिल्डिंग ब्लॉक के ऊपर बनाए जाते हैं. ये ब्लॉक, 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 का इस्तेमाल करके, एक ऐसा कॉम्पोनेंट बनाया जा सकता है जो कर्सर घुमाने, फ़ोकस करने, और दबाने से होने वाले इंटरैक्शन को हैंडल करता है. इसके लिए, आपको निचले लेवल के एपीआई को एक साथ इस्तेमाल करने की ज़रूरत नहीं होती. अगर आपको अपने कॉम्पोनेंट को भी क्लिक करने लायक बनाना है, तो 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 के लिए, स्टैंडर्ड फ़्लो एपीआई का इस्तेमाल किया जा सकता है. उदाहरण के लिए, मान लें कि आपको किसी 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 से वैल्यू पा सकते हैं, ताकि किसी कॉम्पोनेंट में उनके दिखने या काम करने के तरीके को बदला जा सके. ऐसा किसी अन्य 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 में किसी अन्य ग्राफ़िक्स एपीआई की तरह ही, ड्राइंग के एक जैसे निर्देशों का इस्तेमाल करके स्केल इफ़ेक्ट रेंडर कर सके.

    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 मिलता है. इसलिए, अन्य ड्राइंग एपीआई की तरह ही, कॉन्टेंट के ऊपर या नीचे किसी भी तरह का इफ़ेक्ट बनाया जा सकता है. उदाहरण के लिए, कॉम्पोनेंट को दबाने पर, उसके चारों ओर ऐनिमेटेड बॉर्डर और उसके ऊपर ओवरले बनाया जा सकता है:

एक बटन, जिसे दबाने पर इंद्रधनुषी रंग का इफ़ेक्ट दिखता है
पांचवीं इमेज. 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 को शुरुआत में कई बार दबाने पर भी, इसे हैंडल किया जाता है. अगर किसी मौजूदा प्रेस या रेस्टिंग ऐनिमेशन के दौरान कोई प्रेस होता है, तो पिछला ऐनिमेशन रद्द हो जाता है और प्रेस ऐनिमेशन शुरू से शुरू होता है. एक साथ कई इफ़ेक्ट (जैसे, रिपल इफ़ेक्ट, जिसमें नया रिपल ऐनिमेशन, अन्य रिपल के ऊपर दिखेगा) को सपोर्ट करने के लिए, ऐनिमेशन को सूची में ट्रैक किया जा सकता है. इसके लिए, मौजूदा ऐनिमेशन को रद्द करके नए ऐनिमेशन शुरू करने की ज़रूरत नहीं है.