[Android] มาลองเขียน Android UI ด้วย Jetpack Compose กัลลลลลล!!!

Pongpiya Rukwirojsuk
Lotus’s IT
Published in
8 min readNov 29, 2023

เชื่อว่าชาวเดฟ Android ทุกคน เวลาจะสร้าง Screen ขึ้นมาแต่ละหน้า ก็คงจะหนีไม่พ้นกับการที่จะต้องวาด UI ผ่าน XML กันมาก่อน เพราะมันเป็นสิ่งที่แสดงผลให้กับ User ได้เห็นและใช้งาน ไม่ว่าจะกดปุ่ม พรีวิวข้อมูล บล่าๆ สิ่งพวกนี้เป็นสิ่งจำเป็นที่เราจะต้องวาดขึ้นมา แต่…

“เบื่อไหมกับการวาด UI แบบเดิมๆ เรามีวิธีอื่นไหมนะ ที่จะสามารถวาด UI ที่ไม่ใช่ XML อย่างเดียว ???” 🤔

สิ่งนี้ก็คือการใช้ Jetpack Compose ที่จะเข้ามาเปลี่ยนรูปแบบในการวาด view ของเรานี่เอง. 🎉

https://developer.android.com/courses/jetpack-compose/course

สวัสดีครับ ก่อนอื่นขอแนะนำตัวก่อน ผมชื่อเต้ เป็น Android developer ได้มีโอกาสมาเขียน Blog ครั้งนี้เป็นครั้งแรก อยากจะนำเสนอการใช้ “Jetpack Compose” ให้เพื่อน ๆ ลองไปใช้งานเล่น เผื่อจะเป็นประโยชน์ไม่มากก็น้อยนะครับผม

Jetpack Compose” คือ Toolkit สำหรับสร้าง Android UI ด้วย code แบบ DSL Style (domain-specific language) จริงๆมันก็แอบคล้าย Swift UI ฝั่ง iOS หรือการเขียน UI ใน Flutter นั่นแหละ

ซึ่งในปัจจุบันเราจะใช้ XML เป็น Imperative UI เป็นการสร้าง XML และมี getter/setter ให้เราสามารถ set data เข้า view นั้นๆได้ แตกต่างจากการใช้ Compose ที่จะเป็น Declarative UI

โดยรายละเอียดเพิ่มเติม เพื่อนๆสามารถไปตามดูได้ตามลิ้งที่แนบไว้ได้เลยฮะ

ตอนนี้เรามาเริ่มจากการ Setup Project ของเราก่อนกันเลยดีกว่า 🔥🔥🔥

ก่อนอื่นให้เราเริ่มต้นการสร้าง Project โดยเลือก Empty Activity ครับ

ปล. Android Studio ของผมตอนนี้ใช้ Android Studio Giraffe | 2022.3.1 ถ้าของเพื่อน ๆใช้ version ต่ำกว่าหรือไม่มีให้เลือก อาจจะให้ไปเลือกใช้ Empty compose Activity ตามรูปด้านล่างแทนนะครับ

จากนั้นเราก็ตั้งชื่อ Project name ของเราได้เลยครับ ตรงนี้ผมขอตั้งชื่อ Project name เป็น “DemoAndroidCompose” ครับ

ทีนี้ก็รอ Gradle project sycn จนเสร็จ ลอง Run สักที จะได้หน้าตาก็จะเป็นประมาณนี้ ก็พร้อมให้เราใช้งานได้แล้วครับผม

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

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DemoAndroidComposeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}
}
}

โดยปกติถ้าเป็น XML จะมีการ set content view ในรูปแบบประมานนี้เนอะ setContentView(R.layout.activity_main)แต่ว่าใน Jetpack Compose เราจะใช้ setContent{} แทน ข้างในมีการกำหนด theme โดยอิงชื่อตามโปรเจกที่เราสร้างและ UI ต่างๆที่เราวาดจะถูกกำกับ Annotation ด้วย @Composableและถูกเรียกใช้อยู่ภายใน surface(){}

อย่างเช่น function Greeting ที่รับค่าที่เป็น String โดยภายใน function จะ return TextViewUI ออกมาโดยจะแสดงข้อความ จาก String ที่ถูกรับเข้ามาเพื่อ render ข้อความ

@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!") //"Hello Android!"
}

.

.

เห็นอะไรอะเปล่าา มันสามารถ render view แบบ real-time ได้ด้วยนะ 😱

เราสามารถดู Preview แบบ Real-time ได้ โดย function ที่ควบคุมการแสดงผล
นั่นก็คือ

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
DemoAndroidComposeTheme {
Greeting("Android")
}
}

โดย function นี้จะถูกกำกับ Annotation ด้วย @Previewเพิ่มเติมขึ้นมาครับ เป็นเพียงการ render view แบบ real-time เท่านั้น โดยที่ไม่ได้ Run app ของเราขึ้นมาทั้งหมด ซึ่งจะไม่ได้มีผลกับการที่เราไปเรียกใช้ view นี้ใน MainActivity class

จะสังเกตุได้ว่าข้อความที่ preview แบบ real-time กับ run app ข้อความจะแตกต่างกันประมาณนี้ครับผม

โดยเจ้า @Preview สามารถ preview ได้หลายๆตัวพร้อมๆกันได้ด้วยนะ

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Greeting("World")
}

@Preview(showBackground = true)
@Composable
fun ButtonPreview() {
Button(onClick = { /*TODO*/ }) {
Text(text = "Button")
}
}

เราสามารถใส่พวก padding หรือ set background ต่างๆเข้าไปก็ได้นะ โดยใช้งานผ่าน modifier โดยจะเป็น fully interface สามารถพิมพ์ dot ต่อได้เลย เราไม่ต้องทำการสร้าง shape drawable ต่างๆ สามารถทำงานผ่าน code ของเราได้เลยครับ

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Surface(color = Color.Blue) {
Text(
text = "World",
modifier = Modifier
.padding(12.dp)
.background(
color = Color.Green,
shape = RoundedCornerShape(4.dp)
).padding(8.dp)
)
}
}

เดี๋ยวเราลองมาทำ Mini Project กันนิดนึงดีกว่า

Mini Project ตัวนี้จะเกี่ยวกับการแสดงหน้า list ของคูปองส่วนลด โดยเมื่อกดคูปองส่วนลดแล้ว จะมีการรวมส่วนลดให้ผู้ใช้เห็น และสามารถเคลียร์ (Clear) ส่วนลดทั้งหมดได้ด้วย หน้าตาของแอปจะประมาณนี้เลยย

จากรูปที่เห็นจะสังเกตุว่ามี Layer UI อยู่ 2 ส่วนหลักๆ ก็คือ

  1. ส่วนที่เป็น Top/Bottom AppBar (กรอบสีแดง)
  2. ส่วนที่เป็น List ของคูปองส่วนลด (กรอบสีน้ำเงิน)

เราจะมาเริ่มดูส่วนที่เป็น AppBar ก่อนเนอะ เริ่มจากการใช้ Scaffold ที่จะเป็นตัวในการเเยก layer ระหว่างตัว AppBar กับ Content ออกมาให้เราฮะ

Scaffold(
topBar = { /* Top Bar */ },
bottomBar = { /* Bottom Bar */ },
floatingActionButton = { /* FAB */ },
) {
/* Content */
}

ซึ่งสามารถเข้าไปอ่านรายละเอียดเพิ่มเติม Scaffold ตามลิ้งที่แปะไว้ให้ได้เลย

1. Top/Bottom AppBar

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateView() {
var totalDiscount by rememberSaveable { mutableStateOf(0) }
val discountList = mockData()

Scaffold(
modifier = Modifier.fillMaxSize(),
//TopAppBar
topBar = {
TopAppBar(
title = {
Text("Menu")
},
navigationIcon = {
IconButton(onClick = { /** Handle action something **/ }) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {
IconButton(onClick = { totalDiscount = 0 /** ลบส่วนลดที่เก็บไว้ **/ }) {
Icon(imageVector = Icons.Filled.Delete, null)
}
},
)
},
//BottomAppBar
bottomBar = {
BottomAppBar(
containerColor = Color.Cyan,
) {
Icon(
modifier = Modifier.padding(start = 12.dp),
imageVector = Icons.Filled.Favorite,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = "Total discount $totalDiscount ฿",
fontWeight = FontWeight.Bold
)
}
}
) { contentPadding ->
/** Content **/
}
}

ส่วนแรก Top AppBar

  • title = เราจะเรียกใช้ Text(“…”) ไว้สำหรับตั้งชื่อ ในที่นี้คือ “Menu”
  • navigationIcon = เราจะใช้ IconButton เพื่อจะให้แสดงปุ่ม back ทางด้านฝั่งซ้าย ถ้าต้องการกดแล้วให้มี action สามารถเขียนเข้าไปใน blog ของ
    onClick = {} ที่มีได้เลยครับ
  • actions = หลักการคล้ายกับ navigationIcon แต่ในที่นี้จะแสดงฝั่งขวาโดยเราจะใส่เป็นไอคอนรูปถังขยะ โดย action คือ ให้ทำการลบ totalDiscount ที่เรา
    เก็บไว้ออกไปครับ

ส่วนที่สอง Bottom AppBar เราจะแสดงจำนวน total discount เราจึงใช้ Text(“…”) แสดงออกมาให้เห็นเพียงเท่านั้น ในที่นี้คือ “Total discount $totalDiscount ฿”

2. Content List

@Composable
fun RenderContentList(
discountModel: List<DiscountModel>,
contentPadding: PaddingValues,
discount: (Int) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
contentPadding = PaddingValues(8.dp)
) {
items(discountModel) { item ->
RenderListItem(discountModel = item) {
discount.invoke(it)
}
}
}
}

ในส่วนของ content list โดยปกติเวลาเราจะสร้าง list ขึ้นมา ถ้าเป็นวิธีเดิมเราจะต้องสร้าง XML ไฟล์โดยใช้ RecyclerView และมีการสร้าง Adapter

แต่ ณ ตอนนี้เราจะมาใช้เจ้า LazyColumn() แทนในการแสดง Content ซึ่งในตัว LazyColumn() เราสามารถกำหนด data ต่างๆ ผ่านทาง Params ที่เราจะใช้จะเป็น

  • modifier สำหรับกำหนดความกว้างให้เต็มหน้าจอ
  • contentPadding สำหรับกำหนด padding ของ column กับ row content ด้านใน
  • items() เป็น extension function ที่เรียก itemsIndexed() แสดง item view with index

พอมี list แล้วเราก็ต้องมี view ของ item ใช่มะ ก็จะเขียนประมาณนี้เลย

@Composable
fun RenderListItem(
discountModel: DiscountModel,
discount: (Int) -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable {
discount.invoke(discountModel.discount)
}
) {
Row(
modifier = Modifier.padding(8.dp)
) {
Image(
painter = painterResource(discountModel.imageResource),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(90.dp)
)
Column(
modifier = Modifier
.padding(20.dp, 0.dp, 0.dp, 0.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = discountModel.menuName,
fontWeight = FontWeight.Bold
)
Text(
text = discountModel.menuDesc,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

โดยเจ้าตัว Item เราจะใช้เป็น CardView โดยภายใน content จะเเบ่งสัดส่วนด้วย Row และ Column

เท่านี้ส่วนประกอบของ view เราก็จะครบทุกองค์ประกอบแล้ว ทีนี้ก็เหลือเพียง data ที่จะต้องส่งต่อให้ view ครับ ในที่นี้เราจะทำการจำลองสร้าง model ขึ้นมาครับ โดยเดี๋ยวเราจะสร้างไฟล์ขึ้นมาชื่อว่า “DiscountModel”

data class DiscountModel(
val imageResource: Int = R.drawable.ic_launcher_background,
val title: String,
val description: String,
val discount: Int
)

จากนั้นเราก็จะทำการ mock ข้อมูลเข้าไป แล้วไปเรียกใช้ประมาณนี้ครับ

private fun mockData(): List<DiscountModel> {
return listOf(
DiscountModel(
title = "Discount 5 ฿",
description = "Discount coupon",
discount = 5
),
DiscountModel(
title = "Discount 10 ฿",
description = "Discount coupon",
discount = 10
),
DiscountModel(
title = "Discount 15 ฿",
description = "Discount coupon",
discount = 15
),
DiscountModel(
title = "Discount 20 ฿",
description = "Discount coupon",
discount = 20
),
DiscountModel(
title = "Discount 25 ฿",
description = "Discount coupon",
discount = 25
),
DiscountModel(
title = "Discount 30 ฿",
description = "Discount coupon",
discount = 30
),
DiscountModel(
title = "Discount 100 ฿",
description = "Discount coupon",
discount = 100
)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateView() {
var totalDiscount by rememberSaveable { mutableStateOf(0) }
val discountList = mockData()

Scaffold(
/** -------- **/
) { contentPadding ->
/** -------- **/
}
}

Final Code + Logic การทำงานทั้งหมด

  • MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DemoAndroidComposeTheme {
CreateView()
}
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateView() {
var totalDiscount by rememberSaveable { mutableStateOf(0) }
val discountList = mockData()

Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text("Menu")
},
navigationIcon = {
IconButton(onClick = { /** Handle action something **/ }) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {
IconButton(onClick = { totalDiscount = 0 }) {
Icon(imageVector = Icons.Filled.Delete, contentDescription = null)
}
},
)
},
bottomBar = {
BottomAppBar(
containerColor = Color.Cyan,
) {
Icon(
modifier = Modifier.padding(start = 12.dp),
imageVector = Icons.Filled.Favorite,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = "Total discount $totalDiscount ฿",
fontWeight = FontWeight.Bold
)
}
}

) { contentPadding ->
RenderContentList(discountModel = discountList, contentPadding) { discount ->
totalDiscount += discount
}
}
}

@Composable
fun RenderContentList(
discountModel: List<DiscountModel>,
contentPadding: PaddingValues,
discount: (Int) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
contentPadding = PaddingValues(8.dp)
) {
items(discountModel) { item ->
RenderListItem(discountModel = item) {
discount.invoke(it)
}
}
}
}

@Composable
fun RenderListItem(
discountModel: DiscountModel,
discount: (Int) -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable {
discount.invoke(discountModel.discount)
}
) {
Row(
modifier = Modifier.padding(8.dp)
) {
Image(
painter = painterResource(discountModel.imageResource),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(90.dp)
)
Column(
modifier = Modifier
.padding(20.dp, 0.dp, 0.dp, 0.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = discountModel.menuName,
fontWeight = FontWeight.Bold
)
Text(
text = discountModel.menuDesc,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

private fun mockData(): List<DiscountModel> {
return listOf(
DiscountModel(
menuName = "Discount 5 ฿",
menuDesc = "Discount coupon",
discount = 5
),
DiscountModel(
menuName = "Discount 10 ฿",
menuDesc = "Discount coupon",
discount = 10
),
DiscountModel(
menuName = "Discount 15 ฿",
menuDesc = "Discount coupon",
discount = 15
),
DiscountModel(
menuName = "Discount 20 ฿",
menuDesc = "Discount coupon",
discount = 20
),
DiscountModel(
menuName = "Discount 25 ฿",
menuDesc = "Discount coupon",
discount = 25
),
DiscountModel(
menuName = "Discount 30 ฿",
menuDesc = "Discount coupon",
discount = 30
),
DiscountModel(
menuName = "Discount 100 ฿",
menuDesc = "Discount coupon",
discount = 100
)
)
}
  • DiscountModel.kt
data class DiscountModel(
val imageResource: Int = R.drawable.ic_launcher_background,
val menuName: String,
val menuDesc: String,
val discount: Int
)

ขอเสริมทิ้งท้ายอีกนิดนึงครับ

เจ้าตัว GreetingPreview นอกจากจะสามารถดู view แบบ real-tiem ได้แล้ว ยังสามารถกด action ต่างๆบน view ได้ด้วยนะ โดยเราต้องได้กด Start Interactive Mode

เท่านี้เราจะ scroll หรือ click อะไรต่างๆ เหมือนเรา run app ได้เลยค้าบบ

สุดท้ายนี้ขอขอบคุณที่อ่านจนมาถึงตรงนี้มากนะครับ 😂

บทความนี้เป็นบทความแรกของผม หากมีข้อผิดพลาด ต้องขออภัยไว้ ณ ตรงนี้ด้วยนะครับผม พร้อมรับคำติชม เพื่อปรับปรุงตัวให้บทความต่อไปครับ ขอบคุณครับ 🙏🏻

--

--