ว่าด้วยเรื่อง RecyclerView กับการใช้งานจริงในแบบฉบับเจ้าของบล็อก ตอนที่ 3
ในตอนล่าสุดเจ้าของบล็อกก็ได้แสดงตัวอย่างการเรียกใช้งาน RecyclerView ที่ดูเหมือนว่าจะเสร็จแล้ว แต่สุดท้ายแล้วก็ยังไม่เสร็จดีนัก ซึ่งในบทความนี้ก็จะมาแสดงให้เห็นกันว่าการเขียนแบบที่เจ้าของบล็อกใช้นั้นมันแก้ไขได้ง่ายจริงหรือ? มาอ่านกันต่อได้เลย~
บทความในซีรีย์เดียวกัน
ย้อนความ
ในตอนล่าสุดเจ้าของบล็อกได้สร้าง RecyclerView ขึ้นมาโดยมีผลลัพธ์แบบนี้
ผู้ที่หลงเข้ามาอ่านสามารถดาวน์โหลดตัวอย่างได้จาก Lovely Recycler View — Part 2 Ending [GitHub] เพื่อความต่อเนื่องครับ
แต่ทีนี้ปัญหาที่เกิดขึ้นคือ มันดันไม่ตรงกับ Requirement ที่กำหนดไว้ในตอนแรก ตรงที่สีของ Section นั้นจะต้องเปลี่ยนไปตามประเภทของสินค้า
การทำให้แถบ Section มีสีแยกกันนี้ ไม่ต่างอะไรกับการที่นักพัฒนาต้องกลับมาเพิ่มหรือแก้ไขการทำงานต่างๆที่อยู่ใน RecyclerView นั่นเอง
ดังนั้นเจ้าของบล็อกจะแก้ไขยังไงดีล่ะ?
มาแก้ไขให้มันถูกต้องกันเถอะ
เพื่อให้กำหนดสีพื้นหลังแยกกันได้ ก็ต้องเริ่มจากเพิ่ม ID ให้กับ LinearLayout ใน Layout Resource ของ view_section.xml
เป็นอย่างแรก (สมมติว่ากำหนดชื่อเป็น @+id/layout_section_container
)
<!-- view_section.xml -->
<LinearLayout
android:id="@+id/layout_section_container"
... >
<TextView
android:id="@+id/tv_section"
... />
</LinearLayout>
และในการกำหนดสีพื้นหลังที่เหมาะสมนั้นควรใช้วิธีแนบข้อมูลเพิ่มเข้าไปใน Section แล้วให้ SectionViewHolder ดึงค่าสีมากำหนดเป็นสีพื้นหลังนั่นเอง (ตามหลักการที่กำหนดไว้ว่าควรให้ ViewHolder จัดการเรื่องพวกนี้เอง)
เริ่มจากเพิ่ม backgroundColor
เข้าไปใน Section ก่อน
@Parcelize
data class Section(
val section: String,
val backgroundColor: Int
) : OrderDetailItem(OrderDetailType.TYPE_SECTION)
แล้วใน SectionViewHolder ก็ให้ดึงค่าดังกล่าวมากำหนดเป็นสีพื้นหลังของ LinearLayout ซะ
class SectionViewHolder(private val binding: ViewSectionBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: OrderDetailItem.Section) {
/* ... */
binding.layoutSectionContainer.setBackgroundColor(item.backgroundColor)
}
}
ถ้าใช้ ViewBinding จะกำหนดสีพื้นหลังที่ Root เลยก็ได้เช่นกัน จะได้ไม่ต้องสร้าง ID ให้ LinearLayout
กลับไปที่ OrderDetailConverter ที่ทำหน้าที่สร้าง Section ขึ้นมา ก็จะต้องแนบค่าสีเข้าไปด้วยเช่นกัน
// OrderDetailConverter.kt
fun createSectionAndOrder(
/* ... */,
foodTitleColor: Int,
bookTitleColor: Int,
musicTitleColor: Int
): List<OrderDetailItem> {
return mutableListOf<OrderDetailItem>().apply {
// Food
if (!orderDetail.foodList.isNullOrEmpty()) {
add(
OrderDetailItem.Section(
section = /* ... */,
backgroundColor = foodTitleColor
)
)
/* ... */
}
// Book
if (!orderDetail.bookList.isNullOrEmpty()) {
add(
OrderDetailItem.Section(
section = /* ... */,
backgroundColor = bookTitleColor
)
)
/* ... */
}
// Music
if (!orderDetail.musicList.isNullOrEmpty()) {
add(
OrderDetailItem.Section(
section = /* ... */,
backgroundColor = musicTitleColor
)
)
/* ... */
}
}
}
แล้วให้ Activity โยนค่าสีเพิ่มเข้ามาในตอนที่เรียกคำสั่ง createSectionAndOrder(...)
ด้วย
class MainActivity : AppCompatActivity() {
/* ... */
private fun convertToOrderDetailItems(orderDetail: OrderDetail): List<OrderDetailItem> {
/* ... */
val foodTitleColor = ContextCompat.getColor(this, R.color.sky_light_blue)
val bookTitleColor = ContextCompat.getColor(this, R.color.funny_dark_pink)
val musicTitleColor = ContextCompat.getColor(this, R.color.natural_green)
return mutableListOf<OrderDetailItem>().apply {
/* ... */
addAll(OrderDetailConverter.createSectionAndOrder(orderDetail, foodTitle, bookTitle, musicTitle, currency, foodTitleColor, bookTitleColor, musicTitleColor))
/* ... */
}
}
}
เมื่อดูผลลัพธ์ที่เกิดขึ้นก็จะเห็นว่า Section เปลี่ยนสีตามที่กำหนดแล้ว
จริงๆจะใส่ Logic สำหรับสีพื้นหลังไว้ใน ViewHolder ก็ได้เช่นกัน แต่การเอา Logic มาไว้ที่ OrderDetailConverter แทน จะช่วยให้ Logic ทั้งหมดอยู่ที่เพียงคลาสเดียวเท่านั้น ซึ่งเป็นคลาสที่สามารถเขียนเทสได้ง่ายที่สุด (การเขียนเทสใน Adapter และ ViewHolder ไม่ได้สนุกอย่างที่คิด)
อ่ะ แค่นี้คงเล็กน้อย มาดูอีกหนึ่งตัวอย่างกัน
ถึงแม้ว่าโค้ดนี้จะดูเรียบร้อยแล้ว พร้อมใช้งานแล้ว แต่ลองนึกภาพว่าถ้าลูกค้ามีบางอย่างที่อยากจะให้เพิ่มเข้าไปล่ะ?
“น้องครับๆ ถ้า Web Service ไม่ส่งข้อมูลอะไรมาให้ ให้แสดงเป็น….”
ก็นั่นล่ะฮะ ความรู้สึกที่คุ้นเคย
เอ…ถ้างั้นก็ต้องจำลองว่า Web Service ไม่ส่งข้อมูลมาให้สินะ.. ข้อมูลที่ส่งกลับมาจะเป็นแบบไหนได้มั่งหว่า?
{
"food_list": [],
"book_list": [],
"music_list": []
}
หรือ
{
"food_list": null,
"book_list": null,
"music_list": null
}
หรือ
null
ซึ่งทั้งหมดนี้มีโอกาสที่เกิดขึ้นได้จริง
ปรับให้คลาส FakeNetwork สามารถส่งข้อมูลได้หลายแบบเสียก่อน
ให้คลาสดังกล่าวสุ่มผลลัพธ์ที่จะส่งกลับมานั่นเอง ก็จะมีทั้งแบบมีข้อมูล ไม่มีข้อมูล เพื่อดูว่า RecyclerView ของเจ้าของบล็อกทำงานได้ถูกต้องหรือป่าว
// FakeNetwork.kt
object FakeNetwork {
fun getFakeOrderDetail(callback: (orderDetail: OrderDetail) -> Unit) {
Handler(Looper.getMainLooper()).postDelayed({
callback.invoke(createFakeOrderDetail())
}, 2000)
}
private fun createFakeOrderDetail(): OrderDetail {
val fakeJsonList = arrayOf(
"{\"food_list\":[{\"order_name\":\"Chicken\",\"amount\":2,\"price\":400},{\"order_name\":\"Egg\",\"amount\":24,\"price\":120}],\"book_list\":[{\"ISBN\":\"9780804139038\",\"book_name\":\"The Martian: A Novel\",\"author\":\"Andy Weir\",\"publish_date\":\"11 February 2014\",\"publication\":\"Broadway Books\",\"price\":314,\"pages\":384},{\"ISBN\":\"9781449327972\",\"book_name\":\"Embedded Android: Porting, Extending, and Customizing\",\"author\":\"Karim Yaghmour\",\"publish_date\":\"12 March 2013\",\"publication\":\"O'Reilly Media, Inc.\",\"price\":475,\"pages\":412},{\"ISBN\":\"9780545229937\",\"book_name\":\"The Hunger Games\",\"author\":\"Suzanne Collins\",\"publish_date\":\"1 September 2009\",\"publication\":\"Scholastic Inc.\",\"price\":279,\"pages\":384}],\"music_list\":[{\"artist\":\"Green Day\",\"album\":\"American Idiot\",\"release_date\":\"8 September 2004\",\"track\":9,\"price\":330}]}",
"{\"food_list\":[],\"book_list\":[],\"music_list\":[]}",
"{\"food_list\":null,\"book_list\":null,\"music_list\":null}",
"{ }",
"",
"null"
)
val index: Int = Random.nextInt(fakeJsonList.size)
return Gson().fromJson(fakeJsonList[index], OrderDetail::class.java)
}
}
ทั้งนี้จะเปลี่ยนเงื่อนไขในการส่งข้อมูลแบบใดก็ได้ เพื่อให้ง่ายต่อการเทสมากขึ้น
เพิ่มโค้ดสำหรับกรณีที่ไม่มีข้อมูล
เมื่อต้องการเพิ่มอะไรใน Adapter สิ่งที่ต้องทำมีดังนี้
- สร้าง Layout Resource
- สร้าง ViewHolder
- เพิ่ม Type ใหม่
- สร้าง Data Class หรือ Object ของ ViewHolder โดยมี Type ใหม่
- เพิ่ม Type และ ViewHolder ตัวใหม่ใน
onCreateViewHolder(...)
และonBindViewHolder(...)
ของ Adapter - สร้าง Data Class หรือ Object ใน Converter เมื่อเข้าเงื่อนไขที่ต้องการ
จะเห็นว่าสิ่งแรกที่ต้องทำนั้นไม่ใช่ส่วนของ Logic แต่จะเป็น Layout Resource เพราะจะต้องกำหนดก่อนว่าถ้าไม่มีข้อมูลจะต้องแสดง Layout ยังไง
ซึ่งเจ้าของบล็อกก็สมมติว่ามันจะต้องแสดงแบบนี้ละกันเนอะ
ดังนั้นเจ้าของบล็อกก็จะสร้าง Layout ขึ้นมาแบบนี้
<!-- strings.xml -->
<resources>
<!-- ... -->
<string name="no_order_selected">No order selected</string>
</resources>
<!-- view_no_order.xml -->
<LinearLayout ... >
<TextView ... />
</LinearLayout>
เพิ่ม Type สำหรับ ViewHolder แบบใหม่ เข้าไปในคลาส OrderDetailType (อย่าให้ซ้ำกับของเก่าล่ะ)
object OrderDetailType {
/* ... */
const val TYPE_NO_ORDER = 9
}
เพิ่ม Object สำหรับ NoOrder โดยกำหนด Type เป็น TYPE_NO_ORDER
@Parcelize
object NoOrder : OrderDetailItem(OrderDetailType.TYPE_NO_ORDER)
อ้อ แล้วก็ต้องสร้าง ViewHolder ด้วยนะ
// NoOrderViewHolder.kt
class NoOrderViewHolder(binding: ViewNoOrderBinding) : RecyclerView.ViewHolder(binding.root)
จากนั้นก็กำหนด ViewHolder และ Binding ให้เรียบร้อย
class OrderDetailAdapter(
private val onPositiveButtonClicked: () -> Unit,
private val onNegativeButtonClicked: () -> Unit,
private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var orderDetailItems: List<OrderDetailItem> = listOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) {
/* ... */
OrderDetailType.TYPE_NO_ORDER ->
NoOrderViewHolder(ViewNoOrderBinding.inflate(LayoutInflater.from(parent.context), parent, false))
/* ... */
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
orderDetailItems.getOrNull(position)?.let { item ->
when {
/* ... */
holder is NoOrderViewHolder && item is OrderDetailItem.NoOrder -> { /* Do nothing */
}
}
}
}
/* ... */
}
เพียงเท่านี้ Adapter ของเจ้าของบล็อกก็พร้อมที่จะแสดง Type ตัวใหม่แล้ว ที่เหลือก็แค่กำหนดข้อมูลที่ส่งเข้ามาให้เป็น Type ที่ต้องการเท่านั้นเอง
ดังนั้นใน Activity ที่เรียก OrderDetailConverter ก็จะต้องเพิ่มเงื่อนไขเข้าไปว่าถ้ามีข้อมูล Food, Book หรือ Music ก็จะแสดงข้อมูลตามปกติ แต่ถ้าไม่มีข้อมูลซักตัวก็จะแสดงข้อมูลเป็น NoOrder แทน
class MainActivity : AppCompatActivity() {
/* ... */
private fun convertToOrderDetailItems(orderDetail: OrderDetail): List<OrderDetailItem> {
/* ... */
return if (isOrderDetailAvailable(orderDetail)) {
mutableListOf<OrderDetailItem>().apply {
/* ... */
addAll(OrderDetailConverter.createSectionAndOrder(orderDetail, foodTitle, bookTitle, musicTitle, currency, foodTitleColor, bookTitleColor, musicTitleColor))
/* ... */
}
} else {
mutableListOf<OrderDetailItem>().apply {
add(OrderDetailConverter.createUserDetail(name))
add(OrderDetailConverter.createTitle(title))
add(OrderDetailConverter.createNoOrder())
}
}
}
private fun isOrderDetailAvailable(orderDetail: OrderDetail): Boolean {
return orderDetail.foodList?.isNotEmpty() == true ||
orderDetail.bookList?.isNotEmpty() == true ||
orderDetail.musicList?.isNotEmpty() == true
}
}
สำหรับเงื่อนไขที่จะเช็ค จะสร้างเป็น Method ที่ชื่อว่า isOrderDetailAvailable(...)
ที่ข้างในจะเช็ค List ของ Food, Book และ Music นั่นเอง
เสร็จเรียบร้อยจ้า
จะเห็นว่าวิธีที่เจ้าของบล็อกใช้จะเน้นไปที่คำสั่งใน Adapter จะต้องเรียบง่าย ไม่ซับซ้อน และเอาความซับซ้อนทั้งหมดไปไว้ที่ Activity หรือ Converter ที่เป็นตัวกำหนดว่าจะให้ RecyclerView แสดงข้อมูลอะไรบ้างแทน
ซึ่งจะสะดวกมากเวลาต้องแก้ไขการแสดงข้อมูลต่างๆใน RecyclerView ยกตัวอย่างเช่น อยากให้แสดงราคารวมเป็น 0 บาท ในกรณีที่ไม่มีข้อมูลใดๆ
ที่ต้องทำก็แค่แก้ไขเงื่อนไขในการแสดงผลให้ตรงกับที่ต้องการนั่นเอง
class MainActivity : AppCompatActivity() {
/* ... */
private fun convertToOrderDetailItems(orderDetail: OrderDetail): List<OrderDetailItem> {
/* ... */
} else {
mutableListOf<OrderDetailItem>().apply {
add(OrderDetailConverter.createUserDetail(name))
add(OrderDetailConverter.createTitle(title))
add(OrderDetailConverter.createNoOrder())
add(OrderDetailConverter.createTitle(summaryTitle))
add(OrderDetailConverter.createTotal(orderDetail, currency))
}
}
}
/* ... */
}
เพราะข้างใน Converter มีการคำนวณเผื่อกรณีที่ไม่มีข้อมูลอยู่แล้ว ซึ่งจะได้ออกมาเป็น 0฿
นั่นเอง
เป็นอันเสร็จเรียบร้อย
ซึ่งผู้ที่หลงเข้ามาอ่านก็สามารถนำไปปรับเปลี่ยนรูปแบบการทำงานได้ตามใจชอบ เช่น อยากจะย้ายส่วนที่เป็น Logic ใน Activity ไปอยู่ใน Converter ทั้งหมดก็ย่อมทำได้เช่นกัน
สำหรับผู้ที่หลงเข้ามาอ่านคนใดที่อยากจะดูโค้ดในตอนที่ 3 นี้ ก็สามารถไปดาวน์โหลดได้ที่ Lovely Recycler View — Part 3 Ending [Github]
แต่เอาเข้าจริงเจ้าของบล็อกก็ยังรู้สึกว่ามันไม่ค่อยซับซ้อนซักเท่าไรเลยเนอะ เพราะงั้นในตอนที่ 4 มาทำให้มันยุ่งยากขึ้นมากกว่านี้กันเถอะ!!