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

‫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 משקף את הערך של הפרמטר enabled שצוין.
  • מקצים את 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 = remember(interactionSource) { MutableStyleState(interactionSource) }
    styleState.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(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { 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(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { 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)
}