ในตอนล่าสุดเจ้าของบล็อกก็ได้แสดงตัวอย่างการเรียกใช้งาน RecyclerView ที่ดูเหมือนว่าจะเสร็จแล้ว แต่สุดท้ายแล้วก็ยังไม่เสร็จดีนัก ซึ่งในบทความนี้ก็จะมาแสดงให้เห็นกันว่าการเขียนแบบที่เจ้าของบล็อกใช้นั้นมันแก้ไขได้ง่ายจริงหรือ? มาอ่านกันต่อได้เลย~

บทความในซีรีย์เดียวกัน

ย้อนความ

ในตอนล่าสุดเจ้าของบล็อกได้สร้าง RecyclerView ขึ้นมาโดยมีผลลัพธ์แบบนี้

ผู้ที่หลงเข้ามาอ่านสามารถดาวน์โหลดตัวอย่างได้จาก Lovely Recycler View — Part 2 Ending [GitHub] เพื่อความต่อเนื่องครับ

akexorcist/LovelyRecyclerView
[Android] RecyclerView with complex layout and data - akexorcist/LovelyRecyclerView

แต่ทีนี้ปัญหาที่เกิดขึ้นคือ มันดันไม่ตรงกับ 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]

akexorcist/LovelyRecyclerView
[Android] RecyclerView with complex layout and data - akexorcist/LovelyRecyclerView

แต่เอาเข้าจริงเจ้าของบล็อกก็ยังรู้สึกว่ามันไม่ค่อยซับซ้อนซักเท่าไรเลยเนอะ เพราะงั้นในตอนที่ 4 มาทำให้มันยุ่งยากขึ้นมากกว่านี้กันเถอะ!!