Modifier.composed נוסף ב-Compose 1.0 כדי לאפשר לכם לגשת לרכיבי קומפוזיציה מתוך שינויים. לדוגמה, אחד מתרחישי השימוש העיקריים הוא יצירת משנה מצב שזוכר את המצב המקומי ומשתף אותו עם משנים אחרים במפעל 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 הוא אובייקט עם שמירת מצב (stateful) לטווח ארוך שנוצר פעם אחת לכל Modifier.Element שמוחל על LayoutNode, והוא שורד את הרה-קומפוזיציה במקום להיווצר מחדש באמצעות קומפוזיציה בכל מעבר. למידע נוסף על הסיבות לעיצוב של Modifier.Node ועל אופן העיצוב, אפשר לעיין במאמר מידע מעמיק על Compose Modifiers.
במאמר הזה מוסבר איך לבצע מיגרציה מ-Modifier.composed אל Modifier.Node. מידע נוסף על שימוש כללי ב-API הזה זמין במאמר הטמעה של התנהגות מותאמת אישית של מקש צירוף באמצעות Modifier.Node.
יתרונות הביצועים של Modifier.Node
השימוש ב-Modifier.composed יוצר כמה צווארי בקבוק בביצועים:
- תקורה של ניהול מצב: ניהול מצב בהיקף הזה דורש
rememberקריאות ואובייקטים של מצב תמונת מצב, מה שמנפח את טבלת המשבצות עם קבוצות קומפוזיציה מיותרות ומגדיל את העומס על הזיכרון. - גישה יקרה למחזור החיים: כדי לגשת למחזור החיים של ה-modifier צריך להשתמש באפקטים כמו
DisposableEffect, מה שמגדיל במהירות את העבודה שנדרשת לתרחישי שימוש פשוטים יותר. - אי אפשרות דילוג: מכיוון שפונקציית ה-lambda שמועברת אל
composedמחזירהModifier, קומפיילר ה-Compose לא יכול לסמן אותה כפונקציה שאפשר לדלג עליה, ולכן הוא מאלץ אותה לפעול מחדש בכל פעם שהפריסה מורכבת מחדש. - ממויזציה ושוויוניות שבורות: מכיוון שפונקציית התוסף החיצונית עצמה היא לא
@Composable, הקומפיילר לא יכול לבצע ממויזציה של פונקציית ה-lambda הפנימית, ולכן מוקצים הקצאות חדשות של lambda בכל קריאה. היעדר הממויזציה הזה גורם לשבירה ישירה של שוויון משנים (equals), כיComposedModifierמשווה למבדות לפי הפניה. לכן, Compose מתייחס למאפיין כאל שינוי בכל פריים, גם כשהפרמטרים סטטיים. - אין העברה חכמה של שינויים: בלי מעקב אחרי פרמטרים שאפשר להרכיב ברמה העליונה, אין דרך להשוות בין קלט חדש לקלט קודם כדי להעביר שינויים בצורה חכמה.
בסך הכול, הצורה של Modifier.composed API מעודדת כתיבת קוד יקר ומונעת מזמן הריצה של Compose להחיל אופטימיזציות נוספות של modifier.
השלבים העיקריים להעברה
בדוגמה הבאה מוצג מגביל מותאם אישית טיפוסי שהוטמע באמצעות 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, בהתאם למה שנדרש לשינוי המותאם אישית (לדוגמה,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 } }
מעדכנים את הפקטורי (factory) של מקש הצירוף כך שיפנה אל
ModifierNodeElement:fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)
מתכונים נפוצים להעברה
בדוגמאות הבאות מוצגות הדרכים להעביר תבניות נפוצות מ-Modifier.composed למפעלים של משני Modifier.Node או @Composable.
גישה ל-CompositionLocal
תבנית: קריאה של CompositionLocal יחיד, כמו LocalDensity, Theme או LocalView.
מסלול ההעברה: מסמנים את התוסף ב-@Composable. יש הבדל סמנטי בין שימוש במפעל של משנה composed לבין שימוש במפעל של משנה @Composable כדי לגשת אל CompositionLocal – במפעל של משנה @Composable, הערכים של CompositionLocal נפתרים באתר הקריאה של המפעל של המשנה. אם זו לא ההתנהגות הרצויה, צריך להשתמש בהטמעה מותאמת אישית של Modifier.Node
שקוראת את CompositionLocals באמצעות
CompositionLocalConsumerModifierNode.
מידע נוסף זמין במאמר יצירת משנה בהתאמה אישית באמצעות מפעל משנים של קומפוזיציה.
// ❌ 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) }
גישה להיקף של קורוטינה
תבנית: נעשה שימוש ב-Modifier.composed כדי להפעיל את rememberCoroutineScope כדי לגשת לאובייקט coroutineScope להפעלת קורוטינות.
נתיב העברה: משתמשים ב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() } }
שימוש באפקט
תבנית: שימוש באפקטים כדי להפעיל פעולות שקשורות למחזור החיים של ההרכבה (לדוגמה, כש-Modifier.composed נכנס או יוצא מההרכבה).
נתיב ההעברה: ל-Modifier.Node יש קריאות חוזרות ברורות למחזור החיים שאפשר להשתמש בהן כדי לבצע את אותן פעולות. לדוגמה, בדרך כלל אפשר להחליף את 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 למשנה מותאם אישית שעוקב אחרי קריאות חוזרות (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 } } } }