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

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

วันที่
รูปที่ 1 ปุ่มที่ปรากฏว่าเปิดใช้อยู่เสมอ โดยไม่มีการกดกระเพื่อม
รูปที่ 2 ปุ่มที่มีระลอกคลื่นของการกดจะแสดงสถานะการเปิดใช้ที่สอดคล้องกัน

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

สถานะการโต้ตอบ

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

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

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

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

หากต้องการดูการโต้ตอบล่าสุด ให้ดูข้อมูลจาก ในรายการ ตัวอย่างเช่น นี่คือวิธีการใช้งานระลอกคลื่น 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")
}

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

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

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

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

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

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

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

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