الأشكال في Compose

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

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

لإنشاء مضلع مستدير مخصص في 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 منه للحصول على الشكل إلى نموذج يمكنك إنشاء يعرف كيفية الرسم.

تقريب زوايا مضلّع

لتقريب زوايا المضلّع، استخدِم المَعلمة CornerRounding. هذا النمط يأخذ معاملين، radius وsmoothing. تكون كل زاوية مستديرة من 1 إلى 3 منحنيات مكعبة، يحتوي مركزها على شكل قوس دائري، بينما يحتوي الاثنان منحنيات الجانب ("المحيطة") للانتقال من حافة الشكل إلى منحنى الوسط.

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

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% من المسافة بين مثلث مستدير ومربع

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

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

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

إضافة حركة إلى عملية تغيير الشكل بلا حدود

لرسم شكل متحرك إلى ما لا نهاية، استخدم 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.

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

مصادر إضافية

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