الأشكال في Compose

باستخدام الميزة Compose، يمكنك إنشاء أشكال مكوّنة من مضلّعات. على سبيل المثال، يمكنك عمل الأنواع التالية من الأشكال:

سداسي سداسي أزرق في وسط منطقة الرسم
الشكل 1. أمثلة على الأشكال المختلفة التي يمكنك إنشاؤها باستخدام مكتبة أشكال الرسومات

لإنشاء مضلّع مستدير مخصّص في Compose، أضِف التبعية graphics-shapes إلى app/build.gradle:

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

تتيح لك هذه المكتبة إنشاء أشكال تم إنشاؤها من المضلّعات. وعلى الرغم من أنّ الأشكال المضلّعة لا تحتوي إلا على حواف مستقيمة وزوايا حادة، تتيح لك هذه الأشكال إنشاء زوايا مستديرة اختيارية. إنه يجعل من السهل التحوّل بين شكلين مختلفين. يُعد التحوّل صعبًا بين الأشكال العشوائية، ويميل إلى أن يكون مشكلة في وقت التصميم. لكن هذه المكتبة تجعل الأمر بسيطًا من خلال التحويل بين هذه الأشكال ذات الهياكل المضلّعة المتشابهة.

إنشاء مضلّعات

يؤدي المقتطف التالي إلى إنشاء شكل مضلّع أساسي يحتوي على 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 هو شكل جديد يمثّل صورة متحركة بين شكلين مضلّعين. للتحول بين شكلين، أنشئ 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% من المسافة بين مثلث مستدير ومربّع.

في معظم السيناريوهات، يتم التحوّل كجزء من الرسوم المتحركة، وليس مجرد عرض ثابت. لإضافة تأثير متحرك بين هاتين الميزتين، يمكنك استخدام واجهات برمجة التطبيقات للصور المتحركة العادية في 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)
    )
}

وينتج عن ذلك ما يلي:

مضلّع يحتوي على النص "مرحبًا Compose" في المنتصف.
الشكل 10. مضلّع سداسي يتضمّن النص "مرحبًا 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 الذي يأخذ صفيفًا عائمًا من إحداثي س، ص.

لتقسيم مضلّع القلب، يُرجى العِلم أنّ نظام الإحداثيات القطبية لتحديد النقاط يسهّل هذا الأمر مقارنةً باستخدام نظام الإحداثيات الديكارتية (س،ص)، حيث يبدأ على الجانب الأيمن ويتابع في اتجاه عقارب الساعة مع ضبط 270° في موضع الساعة 12:

يدان تشكلان شكل القلب
الشكل 15. شكل قلب مع إحداثيات.

يمكن الآن تحديد الشكل بطريقة أسهل من خلال تحديد الزاوية (O) ونصف القطر من المركز عند كل نقطة:

يدان تشكلان شكل القلب
الشكل 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 غير مخصّصة للاستخدام في الأشكال العشوائية، ولكنها تهدف تحديدًا إلى تبسيط عملية إنشاء مضلّعات مستديرة وتحويلها إلى صور متحركة بينها.

مراجع إضافية

لمزيد من المعلومات والأمثلة، يُرجى الاطّلاع على المراجع التالية: