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

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

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

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

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

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

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

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