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

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

לשינויים יש כמה חלקים:

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

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

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

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

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

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.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 שמקבל קריאות חזרה מסוג onMeasured ו-onPlaced.

דוגמה

GlobalPositionAwareModifierNode

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

דוגמה

ObserverModifierNode

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

דוגמה

DelegatingNode

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

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

דוגמה

TraversableNode

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

דוגמה

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

ModifierNodeElement

ModifierNodeElement היא כיתת immutable שמכילה את הנתונים ליצירה או לעדכון של המשנה בהתאמה אישית:

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

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

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

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