الأشكال في Compose

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

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

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

النطاق الجغرافي

radius هو نصف قطر الدائرة المستخدَمة لتقريب رأس.

على سبيل المثال، يتم إنشاء المثلث التالي ذو الزاوية المستديرة على النحو التالي:

مثلث بزوايا مستديرة
الشكل 3. مثلث بزوايا مستديرة
يحدد نصف القطر المستدير r حجم التقريب الدائري للزوايا الدائرية
الشكل 4. يحدِّد نصف قطر التقريب r حجم التقريب الدائري للزوايا المستديرة.

تسوية

التمويه هو عامل يحدّد المدة التي يستغرقها الانتقال من الجزء الدائري من الزاوية إلى الحافة. يؤدي عامل التمويه الذي يُحدَّد على القيمة 0 (غير مموَّه، وهي القيمة التلقائية لسمة CornerRounding) إلى تمويه دائري تام للزوايا. يؤدي عامل التجانس غير الصفري (ما يصل إلى 1.0 كحد أقصى) إلى تقريب الزاوية بثلاثة منحنيات منفصلة.

ينتج عن عامل التنعيم 0 (غير منسَّق) منحنى ثلاثي واحد يلي
دائرة حول الزاوية بنِصف قطر التقريب المحدّد، كما هو موضّح في المثال
السابق.
الشكل 5. ينتج عن عامل التنعيم 0 (غير منسَّق) منحنى ثلاثي واحد يلي دائرة حول الزاوية مع نصف قطر التقريب المحدّد، كما هو موضّح في المثال السابق.
ينتج عن عامل التنعيم غير الصفري ثلاثة منحنيات ثلاثية لتقريب
النقطة: المنحنى الدائري الداخلي (كما في السابق) بالإضافة إلى منحنين جانبيين
ينتقلان بين المنحنى الداخلي وحوافي المضلع.
الشكل 6. ينتج عن عامل التنعيم غير الصفري ثلاثة منحنيات ثلاثية لتقريب النقطة: المنحنى الدائري الداخلي (كما في السابق) بالإضافة إلى منحنين جانبيين ينتقلان بين المنحنى الداخلي وحوافي المضلع.

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

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

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)
    )
}

ويؤدي ذلك إلى ما يلي:

سداسي الأضلاع يتضمّن النص "مرحبًا، ميزة إنشاء الرسائل" في المنتصف
الشكل 10. سداسي الأضلاع يتضمّن النص "مرحبًا ميزة "إنشاء"" في المنتصف

قد لا يبدو هذا الإجراء مختلفًا كثيرًا عن الإجراء السابق، ولكنه يتيح الاستفادة من ميزات أخرى في ميزة "الإنشاء". على سبيل المثال، يمكن استخدام هذه التقنية لقص صورة وتطبيق ظل حول المنطقة المقتطعة:

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

مصادر إضافية

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