מצבים ואנימציות בסגנונות

‫Styles API מציע גישה הצהרתית ויעילה לניהול שינויים בממשק המשתמש במהלך מצבי אינטראקציה כמו hovered, focused ו-pressed. באמצעות ה-API הזה, אפשר להפחית באופן משמעותי את קוד ה-boilerplate שנדרש בדרך כלל כשמשתמשים בשינויים.

כדי לאפשר עיצוב תגובתי, StyleState פועל כממשק יציב לקריאה בלבד שעוקב אחרי המצב הפעיל של רכיב (למשל, הסטטוס שלו: מופעל, נלחץ או מודגש). בתוך StyleScope, אפשר לגשת לזה דרך המאפיין state כדי להטמיע לוגיקה מותנית ישירות בהגדרות הסגנון.

אינטראקציה מבוססת-מצב: ריחוף, מיקוד, לחיצה, בחירה, הפעלה, החלפה

הסגנונות כוללים תמיכה מובנית באינטראקציות נפוצות:

  • לחוץ
  • ריחף
  • נבחר
  • מופעל
  • הסוג הוחלף

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

טיפול במצבי אינטראקציה באמצעות פרמטרים של סגנון

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

@Preview
@Composable
private fun OpenButton() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                background(lightPurple)
                border(2.dp, lightPurple)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

איור 1. שינוי צבע הרקע בהתאם למצבי ריחוף ומיקוד.

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

@Composable
private fun OpenButton_CombinedStates() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                // light purple
                background(lightPurple)
                pressed {
                    // When running on a device that can hover, whilst hovering and then pressing the button this would be invoked
                    background(lightOrange)
                }
            }
            pressed {
                // when running on a device without a mouse attached, this would be invoked as you wouldn't be in a hovered state only
                background(lightRed)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

איור 2. מצב של העברת העכבר מעל לחצן ולחיצה עליו בו-זמנית.

רכיבים מורכבים בהתאמה אישית עם Modifier.styleable

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

נניח שמערכת העיצוב שלכם כוללת GradientButton. אולי תרצו ליצור LoginButton שיורש מ-GradientButton, אבל משנה את הצבעים שלו במהלך אינטראקציות, כמו לחיצה.

  • כדי להפעיל עדכוני סגנון של interactionSource, צריך לכלול את interactionSource כפרמטר בתוך הרכיב הניתן להרכבה. משתמשים בפרמטר שסופק, או אם לא סופק פרמטר, מאתחלים MutableInteractionSource חדש.
  • מפעילים את styleState על ידי ציון interactionSource. צריך לוודא שהסטטוס המופעל של styleState משקף את הערך של הפרמטר המופעל שצוין.
  • מקצים את interactionSource לשינויים focusable ו-clickable. לבסוף, מחילים את styleState על הפרמטר styleable של משנה המודעה.

@Composable
private fun GradientButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: Style = Style,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
    val styleState = rememberUpdatedStyleState(interactionSource) {
        it.isEnabled = enabled
    }
    Row(
        modifier =
            modifier
                .clickable(
                    onClick = onClick,
                    enabled = enabled,
                    interactionSource = interactionSource,
                    indication = null,
                )
                .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

עכשיו אפשר להשתמש במצב interactionSource כדי לבצע שינויים בסגנון באמצעות האפשרויות pressed,‏ focused ו-hovered בתוך בלוק הסגנון:

@Preview
@Composable
fun LoginButton() {
    val loginButtonStyle = Style {
        pressed {
            background(
                Brush.linearGradient(
                    listOf(Color.Magenta, Color.Red)
                )
            )
        }
    }
    GradientButton(onClick = {
        // Login logic
    }, style = loginButtonStyle) {
        BaseText("Login")
    }
}

איור 3. שינוי מצב מותאם אישית של רכיב שאפשר להרכיב ממנו רכיבים אחרים על סמך interactionSource.

הנפשת שינויים בסגנון

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

val animatingStyle = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)

    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
    }
}

@Preview
@Composable
private fun AnimatingStyleChanges() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyle)) {

    }
}

איור 4. הנפשת שינויי צבע בלחיצה.

ה-API של animate מקבל animationSpec כדי לשנות את משך הזמן או את הצורה של עקומת האנימציה. בדוגמה הבאה מוצגת אנימציה של גודל התיבה עם מפרט spring:

val animatingStyleSpec = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)
    transformOrigin(TransformOrigin.Center)
    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
        animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) {
            scale(1.2f)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatingStyleChangesSpec() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyleSpec))
}

איור 5. הנפשה של שינויים בגודל ובצבע בלחיצה.

עיצוב מצב מותאם אישית באמצעות StyleState

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

  1. הגדרת מפתח בהתאמה אישית
  2. יצירת תוסף StyleState
  3. קישור למצב מותאם אישית

הגדרת מפתח בהתאמה אישית

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

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

יצירה של פונקציות הרחבה של StyleState

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

// Extension Function on MutableStyleState to query and set the current playState
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Paused })
}

מגדירים את styleState ברכיב הניתן להרכבה וקובעים ש-styleState.playState יהיה שווה למצב הנכנס. מעבירים את styleState לפונקציה styleable בשינוי.

בתוך פונקציית ה-lambda‏ style, אפשר להחיל עיצוב מבוסס-מצב למצבים מותאמים אישית, באמצעות פונקציות התוסף שהוגדרו קודם.

זהו קטע הקוד המלא לדוגמה הזו:

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}
val playerStateKey = StyleStateKey<PlayerState>(PlayerState.Stopped)
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(block: () -> Unit) {
    state(playerStateKey, block, { key, state -> state[key] == PlayerState.Paused })

}

@Composable
fun MediaPlayer(
    url: String,
    modifier: Modifier = Modifier,
    style: Style = Style,
    state: PlayerState = remember { PlayerState.Paused }
) {
    // Hoist style state, set playstate as a parameter,
    val styleState = remember { MutableStyleState(null) }
    // Set equal to incoming state to link the two together
    styleState.playerState = state
    Box(
        modifier = modifier.styleable(styleState, Style {
            size(100.dp)
            border(2.dp, Color.Red)

        }, style, )) {

        ///..
    }
}
@Composable
fun StyleStateKeySample() {
    // Using the extension function to change the border color to green while playing
    val style = Style {
        borderColor(Color.Gray)
        playerPlaying {
            animate {
                borderColor(Color.Green)
            }
        }
        playerPaused {
            animate {
                borderColor(Color.Blue)
            }
        }
    }
    val styleState = remember { MutableStyleState(null) }
    styleState[playerStateKey] = PlayerState.Playing

    // Using the style in a composable that sets the state -> notice if you change the state parameter, the style changes. You can link this up to an ViewModel and change the state from there too.
    MediaPlayer(url = "https://example.com/media/video",
        style = style,
        state = PlayerState.Stopped)
}