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

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

לגורמי ההתאמה יש כמה חלקים:

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

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

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

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

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

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

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

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

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

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 ברמה נמוכה יותר ליצירת מגבילים בכתיבה. הוא הוא אותו API שבו Composes מטמיעים התאמות משלהם, גבוהה מאוד ליצירת מגבילים מותאמים אישית.

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

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

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

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, מאפשרת לו לשנות את שיטת השרטוט.

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

צומת

שימוש

קישור לדוגמה

LayoutModifierNode

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

דוגמה

DrawModifierNode

Modifier.Node שממש מתאים לשטח של הפריסה.

דוגמה

CompositionLocalConsumerModifierNode

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

דוגמה

SemanticsModifierNode

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

דוגמה

PointerInputModifierNode

Modifier.Node שמקבל PointerInputChanges.

דוגמה

ParentDataModifierNode

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

דוגמה

LayoutAwareModifierNode

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

דוגמה

GlobalPositionAwareModifierNode

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

דוגמה

ObserverModifierNode

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

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

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

מפעל התאמות

זוהי פלטפורמת ה-API הציבורית של הצירוף שלך. רוב היישומים פשוט יוצרים את רכיב הצירוף ומוסיפים אותו לשרשרת הצירוף:

// 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 לא מזהים באופן אוטומטי שינויים במצב הכתיבה אובייקטים, כמו 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 val alpha = Animatable(1f)

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

    override fun onAttach() {
        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 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)
        }
    }
}