Modifier.composed เปิดตัวใน Compose 1.0 เพื่อให้คุณเข้าถึงองค์ประกอบ Composition จากตัวแก้ไขได้ ตัวอย่างเช่น กรณีการใช้งานหลักอย่างหนึ่งคือการสร้างตัวแก้ไขแบบเก็บสถานะที่จดจำสถานะในเครื่องและแชร์กับตัวแก้ไขอื่นๆ ในโรงงาน Modifier.composed
// ❌ BAD: Using Modifier.composed is no longer recommended fun Modifier.pressScale( pressedScale: Float = 0.95f, onClick: () -> Unit ): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "pressScale" properties["pressedScale"] = pressedScale } ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (isPressed) pressedScale else 1f, animationSpec = spring(), label = "pressScale" ) this .graphicsLayer { scaleX = scale scaleY = scale } .clickable( interactionSource = interactionSource, indication = null, onClick = onClick ) }
ใช้ Modifier.Node แทน Modifier.composed เนื่องจาก Modifier.Node ปรับปรุงวิธีจัดการสถานะภายในตัวแก้ไข Modifier.Node คือออบเจ็กต์ที่มีอายุการใช้งานยาวนานและเก็บสถานะได้ ซึ่งสร้างขึ้น 1 ครั้งต่อ Modifier.Element ที่ใช้กับ LayoutNode และจะคงอยู่ตลอดการจัดองค์ประกอบใหม่แทนที่จะสร้างขึ้นใหม่ผ่านการจัดองค์ประกอบในทุกรอบ ดูข้อมูลเพิ่มเติมเกี่ยวกับเหตุผลและวิธีที่เราออกแบบ Modifier.Node ได้ที่ข้อมูลเจาะลึกเกี่ยวกับตัวแก้ไข Compose
เอกสารนี้อธิบายวิธีย้ายข้อมูลจาก Modifier.composed ไปยัง Modifier.Node ดูข้อมูลเพิ่มเติมเกี่ยวกับการใช้งานทั่วไปของ API นี้ได้ที่ใช้ลักษณะการทำงานของตัวแก้ไขที่กำหนดเองโดยใช้ Modifier.Node
ประโยชน์ด้านประสิทธิภาพของ Modifier.Node
การใช้ Modifier.composed ทำให้เกิดคอขวดด้านประสิทธิภาพพื้นฐานหลายประการ ดังนี้
- ค่าใช้จ่ายในการจัดการสถานะ: การจัดการสถานะในขอบเขตนี้ต้องใช้การเรียก
rememberและออบเจ็กต์สถานะสแนปชอต ซึ่งจะทำให้ตารางสล็อตมีกลุ่มองค์ประกอบที่ไม่จำเป็นและเพิ่มภาระด้านหน่วยความจำ - การเข้าถึงวงจรที่มีค่าใช้จ่ายสูง: การเข้าถึงวงจรของตัวแก้ไขต้องใช้เอฟเฟกต์ เช่น
DisposableEffectซึ่งจะเพิ่มงานที่จำเป็นสำหรับ Use Case ที่ง่ายกว่าอย่างรวดเร็ว - ไม่สามารถข้ามได้: เนื่องจาก Lambda ที่ส่งไปยัง
composedแสดงผลเป็นModifierคอมไพเลอร์ Compose จึงไม่สามารถทำเครื่องหมายว่าข้ามได้ ซึ่งทำให้ต้องเรียกใช้ซ้ำทุกครั้งที่เลย์เอาต์จัดองค์ประกอบใหม่ - การจดจำและการเปรียบเทียบความเท่ากันที่ใช้งานไม่ได้: เนื่องจากฟังก์ชันส่วนขยายภายนอกไม่ใช่
@Composableคอมไพเลอร์จึงไม่สามารถจดจำแลมบ์ดาภายในได้ ส่งผลให้มีการจัดสรรแลมบ์ดาใหม่ทุกครั้งที่เรียกใช้ การขาดการจดจำนี้จะทำลายความเท่ากันของตัวแก้ไข (equals) โดยตรง เนื่องจากComposedModifierจะเปรียบเทียบ Lambda ตามการอ้างอิง ด้วยเหตุนี้ Compose จึงถือว่าตัวแก้ไขมีการเปลี่ยนแปลงในทุกเฟรมแม้ว่าพารามิเตอร์จะคงที่ก็ตาม - ไม่มีการเผยแพร่การเปลี่ยนแปลงอัจฉริยะ: หากไม่มีการติดตามพารามิเตอร์ Composable ระดับบนสุด จะไม่มีวิธีเปรียบเทียบอินพุตใหม่กับอินพุตก่อนหน้าสำหรับการเผยแพร่การเปลี่ยนแปลงอัจฉริยะ
โดยรวมแล้ว รูปแบบ API ของ Modifier.composed จะส่งเสริมการเขียนโค้ดที่มีค่าใช้จ่ายสูงและป้องกันไม่ให้รันไทม์ของ Compose ใช้การเพิ่มประสิทธิภาพตัวแก้ไขเพิ่มเติม
ขั้นตอนการย้ายข้อมูลหลัก
ตัวอย่างต่อไปนี้แสดงตัวแก้ไขที่กำหนดเองทั่วไปที่ใช้กับ Modifier.composed ดูบริบทเพิ่มเติมได้ที่ใช้ลักษณะการทำงานของตัวแก้ไขที่กำหนดเองโดยใช้ Modifier.Node
fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = composed { val density = LocalDensity.current val strokePx = with(density) { thickness.toPx() } // Drives how much of the underline is drawn: 0f -> 1f val progress = remember { Animatable(0f) } LaunchedEffect(color, thickness) { progress.snapTo(0f) progress.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = animationDurationMillis) ) } drawBehind { val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) } }
สร้าง
Modifier.Node(หรือDelegatingNode) ที่กำหนดเองโดยทำดังนี้private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node() { fun update(color: Color, thickness: Dp, durationMillis: Int) { } }
ใช้ API เสริมของ
Modifier.Nodeอย่างน้อย 1 รายการ โดยขึ้นอยู่กับสิ่งที่ตัวแก้ไขที่กำหนดเองต้องการ (เช่นPointerInputModifierNodeหากต้องการเข้าถึง API อินพุตตัวชี้)private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node(), DrawModifierNode { private val progress = Animatable(0f) private var animationJob: Job? = null override fun onAttach() { restartAnimation() } fun update(color: Color, thickness: Dp, durationMillis: Int) { val needsRestart = this.color != color || this.thickness != thickness this.color = color this.thickness = thickness this.animationDurationMillis = durationMillis if (needsRestart) restartAnimation() } private fun restartAnimation() { animationJob?.cancel() animationJob = coroutineScope.launch { progress.snapTo(0f) progress.animateTo(1f, tween(animationDurationMillis)) } } override fun ContentDrawScope.draw() { val strokePx = thickness.toPx() val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) drawContent() } }
สร้าง
ModifierNodeElementที่สร้างและอัปเดตโหนดที่กำหนดเอง ดังนี้private class UnderlineElement( private val color: Color, private val thickness: Dp, private val animationDurationMillis: Int ) : ModifierNodeElement<UnderlineNode>() { override fun create() = UnderlineNode(color, thickness, animationDurationMillis) override fun update(node: UnderlineNode) { node.update(color, thickness, animationDurationMillis) } override fun InspectorInfo.inspectableProperties() { name = "underline" properties["color"] = color properties["thickness"] = thickness properties["animationDurationMillis"] = animationDurationMillis } override fun hashCode(): Int { var result = color.hashCode() result = 31 * result + thickness.hashCode() result = 31 * result + animationDurationMillis.hashCode() return result } override fun equals(other: Any?): Boolean { if (this === other) return true val otherElement = other as? UnderlineElement ?: return false return color == otherElement.color && thickness == otherElement.thickness && animationDurationMillis == otherElement.animationDurationMillis } }
อัปเดต Modifier Factory ให้ชี้ไปที่
ModifierNodeElementดังนี้fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)
สูตรการย้ายข้อมูลทั่วไป
สูตรต่อไปนี้แสดงวิธีย้ายข้อมูลรูปแบบทั่วไปจาก Modifier.composed ไปยัง Modifier Factory ของ Modifier.Node หรือ @Composable
เข้าถึง CompositionLocal
รูปแบบ: การอ่าน CompositionLocal รายการเดียว เช่น LocalDensity, Theme หรือ LocalView
เส้นทางการย้ายข้อมูล: ทำเครื่องหมายตัวแก้ไขด้วย @Composable การใช้ตัวแก้ไข composed และโรงงานตัวแก้ไข @Composable เพื่อเข้าถึง CompositionLocal มีความแตกต่างกันในเชิงความหมาย โดยเมื่อใช้โรงงาน @Composable ระบบจะแก้ไขค่า CompositionLocal ที่ Call Site ของโรงงานตัวแก้ไข หากไม่ต้องการให้เกิดลักษณะการทำงานนี้ ให้ใช้การติดตั้งใช้งาน Modifier.Node ที่กำหนดเองซึ่งอ่าน CompositionLocal โดยใช้ CompositionLocalConsumerModifierNode
ดูข้อมูลเพิ่มเติมได้ที่สร้างตัวปรับแต่งที่กำหนดเองโดยใช้ Factory Composable
// ❌ BAD: Using Modifier.composed to read a single CompositionLocal fun Modifier.themedContainerBorder(): Modifier = composed { Modifier.border( BorderStroke( width = 2.dp, color = LocalColorScheme.current.primaryColor, ) ) .clipToBounds() }
// ✅ GOOD: If the modifier is @Composable, it should be able to access the locals. @Composable fun Modifier.themedContainerBorder() = this then Modifier.border( BorderStroke( width = 2.dp, color = MyTheme.mainColor, ) ) .clipToBounds()
รูปแบบ: การอ่าน CompositionLocal ที่อาจใช้กับตัวแก้ไขที่ตามมา
เส้นทางการย้ายข้อมูล: สร้าง Modifier.Node ที่กำหนดเองซึ่งใช้ CompositionLocalConsumerModifierNode และรวมความสามารถของตัวแก้ไขทั้งหมดเข้าด้วยกัน
// ❌ BAD: Using Modifier.composed to read a CompositionLocal then using it in another modifier. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = composed { // Reading LocalThemePadding.current.small (CompositionLocal) val extraPadding = LocalThemePadding.current.small Modifier.padding(basePadding + extraPadding) }
// ✅ GOOD: A custom Modifier that combines the capabilities of both (layout and composition local reader) modifiers. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = this.then(AdaptivePaddingElement(basePadding)) private data class AdaptivePaddingElement( val basePadding: Dp, ) : ModifierNodeElement<AdaptivePaddingNode>() { override fun create() = AdaptivePaddingNode(basePadding) override fun update(node: AdaptivePaddingNode) { node.basePadding = basePadding } override fun InspectorInfo.inspectableProperties() { name = "adaptiveAccessibilityPadding" properties["basePadding"] = basePadding } } private class AdaptivePaddingNode( var basePadding: Dp, ) : Modifier.Node(), LayoutModifierNode, CompositionLocalConsumerModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val extraPadding = currentValueOf(LocalThemePadding).small val total = (basePadding + extraPadding).roundToPx() val horizontal = total * 2 val vertical = total * 2 val placeable = measurable.measure(constraints.offset(-horizontal, -vertical)) val width = constraints.constrainWidth(placeable.width + horizontal) val height = constraints.constrainHeight(placeable.height + vertical) return layout(width, height) { placeable.place(total, total) } } }
เข้าถึงฟังก์ชันที่ประกอบกันได้ซึ่งไม่ใช่เลย์เอาต์
รูปแบบ: ตัวแก้ไขต้องเข้าถึงฟังก์ชันที่มีคำอธิบายประกอบด้วย @Composable และแสดงผลออบเจ็กต์ (เช่น colorResource หรือ ScrollableDefaults.flingBehavior)
เส้นทางการย้ายข้อมูล: ใส่คำอธิบายประกอบตัวแก้ไขด้วย @Composable
// ❌ BAD: Using Modifier.composed to access a composable function such as colorResource fun Modifier.niceBackground() = composed { // Reading composable function colorResource val gradientColor1 = colorResource(R.color.my_special_color) background(color = gradientColor1, shape = CircleShape) }
// ✅ GOOD: A modifier can be annotation with @Composable to reference composable functions. @Composable // Modifier can be Composable itself. private fun Modifier.niceBackground(): Modifier { val gradientColor1 = colorResource(R.color.my_special_color) return this.background(color = gradientColor1, shape = CircleShape) }
เข้าถึงขอบเขต Coroutine
รูปแบบ: Modifier.composed ใช้เพื่อเรียกใช้ rememberCoroutineScope เพื่อเข้าถึงออบเจ็กต์ coroutineScope สำหรับเปิดใช้ Coroutine
เส้นทางการย้ายข้อมูล: ใช้ Modifier.Node ที่กำหนดเองซึ่งมีพร็อพเพอร์ตี้ coroutineScope ที่เชื่อมโยงกับวงจรของตัวแก้ไข (เช่น rememberCoroutineScope ภายใน Modifier.composed)
// ❌ BAD: Using Modifier.composed to get access to a coroutine scope. fun Modifier.onClickAsyncComposed(onClick: suspend () -> Unit): Modifier = composed { val scope = rememberCoroutineScope() Modifier.pointerInput(onClick) { detectTapGestures { // Needs a coroutine scope to launch suspend lambda. scope.launch { onClick() } } } }
// ✅ GOOD: A custom Modifier.Node has a scoped (modifier lifecycle) coroutineScope that can be used to launch async work. fun Modifier.onClickAsync(onClick: suspend () -> Unit): Modifier = this.then(OnClickAsyncElement(onClick)) private data class OnClickAsyncElement(val onClick: suspend () -> Unit) : ModifierNodeElement<OnClickAsyncNode>() { override fun create(): OnClickAsyncNode = OnClickAsyncNode(onClick) override fun update(node: OnClickAsyncNode) { node.update(onClick) } override fun InspectorInfo.inspectableProperties() { name = "onClickAsync" properties["onClick"] = onClick } } private class OnClickAsyncNode(private var onClick: suspend () -> Unit) : DelegatingNode(), PointerInputModifierNode { private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { // Modifier.Node provides `coroutineScope` directly. coroutineScope.launch { onClick() } } } ) fun update(onClick: suspend () -> Unit) { if (this.onClick != onClick) { this.onClick = onClick pointerInputNode.resetPointerInputHandler() } } override fun onPointerEvent( pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize, ) { pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) } override fun onCancelPointerInput() { pointerInputNode.onCancelPointerInput() } }
จดจำสถานะ
รูปแบบ: การใช้ remember ใน Modifier.composed เพื่อบันทึกสถานะในการจัดองค์ประกอบใหม่
เส้นทางการย้ายข้อมูล: Modifier.Node สร้างขึ้นเพื่อเก็บสถานะในลักษณะเดียวกัน สถานะสามารถเก็บไว้ภายในอินสแตนซ์ได้ เช่นเดียวกับพร็อพเพอร์ตี้คลาสอื่นๆ ที่มีวงจรที่ชัดเจนกว่า
// ❌ BAD: Using Modifier.composed to make the modifier stateful. fun Modifier.tapCountHighlightComposed(colors: List<Color>): Modifier = composed { // 1. Must use `remember` so `tapCount` isn't reset to 0 on every recomposition var tapCount by remember { mutableIntStateOf(0) } Modifier .pointerInput(colors) { detectTapGestures { tapCount++ } } .drawBehind { drawRect(colors[tapCount % colors.size]) } }
// ✅ GOOD: Modifier.Node is the recommended way of creating stateful modifiers. fun Modifier.tapCountHighlight(colors: List<Color>): Modifier = this then TapCountHighlightElement(colors) private data class TapCountHighlightElement( val colors: List<Color>, ) : ModifierNodeElement<TapCountHighlightNode>() { override fun create() = TapCountHighlightNode(colors) override fun update(node: TapCountHighlightNode) { node.updateColors(colors) } override fun InspectorInfo.inspectableProperties() { name = "tapCountHighlight" properties["colors"] = colors } } private class TapCountHighlightNode( private var colors: List<Color>, ) : DelegatingNode(), DrawModifierNode { private var tapCount = 0 // Stateful modifier, this property will survive recompositions since Modifier.Nodes are held in the modifier tree. private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { tapCount++ invalidateDraw() } } ) override fun ContentDrawScope.draw() { drawRect(colors[tapCount % colors.size]) drawContent() } fun updateColors(colors: List<Color>) { this.colors = colors invalidateDraw() } }
ใช้เอฟเฟกต์
รูปแบบ: การใช้เอฟเฟกต์เพื่อดำเนินการที่เชื่อมโยงกับวงจรของ Composition (เช่น เมื่อ Modifier.composed เข้าหรือออกจาก Composition)
เส้นทางการย้ายข้อมูล: Modifier.Node มี Lifecycle Callback ที่ชัดเจนซึ่งสามารถใช้เพื่อดำเนินการเดียวกันได้ เช่น โดยทั่วไปแล้ว คุณสามารถแทนที่ LaunchedEffect ด้วยการใช้ coroutineScope ภายในเมธอด Modifier.Node onAttach ได้ ดังนี้
// ❌ BAD: Using Modifier.composed to launch/run an effect. fun Modifier.logImpressionComposed( targetId: String, onLog: suspend (targetId: String) -> Unit, ): Modifier = composed { // LaunchedEffect is tied to Composition lifecycle LaunchedEffect(targetId) { onLog(targetId) } this }
// ✅ GOOD: Modifier.Node has lifecycle callbacks (e.g onAttach, onDetach) that can be used to emulate effects behaviors. fun Modifier.logImpression(targetId: String, onLog: suspend (targetId: String) -> Unit): Modifier = this.then(LogImpressionElement(targetId, onLog)) private data class LogImpressionElement( val targetId: String, val onLog: suspend (targetId: String) -> Unit, ) : ModifierNodeElement<LogImpressionNode>() { override fun create(): LogImpressionNode = LogImpressionNode(targetId, onLog) override fun update(node: LogImpressionNode) { node.update(targetId, onLog) } override fun InspectorInfo.inspectableProperties() { name = "logImpression" properties["targetId"] = targetId } } private class LogImpressionNode( var targetId: String, var onLog: suspend (targetId: String) -> Unit, ) : Modifier.Node() { private var job: Job? = null override fun onAttach() { super.onAttach() runEffect() // Uses onAttach to track modifier lifecycle. } fun update(targetId: String, onLog: suspend (targetId: String) -> Unit) { // Re-run the effect if the key (`targetId`) changed if (this.targetId != targetId) { runEffect() } this.targetId = targetId this.onLog = onLog } private fun runEffect() { job?.cancel() job = coroutineScope.launch { onLog(targetId) } } }
คงสถานะภาพเคลื่อนไหว
รูปแบบ: Modifier.composed โดยใช้ animate*AsState
เส้นทางการย้ายข้อมูล: animate*AsState สามารถแยกย่อยเป็นตัวแก้ไขที่กำหนดเองซึ่งจะตรวจสอบ Lifecycle Callback และเก็บสถานะ Animatable ไว้ได้ ดังนี้
// ❌ BAD: Using Modifier.composed to save an animation state. fun Modifier.fadeInOnHoverComposed(isHovered: Boolean): Modifier = composed { val alpha by animateFloatAsState( targetValue = if (isHovered) 1f else 0.4f, animationSpec = tween(durationMillis = 300), label = "alphaAnimation", ) Modifier.graphicsLayer { this.alpha = alpha } }
// ✅ GOOD: Animation state can be saved in Modifier.Node like other types of stateful implementations. fun Modifier.fadeInOnHover(isHovered: Boolean): Modifier = this.then(FadeInOnHoverElement(isHovered)) private data class FadeInOnHoverElement(val isHovered: Boolean) : ModifierNodeElement<FadeInOnHoverNode>() { override fun create(): FadeInOnHoverNode = FadeInOnHoverNode(isHovered) override fun update(node: FadeInOnHoverNode) { node.update(isHovered) } override fun InspectorInfo.inspectableProperties() { name = "fadeInOnHover" properties["isHovered"] = isHovered } } private class FadeInOnHoverNode(var isHovered: Boolean) : Modifier.Node(), LayoutModifierNode { // 1. Persistent Animatable field on the Node instance private val alphaAnimatable = Animatable(if (isHovered) 1f else 0.4f) override fun onAttach() { super.onAttach() startAnimation(isHovered) } // 2. Trigger animation imperatively when `isHovered` argument changes fun update(isHovered: Boolean) { if (this.isHovered != isHovered) { this.isHovered = isHovered if (isAttached) { startAnimation(isHovered) } } } private fun startAnimation(hovered: Boolean) { val targetAlpha = if (hovered) 1f else 0.4f // Use Node's built-in coroutineScope coroutineScope.launch { alphaAnimatable.animateTo( targetValue = targetAlpha, animationSpec = tween(durationMillis = 300), ) } } override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { // Read current animation value during layout placement layer placeable.placeWithLayer(0, 0) { alpha = alphaAnimatable.value } } } }