اشکال در نوشتن

با 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 بین صفر و یک ارائه دهید تا شکل آن بین شکل‌های شروع (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()
)

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

۵۰٪ فاصله بین یک مثلث گرد و یک مربع
شکل ۸. ۵۰٪ فاصله بین یک مثلث گرد و یک مربع.

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

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

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

استفاده از اصلاح‌کننده‌ی 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)
    )
}

این منجر به موارد زیر می‌شود:

شش ضلعی با متن «سلام، نوشتن» در مرکز.
شکل ۱۰. شش‌ضلعی با متن «سلام، بنویس» در مرکز.

این ممکن است تفاوت چندانی با رندر قبلی نداشته باشد، اما امکان استفاده از سایر ویژگی‌های 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) می‌کند، که در آن از سمت راست شروع می‌شود و در جهت عقربه‌های ساعت، با 270° در موقعیت ساعت 12، ادامه می‌یابد:

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

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

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

اکنون می‌توان رأس‌ها را ایجاد کرد و به تابع 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 برای استفاده برای اشکال دلخواه در نظر گرفته نشده است، بلکه به طور خاص برای ساده‌سازی ایجاد چندضلعی‌های گرد و انیمیشن‌های مورف بین آنها در نظر گرفته شده است.

منابع اضافی

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