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

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

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

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

implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"

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

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

קטע הקוד הבא יוצר צורה בסיסית של פוליגון עם 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

אובייקט 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 כדי לשנות את אופן הרינדור של רכיב 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))
}

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

Morph שהוחל כקליק בין שתי צורות
איור 12. Morph שהוחל כקליק בין שתי צורות.

אנימציה של צורות שמשתנות ללא הגבלה

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

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

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