מערכות עיצוב בהתאמה אישית בכתיבה

‫Material היא מערכת העיצוב המומלצת שלנו, ו-Jetpack Compose כוללת הטמעה של Material, אבל אתם לא חייבים להשתמש בה. ‫Material מבוסס כולו על ממשקי API ציבוריים, כך שאפשר ליצור מערכת עיצוב משלכם באותו אופן.

יש כמה גישות אפשריות:

אפשר גם להמשיך להשתמש ברכיבי Material עם מערכת עיצוב מותאמת אישית. אפשר לעשות את זה, אבל יש כמה דברים שכדאי לזכור כדי להתאים את הפעולה לגישה שבחרתם.

כדי לקבל מידע נוסף על מבנים וממשקי API ברמה נמוכה יותר שמשמשים את MaterialTheme ומערכות עיצוב בהתאמה אישית, אפשר לעיין במדריך Anatomy of a theme in Compose.

הרחבת עיצוב Material

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

הגישה הפשוטה ביותר היא להוסיף מאפיינים של תוסף:

// Use with MaterialTheme.colorScheme.snackbarAction
val ColorScheme.snackbarAction: Color
    @Composable
    get() = if (isSystemInDarkTheme()) Red300 else Red700

// Use with MaterialTheme.typography.textFieldInput
val Typography.textFieldInput: TextStyle
    get() = TextStyle(/* ... */)

// Use with MaterialTheme.shapes.card
val Shapes.card: Shape
    get() = RoundedCornerShape(size = 20.dp)

כך מתקבלת עקביות עם ממשקי API לשימוש ב-MaterialTheme. דוגמה לכך שמוגדר על ידי Compose עצמו היא surfaceColorAtElevation, שקובע את צבע המשטח שבו צריך להשתמש בהתאם לגובה.

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

נניח שרוצים להוסיף עוד שני צבעים – caution ו-onCaution, צבע צהוב שמשמש לפעולות שהן מסוכנות למחצה – וגם לשמור על צבעי Material הקיימים:

@Immutable
data class ExtendedColors(
    val caution: Color,
    val onCaution: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        caution = Color.Unspecified,
        onCaution = Color.Unspecified
    )
}

@Composable
fun ExtendedTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val extendedColors = ExtendedColors(
        caution = Color(0xFFFFCC02),
        onCaution = Color(0xFF2C2D30)
    )
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        MaterialTheme(
            /* colors = ..., typography = ..., shapes = ... */
            content = content
        )
    }
}

// Use with eg. ExtendedTheme.colors.caution
object ExtendedTheme {
    val colors: ExtendedColors
        @Composable
        get() = LocalExtendedColors.current
}

הוא דומה לממשקי API לשימוש ב-MaterialTheme. הוא גם תומך בכמה ערכות נושא, כי אפשר להטמיע תגי ExtendedThemeבאותו אופן כמו תגי MaterialTheme.

שימוש ברכיבי Material

כשמרחיבים את ערכת העיצוב של Material, הערכים הקיימים של MaterialTheme נשמרים ורכיבי Material עדיין כוללים ערכי ברירת מחדל סבירים.

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

@Composable
fun ExtendedButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = ExtendedTheme.colors.caution,
            contentColor = ExtendedTheme.colors.onCaution
            /* Other colors use values from MaterialTheme */
        ),
        onClick = onClick,
        modifier = modifier,
        content = content
    )
}

לאחר מכן, מחליפים את השימוש ב-Button ב-ExtendedButton במקומות המתאימים.

@Composable
fun ExtendedApp() {
    ExtendedTheme {
        /*...*/
        ExtendedButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

החלפת מערכות משנה של Material

במקום להרחיב את העיצוב של Material, יכול להיות שתרצו להחליף מערכת אחת או יותר – Colors, Typography או Shapes – בהטמעה בהתאמה אישית, תוך שמירה על שאר המערכות.

נניח שאתם רוצים להחליף את סוגי הצורות ואת שיטות הצורות, אבל לשמור על שיטת הצבעים:

@Immutable
data class ReplacementTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class ReplacementShapes(
    val component: Shape,
    val surface: Shape
)

val LocalReplacementTypography = staticCompositionLocalOf {
    ReplacementTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalReplacementShapes = staticCompositionLocalOf {
    ReplacementShapes(
        component = RoundedCornerShape(ZeroCornerSize),
        surface = RoundedCornerShape(ZeroCornerSize)
    )
}

@Composable
fun ReplacementTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val replacementTypography = ReplacementTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val replacementShapes = ReplacementShapes(
        component = RoundedCornerShape(percent = 50),
        surface = RoundedCornerShape(size = 40.dp)
    )
    CompositionLocalProvider(
        LocalReplacementTypography provides replacementTypography,
        LocalReplacementShapes provides replacementShapes
    ) {
        MaterialTheme(
            /* colors = ... */
            content = content
        )
    }
}

// Use with eg. ReplacementTheme.typography.body
object ReplacementTheme {
    val typography: ReplacementTypography
        @Composable
        get() = LocalReplacementTypography.current
    val shapes: ReplacementShapes
        @Composable
        get() = LocalReplacementShapes.current
}

שימוש ברכיבי Material

אם החלפתם מערכת אחת או יותר של MaterialTheme, יכול להיות ששימוש ברכיבי Material כמו שהם יוביל לערכים לא רצויים של צבע, סוג או צורה של Material.

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

@Composable
fun ReplacementButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        shape = ReplacementTheme.shapes.component,
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = ReplacementTheme.typography.body
            ) {
                content()
            }
        }
    )
}

לאחר מכן, מחליפים את השימוש ב-Button ב-ReplacementButton במקומות המתאימים.

@Composable
fun ReplacementApp() {
    ReplacementTheme {
        /*...*/
        ReplacementButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

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

יכול להיות שתרצו להחליף את Material Theming במערכת עיצוב מותאמת אישית לחלוטין. נניח ש-MaterialTheme מספק את המערכות הבאות:

  • Colors,‏ Typography ו-Shapes: מערכות של התאמת עיצוב של Google Material Design
  • TextSelectionColors: הצבעים שמשמשים לבחירת טקסט על ידי Text ו-TextField
  • Ripple ו-RippleTheme: הטמעה של חומרים Indication

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

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

בדוגמת הקוד הבאה, אנחנו יוצרים מודל של מערכת צבעים מותאמת אישית שכוללת מעברי צבע (List<Color>), כוללת מערכת סוגים, מציגים מערכת חדשה של גובה ומסננים מערכות אחרות שסופקו על ידי MaterialTheme:

@Immutable
data class CustomColors(
    val content: Color,
    val component: Color,
    val background: List<Color>
)

@Immutable
data class CustomTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class CustomElevation(
    val default: Dp,
    val pressed: Dp
)

val LocalCustomColors = staticCompositionLocalOf {
    CustomColors(
        content = Color.Unspecified,
        component = Color.Unspecified,
        background = emptyList()
    )
}
val LocalCustomTypography = staticCompositionLocalOf {
    CustomTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalCustomElevation = staticCompositionLocalOf {
    CustomElevation(
        default = Dp.Unspecified,
        pressed = Dp.Unspecified
    )
}

@Composable
fun CustomTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val customColors = CustomColors(
        content = Color(0xFFDD0D3C),
        component = Color(0xFFC20029),
        background = listOf(Color.White, Color(0xFFF8BBD0))
    )
    val customTypography = CustomTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val customElevation = CustomElevation(
        default = 4.dp,
        pressed = 8.dp
    )
    CompositionLocalProvider(
        LocalCustomColors provides customColors,
        LocalCustomTypography provides customTypography,
        LocalCustomElevation provides customElevation,
        content = content
    )
}

// Use with eg. CustomTheme.elevation.small
object CustomTheme {
    val colors: CustomColors
        @Composable
        get() = LocalCustomColors.current
    val typography: CustomTypography
        @Composable
        get() = LocalCustomTypography.current
    val elevation: CustomElevation
        @Composable
        get() = LocalCustomElevation.current
}

שימוש ברכיבי Material

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

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

מומלץ לגשת לערכים שהגדרתם מהעיצוב המותאם אישית. לחלופין, אם התבנית לא מספקת Color, TextStyle, Shape או מערכות אחרות, אפשר להגדיר אותן בהארדקוד.

@Composable
fun CustomButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = CustomTheme.colors.component,
            contentColor = CustomTheme.colors.content,
            disabledContainerColor = CustomTheme.colors.content
                .copy(alpha = 0.12f)
                .compositeOver(CustomTheme.colors.component),
            disabledContentColor = CustomTheme.colors.content
                .copy(alpha = 0.38f)

        ),
        shape = ButtonShape,
        elevation = ButtonDefaults.elevatedButtonElevation(
            defaultElevation = CustomTheme.elevation.default,
            pressedElevation = CustomTheme.elevation.pressed
            /* disabledElevation = 0.dp */
        ),
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = CustomTheme.typography.body
            ) {
                content()
            }
        }
    )
}

val ButtonShape = RoundedCornerShape(percent = 50)

אם הוספתם סוגים חדשים של מחלקות – כמו List<Color> כדי לייצג מעברי צבעים – יכול להיות שעדיף להטמיע רכיבים מאפס במקום לעטוף אותם. לדוגמה, אפשר לעיין ב-JetsnackButton מהדוגמה של Jetsnack.