الأشكال في 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. تتكون كل زاوية مستديرة من 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% من المسافة بين مثلث دائري ومربّع.

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

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

مضلّع سداسي يحتوي على النص "helloCompose" في المنتصف.
الشكل 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. شكل قلب مع الإحداثيات

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

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

مصادر إضافية

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