الأشكال في Compose

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

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

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

قد لا يبدو هذا مختلفًا كثيرًا عن العرض السابق، ولكنّه يتيح الاستفادة من ميزات أخرى في 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.

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

مراجع إضافية

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