คอมโพเนนต์ของอินเทอร์เฟซผู้ใช้จะให้ความคิดเห็นแก่ผู้ใช้อุปกรณ์ตามวิธีที่คอมโพเนนต์ตอบสนองต่อการโต้ตอบของผู้ใช้ คอมโพเนนต์ทุกรายการมีวิธีตอบสนองต่อ การโต้ตอบของตัวเอง ซึ่งจะช่วยให้ผู้ใช้ทราบว่าการโต้ตอบของตนเองทำอะไรได้บ้าง ตัวอย่างเช่น หากผู้ใช้แตะปุ่มบนหน้าจอสัมผัสของอุปกรณ์ ปุ่มนั้น น่าจะมีการเปลี่ยนแปลงในบางลักษณะ เช่น อาจมีการเพิ่มสีไฮไลต์ การเปลี่ยนแปลงนี้ ช่วยให้ผู้ใช้ทราบว่าตนแตะปุ่ม หากผู้ใช้ไม่ต้องการทำเช่นนั้น ผู้ใช้จะทราบว่าต้องลากนิ้วออกจากปุ่มก่อนปล่อย มิฉะนั้นปุ่มจะเปิดใช้งาน
เอกสารประกอบเกี่ยวกับ ท่าทางสัมผัส ของ 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 คุณจึงรวบรวมการโต้ตอบจาก Flow ได้ในลักษณะเดียวกับ
การทำงานกับ Flow อื่นๆ ดูข้อมูลเพิ่มเติมเกี่ยวกับการตัดสินใจด้านการออกแบบนี้ได้ที่บล็อกโพสต์การโต้ตอบที่ส่องสว่าง
สถานะการโต้ตอบ
คุณอาจต้องการขยายฟังก์ชันการทำงานในตัวของคอมโพเนนต์ด้วยการติดตามการโต้ตอบด้วยตนเอง
เช่น คุณอาจต้องการให้ปุ่ม
เปลี่ยนสีเมื่อกด วิธีที่ง่ายที่สุดในการติดตามการโต้ตอบคือการสังเกตสถานะการโต้ตอบที่เหมาะสม 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() ด้วย เมธอดเหล่านี้เป็นเมธอดอำนวยความสะดวกที่สร้างขึ้นบน 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 วัสดุ ทำหน้าที่ทั้งเป็นผู้ผลิตและ
ผู้บริโภค โดยจะจัดการเหตุการณ์อินพุตและโฟกัส รวมถึงเปลี่ยนลักษณะที่ปรากฏ
เพื่อตอบสนองต่อเหตุการณ์เหล่านี้ เช่น แสดงระลอกคลื่นหรือภาพเคลื่อนไหวของ
ระดับความสูง ด้วยเหตุนี้ จึงทำให้ 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 เป็นพารามิเตอร์ได้
เมื่อใดก็ตามที่มีการวางเมาส์เหนือคอมโพเนนต์ คอมโพเนนต์จะปล่อย 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 เป็นพารามิเตอร์ ตอนนี้ทั้ง
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 เป็นการแยกส่วนในระดับที่สูงกว่า 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" }
เนื่องจาก Interactions ทั้งหมดมีโครงสร้างเดียวกัน จึงไม่มีความแตกต่างกันมากนักในโค้ดเมื่อทำงานกับการโต้ตอบของผู้ใช้ประเภทต่างๆ รูปแบบโดยรวมจึงเหมือนกัน
โปรดทราบว่าตัวอย่างก่อนหน้าในส่วนนี้แสดงถึง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ขั้นสูงที่มีเส้นขอบเคลื่อนไหว
ตัวอย่าง: สร้างคอมโพเนนต์ที่มีการจัดการการโต้ตอบที่กำหนดเอง
หากต้องการดูวิธีสร้างคอมโพเนนต์ด้วยการตอบกลับที่กำหนดเองต่ออินพุต โปรดดู ตัวอย่างปุ่มที่แก้ไขแล้ว ในกรณีนี้ สมมติว่าคุณต้องการปุ่มที่ ตอบสนองต่อการกดด้วยการเปลี่ยนลักษณะที่ปรากฏ
โดยสร้าง 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 ที่มีอยู่ จึงตอบสนองต่อการโต้ตอบของผู้ใช้ในลักษณะปกติทั้งหมด เมื่อผู้ใช้
กดปุ่ม ความทึบของปุ่มจะเปลี่ยนไปเล็กน้อย เช่นเดียวกับปุ่ม Material Button ทั่วไป
สร้างและใช้เอฟเฟกต์ที่กำหนดเองที่นำมาใช้ซ้ำได้ด้วย Indication
ในส่วนก่อนหน้า คุณได้เรียนรู้วิธีเปลี่ยนส่วนของคอมโพเนนต์เพื่อตอบสนองต่อInteractionต่างๆ เช่น การแสดงไอคอนเมื่อกด คุณสามารถใช้วิธีเดียวกันนี้เพื่อเปลี่ยนค่าของพารามิเตอร์ที่คุณระบุให้กับคอมโพเนนต์ หรือเปลี่ยนเนื้อหาที่แสดงภายในคอมโพเนนต์ได้ แต่จะใช้ได้เฉพาะในระดับคอมโพเนนต์เท่านั้น โดยปกติแล้ว แอปพลิเคชันหรือระบบการออกแบบ
จะมีระบบทั่วไปสำหรับเอฟเฟกต์ภาพแบบมีสถานะ ซึ่งเป็นเอฟเฟกต์ที่ควร
ใช้กับคอมโพเนนต์ทั้งหมดในลักษณะที่สอดคล้องกัน
หากคุณกำลังสร้างระบบการออกแบบประเภทนี้ การปรับแต่งคอมโพเนนต์หนึ่งและ นำการปรับแต่งนี้ไปใช้ซ้ำกับคอมโพเนนต์อื่นๆ อาจเป็นเรื่องยากด้วยเหตุผลต่อไปนี้
- คอมโพเนนต์ทุกรายการในระบบการออกแบบต้องมีบอยเลอร์เพลตเดียวกัน
- คุณอาจลืมใช้เอฟเฟกต์นี้กับคอมโพเนนต์ที่สร้างขึ้นใหม่และคอมโพเนนต์ที่คลิกได้ที่กำหนดเองได้ง่ายๆ
- การใช้เอฟเฟกต์ที่กำหนดเองร่วมกับเอฟเฟกต์อื่นๆ อาจทำได้ยาก
หากต้องการหลีกเลี่ยงปัญหาเหล่านี้และปรับขนาดคอมโพเนนต์ที่กำหนดเองในระบบได้อย่างง่ายดาย
คุณสามารถใช้ Indication ได้
Indication แสดงถึงเอฟเฟกต์ภาพที่นำกลับมาใช้ใหม่ได้ ซึ่งสามารถใช้กับ
คอมโพเนนต์ในแอปพลิเคชันหรือระบบการออกแบบได้ Indication แบ่งออกเป็น 2 ส่วน
ดังนี้
IndicationNodeFactory: Factory ที่สร้างอินสแตนซ์Modifier.Nodeซึ่ง แสดงเอฟเฟกต์ภาพสำหรับคอมโพเนนต์ สำหรับการติดตั้งใช้งานที่ง่ายกว่าซึ่งไม่มีการเปลี่ยนแปลงในคอมโพเนนต์ต่างๆ คุณสามารถใช้เป็น Singleton (ออบเจ็กต์) และนำกลับมาใช้ใหม่ได้ทั่วทั้งแอปพลิเคชันอินสแตนซ์เหล่านี้อาจมีสถานะหรือไม่มีสถานะก็ได้ เนื่องจากสร้างขึ้นต่อคอมโพเนนต์ จึงสามารถดึงค่าจาก
CompositionLocalเพื่อเปลี่ยนลักษณะการแสดงผลหรือลักษณะการทำงานภายในคอมโพเนนต์หนึ่งๆ ได้ เช่นเดียวกับModifier.Nodeอื่นๆModifier.indication: ตัวแก้ไขที่วาดIndicationสำหรับ คอมโพเนนต์Modifier.clickableและตัวแก้ไขการโต้ตอบระดับสูงอื่นๆ ยอมรับพารามิเตอร์การบ่งชี้โดยตรง จึงไม่เพียงแค่ปล่อยInteractions แต่ยังวาดเอฟเฟกต์ภาพสำหรับInteractions ที่ปล่อยออกมาได้ด้วย ดังนั้นในกรณีที่ซับซ้อนไม่มาก คุณสามารถใช้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 ให้ทำตาม
ขั้นตอนต่อไปนี้
สร้าง
Modifier.Nodeที่รับผิดชอบในการใช้เอฟเฟกต์การปรับขนาด เมื่อแนบแล้ว โหนดจะสังเกตแหล่งที่มาของการโต้ตอบ ซึ่งคล้ายกับตัวอย่างก่อนหน้า ความแตกต่างเพียงอย่างเดียวในที่นี้คือฟังก์ชันนี้จะเปิดภาพเคลื่อนไหวโดยตรง แทนที่จะแปลงการโต้ตอบที่เข้ามาเป็นสถานะโหนดต้องใช้
DrawModifierNodeเพื่อให้สามารถลบล้างContentDrawScope#draw()และแสดงผลเอฟเฟกต์การปรับขนาดโดยใช้คำสั่งวาดภาพเดียวกันกับ API กราฟิกอื่นๆ ใน Composeการเรียกใช้
drawContent()ที่พร้อมใช้งานจากตัวรับContentDrawScopeจะดึง คอมโพเนนต์จริงที่ควรใช้กับIndicationดังนั้นคุณจึง เพียงแค่เรียกใช้ฟังก์ชันนี้ในการเปลี่ยนรูปแบบการปรับขนาด ตรวจสอบว่าการติดตั้งใช้งานของคุณเรียกใช้drawContent()เสมอในบางจุด ไม่เช่นนั้น ระบบจะไม่วาดคอมโพเนนต์ที่คุณใช้Indicationกับคอมโพเนนต์นั้นIndicationprivate 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() } } }
สร้าง
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 }
Modifier.clickableใช้Modifier.indicationภายใน ดังนั้นหากต้องการสร้างคอมโพเนนต์ที่คลิกได้ด้วยScaleIndicationสิ่งที่คุณต้องทำคือระบุIndicationเป็นพารามิเตอร์ให้กับclickableBox( modifier = Modifier .size(100.dp) .clickable( onClick = {}, indication = ScaleIndication, interactionSource = null ) .background(Color.Blue), contentAlignment = Alignment.Center ) { Text("Hello!", color = Color.White) }
นอกจากนี้ยังช่วยให้สร้างคอมโพเนนต์ระดับสูงที่ใช้ซ้ำได้ง่ายโดยใช้คอมโพเนนต์ที่กำหนดเอง
Indication— ปุ่มอาจมีลักษณะดังนี้@Composable fun ScaleButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, interactionSource: MutableInteractionSource? = null, shape: Shape = CircleShape, content: @Composable RowScope.() -> Unit ) { Row( modifier = modifier .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp) .clickable( enabled = enabled, indication = ScaleIndication, interactionSource = interactionSource, onClick = onClick ) .border(width = 2.dp, color = Color.Blue, shape = shape) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content ) }
จากนั้นคุณสามารถใช้ปุ่มในลักษณะต่อไปนี้
ScaleButton(onClick = {}) { Icon(Icons.Filled.ShoppingCart, "") Spacer(Modifier.padding(10.dp)) Text(text = "Add to cart!") }
Indicationสร้าง Indication ขั้นสูงที่มีเส้นขอบเคลื่อนไหว
Indication ไม่ได้จำกัดอยู่แค่เอฟเฟกต์การเปลี่ยนรูปแบบ เช่น การปรับขนาดคอมโพเนนต์
เนื่องจาก IndicationNodeFactory จะแสดงผล Modifier.Node คุณจึงวาดเอฟเฟกต์ใดก็ได้เหนือหรือใต้เนื้อหาได้เช่นเดียวกับ API การวาดอื่นๆ เช่น คุณวาดเส้นขอบแบบเคลื่อนไหวรอบคอมโพเนนต์และภาพซ้อนทับบน
ด้านบนของคอมโพเนนต์ได้เมื่อมีการกด
Indicationการใช้งาน Indication ในที่นี้คล้ายกับตัวอย่างก่อนหน้ามาก
เพียงแค่สร้างโหนดที่มีพารามิเตอร์บางอย่าง เนื่องจากเส้นขอบเคลื่อนไหวขึ้นอยู่กับรูปร่างและเส้นขอบของคอมโพเนนต์ที่ใช้ Indication การใช้งาน
Indication จึงต้องระบุรูปร่างและความกว้างของเส้นขอบเป็นพารามิเตอร์ด้วย
data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory { override fun create(interactionSource: InteractionSource): DelegatableNode { return NeonNode( shape, // Double the border size for a stronger press effect borderWidth * 2, interactionSource ) } }
การใช้งาน Modifier.Node ยังมีแนวคิดเดียวกันด้วย แม้ว่าโค้ดการวาดจะซับซ้อนกว่าก็ตาม เช่นเดียวกับก่อนหน้านี้ ฟีเจอร์นี้จะสังเกตInteractionSource
เมื่อแนบแล้ว เปิดใช้ภาพเคลื่อนไหว และใช้ DrawModifierNode เพื่อวาด
เอฟเฟกต์บนเนื้อหา
private class NeonNode( private val shape: Shape, private val borderWidth: Dp, private val interactionSource: InteractionSource ) : Modifier.Node(), DrawModifierNode { var currentPressPosition: Offset = Offset.Zero val animatedProgress = Animatable(0f) val animatedPressAlpha = Animatable(1f) var pressedAnimation: Job? = null var restingAnimation: Job? = null private suspend fun animateToPressed(pressPosition: Offset) { // Finish any existing animations, in case of a new press while we are still showing // an animation for a previous one restingAnimation?.cancel() pressedAnimation?.cancel() pressedAnimation = coroutineScope.launch { currentPressPosition = pressPosition animatedPressAlpha.snapTo(1f) animatedProgress.snapTo(0f) animatedProgress.animateTo(1f, tween(450)) } } private fun animateToResting() { restingAnimation = coroutineScope.launch { // Wait for the existing press animation to finish if it is still ongoing pressedAnimation?.join() animatedPressAlpha.animateTo(0f, tween(250)) animatedProgress.snapTo(0f) } } override fun onAttach() { coroutineScope.launch { interactionSource.interactions.collect { interaction -> when (interaction) { is PressInteraction.Press -> animateToPressed(interaction.pressPosition) is PressInteraction.Release -> animateToResting() is PressInteraction.Cancel -> animateToResting() } } } } override fun ContentDrawScope.draw() { val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition( currentPressPosition, size ) val brush = animateBrush( startPosition = startPosition, endPosition = endPosition, progress = animatedProgress.value ) val alpha = animatedPressAlpha.value drawContent() val outline = shape.createOutline(size, layoutDirection, this) // Draw overlay on top of content drawOutline( outline = outline, brush = brush, alpha = alpha * 0.1f ) // Draw border on top of overlay drawOutline( outline = outline, brush = brush, alpha = alpha, style = Stroke(width = borderWidth.toPx()) ) } /** * Calculates a gradient start / end where start is the point on the bounding rectangle of * size [size] that intercepts with the line drawn from the center to [pressPosition], * and end is the intercept on the opposite end of that line. */ private fun calculateGradientStartAndEndFromPressPosition( pressPosition: Offset, size: Size ): Pair<Offset, Offset> { // Convert to offset from the center val offset = pressPosition - size.center // y = mx + c, c is 0, so just test for x and y to see where the intercept is val gradient = offset.y / offset.x // We are starting from the center, so halve the width and height - convert the sign // to match the offset val width = (size.width / 2f) * sign(offset.x) val height = (size.height / 2f) * sign(offset.y) val x = height / gradient val y = gradient * width // Figure out which intercept lies within bounds val intercept = if (abs(y) <= abs(height)) { Offset(width, y) } else { Offset(x, height) } // Convert back to offsets from 0,0 val start = intercept + size.center val end = Offset(size.width - start.x, size.height - start.y) return start to end } private fun animateBrush( startPosition: Offset, endPosition: Offset, progress: Float ): Brush { if (progress == 0f) return TransparentBrush // This is *expensive* - we are doing a lot of allocations on each animation frame. To // recreate a similar effect in a performant way, it would be better to create one large // gradient and translate it on each frame, instead of creating a whole new gradient // and shader. The current approach will be janky! val colorStops = buildList { when { progress < 1 / 6f -> { val adjustedProgress = progress * 6f add(0f to Blue) add(adjustedProgress to Color.Transparent) } progress < 2 / 6f -> { val adjustedProgress = (progress - 1 / 6f) * 6f add(0f to Purple) add(adjustedProgress * MaxBlueStop to Blue) add(adjustedProgress to Blue) add(1f to Color.Transparent) } progress < 3 / 6f -> { val adjustedProgress = (progress - 2 / 6f) * 6f add(0f to Pink) add(adjustedProgress * MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } progress < 4 / 6f -> { val adjustedProgress = (progress - 3 / 6f) * 6f add(0f to Orange) add(adjustedProgress * MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } progress < 5 / 6f -> { val adjustedProgress = (progress - 4 / 6f) * 6f add(0f to Yellow) add(adjustedProgress * MaxOrangeStop to Orange) add(MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } else -> { val adjustedProgress = (progress - 5 / 6f) * 6f add(0f to Yellow) add(adjustedProgress * MaxYellowStop to Yellow) add(MaxOrangeStop to Orange) add(MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } } } return linearGradient( colorStops = colorStops.toTypedArray(), start = startPosition, end = endPosition ) } companion object { val TransparentBrush = SolidColor(Color.Transparent) val Blue = Color(0xFF30C0D8) val Purple = Color(0xFF7848A8) val Pink = Color(0xFFF03078) val Orange = Color(0xFFF07800) val Yellow = Color(0xFFF0D800) const val MaxYellowStop = 0.16f const val MaxOrangeStop = 0.33f const val MaxPinkStop = 0.5f const val MaxPurpleStop = 0.67f const val MaxBlueStop = 0.83f } }
ความแตกต่างหลักในที่นี้คือตอนนี้มีระยะเวลาขั้นต่ำสำหรับภาพเคลื่อนไหวที่มีฟังก์ชัน animateToResting() แล้ว ดังนั้นแม้ว่าจะปล่อยการกดทันที แต่ภาพเคลื่อนไหวของการกดจะยังคงดำเนินต่อไป นอกจากนี้ ยังมีการจัดการ
สำหรับการกดอย่างรวดเร็วหลายครั้งที่จุดเริ่มต้นของ animateToPressed ด้วย หากมีการกด
เกิดขึ้นระหว่างการกดที่มีอยู่หรือภาพเคลื่อนไหวขณะพัก ระบบจะยกเลิกภาพเคลื่อนไหวที่อยู่ก่อนหน้า
และเริ่มภาพเคลื่อนไหวการกดตั้งแต่ต้น หากต้องการรองรับเอฟเฟกต์หลายรายการ
พร้อมกัน (เช่น เอฟเฟกต์ระลอกคลื่นที่ภาพเคลื่อนไหวระลอกคลื่นใหม่จะวาด
ทับระลอกคลื่นอื่นๆ) คุณสามารถติดตามภาพเคลื่อนไหวในรายการแทนที่จะ
ยกเลิกภาพเคลื่อนไหวที่มีอยู่และเริ่มภาพเคลื่อนไหวใหม่
แนะนำสำหรับคุณ
- หมายเหตุ: ข้อความลิงก์จะแสดงเมื่อ JavaScript ปิดอยู่
- ทำความเข้าใจท่าทางสัมผัส
- Kotlin สำหรับ Jetpack Compose
- คอมโพเนนต์และเลย์เอาต์ของ Material