Modifier.composed הוצג ב-Compose 1.0 כדי לאפשר לכם לגשת לרכיבי קומפוזיציה מתוך מגבילים. לדוגמה, תרחיש לדוגמה מרכזי הוא יצירת משתנה עם מצב שזוכר את המצב המקומי ומשתף אותו עם משתנים אחרים ב-factory של 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, והוא שורד את ה-recomposition במקום לעבור מטריאליזציה מחדש באמצעות קומפוזיציה בכל מעבר. למידע נוסף על הסיבות והאופן שבהם עיצבנו את Modifier.Node, קראו את המאמר הסבר מעמיק על משתני Compose.
במאמר הזה מוסבר איך לעבור מ-Modifier.composed ל-Modifier.Node. מידע נוסף על השימוש הכללי ב-API הזה זמין במאמר הטמעת התנהגות של משתנה מותאם אישית באמצעות Modifier.Node.
יתרונות הביצועים של Modifier.Node
השימוש ב-Modifier.composed יוצר כמה צווארי בקבוק בסיסיים בביצועים:
- תקורה של ניהול מצב: כדי לנהל את המצב בהיקף הזה, צריך קריאות ל-
rememberואובייקטים של מצב תמונת מצב, שמנפחים את טבלת המשבצות עם קבוצות קומפוזיציה מיותרות ומגבירים את הלחץ על הזיכרון. - גישה יקרה למחזור החיים: כדי לגשת למחזור החיים של המשתנה, צריך להשתמש באפקטים כמו
DisposableEffect, מה שמגדיל במהירות את העבודה שנדרשת בתרחישים פשוטים יותר לדוגמה. - חוסר יכולת דילוג: מכיוון שהלמבדה שמועברת אל
composedמחזירהModifier, המהדר של Compose לא יכול לסמן אותה כניתנת לדילוג, מה שמאלץ אותה לפעול מחדש בכל פעם שהפריסה עוברת קומפוזיציה מחדש. - שמירת תוצאות של פונקציות (memoization) ושוויון לא תקינים: מכיוון שפונקציית ההרחבה החיצונית עצמה היא לא
@Composable, המקמפל לא יכול לשמור את התוצאות של פונקציית הלמבדה הפנימית, וכתוצאה מכך נוצרות הקצאות למבדה חדשות בכל קריאה. היעדר ה-memoization הזה פוגע ישירות בשוויון של המשנים (equals), כיComposedModifierמשווה בין פונקציות למבדה לפי הפניה. לכן, פיתוח נייטיב מתייחס לשינוי כאל שינוי בכל פריים, גם כשהפרמטרים סטטיים. - אין הפצה חכמה של שינויים: בלי מעקב אחרי פרמטרים של פונקציות קומפוזביליות ברמה העליונה, אין דרך להשוות בין קלט חדש לקלט קודם כדי להפיץ שינויים בצורה חכמה.
באופן כללי, צורת ה-API של Modifier.composed מעודדת כתיבת קוד יקר ומונעת מזמן הריצה של 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 } }
מעדכנים את ה-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.Node או @Composable.
גישה ל-CompositionLocal
דפוס: קריאת CompositionLocal יחיד, כמו LocalDensity, Theme או LocalView.
מסלול ההעברה: מסמנים את המגדיר ב-@Composable. יש הבדל סמנטי בין שימוש ב-modifier מסוג composed לבין שימוש ב-modifier factory מסוג @Composable כדי לגשת ל-CompositionLocal – עם factory מסוג @Composable, ערכי CompositionLocal נפתרים באתר הקריאה של ה-modifier factory. אם זו לא ההתנהגות הרצויה, צריך להשתמש בהטמעה מותאמת אישית של Modifier.Node שקוראת CompositionLocal באמצעות 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 ומשלב את כל היכולות של משתני ה-modifier.
// ❌ 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).
מסלול ההעברה: מוסיפים הערה ל-modifier באמצעות @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 שקשור למחזור החיים של ה-modifier (כמו 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 למשתנה מותאם אישית שעוקב אחרי הקריאות החוזרות במחזור החיים ומכיל מצב 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 } } } }