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

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

משושה כחול במרכז אזור הציור
איור 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#translate().DrawScope

שינוי צורה

אובייקט 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% מהדרך בין משולש מעוגל לריבוע.

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

שימוש במצולע כקליפ

בדרך כלל משתמשים במגדיר 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' (שלום, כתיבה בעזרת AI) במרכז.

יכול להיות שהשינוי לא ייראה משמעותי, אבל הוא מאפשר להשתמש בתכונות אחרות ב-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, משנה את הגודל שלו ומתרגם אותו כך שיתאים בצורה נכונה. שימו לב להעברה של progress כדי שאפשר יהיה להנפיש את הצורה:

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), שבה מתחיל בצד ימין וממשיך עם כיוון השעון, ו-270° נמצא במיקום השעה 12:

צורת לב
איור 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 לא מיועדת לשימוש בצורות שרירותיות, אלא נועדה במיוחד לפשט את יצירת המצולעים המעוגלים ואת אנימציות המורפינג ביניהם.

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

מידע נוסף ודוגמאות זמינים במקורות המידע הבאים: