شکل‌ها در Compose

با Compose می‌توانید شکل‌هایی بسازید که از چندضلعی‌ها تشکیل شده‌اند. برای مثال، می‌توانید انواع شکل‌های زیر را بسازید:

شش‌ضلعی آبی در مرکز ناحیه طراحی
شکل ۱. نمونه‌هایی از شکل‌های مختلفی که می‌توانید با کتابخانه 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()
)

شش‌ضلعی آبی در مرکز ناحیه طراحی
شکل ۲. شش‌ضلعی آبی در مرکز ناحیه طراحی.

در این مثال، کتابخانه یک RoundedPolygon ایجاد می‌کند که هندسه نشان‌دهنده شکل درخواستی را نگه می‌دارد. برای ترسیم آن شکل در برنامه Compose، باید شیء Path را از آن دریافت کنید تا شکل را به فرمی تبدیل کنید که Compose نحوه ترسیم آن را می‌داند.

گرد کردن گوشه‌های چندضلعی

برای گرد کردن گوشه‌های چندضلعی، از پارامتر CornerRounding استفاده کنید. این دو پارامتر radius و smoothing را می‌گیرد. هر گوشه گرد از ۱ تا ۳ منحنی مکعبی تشکیل شده است که مرکز آن شکلی شبیه به کمان دایره‌ای دارد و دو منحنی کناری («جناحی») از لبه شکل به منحنی مرکزی منتقل می‌شوند.

شعاع

radius شعاع دایره‌ای است که برای گرد کردن رأس استفاده می‌شود.

برای مثال، مثلث گوشه گرد زیر به‌صورت زیر ساخته می‌شود:

مثلث با گوشه‌های گرد
شکل ۳. مثلث با گوشه‌های گرد.
شعاع گرد کردن r اندازه گرد کردن دایره‌ای گوشه‌های گرد را تعیین می‌کند
شکل ۴. شعاع گرد کردن r اندازه گرد کردن دایره‌ای گوشه‌های گرد را تعیین می‌کند.

صاف کردن

هموارسازی عاملی است که تعیین می‌کند چقدر طول می‌کشد تا از بخش گرد دایره‌ای گوشه به لبه برسیم. ضریب هموارسازی ۰ (هموارنشده، مقدار پیش‌فرض برای CornerRounding) منجر به گرد شدن کاملاً دایره‌ای گوشه می‌شود. عامل هموارسازی غیرصفر (تا حداکثر ۱٫۰) منجر به گرد شدن گوشه با سه منحنی مجزا می‌شود.

ضریب هموارسازی ۰ (هموارنشده) یک منحنی مکعبی تولید می‌کند که
دایره‌ای را در اطراف گوشه با شعاع گرد کردن مشخص‌شده دنبال می‌کند، مانند
مثال قبلی
شکل ۵. عامل هموارسازی ۰ (هموارنشده) یک منحنی مکعبی تکی ایجاد می‌کند که دایره‌ای را در اطراف گوشه با شعاع گرد کردن مشخص‌شده دنبال می‌کند، مانند مثال قبلی.
یک عامل هموارسازی غیرصفر سه منحنی مکعبی برای گرد کردن رأس تولید می‌کند: منحنی دایره‌ای داخلی (مانند قبل) به‌علاوه دو منحنی جانبی که بین منحنی داخلی و لبه‌های چندضلعی انتقال می‌یابند.
شکل ۶. یک عامل هموارسازی غیرصفر سه منحنی مکعبی برای گرد کردن رأس تولید می‌کند: منحنی دایره‌ای داخلی (مانند قبل) به‌علاوه دو منحنی جانبی که بین منحنی داخلی و لبه‌های چندضلعی انتقال می‌یابند.

برای مثال، تکه‌کد زیر تفاوت جزئی در تنظیم هموارسازی روی ۰ درمقابل ۱ را نشان می‌دهد:

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

دو مثلث سیاه که تفاوت در پارامتر هموارسازی را نشان می‌دهد.
شکل ۷. دو مثلث سیاه که تفاوت در پارامتر هموارسازی را نشان می‌دهد.

اندازه و موقعیت

به‌طور پیش‌فرض، شکلی با شعاع 1 در اطراف مرکز (0, 0) ایجاد می‌شود. این شعاع نشان‌دهنده فاصله بین مرکز و رئوس خارجی چندضلعی است که شکل براساس آن ساخته شده است. توجه داشته باشید که گرد کردن گوشه‌ها منجر به شکل کوچک‌تری می‌شود زیرا گوشه‌های گردشده نسبت به رأس‌هایی که گرد می‌شوند به مرکز نزدیک‌تر خواهند بود. برای تعیین اندازه چندضلعی، مقدار radius را تنظیم کنید. برای تنظیم موقعیت، centerX یا centerY چندضلعی را تغییر دهید. یا اینکه شیء را بااستفاده از توابع تبدیل استاندارد DrawScope مثل DrawScope#translate() تبدیل کنید تا اندازه، موقعیت، و چرخش آن تغییر کند.

شکل‌های دگرگون‌شونده

شیء Morph شکل جدیدی است که پویانمایی بین دو شکل چندضلعی را نشان می‌دهد. برای دگردیسی بین دو شکل، دو RoundedPolygons و یک شیء Morph ایجاد کنید که این دو شکل را می‌گیرد. برای محاسبه شکل بین شکل‌های شروع و پایان، مقدار progress بین صفر و یک را ارائه دهید تا شکل آن بین شکل‌های شروع (۰) و پایان (۱) تعیین شود:

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

در مثال بالا، پیشرفت دقیقاً در نیمه راه بین دو شکل است (مثلث گرد و مربع)، که نتیجه زیر را تولید می‌کند:

۵۰٪ از مسیر بین مثلث گرد و مربع
شکل ۸. ۵۰٪ از مسیر بین مثلث گرد و مربع.

در بیشتر سناریوها، دگردیسی به‌عنوان بخشی از پویانمایی انجام می‌شود و نه فقط به‌عنوان یک پرداخت ایستا. برای پویانمایی بین این دو، می‌توانید از Animation APIs in 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()
)

به‌طور بی‌نهایت بین مربع و مثلث گردشده تغییر شکل می‌دهد
شکل ۹. به‌طور نامحدود بین مربع و مثلث گرد تغییر شکل می‌دهد.

استفاده از چندضلعی به‌عنوان کلیپ

معمولاً از اصلاح‌کننده clip در Compose برای تغییر نحوه رندر شدن یک composable و بهره‌برداری از سایه‌هایی که در اطراف ناحیه برش ترسیم می‌شوند استفاده می‌شود:

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» در مرکز.
شکل ۱۰. شش‌ضلعی با نوشتار «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)

    )
}

سگ در شش‌ضلعی با سایه اعمال‌شده در اطراف لبه‌ها
شکل ۱۱. شکل سفارشی به‌عنوان کلیپ اعمال شد.

دگرگونی دکمه هنگام کلیک

می‌توانید از کتابخانه 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))
}

این کار منجر به پویانمایی زیر هنگام تک‌ضرب زدن روی چارگوش می‌شود:

دگرگونی به‌عنوان کلیک بین دو شکل اعمال شد
شکل ۱۲. «دگردیسی» به‌عنوان کلیک بین دو شکل اعمال شد.

پویانمایی دگردیسی شکل به‌صورت بی‌نهایت

برای متحرک‌سازی بی‌پایان شکل دگردیسی، از 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)
        )
    }
}

این کد نتیجه جالب زیر را ارائه می‌دهد:

شکل قلب
شکل ۱۳. عکس نمایه که با شکل کنگره‌ای چرخان برش داده شده است.

چندضلعی‌های سفارشی

اگر شکل‌های ایجادشده از چندضلعی‌های منتظم مورد استفاده شما را پوشش نمی‌دهند، می‌توانید شکل سفارشی‌تری با فهرستی از رأس‌ها ایجاد کنید. برای مثال، شاید بخواهید شکلی شبیه قلب ایجاد کنید:

شکل قلب
شکل ۱۴. شکل قلب.

می‌توانید رأس‌های تکی این شکل را بااستفاده از سرریز RoundedPolygon که آرایه اعشاری از مختصات x و y را می‌گیرد مشخص کنید.

برای تجزیه چندضلعی قلب، توجه داشته باشید که سیستم مختصات قطبی برای مشخص کردن نقاط، این کار را آسان‌تر از استفاده از سیستم مختصات دکارتی (x,y) می‌کند، جایی که 0° از سمت راست شروع می‌شود و در جهت عقربه‌های ساعت ادامه می‌یابد، با 270° در موقعیت ساعت ۱۲:

شکل قلب
شکل ۱۵. شکل قلب با مختصات.

اکنون می‌توان شکل را با مشخص کردن زاویه (𝜭) و شعاع از مرکز در هر نقطه به‌شکل آسان‌تری تعریف کرد:

شکل قلب
شکل ۱۶. شکل قلب با مختصات، بدون گرد کردن.

اکنون می‌توان رأس‌ها را ایجاد کرد و به تابع 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)
)

این کار به قلب صورتی منجر می‌شود:

شکل قلب
شکل ۱۷. نتیجه قلبی‌شکل.

اگر شکل‌های قبلی مورد استفاده شما را پوشش نمی‌دهند، از کلاس Path برای رسم شکل سفارشی یا بار کردن فایل ImageVector از دیسک استفاده کنید. کتابخانه graphics-shapes برای استفاده در شکل‌های دلخواه درنظر گرفته نشده است، بلکه به‌طور خاص برای ساده‌سازی ایجاد چندضلعی‌های گرد و پویانمایی‌های دگردیسی بین آن‌ها طراحی شده است.

منابع بیشتر

برای اطلاعات بیشتر و نمونه‌ها، منابع زیر را ببینید: