Shape เป็นหนึ่งในความสามารถบน Jetpack Compose ที่ช่วยให้นักพัฒนาสามารถสร้าง UI ในรูปทรงต่าง ๆ ได้หลากหลายตามความต้องการ ซึ่งจะช่วยให้นักพัฒนาสร้าง UI ที่มีรูปร่างซับซ้อนได้ง่าย (เมื่อเทียบกับ Android Views)

บทความในชุดเดียวกัน

โดยนักพัฒนาจะเห็น Shape อยู่เป็นประจำเมื่อใช้ Modifier สำหรับ Composable Function ใด ๆ ก็ตาม

Box(
    modifier = Modifier
        .size(100.dp)
        .background(
            color = Red400,
            shape = CircleShape
        )
) { /* .. */ }

จากโค้ดตัวอย่างนี้จะได้ Box ที่มีขนาด 100 dp และมีพื้นหลัง (Background) เป็นวงกลมสีแดง

ในกรณีที่ไม่ได้กำหนด Shape ใด ๆ จะได้พื้นหลังเป็นรูปทรงสี่เหลี่ยมเสมอ

และนอกเหนือจากการกำหนดพื้นหลังแล้ว ก็จะเห็น Shape ได้จากการกำหนดค่าอย่างอื่นใน Modifier ด้วยเช่นกัน

ยกตัวอย่างเช่น นักพัฒนาต้องการเพิ่มเงาให้กับโค้ดก่อนหน้านี้ ก็ให้เพิ่มคำสั่ง Shadow เข้าไปแบบนี้

Box(
    modifier = Modifier
        .size(100.dp)
        .shadow(
            elevation = 4.dp,
            shape = CircleShape
        )
        .background(
            color = Red400,
            shape = CircleShape
        )
) { /* .. */ }

ก็จะได้เงาที่มี Shape เป็นแบบเดียวกับที่กำหนดไว้ใน Background ในทันที

หรือการ Crop รูปภาพสี่เหลี่ยมเพื่อแสดงเป็นรูปวงกลม ก็สามารถใช้คำสั่ง Clip ใน Modifier ได้เลย

Image(
    modifier = Modifier
        .size(48.dp)
        .clip(CircleShape),
    painter = painterResource(R.drawable.ic_launcher_background),
)

จากตัวอย่างทั้งหมดนี้จะเห็นว่า Shape เป็นหนึ่งในความสามารถสำคัญของ Jetpack Compose ที่จะช่วยให้นักพัฒนาสร้าง UI ในรูปแบบต่าง ๆ ได้ง่ายขึ้น เมื่อเทียบกับการสร้าง UI ด้วย Android Views ที่ต้องเขียนโค้ดจำนวนเยอะกว่าเพื่อให้ได้ผลลัพธ์ที่คล้ายกัน

Jetpack Compose มี Shape แบบไหนให้ใช้งานบ้าง

โดยปกตินักพัฒนาสามารถเรียกใช้งาน Shape พื้นฐานบางส่วนที่มีให้ใน Jetpack Compose ได้เลย ซึ่งจะมีดังนี้

  • CircleShape
  • RectangleShape
  • RoundedCornerShape
  • CutCornerShape

ในการใช้งาน Shape เหล่านี้ก็จะมี Parameter ที่จะต้องกำหนดแตกต่างกันออกไปตามแต่ละ Shape และสำหรับ Shape บางตัว เช่น RoundedCornerShape ที่สามารถกำหนดค่าได้หลายรูปแบบ

// DP
RoundedCornerShape(
    size = 16.dp
)

RoundedCornerShape(
    topStart = 8.dp,
    topEnd = 8.dp,
    bottomEnd = 0.dp,
    bottomStart = 0.dp,
)

// Percent
RoundedCornerShape(
    percent = 50
)

// CornerSize
RoundedCornerShape(
    corner = CornerSize(16.dp)
)

หรือบางตัวอย่าง CircleShape และ RectangleShape ที่ไม่มี Parameter ก็จะเป็น Object ที่สามารถนำไปใช้งานได้ทันทีเลย

และนอกจากนี้ นักพัฒนาสามารถสร้างรูปทรงตามที่ต้องการโดยสร้าง Shape ขึ้นมาเองก็ได้ ดังนั้นถ้ามีรูปทรงอื่น ๆ นอกเหนือจากรูปทรงพื้นฐานที่ Jetpack Compose มีให้ นักพัฒนาก็สามารถสร้างขึ้นมาด้วยตัวเองได้เลย

การสร้าง Shape ขึ้นมาเอง

ในการสร้าง Shape จะมีอยู่ 2 ทางเลือกด้วยกัน คือ สร้างจาก Shape โดยตรง และสร้างจาก GenericShape

// Shape
class CouponShape : Shape {
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
        val path: Path = this
        /* ... */
    }
}

// GenericShape
val couponShape = GenericShape { size: Size, layoutDirection: LayoutDirection ->  
    val path: Path = this
    /* ... */
}

ทั้งคู่จะประกาศโค้ดแตกต่างกัน แต่ให้ผลลัพธ์เหมือนกัน ดังนั้นจะใช้แบบไหนก็ได้

โดยดั้งเดิมนั้น Parameter สำหรับ Shape จะมีอยู่ทั้งหมด 3 ตัวด้วยกันคือ

  • Size ที่จะบอกขนาดพื้นที่ของ Composable Function ที่นำ Shape ไปใช้งาน
  • LayoutDirection สำหรับการสร้าง Shape ให้รองรับแสดงผลแบบ Right-to-left (RTL) และ Left-to-right (LTR)
  • Density ที่จะบอก Screen Density และ Font Scale เพื่อใช้ในการสร้าง Shape ที่จำเป็นต้องคำนวณค่าจาก Dp หรือ Sp
การสร้าง Shape ด้วย GenericShape จะไม่มีค่า Density ให้ใช้งาน

นักพัฒนาจะต้องเขียนโค้ดเพื่อคำนวณและสร้างเป็น Shape ที่ต้องการให้ได้ผลลัพธ์ออกมาเป็น Outline ไม่ว่าจะเป็น

  • Outline.Rounded(roundRect: RoundRect) สำหรับรูปวงกลม
  • Outline.Rectangle(rect: Rect) สำหรับรูปสี่เหลี่ยม
  • Outline.Generic(path: Path) สำหรับรูปทรงใด ๆ ก็ตาม

จะเห็นว่า Outline แต่ละแบบต้องการ Parameter แตกต่างกันออกไป และสำหรับการสร้าง Shape ขึ้นมาใช้งานเอง และส่วนใหญ่มักจะเป็น Outline.Generic ที่ต้องได้ผลลัพธ์ออกมาเป็น Path จึงทำให้โค้ดที่จำเป็นสำหรับการสร้าง Shape ส่วนใหม่จะมีลักษณะประมาณนี้

class CustomShape : Shape {
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
        val rect = size.toRect()
        val path = Path().apply {
            // Create path with your logic
        }
        return Outline.Generic(path)
    }
}

การแปลง Size ให้เป็น Rect จะนำไปใช้งานได้สะดวกกว่า เพราะใน Rect จะมีการคำนวณค่าบางอย่างให้เรียบร้อยแล้ว ทำให้ไม่ต้องเขียนโค้ดเพื่อคำนวณค่าเหล่านั้นด้วยตัวเอง

// Size
val size: Size = /* ... */

val width = size.width
val height = size.height

// Rect
val rect: Rect = /* ... */

val width: Float = rect.width
val height: Float = rect.height

val top: Float = rect.top
val topLeft: Offset = rect.topLeft
val topRight: Offset = rect.topRight
val topCenter: Offset = rect.topCenter

val bottom: Float = rect.bottom
val bottomLeft: Offset = rect.bottomLeft
val bottomRight: Offset = rect.bottomRight
val bottomCenter: Offset = rect.bottomCenter

val right: Float = rect.right
val centerRight: Offset = rect.centerRight

val left: Float = rect.left
val centerLeft: Offset = rect.centerLeft

ตัวอย่างการสร้าง Shape ขึ้นมาเอง

สมมติว่าเจ้าของบล็อกต้องการใช้ Shape เพื่อสร้าง UI ที่มีรูปร่างเป็นคูปองแบบนี้

ใช่ครับ ผมเอาตัวอย่างมาจากบทความที่ผมเคยเขียนไว้นั่นเอง เบื้องหลังการสร้าง UI สำหรับคูปองเพื่อใช้งานในแอป LINE MAN ที่เป็นมิตรต่อเพื่อนร่วมทีม

เพื่อไม่ให้เป็นการเสียเวลา โค้ดที่ได้จึงมีลักษณะเป็นแบบนี้

class CouponShape(val cornerRadius: Dp, val holeSize: Dp) : Shape {
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
        val rect = size.toRect()
        val cornerRadiusInPx = cornerRadius.toPx(density)
        val holeRadiusInPx = holeSize.toPx(density) / 2f
        val path = Path().apply {
            // Create path with your logic
            moveTo(cornerRadiusInPx, 0f)
            lineTo(rect.width - cornerRadiusInPx, 0f)
            arcTo(
                Rect(
                    left = rect.width - cornerRadiusInPx,
                    top = 0f,
                    right = rect.width,
                    bottom = cornerRadiusInPx,
                ),
                startAngleDegrees = 270f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )
            lineTo(rect.width, rect.centerRight.y - holeRadiusInPx)
            arcTo(
                Rect(
                    left = rect.centerRight.x - holeRadiusInPx,
                    top = rect.centerRight.y - holeRadiusInPx,
                    right = rect.centerRight.x + holeRadiusInPx,
                    bottom = rect.centerRight.y + holeRadiusInPx,
                ),
                startAngleDegrees = 270f,
                sweepAngleDegrees = -180f,
                forceMoveTo = false
            )
            lineTo(rect.width, rect.height - cornerRadiusInPx)
            arcTo(
                Rect(
                    left = rect.width - cornerRadiusInPx,
                    top = rect.height - cornerRadiusInPx,
                    right = rect.width,
                    bottom = rect.height,
                ),
                startAngleDegrees = 0f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )
            lineTo(cornerRadiusInPx, rect.height)
            arcTo(
                Rect(
                    left = 0f,
                    top = rect.height - cornerRadiusInPx,
                    right = cornerRadiusInPx,
                    bottom = rect.height,
                ),
                startAngleDegrees = 90f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )
            lineTo(0f, rect.centerLeft.y + holeRadiusInPx)
            arcTo(
                Rect(
                    left = -holeRadiusInPx,
                    top = rect.centerLeft.y - holeRadiusInPx,
                    right = holeRadiusInPx,
                    bottom = rect.centerLeft.y + holeRadiusInPx,
                ),
                startAngleDegrees = 90f,
                sweepAngleDegrees = -180f,
                forceMoveTo = false
            )
            lineTo(0f, cornerRadiusInPx)
            arcTo(
                Rect(
                    left = 0f,
                    top = 0f,
                    right = cornerRadiusInPx,
                    bottom = cornerRadiusInPx,
                ),
                startAngleDegrees = 180f,
                sweepAngleDegrees = 90f,
                forceMoveTo = false
            )
            close()
        }
        return Outline.Generic(path)
    }

    private fun Dp.toPx(density: Density) = with(density) { [email protected]() }
}

จากนั้นก็เอา CouponShape ที่ได้ไปสร้างเป็น UI ตามที่ต้องการ

Box(
    modifier = Modifier
        .width(200.dp)
        .height(50.dp)
        .background(
            color = Red50,
            shape = CouponShape(16.dp, 16.dp)
        )
        .border(
            width = 1.dp,
            color = Red500,
            shape = CouponShape(16.dp, 16.dp)
        )
) { /* ... */ }

ในการนำ CouponShape ไปใช้งาน จะต้องกำหนดค่าให้กับ background(...) และ border(...) แยกกัน เพราะ Modifier มองว่าทั้ง 2 ค่าเป็นคนละส่วน จึงสะดวกต่อการนำไปใช้งาน เพราะจะสร้าง UI ที่มีแต่พื้นหลัง (Background) ก็ได้ มีขอบ (Border) ก็ได้ หรือจะให้มีเงา (Shadow) ก็ได้เช่นกัน

นั่นจึงทำให้การสร้าง UI ด้วย Shape และ Modifier มีประโยชน์ตรงที่สามารถไปใช้งานได้สะดวกและใช้งานซ้ำได้ง่าย เมื่อเทียบกับไฟล์ภาพที่กำหนดค่าอะไรไม่ได้ และมีขนาดที่ตายตัว

สรุป

Shape เป็นหนึ่งในการทำงานของ Jetpack Compose ที่ช่วยให้นักพัฒนาสามารถสร้าง UI ที่มีรูปร่างตามที่ต้องการได้ง่าย ซึ่งจะช่วยลดงานที่ต้องใช้ไฟล์ภาพให้น้อยลง อีกทั้งยังนำไปประยุกต์ใช้งานได้สะดวกกว่าไฟล์ภาพอีกด้วย

แต่ในขณะเดียวกันการสร้าง Shape ในบางรูปแบบก็จะสร้างได้ยากกว่าการสร้างไฟล์ภาพ ดังนั้นการจะเลือกสร้าง Shape หรือใช้เป็นไฟล์ภาพก็ควรพิจารณาตามความเหมาะสมของงานด้วยเช่นกัน