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

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

למקשי הצירופים יש כמה חלקים:

  • מפעל לשינוי מאפיינים
    • זו פונקציית הרחבה ב-Modifier, שמספקת API אידיומטי לשינוי ומאפשרת לשרשר שינויים. ה-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.

שימוש במפעל של משנים שאפשר להוסיף להם קומפוזיציה כדי ליצור משנה מאפשר גם להשתמש בממשקי API של Compose ברמה גבוהה יותר, כמו animate*AsState וממשקי API אחרים של Compose לאנימציה שמגובה על ידי מצב. לדוגמה, בקטע הקוד הבא מוצג משנה שמפעיל אנימציה של שינוי אלפא כשמפעילים או משביתים אותו:

@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
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 מותאם אישית, כי משנים מקומיים של קומפוזיציה ייפתרו בצורה נכונה באתר השימוש וניתן להעלות אותם בבטחה.

השינויים בפונקציות קומפוזביליות אף פעם לא נפסחים

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

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

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

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 שיוצרת ומעדכנת מופעים של צומת modifier.
  • מפעל אופציונלי של משנים, כפי שפורט קודם.

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

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

Modifier.Node

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

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

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

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

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

צומת

שימוש

קישור לדוגמה

LayoutModifierNode

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

דוגמה

DrawModifierNode

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

דוגמה

CompositionLocalConsumerModifierNode

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

דוגמה

SemanticsModifierNode

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

דוגמה

PointerInputModifierNode

Modifier.Node שמקבל PointerInputChanges.

דוגמה

ParentDataModifierNode

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

דוגמה

LayoutAwareModifierNode

Modifier.Node שמקבל קריאות חוזרות של onMeasured ו-onPlaced.

דוגמה

GlobalPositionAwareModifierNode

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

דוגמה

ObserverModifierNode

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

דוגמה

DelegatingNode

Modifier.Node שיכול להעביר עבודה למופעים אחרים של Modifier.Node.

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

דוגמה

TraversableNode

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

דוגמה

הצמתים נפסלים אוטומטית כשקוראים לעדכון ברכיב המתאים שלהם. בדוגמה שלנו יש 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 בפעם הראשונה. בדרך כלל, הפעולה הזו כוללת בנייה של הצומת והגדרת הפרמטרים שהועברו למפעל של שינוי המאפיינים.
  2. update: הפונקציה הזו מופעלת בכל פעם שהמשנה הזה מסופק באותו מקום שבו הצומת הזה כבר קיים, אבל מאפיין השתנה. הוא נקבע על ידי המתודה equals של המחלקה. צומת השינוי שנוצר קודם נשלח כפרמטר לקריאה update. בשלב הזה, צריך לעדכן את המאפיינים של הצמתים כך שיתאימו לפרמטרים המעודכנים. היכולת לעשות שימוש חוזר בצמתים בצורה הזו היא המפתח לשיפור הביצועים ש-Modifier.Node מביא איתו. לכן, צריך לעדכן את הצומת הקיים ולא ליצור צומת חדש במתודה update. בדוגמה של העיגול, הצבע של הצומת מתעדכן.

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

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

פקטורי (factory) של עיבוד

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

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

דוגמה מלאה

שלושת החלקים האלה משולבים יחד כדי ליצור את שינוי ההתאמה האישית לצורך שרטוט עיגול באמצעות ממשקי ה-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, יכולות להיות כמה סיטואציות נפוצות:

אפס פרמטרים

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

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.Node על פני משנים שנוצרו רק באמצעות מפעל קומפוזבילי הוא שהם יכולים לקרוא את הערך של המקומי של הקומפוזיציה מהמקום שבו נעשה שימוש במשנה בעץ ממשק המשתמש, ולא מהמקום שבו המשנה מוקצה, באמצעות currentValueOf.

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

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

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

כדי להגיב לשינויים במצב מחוץ להיקף ולעדכן אוטומטית את המשנה, משתמשים ב-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 מתבצע. אם יש לכם משנים מורכבים, יכול להיות שתרצו להשבית את ההתנהגות הזו כדי לקבל שליטה מדויקת יותר על המקרים שבהם המשנה מבטל שלבים.

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

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

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)
        }
    }
}