الأشكال في Compose

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

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

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

implementation "androidx.graphics:graphics-shapes:1.0.1"

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

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

ينشئ مقتطف الرمز البرمجي التالي شكل مضلّع أساسي بست نقاط في وسط مساحة الرسم:

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

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

شكل سداسي الأضلاع يتضمّن النص `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:

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

مراجع إضافية

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