צורות בניסוח אוטומטי

באמצעות Compose, אתם יכולים ליצור צורות שמורכבות ממצולעים. לדוגמה, אפשר ליצור את הצורות הבאות:

משושה כחול במרכז אזור השרטוט
איור 1. דוגמאות לצורות שונות שאפשר ליצור באמצעות הספרייה graphics-shapes

כדי ליצור פוליגון מעוגל בהתאמה אישית ב-Compose, מוסיפים את יחסי התלות של graphics-shapes לקובץ app/build.gradle:

implementation "androidx.graphics:graphics-shapes:1.0.1"

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

יצירת פוליגונים

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

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 6,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Blue)
            }
        }
        .fillMaxSize()
)

משושה כחול במרכז אזור השרטוט
איור 2. משושה כחול במרכז אזור השרטוט.

בדוגמה הזו, הספרייה יוצרת RoundedPolygon שמכיל את הגיאומטריה שמייצגת את הצורה המבוקשת. כדי לשרטט את הצורה הזו באפליקציית Compose, צריך לקבל ממנה אובייקט Path כדי להעביר את הצורה לפורמט ש-Compose יודע לשרטט.

עיגול הפינות של פוליגון

כדי לעגל את הפינות של פוליגון, משתמשים בפרמטר CornerRounding. הפונקציה הזו מקבלת שני פרמטרים, radius ו-smoothing. כל פינה מעוגלת מורכבת מ-1 עד 3 עקומות מעוקבות, שהמרכז שלהן הוא בצורת קשת מעגלית, ושתי העקומות הצדדיות ("המאגפות") עוברות מקצה הצורה לעקומה המרכזית.

רדיוס

הערך radius הוא הרדיוס של המעגל שמשמש לעיגול קודקוד.

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

משולש עם פינות מעוגלות
איור 3. משולש עם פינות מעוגלות.
רדיוס העיגול r קובע את גודל העיגול של הפינות המעוגלות
איור 4. רדיוס העיגול r קובע את גודל העיגול של הפינות המעוגלות.

החלקה

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

אם מקדם ההחלקה הוא 0 (ללא החלקה), נוצרת עקומה מעוקבת אחת שעוקבת אחרי מעגל מסביב לפינה עם רדיוס העיגול שצוין, כמו בדוגמה הקודמת.
איור 5. אם מקדם ההחלקה הוא 0 (ללא החלקה), נוצרת עקומת קוביק אחת שעוקבת אחרי מעגל מסביב לפינה עם רדיוס העיגול שצוין, כמו בדוגמה הקודמת.
אם מקדם ההחלקה שונה מאפס, נוצרות שלוש עקומות מעוקבות שמעגלות את הקודקוד: העקומה המעגלית הפנימית (כמו קודם) ושתי עקומות צדדיות שיוצרות מעבר בין העקומה הפנימית לבין קצוות המצולע.
איור 6. אם מקדם ההחלקה שונה מאפס, נוצרות שלוש עקומות מעוקבות שמעגלות את הקודקוד: העקומה המעגלית הפנימית (כמו קודם) ושתי עקומות משני הצדדים שיוצרות מעבר בין העקומה הפנימית לבין קצוות המצולע.

לדוגמה, קטע הקוד הבא ממחיש את ההבדל הקל בהגדרת ההחלקה ל-0 לעומת 1:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Black)
            }
        }
        .size(100.dp)
)

שני משולשים שחורים שמראים את ההבדל בפרמטר ההחלקה.
איור 7. שני משולשים שחורים שמראים את ההבדל בפרמטר ההחלקה.

גודל ומיקום

כברירת מחדל, הצורה נוצרת עם רדיוס של 1 מסביב למרכז (0, 0). הרדיוס הזה מייצג את המרחק בין המרכז לבין הקודקודים החיצוניים של המצולע שעליו הצורה מבוססת. חשוב לזכור שעיגול הפינות יוצר צורה קטנה יותר, כי הפינות המעוגלות יהיו קרובות יותר למרכז מאשר הקודקודים שמעוגלים. כדי לשנות את הגודל של מצולע, משנים את הערך של radius. כדי לשנות את המיקום, משנים את centerX או את centerY של הפוליגון. אפשר גם לשנות את הגודל, המיקום והסיבוב של האובייקט באמצעות פונקציות הטרנספורמציה הרגילות של DrawScope, כמו DrawScope#translate().

שינוי צורה

אובייקט Morph הוא צורה חדשה שמייצגת אנימציה בין שתי צורות מצולעות. כדי ליצור מעבר בין שתי צורות, יוצרים שתי צורות RoundedPolygons ואובייקט Morph שמקבל את שתי הצורות האלה. כדי לחשב צורה בין צורת ההתחלה לצורת הסיום, צריך לספק ערך progress בין אפס לאחד כדי לקבוע את הצורה בין צורת ההתחלה (0) לצורת הסיום (1):

Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = 0.5f).asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

בדוגמה שלמעלה, ההתקדמות היא בדיוק באמצע הדרך בין שתי הצורות (משולש מעוגל וריבוע), והתוצאה היא:

50% מהדרך בין משולש מעוגל לריבוע
איור 8. 50% מהדרך בין משולש מעוגל לריבוע.

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

val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation")
val morphProgress = infiniteAnimation.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        tween(500),
        repeatMode = RepeatMode.Reverse
    ),
    label = "morph"
)
Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = morphProgress.value)
                .asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

שינוי צורה אינסופי בין ריבוע למשולש מעוגל
איור 9. שינוי צורה אינסופי בין ריבוע למשולש מעוגל.

שימוש בפוליגון כקליפ

בדרך כלל משתמשים ב-modifier clip ב-Compose כדי לשנות את אופן העיבוד של פונקציה קומפוזבילית, וכדי לנצל את הצללים שמשורטטים מסביב לאזור החיתוך:

fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) }
class RoundedPolygonShape(
    private val polygon: RoundedPolygon,
    private var matrix: Matrix = Matrix()
) : Shape {
    private var path = Path()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        path.rewind()
        path = polygon.toPath().asComposePath()
        matrix.reset()
        val bounds = polygon.getBounds()
        val maxDimension = max(bounds.width, bounds.height)
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)

        path.transform(matrix)
        return Outline.Generic(path)
    }
}

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

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier
        .clip(clip)
        .background(MaterialTheme.colorScheme.secondary)
        .size(200.dp)
) {
    Text(
        "Hello Compose",
        color = MaterialTheme.colorScheme.onSecondary,
        modifier = Modifier.align(Alignment.Center)
    )
}

התוצאה היא:

משושה עם הטקסט "hello compose" במרכז.
איור 10. משושה עם הטקסט "Hello Compose" במרכז.

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

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
) {
    Image(
        painter = painterResource(id = R.drawable.dog),
        contentDescription = "Dog",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .graphicsLayer {
                this.shadowElevation = 6.dp.toPx()
                this.shape = clip
                this.clip = true
                this.ambientShadowColor = Color.Black
                this.spotShadowColor = Color.Black
            }
            .size(200.dp)

    )
}

כלב במשושה עם צל מסביב לקצוות
איור 11. הצורה המותאמת אישית הוחלה כקליפ.

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

אתם יכולים להשתמש בספרייה graphics-shape כדי ליצור כפתור שמשתנה בין שתי צורות בלחיצה. קודם כול, יוצרים MorphPolygonShape שמרחיב את Shape, משנים את קנה המידה שלו ומתרגמים אותו כך שיתאים. שימו לב להעברה של ההתקדמות כדי שניתן יהיה להוסיף אנימציה לצורה:

class MorphPolygonShape(
    private val morph: Morph,
    private val percentage: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)
        return Outline.Generic(path)
    }
}

כדי להשתמש בצורת המורפינג הזו, צריך ליצור שני פוליגונים, shapeA ו-shapeB. יוצרים וזוכרים את Morph. אחר כך, מחילים את השינוי על הכפתור בתור קו מתאר של קליפ, ומשתמשים ב-interactionSource בלחיצה בתור הכוח המניע מאחורי האנימציה:

val shapeA = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val shapeB = remember {
    RoundedPolygon.star(
        6,
        rounding = CornerRounding(0.1f)
    )
}
val morph = remember {
    Morph(shapeA, shapeB)
}
val interactionSource = remember {
    MutableInteractionSource()
}
val isPressed by interactionSource.collectIsPressedAsState()
val animatedProgress = animateFloatAsState(
    targetValue = if (isPressed) 1f else 0f,
    label = "progress",
    animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium)
)
Box(
    modifier = Modifier
        .size(200.dp)
        .padding(8.dp)
        .clip(MorphPolygonShape(morph, animatedProgress.value))
        .background(Color(0xFF80DEEA))
        .size(200.dp)
        .clickable(interactionSource = interactionSource, indication = null) {
        }
) {
    Text("Hello", modifier = Modifier.align(Alignment.Center))
}

התוצאה היא האנימציה הבאה כשמקישים על התיבה:

אפקט המעבר הוחל בלחיצה בין שתי צורות
איור 12. המעבר "שינוי צורה" מופעל בלחיצה בין שתי צורות.

אנימציה של צורה שמשתנה בלי הפסקה

כדי להנפיש צורה משתנה בלי סוף, משתמשים ב-rememberInfiniteTransition. למטה מוצגת דוגמה לתמונת פרופיל שמשנה את הצורה שלה (ומסתובבת) בלי סוף לאורך זמן. בגישה הזו משתמשים בשינוי קטן של MorphPolygonShape שמוצג למעלה:

class CustomRotatingMorphShape(
    private val morph: Morph,
    private val percentage: Float,
    private val rotation: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)
        matrix.rotateZ(rotation)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)

        return Outline.Generic(path)
    }
}

@Preview
@Composable
private fun RotatingScallopedProfilePic() {
    val shapeA = remember {
        RoundedPolygon(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val shapeB = remember {
        RoundedPolygon.star(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val morph = remember {
        Morph(shapeA, shapeB)
    }
    val infiniteTransition = rememberInfiniteTransition("infinite outline movement")
    val animatedProgress = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    val animatedRotation = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            tween(6000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Image(
            painter = painterResource(id = R.drawable.dog),
            contentDescription = "Dog",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .clip(
                    CustomRotatingMorphShape(
                        morph,
                        animatedProgress.value,
                        animatedRotation.value
                    )
                )
                .size(200.dp)
        )
    }
}

הקוד הזה נותן את התוצאה המגניבה הבאה:

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

פוליגונים בהתאמה אישית

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

צורת לב
איור 14. צורת לב.

אפשר לציין את הקודקודים הנפרדים של הצורה הזו באמצעות עומס היתר RoundedPolygon שמקבל מערך של נקודות צפות של קואורדינטות x ו-y.

כדי לפרק את המצולע בצורת לב, שימו לב שמערכת הקואורדינטות הקוטביות לציון נקודות הופכת את זה לקל יותר מאשר שימוש במערכת הקואורדינטות הקרטזיות (x,y), שבה 0° מתחיל בצד ימין, וממשיך בכיוון השעון, עם 270° במיקום של השעה 12:00:

צורת לב
איור 15. צורה של לב עם קואורדינטות.

עכשיו אפשר להגדיר את הצורה בצורה קלה יותר על ידי ציון הזווית (𝜭) והרדיוס מהמרכז בכל נקודה:

צורת לב
איור 16. צורה של לב עם קואורדינטות, בלי עיגול.

עכשיו אפשר ליצור את הקודקודים ולהעביר אותם לפונקציה RoundedPolygon:

val vertices = remember {
    val radius = 1f
    val radiusSides = 0.8f
    val innerRadius = .1f
    floatArrayOf(
        radialToCartesian(radiusSides, 0f.toRadians()).x,
        radialToCartesian(radiusSides, 0f.toRadians()).y,
        radialToCartesian(radius, 90f.toRadians()).x,
        radialToCartesian(radius, 90f.toRadians()).y,
        radialToCartesian(radiusSides, 180f.toRadians()).x,
        radialToCartesian(radiusSides, 180f.toRadians()).y,
        radialToCartesian(radius, 250f.toRadians()).x,
        radialToCartesian(radius, 250f.toRadians()).y,
        radialToCartesian(innerRadius, 270f.toRadians()).x,
        radialToCartesian(innerRadius, 270f.toRadians()).y,
        radialToCartesian(radius, 290f.toRadians()).x,
        radialToCartesian(radius, 290f.toRadians()).y,
    )
}

צריך לתרגם את הקודקודים לקואורדינטות קרטזיות באמצעות הפונקציה radialToCartesian:

internal fun Float.toRadians() = this * PI.toFloat() / 180f

internal val PointZero = PointF(0f, 0f)
internal fun radialToCartesian(
    radius: Float,
    angleRadians: Float,
    center: PointF = PointZero
) = directionVectorPointF(angleRadians) * radius + center

internal fun directionVectorPointF(angleRadians: Float) =
    PointF(cos(angleRadians), sin(angleRadians))

הקוד הקודם נותן את הקודקודים הגולמיים של הלב, אבל צריך לעגל פינות ספציפיות כדי לקבל את צורת הלב שנבחרה. הפינות ב-90° וב-270° לא מעוגלות, אבל הפינות האחרות כן. כדי לעגל פינות ספציפיות, משתמשים בפרמטר perVertexRounding:

val rounding = remember {
    val roundingNormal = 0.6f
    val roundingNone = 0f
    listOf(
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
    )
}

val polygon = remember(vertices, rounding) {
    RoundedPolygon(
        vertices = vertices,
        perVertexRounding = rounding
    )
}
Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygonPath = polygon.toPath().asComposePath()
            onDrawBehind {
                scale(size.width * 0.5f, size.width * 0.5f) {
                    translate(size.width * 0.5f, size.height * 0.5f) {
                        drawPath(roundedPolygonPath, color = Color(0xFFF15087))
                    }
                }
            }
        }
        .size(400.dp)
)

התוצאה היא הלב הוורוד:

צורת לב
איור 17. תוצאה בצורת לב.

אם הצורות שלמעלה לא מתאימות לתרחיש לדוגמה שלכם, אתם יכולים להשתמש במחלקה Path כדי לשרטט צורה בהתאמה אישית, או לטעון קובץ ImageVector מהדיסק. הספרייה graphics-shapes לא מיועדת לשימוש בצורות שרירותיות, אלא נועדה במיוחד לפשט את היצירה של מצולעים מעוגלים ואנימציות של שינוי צורה ביניהם.

מקורות מידע נוספים

למידע נוסף ודוגמאות, אפשר לעיין במקורות המידע הבאים: