การจัดการการโต้ตอบของผู้ใช้

คอมโพเนนต์อินเทอร์เฟซผู้ใช้จะให้ความคิดเห็นแก่ผู้ใช้อุปกรณ์ตามวิธีที่คอมโพเนนต์ตอบสนองต่อการโต้ตอบของผู้ใช้ คอมโพเนนต์แต่ละรายการมีวิธีตอบสนองต่อการโต้ตอบของตัวเอง ซึ่งช่วยให้ผู้ใช้ทราบว่าการโต้ตอบของตนกำลังทำอะไรอยู่ ตัวอย่างเช่น หากผู้ใช้แตะปุ่มบนหน้าจอสัมผัสของอุปกรณ์ ปุ่มนั้นอาจเปลี่ยนแปลงไปในลักษณะใดลักษณะหนึ่ง เช่น การเพิ่มสีไฮไลต์ การเปลี่ยนแปลงนี้ช่วยให้ผู้ใช้ทราบว่าตนได้แตะปุ่มแล้ว หากผู้ใช้ไม่ต้องการดำเนินการดังกล่าว ผู้ใช้จะทราบว่าต้องลากนิ้วออกจากปุ่มก่อนที่จะปล่อย ไม่เช่นนั้นปุ่มจะทำงาน

รูปที่ 1 ปุ่มที่ปรากฏขึ้นมาโดยเปิดใช้อยู่เสมอ โดยไม่มีการแสดงการกด
รูปที่ 2 ปุ่มที่มีเอฟเฟกต์ริปเปิลเมื่อกดซึ่งแสดงสถานะเปิดใช้ตามความเหมาะสม

เอกสารประกอบเกี่ยวกับท่าทางสัมผัสของ Compose จะอธิบายวิธีที่คอมโพเนนต์ Compose จัดการเหตุการณ์ตัวชี้ระดับต่ำ เช่น การเลื่อนตัวชี้และการคลิก โดยค่าเริ่มต้น Compose จะแยกเหตุการณ์ระดับต่ำเหล่านั้นออกเป็นการโต้ตอบระดับสูง เช่น ชุดเหตุการณ์ตัวชี้อาจรวมกันเป็นการกดและปล่อยปุ่ม การทำความเข้าใจนามธรรมระดับสูงเหล่านั้นจะช่วยให้คุณปรับแต่งวิธีที่ UI ตอบสนองต่อผู้ใช้ได้ เช่น คุณอาจต้องการปรับแต่งลักษณะที่ปรากฏของคอมโพเนนต์เมื่อผู้ใช้โต้ตอบด้วย หรืออาจต้องการเก็บบันทึกการกระทำของผู้ใช้เหล่านั้นไว้ เอกสารนี้จะให้ข้อมูลที่จำเป็นในการแก้ไของค์ประกอบ UI มาตรฐานหรือออกแบบองค์ประกอบของคุณเอง

การโต้ตอบ

ในหลายกรณี คุณไม่จำเป็นต้องทราบว่าคอมโพเนนต์ Compose ตีความการโต้ตอบของผู้ใช้อย่างไร ตัวอย่างเช่น Button อาศัย Modifier.clickable เพื่อดูว่าผู้ใช้คลิกปุ่มหรือไม่ หากคุณเพิ่มปุ่มทั่วไปลงในแอป คุณสามารถกำหนดโค้ด onClick ของปุ่ม และ Modifier.clickable จะเรียกใช้โค้ดนั้นเมื่อเหมาะสม ซึ่งหมายความว่าคุณไม่จำเป็นต้องทราบว่าผู้ใช้แตะหน้าจอหรือเลือกปุ่มด้วยแป้นพิมพ์ Modifier.clickable จะทราบว่าผู้ใช้ทำการคลิก และตอบสนองด้วยการเรียกใช้โค้ด onClick

อย่างไรก็ตาม หากต้องการปรับแต่งการตอบสนองของคอมโพเนนต์ UI ต่อลักษณะการทำงานของผู้ใช้ คุณอาจต้องทราบข้อมูลเพิ่มเติมเกี่ยวกับสิ่งที่เกิดขึ้นเบื้องหลัง ส่วนนี้จะให้ข้อมูลบางส่วนดังกล่าว

เมื่อผู้ใช้โต้ตอบกับคอมโพเนนต์ UI ระบบจะแสดงลักษณะการทำงานของผู้ใช้โดยสร้างเหตุการณ์ Interaction จำนวนหนึ่ง เช่น หากผู้ใช้แตะปุ่ม ปุ่มจะสร้าง PressInteraction.Press หากผู้ใช้ยกนิ้วขึ้นภายในปุ่ม ระบบจะสร้าง PressInteraction.Release เพื่อแจ้งให้ปุ่มทราบว่าการคลิกเสร็จสิ้นแล้ว ในทางกลับกัน หากผู้ใช้ลากนิ้วออกนอกปุ่มแล้วยกนิ้วขึ้น ปุ่มจะสร้าง PressInteraction.Cancel เพื่อระบุว่าการกดปุ่มถูกยกเลิก ไม่ใช่เสร็จสมบูรณ์

การโต้ตอบเหล่านี้ไม่มีการแสดงความคิดเห็น กล่าวคือ เหตุการณ์การโต้ตอบระดับต่ำเหล่านี้ไม่ได้มีจุดประสงค์เพื่อตีความความหมายของการกระทำของผู้ใช้หรือลำดับของการกระทำเหล่านั้น นอกจากนี้ยังไม่ตีความว่าการดำเนินการใดของผู้ใช้ที่อาจมีความสำคัญเหนือกว่าการดำเนินการอื่นๆ

โดยทั่วไปแล้ว การโต้ตอบเหล่านี้จะมาเป็นคู่ โดยมีจุดเริ่มต้นและจุดสิ้นสุด การโต้ตอบครั้งที่ 2 จะมีการอ้างอิงถึงการโต้ตอบครั้งแรก ตัวอย่างเช่น หากผู้ใช้แตะปุ่มแล้วยกนิ้วขึ้น การแตะจะสร้างการโต้ตอบ PressInteraction.Press และการยกนิ้วขึ้นจะสร้าง PressInteraction.Release โดย Release จะมีพร็อพเพอร์ตี้ press ที่ระบุ PressInteraction.Press เริ่มต้น

คุณดูการโต้ตอบของคอมโพเนนต์หนึ่งๆ ได้โดยดูที่ InteractionSource ของคอมโพเนนต์นั้น InteractionSource สร้างขึ้นบน Kotlin Flow คุณจึงรวบรวมการโต้ตอบจาก Kotlin Flow ได้ในลักษณะเดียวกับที่ทำงานกับ Flow อื่นๆ ดูข้อมูลเพิ่มเติมเกี่ยวกับการตัดสินใจด้านการออกแบบนี้ได้ที่บล็อกโพสต์ 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()ด้วย จริงๆ แล้วเมธอดเหล่านี้เป็นเมธอดอำนวยความสะดวกที่สร้างขึ้นบน API InteractionSource ระดับต่ำกว่า ในบางกรณี คุณอาจต้องการใช้ฟังก์ชันระดับต่ำกว่าเหล่านั้นโดยตรง

ตัวอย่างเช่น สมมติว่าคุณต้องการทราบว่ามีการกดปุ่มหรือไม่ และยังต้องการทราบด้วยว่ามีการลากปุ่มหรือไม่ หากคุณใช้ทั้ง collectIsPressedAsState() และ collectIsDraggedAsState() Compose จะทำงานซ้ำกันหลายอย่าง และไม่มีการรับประกันว่าคุณจะได้รับการโต้ตอบทั้งหมดตามลำดับที่ถูกต้อง ในสถานการณ์เช่นนี้ คุณอาจต้องทำงานร่วมกับInteractionSourceโดยตรง ดูข้อมูลเพิ่มเติมเกี่ยวกับการติดตามการโต้ตอบด้วยตนเองโดยใช้ InteractionSource ได้ที่การทำงานกับ InteractionSource

ส่วนต่อไปนี้จะอธิบายวิธีใช้และแสดงการโต้ตอบด้วย InteractionSource และ MutableInteractionSource ตามลำดับ

ใช้และปล่อย Interaction

InteractionSource แสดงสตรีมแบบอ่านอย่างเดียวของ Interactions — ไม่สามารถส่ง Interaction ไปยัง InteractionSource ได้ หากต้องการส่ง 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 {
    // ...
}

ตัวแก้ไขนี้เป็นผู้ผลิต ซึ่งสามารถใช้ MutableInteractionSource ที่ระบุเพื่อส่ง HoverInteractions เมื่อวางเมาส์เหนือหรือไม่ได้วางเมาส์เหนือ

สร้างคอมโพเนนต์ที่ใช้และสร้าง

คอมโพเนนต์ระดับสูง เช่น Button ของ Material จะทำหน้าที่เป็นทั้งผู้ผลิตและผู้บริโภค โดยจะจัดการเหตุการณ์อินพุตและโฟกัส รวมถึงเปลี่ยนลักษณะที่ปรากฏเพื่อตอบสนองต่อเหตุการณ์เหล่านี้ เช่น แสดงเอฟเฟกต์ริปเปิลหรือภาพเคลื่อนไหวของการยกขึ้น ด้วยเหตุนี้ จึงแสดง 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 ใช้แนวทางสถาปัตยกรรมแบบเลเยอร์ ดังนั้นคอมโพเนนต์ Material ระดับสูงจึงสร้างขึ้นจากองค์ประกอบพื้นฐานที่สร้าง 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 เดียวกัน และคุณสามารถปรับแต่งลักษณะของการโต้ตอบทั้ง 2 ประเภทได้ในที่เดียวกัน ดังนี้

// 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 เป็น Abstraction ระดับที่สูงกว่า 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

หากต้องการข้อมูลระดับต่ำเกี่ยวกับการโต้ตอบกับคอมโพเนนต์ คุณสามารถใช้ Flow API มาตรฐานสำหรับ 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()

หากต้องการทราบว่าการโต้ตอบล่าสุดคืออะไร ให้ดูรายการสุดท้ายในลิสต์ ตัวอย่างเช่น วิธีที่การใช้งาน Ripple ของ Compose จะหาการวางซ้อนสถานะที่เหมาะสมเพื่อใช้สำหรับการโต้ตอบล่าสุดมีดังนี้

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

เนื่องจาก Interaction ทั้งหมดมีโครงสร้างเหมือนกัน จึงไม่มีความแตกต่างมากนักในโค้ดเมื่อทำงานกับการโต้ตอบของผู้ใช้ประเภทต่างๆ รูปแบบโดยรวมจะเหมือนกัน

โปรดทราบว่าตัวอย่างก่อนหน้านี้ในส่วนนี้แสดงFlowของการโต้ตอบโดยใช้ State ซึ่งช่วยให้สังเกตค่าที่อัปเดตได้ง่าย เนื่องจากค่าสถานะการอ่านจะทำให้เกิดการจัดองค์ประกอบใหม่โดยอัตโนมัติ อย่างไรก็ตาม องค์ประกอบจะรวมเป็นกลุ่มก่อนเฟรม ซึ่งหมายความว่าหากสถานะมีการเปลี่ยนแปลง แล้วเปลี่ยนกลับภายในเฟรมเดียวกัน คอมโพเนนต์ที่สังเกตสถานะจะไม่เห็นการเปลี่ยนแปลง

ซึ่งสำคัญต่อการโต้ตอบ เนื่องจากการโต้ตอบสามารถเริ่มต้นและสิ้นสุดภายในเฟรมเดียวกันได้เป็นประจำ เช่น การใช้ตัวอย่างก่อนหน้ากับ 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 ขั้นสูงที่มีเส้นขอบแบบเคลื่อนไหว

ตัวอย่าง: สร้างคอมโพเนนต์ที่มีการจัดการการโต้ตอบที่กำหนดเอง

หากต้องการดูวิธีสร้างคอมโพเนนต์ที่มีการตอบสนองที่กำหนดเองต่ออินพุต โปรดดูตัวอย่างปุ่มที่แก้ไขแล้วนี้ ในกรณีนี้ สมมติว่าคุณต้องการปุ่มที่ตอบสนองต่อการกดโดยเปลี่ยนรูปลักษณ์

ภาพเคลื่อนไหวของปุ่มที่เพิ่มไอคอนรถเข็นช็อปปิ้งแบบไดนามิกเมื่อคลิก
รูปที่ 3 ปุ่มที่เพิ่มไอคอนแบบไดนามิกเมื่อคลิก

โดยสร้าง Composable ที่กำหนดเองโดยอิงตาม 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()
    }
}

และนี่คือลักษณะของการใช้ Composable ใหม่นั้น

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

เนื่องจาก PressIconButton ใหม่นี้สร้างขึ้นจาก Material Button ที่มีอยู่ จึงตอบสนองต่อการโต้ตอบของผู้ใช้ในลักษณะปกติทั้งหมด เมื่อผู้ใช้กดปุ่ม ปุ่มจะเปลี่ยนความทึบแสงเล็กน้อยเหมือนกับ Button ของ Material ทั่วไป

สร้างและใช้เอฟเฟกต์ที่กำหนดเองซึ่งนำมาใช้ซ้ำได้ด้วย Indication

ในส่วนก่อนหน้านี้ คุณได้เรียนรู้วิธีเปลี่ยนส่วนหนึ่งของคอมโพเนนต์เพื่อตอบสนองต่อ Interaction ต่างๆ เช่น การแสดงไอคอนเมื่อกด คุณสามารถใช้วิธีเดียวกันนี้เพื่อเปลี่ยนค่าของพารามิเตอร์ที่ระบุให้กับคอมโพเนนต์ หรือเปลี่ยนเนื้อหาที่แสดงภายในคอมโพเนนต์ แต่จะใช้ได้กับคอมโพเนนต์แต่ละรายการเท่านั้น บ่อยครั้งที่แอปพลิเคชันหรือระบบการออกแบบจะมีระบบทั่วไปสำหรับเอฟเฟกต์ภาพที่มีสถานะ ซึ่งเป็นเอฟเฟกต์ที่ควรใช้กับคอมโพเนนต์ทั้งหมดในลักษณะที่สอดคล้องกัน

หากคุณกำลังสร้างระบบการออกแบบประเภทนี้ การปรับแต่งคอมโพเนนต์หนึ่งและนำการปรับแต่งนี้มาใช้ซ้ำกับคอมโพเนนต์อื่นๆ อาจทำได้ยากเนื่องจากสาเหตุต่อไปนี้

  • คอมโพเนนต์ทุกรายการในระบบการออกแบบต้องมีต้นแบบเดียวกัน
  • คุณอาจลืมใช้เอฟเฟกต์นี้กับคอมโพเนนต์ที่สร้างขึ้นใหม่และคอมโพเนนต์ที่คลิกได้แบบกำหนดเองได้ง่ายๆ
  • การรวมเอฟเฟกต์ที่กำหนดเองกับเอฟเฟกต์อื่นๆ อาจทำได้ยาก

คุณสามารถใช้ Indication เพื่อหลีกเลี่ยงปัญหาเหล่านี้และปรับขนาดคอมโพเนนต์ที่กำหนดเองในระบบได้อย่างง่ายดาย Indication แสดงถึงเอฟเฟกต์ภาพที่นำกลับมาใช้ใหม่ได้ ซึ่งสามารถใช้กับคอมโพเนนต์ต่างๆ ในแอปพลิเคชันหรือระบบการออกแบบ Indication แบ่งออกเป็น 2 ส่วน ดังนี้

  • IndicationNodeFactory: แฟกทอรีที่สร้างอินสแตนซ์ Modifier.Node ซึ่งแสดงผลเอฟเฟกต์ภาพสำหรับคอมโพเนนต์ สำหรับการใช้งานที่ง่ายขึ้นซึ่งไม่เปลี่ยนแปลงในคอมโพเนนต์ต่างๆ คุณสามารถใช้ Singleton (ออบเจ็กต์) และนำมาใช้ซ้ำได้ทั่วทั้งแอปพลิเคชัน

    อินสแตนซ์เหล่านี้อาจเป็นแบบเก็บสถานะหรือไม่เก็บสถานะก็ได้ เนื่องจากมีการสร้างขึ้นต่อคอมโพเนนต์ จึงสามารถดึงค่าจาก CompositionLocal เพื่อเปลี่ยนลักษณะที่ปรากฏหรือลักษณะการทำงานภายในคอมโพเนนต์หนึ่งๆ ได้ เช่นเดียวกับ Modifier.Node อื่นๆ

  • Modifier.indication: ตัวแก้ไขที่วาด Indication สำหรับคอมโพเนนต์ Modifier.clickable และตัวปรับแต่งการโต้ตอบระดับสูงอื่นๆ จะยอมรับพารามิเตอร์การบ่งชี้โดยตรง ดังนั้นจึงไม่เพียงแต่ปล่อย Interaction เท่านั้น แต่ยังวาดเอฟเฟกต์ภาพสำหรับ Interaction ที่ปล่อยออกมาได้ด้วย ดังนั้น สำหรับกรณีง่ายๆ คุณสามารถใช้ Modifier.clickable ได้โดยไม่ต้องใช้ Modifier.indication

แทนที่เอฟเฟกต์ด้วย 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() และแสดงผลเอฟเฟกต์การปรับขนาดโดยใช้คำสั่งการวาดเดียวกับ API กราฟิกอื่นๆ ใน Compose ได้

    การเรียกใช้ drawContent() ที่พร้อมใช้งานจากตัวรับ ContentDrawScope จะวาดคอมโพเนนต์จริงที่ควรใช้ 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 คุณเพียงแค่ต้องระบุ Indication เป็นพารามิเตอร์ให้กับ clickable

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

ภาพเคลื่อนไหวของปุ่มที่มีไอคอนรถเข็นซูเปอร์มาร์เก็ตซึ่งจะเล็กลงเมื่อกด
รูปที่ 4 ปุ่มที่สร้างด้วย Indication ที่กำหนดเอง

สร้าง Indication ขั้นสูงที่มีเส้นขอบแบบเคลื่อนไหว

Indication ไม่ได้จำกัดอยู่เพียงเอฟเฟกต์การเปลี่ยนรูปแบบ เช่น การปรับขนาดคอมโพเนนต์ เนื่องจาก IndicationNodeFactory แสดงผล Modifier.Node คุณจึงวาดเอฟเฟกต์ประเภทใดก็ได้ที่ด้านบนหรือด้านล่างของเนื้อหา เช่นเดียวกับ API การวาดอื่นๆ เช่น คุณสามารถวาดเส้นขอบแบบเคลื่อนไหวรอบคอมโพเนนต์และวางซ้อนที่ด้านบนของคอมโพเนนต์เมื่อมีการกด ดังนี้

ปุ่มที่มีเอฟเฟกต์สีรุ้งสวยงามเมื่อกด
รูปที่ 5 เอฟเฟกต์เส้นขอบแบบเคลื่อนไหวที่วาดด้วย 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 หากมีการกดเกิดขึ้นระหว่างการกดที่มีอยู่หรือภาพเคลื่อนไหวที่ไม่มีการโต้ตอบ ภาพเคลื่อนไหวก่อนหน้าจะถูกยกเลิก และภาพเคลื่อนไหวของการกดจะเริ่มตั้งแต่ต้น หากต้องการรองรับเอฟเฟกต์หลายรายการที่เกิดขึ้นพร้อมกัน (เช่น เอฟเฟกต์ริปเปิลที่ภาพเคลื่อนไหวริปเปิลใหม่จะวาดทับริปเปิลอื่นๆ) คุณสามารถติดตามภาพเคลื่อนไหวในรายการแทนการยกเลิกภาพเคลื่อนไหวที่มีอยู่และเริ่มภาพเคลื่อนไหวใหม่