באמצעות Compose, אתם יכולים ליצור צורות שמורכבות ממצולעים. לדוגמה, אפשר ליצור את הצורות הבאות:
כדי ליצור פוליגון מעוגל בהתאמה אישית ב-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() )
בדוגמה הזו, הספרייה יוצרת RoundedPolygon שמכיל את הגיאומטריה שמייצגת את הצורה המבוקשת. כדי לשרטט את הצורה הזו באפליקציית Compose, צריך לקבל ממנה אובייקט Path כדי להעביר את הצורה לפורמט ש-Compose יודע לשרטט.
עיגול הפינות של פוליגון
כדי לעגל את הפינות של פוליגון, משתמשים בפרמטר CornerRounding. הפונקציה הזו מקבלת שני פרמטרים, radius ו-smoothing. כל פינה מעוגלת מורכבת מ-1 עד 3 עקומות מעוקבות, שהמרכז שלהן הוא בצורת קשת מעגלית, ושתי העקומות הצדדיות ("המאגפות") עוברות מקצה הצורה לעקומה המרכזית.
רדיוס
הערך radius הוא הרדיוס של המעגל שמשמש לעיגול קודקוד.
לדוגמה, המשולש הבא עם הפינות המעוגלות נוצר כך:
r קובע את גודל העיגול של הפינות המעוגלות.החלקה
החלקה היא גורם שקובע כמה זמן לוקח להגיע מהחלק המעוגל של הפינה לקצה. אם מקדם ההחלקה הוא 0 (ללא החלקה, ערך ברירת המחדל של CornerRounding), הפינות יהיו מעוגלות בצורה מעגלית לחלוטין. אם מקדם ההחלקה שונה מאפס (עד למקסימום של 1.0), הפינה מעוגלת על ידי שלוש עקומות נפרדות.
לדוגמה, קטע הקוד הבא ממחיש את ההבדל הקל בהגדרת ההחלקה ל-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) )
גודל ומיקום
כברירת מחדל, הצורה נוצרת עם רדיוס של 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() )
בדוגמה שלמעלה, ההתקדמות היא בדיוק באמצע הדרך בין שתי הצורות (משולש מעוגל וריבוע), והתוצאה היא:
ברוב המקרים, המורפינג נעשה כחלק מאנימציה, ולא רק כרינדור סטטי. כדי ליצור אנימציה בין שני המצבים האלה, אתם יכולים להשתמש ב-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() )
שימוש בפוליגון כקליפ
בדרך כלל משתמשים ב-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) ) }
התוצאה היא:
יכול להיות שזה לא נראה שונה מאוד ממה שעובד לפני כן, אבל זה מאפשר להשתמש בתכונות אחרות ב-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) ) }
שינוי צורה של כפתור בלחיצה
אתם יכולים להשתמש בספרייה 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)) }
התוצאה היא האנימציה הבאה כשמקישים על התיבה:
אנימציה של צורה שמשתנה בלי הפסקה
כדי להנפיש צורה משתנה בלי סוף, משתמשים ב-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) ) } }
הקוד הזה נותן את התוצאה המגניבה הבאה:
פוליגונים בהתאמה אישית
אם הצורות שנוצרו ממצולעים רגילים לא מתאימות לתרחיש לדוגמה שלכם, אתם יכולים ליצור צורה מותאמת אישית יותר עם רשימה של קודקודים. לדוגמה, אם רוצים ליצור צורה של לב, אפשר לעשות את זה ככה:
אפשר לציין את הקודקודים הנפרדים של הצורה הזו באמצעות עומס היתר RoundedPolygon שמקבל מערך של נקודות צפות של קואורדינטות x ו-y.
כדי לפרק את המצולע בצורת לב, שימו לב שמערכת הקואורדינטות הקוטביות לציון נקודות הופכת את זה לקל יותר מאשר שימוש במערכת הקואורדינטות הקרטזיות (x,y), שבה 0° מתחיל בצד ימין, וממשיך בכיוון השעון, עם 270° במיקום של השעה 12:00:
עכשיו אפשר להגדיר את הצורה בצורה קלה יותר על ידי ציון הזווית (𝜭) והרדיוס מהמרכז בכל נקודה:
עכשיו אפשר ליצור את הקודקודים ולהעביר אותם לפונקציה 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) )
התוצאה היא הלב הוורוד:
אם הצורות שלמעלה לא מתאימות לתרחיש לדוגמה שלכם, אתם יכולים להשתמש במחלקה Path כדי לשרטט צורה בהתאמה אישית, או לטעון קובץ ImageVector מהדיסק. הספרייה graphics-shapes לא מיועדת לשימוש בצורות שרירותיות, אלא נועדה במיוחד לפשט את היצירה של מצולעים מעוגלים ואנימציות של שינוי צורה ביניהם.
מקורות מידע נוספים
למידע נוסף ודוגמאות, אפשר לעיין במקורות המידע הבאים: