สร้าง UI ให้ได้ดั่งใจด้วย Shape ใน Jetpack Compose
Shape เป็นหนึ่งในความสามารถบน Jetpack Compose ที่ช่วยให้นักพัฒนาสามารถสร้าง UI ในรูปทรงต่าง ๆ ได้หลากหลายตามความต้องการ ซึ่งจะช่วยให้นักพัฒนาสร้าง UI ที่มีรูปร่างซับซ้อนได้ง่าย (เมื่อเทียบกับ Android Views)
บทความในชุดเดียวกัน
- Stateless & Stateful Composable
- State Hoisting
- Slot API
- Shape [Now Reading]
- UI Preview
- Composition Local
- Adoption Strategy
โดยนักพัฒนาจะเห็น 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) { this@toPx.toPx() }
}
จากนั้นก็เอา 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 หรือใช้เป็นไฟล์ภาพก็ควรพิจารณาตามความเหมาะสมของงานด้วยเช่นกัน