יצירה של תכונות שינוי בהתאמה אישית

ב-Compose יש הרבה תכונות שינוי להתנהגויות נפוצות, אבל אתם יכולים גם ליצור תכונות שינוי בהתאמה אישית.

למגדירים יש כמה חלקים:

  • מפעל לשינוי
    • זוהי פונקציית תוסף ב-Modifier, שמספקת API אידיומטי ל-modifier ומאפשרת לשרשר modifiers. ה-modifier factory מייצר את רכיבי ה-modifier שבהם Compose משתמש כדי לשנות את ממשק המשתמש.
  • רכיב לשינוי
    • כאן אתם יכולים להטמיע את ההתנהגות של המגדיר.

יש כמה דרכים להטמיע משתנה מותאם אישית, בהתאם לפונקציונליות הנדרשת. בדרך כלל, הדרך הכי פשוטה להטמיע משתנה מותאם אישית היא להטמיע מפעל של משתנים מותאמים אישית שמשלב מפעלים אחרים של משתנים שכבר הוגדרו. אם אתם צריכים התנהגות מותאמת אישית יותר, אתם יכולים להטמיע את הרכיב modifier באמצעות ממשקי ה-API של Modifier.Node, שהם ברמה נמוכה יותר אבל מספקים יותר גמישות.

שרשור של משתנים קיימים

במקרים רבים אפשר ליצור משתנים מותאמים אישית באמצעות משתנים קיימים. לדוגמה, Modifier.clip() מוטמע באמצעות המשתנה graphicsLayer. בשיטה הזו משתמשים ברכיבי שינוי קיימים, ואתם מספקים מפעל משלכם לשינוי בהתאמה אישית.

לפני שאתם מטמיעים משתנה משלכם, כדאי לבדוק אם אתם יכולים להשתמש באותה אסטרטגיה.

fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)

לחלופין, אם אתם משתמשים באותה קבוצה של משתני עיצוב לעיתים קרובות, אתם יכולים לעטוף אותם במשתנה עיצוב משלכם:

fun Modifier.myBackground(color: Color) = padding(16.dp)
    .clip(RoundedCornerShape(8.dp))
    .background(color)

יצירת עיבוד בהתאמה אישית באמצעות מפעל עיבודים קומפוזבילי

אפשר גם ליצור מגדיר מותאם אישית באמצעות פונקציה קומפוזבילית כדי להעביר ערכים למגדיר קיים. זה נקרא composable modifier factory.

שימוש ב-composable modifier factory כדי ליצור מגדיל מאפשר גם להשתמש בממשקי API ברמה גבוהה יותר של פיתוח נייטיב, כמו animate*AsState וממשקי API אחרים של אנימציה שמגובים במצב פיתוח נייטיב. לדוגמה, קטע הקוד הבא מציג משתנה שיוצר אנימציה של שינוי אלפא כשהוא מופעל או מושבת:

@Composable
fun Modifier.fade(enable: Boolean): Modifier {
    val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
    return this then Modifier.graphicsLayer { this.alpha = alpha }
}

אם המשתנה המותאם אישית הוא שיטה נוחה לספק ערכי ברירת מחדל מ-CompositionLocal, הדרך הקלה ביותר להטמיע אותו היא להשתמש ב-composable modifier factory:

@Composable
fun Modifier.fadedBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

לגישה הזו יש כמה הסתייגויות, שמפורטות בקטעים הבאים.

ערכי CompositionLocal נפתרים באתר הקריאה של מפעל השינויים

כשיוצרים משתנה מותאם אישית באמצעות מפעל משתנים קומפוזבילי, המשתנים המקומיים של הקומפוזיציה מקבלים את הערך מעץ הקומפוזיציה שבו הם נוצרו, ולא מהמקום שבו הם משמשים. זה עלול להוביל לתוצאות לא צפויות. לדוגמה, נבחן את הדוגמה של משתנה מקומי של קומפוזיציה שהוזכרה קודם, אבל הפעם נטמיע אותה בצורה קצת שונה באמצעות פונקציה קומפוזבילית:

@Composable
fun Modifier.myBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

@Composable
fun MyScreen() {
    CompositionLocalProvider(LocalContentColor provides Color.Green) {
        // Background modifier created with green background
        val backgroundModifier = Modifier.myBackground()

        // LocalContentColor updated to red
        CompositionLocalProvider(LocalContentColor provides Color.Red) {

            // Box will have green background, not red as expected.
            Box(modifier = backgroundModifier)
        }
    }
}

אם אתם לא רוצים שהתכונה תפעל ככה, השתמשו ב-Modifier.Node בהתאמה אישית במקום זאת, כי המשתנים המקומיים של הקומפוזיציה יפורשו בצורה נכונה באתר השימוש וניתן להעביר אותם בבטחה.

אף פעם לא מדלגים על פונקציות קומפוזביליות עם משתני שינוי

המערכת אף פעם לא מדלגת על פונקציות פקטורי קומפוזביליות של מגדירי שינוי, כי אי אפשר לדלג על פונקציות קומפוזביליות שיש להן ערכי החזרה. זה אומר שפונקציית ה-modifier תופעל בכל הרכבה מחדש, וזה יכול להיות יקר אם היא מורכבת מחדש לעיתים קרובות.

צריך לקרוא לשינויים של פונקציה קומפוזבילית בתוך פונקציה קומפוזבילית

כמו כל הפונקציות הקומפוזביליות, צריך לקרוא ל-composable factory modifier מתוך הקומפוזיציה. זה מגביל את המקום שאליו אפשר להעביר את ה-modifier, כי אי אפשר להעביר אותו מחוץ לקומפוזיציה. לעומת זאת, אפשר להעביר פונקציות מפעל של משתני שינוי לא קומפוזביליים אל מחוץ לפונקציות קומפוזביליות כדי לאפשר שימוש חוזר קל יותר ולשפר את הביצועים:

val extractedModifier = Modifier.background(Color.Red) // Hoisted to save allocations

@Composable
fun Modifier.composableModifier(): Modifier {
    val color = LocalContentColor.current.copy(alpha = 0.5f)
    return this then Modifier.background(color)
}

@Composable
fun MyComposable() {
    val composedModifier = Modifier.composableModifier() // Cannot be extracted any higher
}

הטמעת התנהגות של משתנה התאמה אישית באמצעות Modifier.Node

‫Modifier.Node הוא API ברמה נמוכה יותר ליצירת מגבילים ב-Compose. זהו אותו API שבו Compose מטמיע את מגדירי השינוי שלו, והוא הדרך היעילה ביותר ליצור מגדירי שינוי בהתאמה אישית.

הטמעה של משתנה מותאם אישית באמצעות Modifier.Node

יש שלושה חלקים להטמעה של משתנה מותאם אישית באמצעות Modifier.Node:

  • הטמעה של Modifier.Node שמכילה את הלוגיקה והמצב של המשתנה.
  • ‫ModifierNodeElement שיוצרת ומעדכנת מופעים של צומתי שינוי.
  • מפעל אופציונלי לשינוי, כפי שפורט קודם.

מחלקות ModifierNodeElement הן חסרות מצב (stateless) ומוקצות מכונות חדשות בכל הרכבה מחדש, ואילו מחלקות Modifier.Node יכולות להיות בעלות מצב (stateful) והן ישרדו כמה הרכבות מחדש, ואפילו אפשר לעשות בהן שימוש חוזר.

בקטע הבא מתואר כל חלק ומוצגת דוגמה ליצירת משתנה בהתאמה אישית לציור עיגול.

Modifier.Node

ההטמעה של Modifier.Node (בדוגמה הזו, CircleNode) מטמיעה את הפונקציונליות של ה-modifier המותאם אישית.

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

בדוגמה הזו, הפונקציה מציירת את העיגול בצבע שהועבר לפונקציית ה-modifier.

צומת מטמיע את Modifier.Node וגם אפס או יותר סוגי צמתים. יש סוגי צמתים שונים, בהתאם לפונקציונליות שהמשתנה דורש. בדוגמה הקודמת, צריך להיות אפשר לצייר, ולכן היא מטמיעה את DrawModifierNode, שמאפשר לה לשנות את ה-method של הציור.

אלה סוגי המזהים הזמינים:

Node

שימוש

קישור לדוגמה

LayoutModifierNode

‫Modifier.Node שמשנה את האופן שבו התוכן שהוא כולל נמדד ומסודר.

דוגמה

DrawModifierNode

‫Modifier.Node שמצויר במרחב של הפריסה.

דוגמה

CompositionLocalConsumerModifierNode

הטמעת הממשק הזה מאפשרת ל-Modifier.Node לקרוא את המשתנים המקומיים של הקומפוזיציה.

דוגמה

SemanticsModifierNode

‫Modifier.Node שמוסיף מפתח/ערך של סמנטיקה לשימוש בבדיקות, בנגישות ובמקרים דומים.

דוגמה

PointerInputModifierNode

‫Modifier.Node שמקבל PointerInputChanges.

דוגמה

ParentDataModifierNode

Modifier.Node שמספק נתונים לפריסת האם.

דוגמה

LayoutAwareModifierNode

‫Modifier.Node שמקבל קריאות חוזרות (callbacks) מסוג onMeasured ו-onPlaced.

דוגמה

GlobalPositionAwareModifierNode

‫Modifier.Node שמקבל קריאה חוזרת (callback) מסוג onGloballyPositioned עם ה-LayoutCoordinates הסופי של הפריסה, כשהמיקום הגלובלי של התוכן עשוי להשתנות.

דוגמה

ObserverModifierNode

Modifier.Nodes שמטמיעים את ObserverNode יכולים לספק הטמעה משלהם של onObservedReadsChanged שתיקרא בתגובה לשינויים באובייקטים של תמונת מצב שנקראים בתוך בלוק observeReads.

דוגמה

DelegatingNode

‫Modifier.Node שיכול להקצות עבודה למכונות Modifier.Node אחרות.

האפשרות הזו שימושית אם רוצים לשלב כמה הטמעות של צמתים לאחת.

דוגמה

TraversableNode

מאפשר למחלקות Modifier.Node לעבור למעלה או למטה בעץ הצמתים למחלקות מאותו סוג או למפתח מסוים.

דוגמה

הצמתים מבוטלים באופן אוטומטי כשמתבצעת קריאה לעדכון ברכיב התואם שלהם. מכיוון שהדוגמה שלנו היא DrawModifierNode, בכל פעם שמתבצעת קריאה לעדכון ברכיב, הצומת מפעיל שרטוט מחדש והצבע שלו מתעדכן בצורה נכונה. אפשר לבטל את ההשבתה האוטומטית, כמו שמוסבר בקטע ביטול ההשבתה האוטומטית של צמתים.

ModifierNodeElement

‫ModifierNodeElement היא מחלקה שלא ניתנת לשינוי, שמכילה את הנתונים ליצירה או לעדכון של המשתנה המותאם אישית:

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

הטמעות של ModifierNodeElement צריכות לשנות את השיטות הבאות:

  1. ‫create: זו הפונקציה שיוצרת מופע של צומת ה-modifier. הפונקציה הזו נקראת כדי ליצור את הצומת כשמחילים את המשתנה בפעם הראשונה. בדרך כלל, הפעולה הזו כוללת את בניית הצומת והגדרת הפרמטרים שהועברו ל-modifier factory.
  2. ‫update: הפונקציה הזו נקראת בכל פעם שהמשתנה הזה מסופק באותו מקום שבו הצומת הזה כבר קיים, אבל מאפיין השתנה. המידע הזה נקבע לפי ה-method‏ equals של המחלקה. צומת ה-modifier שנוצר קודם נשלח כפרמטר לקריאה ל-update. בשלב הזה, צריך לעדכן את המאפיינים של הצמתים כך שיתאימו לפרמטרים המעודכנים. היכולת לעשות שימוש חוזר בצמתים בדרך הזו היא המפתח לשיפור הביצועים ש-Modifier.Node מביא איתו. לכן, צריך לעדכן את הצומת הקיים במקום ליצור צומת חדש ב-method‏ update. בדוגמה של העיגול, הצבע של הצומת מתעדכן.

בנוסף, בהטמעות של ModifierNodeElement צריך להטמיע גם את equals ואת hashCode. הפונקציה update תיקרא רק אם השוואה שווה עם הרכיב הקודם תחזיר false.

בדוגמה הקודמת השתמשנו במחלקת נתונים כדי לעשות את זה. השיטות האלה משמשות כדי לבדוק אם צריך לעדכן צומת או לא. אם לאלמנט יש מאפיינים שלא משפיעים על הצורך לעדכן צומת, או אם אתם רוצים להימנע משימוש במחלקות נתונים מסיבות של תאימות בינארית, אתם יכולים להטמיע באופן ידני את equals ו-hashCode, לדוגמה, אלמנט ה-modifier של המרווח הפנימי.

מפעל ההרחבות

זהו ממשק ה-API הציבורי של המשתנה שלכם. ברוב ההטמעות, יוצרים את רכיב ה-modifier ומוסיפים אותו לשרשרת ה-modifier:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

דוגמה פתורה

שלושת החלקים האלה משתלבים כדי ליצור את ה-modifier בהתאמה אישית לציור עיגול באמצעות ממשקי ה-API של Modifier.Node:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

מצבים נפוצים שבהם משתמשים ב-Modifier.Node

כשיוצרים משתנים מותאמים אישית באמצעות Modifier.Node, אלה כמה מצבים נפוצים שבהם אתם עשויים להיתקל.

אפס פרמטרים

אם ל-modifier אין פרמטרים, הוא לא צריך להתעדכן אף פעם, ובנוסף, הוא לא צריך להיות מחלקת נתונים. הדוגמה הבאה היא הטמעה של משתנה שמוסיף כמות קבועה של ריפוד לרכיב Composable:

fun Modifier.fixedPadding() = this then FixedPaddingElement

data object FixedPaddingElement : ModifierNodeElement<FixedPaddingNode>() {
    override fun create() = FixedPaddingNode()
    override fun update(node: FixedPaddingNode) {}
}

class FixedPaddingNode : LayoutModifierNode, Modifier.Node() {
    private val PADDING = 16.dp

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val paddingPx = PADDING.roundToPx()
        val horizontal = paddingPx * 2
        val vertical = paddingPx * 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(paddingPx, paddingPx)
        }
    }
}

מקומיות של קומפוזיציית קובץ העזר

התכונות של Modifier.Node לא מזהות באופן אוטומטי שינויים באובייקטים של מצב Compose, כמו CompositionLocal. היתרון של משתני ה-modifier מסוג Modifier.Node על פני משתני modifier שנוצרו באמצעות פונקציית factory קומפוזבילית הוא שהם יכולים לקרוא את הערך של המשתנה המקומי של הקומפוזיציה מהמקום שבו משתמשים במשתנה ה-modifier בעץ ממשק המשתמש, ולא מהמקום שבו משתנה ה-modifier מוקצה, באמצעות currentValueOf.

עם זאת, מופעים של צומתי שינוי לא צופים באופן אוטומטי בשינויי מצב. כדי להגיב באופן אוטומטי לשינוי ב-CompositionLocal, אפשר לקרוא את הערך הנוכחי שלו בתוך היקף:

בדוגמה הזו, הערך של LocalContentColor נבדק כדי לצייר רקע על סמך הצבע שלו. מכיוון ש-ContentDrawScope צופה בשינויים בתמונת המצב, הפעולה הזו משרטטת מחדש באופן אוטומטי כשהערך של LocalContentColor משתנה:

class BackgroundColorConsumerNode :
    Modifier.Node(),
    DrawModifierNode,
    CompositionLocalConsumerModifierNode {
    override fun ContentDrawScope.draw() {
        val currentColor = currentValueOf(LocalContentColor)
        drawRect(color = currentColor)
        drawContent()
    }
}

כדי להגיב לשינויים במצב מחוץ להיקף ולעדכן באופן אוטומטי את ה-modifier, משתמשים ב-ObserverModifierNode.

לדוגמה, Modifier.scrollable משתמש בשיטה הזו כדי לראות שינויים ב-LocalDensity. דוגמה פשוטה:

class ScrollableNode :
    Modifier.Node(),
    ObserverModifierNode,
    CompositionLocalConsumerModifierNode {

    // Place holder fling behavior, we'll initialize it when the density is available.
    val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))

    override fun onAttach() {
        updateDefaultFlingBehavior()
        observeReads { currentValueOf(LocalDensity) } // monitor change in Density
    }

    override fun onObservedReadsChanged() {
        // if density changes, update the default fling behavior.
        updateDefaultFlingBehavior()
    }

    private fun updateDefaultFlingBehavior() {
        val density = currentValueOf(LocalDensity)
        defaultFlingBehavior.flingDecay = splineBasedDecay(density)
    }
}

הוספת אנימציה לשינוי

להטמעות של Modifier.Node יש גישה אל coroutineScope. כך אפשר להשתמש ב-Compose Animatable APIs. לדוגמה, קטע הקוד הזה משנה את CircleNode שמוצג למעלה, כך שהוא יופיע וייעלם שוב ושוב:

class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
    private lateinit var alpha: Animatable<Float, AnimationVector1D>

    override fun ContentDrawScope.draw() {
        drawCircle(color = color, alpha = alpha.value)
        drawContent()
    }

    override fun onAttach() {
        alpha = Animatable(1f)
        coroutineScope.launch {
            alpha.animateTo(
                0f,
                infiniteRepeatable(tween(1000), RepeatMode.Reverse)
            ) {
            }
        }
    }
}

שיתוף מצב בין משתנים באמצעות הקצאה

‫Modifier.Node משתנים יכולים להעביר הרשאות לצמתים אחרים. יש הרבה תרחישים לדוגמה שבהם אפשר להשתמש בזה, כמו חילוץ של הטמעות נפוצות בין מגדירי שינוי שונים, אבל אפשר גם להשתמש בזה כדי לשתף מצב נפוץ בין מגדירי שינוי.

לדוגמה, הטמעה בסיסית של צומת של משתנה שניתן ללחוץ עליו, שמשתף נתוני אינטראקציה:

class ClickableNode : DelegatingNode() {
    val interactionData = InteractionData()
    val focusableNode = delegate(
        FocusableNode(interactionData)
    )
    val indicationNode = delegate(
        IndicationNode(interactionData)
    )
}

ביטול ההסכמה לביטול אוטומטי של תוקף הצמתים

צומתי Modifier.Node מתבטלים באופן אוטומטי כשקריאות ModifierNodeElement התואמות מתעדכנות. במקרה של משתנים מורכבים, יכול להיות שתרצו לבטל את ההתנהגות הזו כדי לקבל שליטה מדויקת יותר על המקרים שבהם המשתנה מבטל שלבים.

זה שימושי במיוחד אם משתמשים ב-modifier בהתאמה אישית כדי לשנות גם את הפריסה וגם את הציור. ביטול ההפעלה של ביטול התוקף האוטומטי מאפשר לבטל את התוקף של השרטוט רק כשמשתנים מאפיינים שקשורים לשרטוט, כמו color. כך אפשר למנוע ביטול של הפריסה ולשפר את הביצועים של ה-modifier.

בדוגמה הבאה מוצג תרחיש היפותטי עם משתנה שינוי שיש לו את המאפיינים color,‏ size ו-onClick lambda. המשתנה הזה מבטל רק את מה שנדרש, ומדלג על כל ביטול מיותר:

class SampleInvalidatingNode(
    var color: Color,
    var size: IntSize,
    var onClick: () -> Unit
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode {
    override val shouldAutoInvalidate: Boolean
        get() = false

    private val clickableNode = delegate(
        ClickablePointerInputNode(onClick)
    )

    fun update(color: Color, size: IntSize, onClick: () -> Unit) {
        if (this.color != color) {
            this.color = color
            // Only invalidate draw when color changes
            invalidateDraw()
        }

        if (this.size != size) {
            this.size = size
            // Only invalidate layout when size changes
            invalidateMeasurement()
        }

        // If only onClick changes, we don't need to invalidate anything
        clickableNode.update(onClick)
    }

    override fun ContentDrawScope.draw() {
        drawRect(color)
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val size = constraints.constrain(size)
        val placeable = measurable.measure(constraints)
        return layout(size.width, size.height) {
            placeable.place(0, 0)
        }
    }
}