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

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

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

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

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

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

יכול להיות שהשינוי לא ייראה משמעותי, אבל הוא מאפשר להשתמש בתכונות אחרות ב-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 on press ככוח המניע מאחורי האנימציה:

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

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

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