อยู่ในระหว่างการปรับปรุงเนื้อหา

ก็ไม่คิดว่าจะเขียนบทความชุดนี้ได้เยอะขนาดนี้ (ตอนแรกตั้งใจว่าจะเขียนแค่ 3 บทความ) แต่เนื่องจากเนื้อหามันเริ่มเลยเถิดไปเรื่อยๆตามไอเดียที่เจ้าของบล็อกอยากจะเขียน เพราะงั้นก็ปล่อยให้มันเป็นไปแล้วกันเนอะ

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

ย้อนความทรงจำ

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

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

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

เมื่อเงื่อนไขการทำงานเพิ่มมากขึ้น

การที่เจ้าของบล็อกแสดงให้เห็นว่ารูปแบบนี้จะยิดหยุ่นต่อการแก้ไขโค้ดในภายหลังอย่างไรก็เป็นแค่ส่วนหนึ่งเท่านั้น แต่สุดท้ายแล้วเจ้าของบล็อกก็เชื่อว่าน่าจะมีผู้ที่หลงเข้ามาอ่านที่สงสัยเพิ่มว่าการทำงานแบบอื่นๆล่ะ จะต้องทำยังไง ดังนั้นก็ลองยกตัวอย่างซักนิดเพื่อให้ครอบคลุมการทำงานมากกว่าเดิม

โค้ดในตอนนี้ยังไม่ได้มีการเปลี่ยนแปลงอะไรกับข้อมูลที่ส่งมาจาก Web Service ดังนั้นสมมติว่าเจ้าของบล็อกกดปุ่ม Confirm เพื่อทำบางอย่างต่อ เช่น เปิดไปอีกหน้า, สรุปข้อมูล หรือส่งข้อมูลไปที่ Web Service เพื่อยืนยันรายการ ที่เจ้าของบล็อกต้องทำก็แค่เอาข้อมูลเดิมนั่นแหละส่งไปทั้งก้อนเลย

แต่เพื่อให้สนุกและท้าทายมากขึ้น มาลองทำให้ RecyclerView ตัวนี้สามารถลบข้อมูลได้ดีกว่า

แล้วมีส่วนไหนของโค้ดที่จะต้องแก้ไขบ้างล่ะเนี่ย?

ลองคิดเล่นๆก่อนที่จะเขียน

อย่างที่รู้กันว่ารูปแบบนี้ เจ้าของบล็อกไม่อยากให้ Adapter ทำอะไรมากนัก เพราะเดี๋ยวโค้ดในนั้นมันจะซับซ้อนเกินไป ถ้าไม่จำเป็นจริงๆก็จะโยนหน้าที่มาให้ Activity หรือ Converter ดังนั้นการที่จะมี Event บางอย่างเกิดขึ้น อย่างเช่น ปุ่ม Cancel และ Confirm เจ้าของบล็อกก็จะกำหนดการทำงานแบบนี้

และก็แน่นอนว่าการลบข้อมูลออกจาก RecyclerView ด้วยเช่นกัน

แต่ก่อนอื่น

จะให้ผู้ใช้ลบข้อมูลด้วยวิธีไหนดี?

โดยปกติแล้วก็คงจะเพิ่มปุ่มลบข้อมูลลงในข้อมูลที่แสดงเป็น Order Type กัน อย่างเช่นเพิ่มปุ่มรูปถังขยะหรือกากบาทเพื่อให้ผู้ใช้สามารถกดเพื่อลบข้อมูลนั้นๆทิ้งได้ ซึ่งจะต้องมีการทำภาพเพิ่มเข้าไปและมีการแก้ไข Layout นิดหน่อย

แต่เพื่อไม่ให้เนื้อหาออกนอกเรื่องเกินจำเป็น เจ้าของบล็อกจึงใช้วิธี “กดค้างเพื่อลบข้อมูล” แทน เพราะจะใช้โค้ดที่ไม่จำเป็นน้อยกว่าวิธีอื่นๆ

ปรับโค้ดของเก่านิดหน่อยเพื่อความพร้อม

หลังจากที่บทความก่อนหน้านี้เจ้าของบล็อกได้เพิ่มโค้ดเพื่อให้คลาส FakeNetwork สามารถส่งข้อมูลแบบสุ่มๆว่าจะมีข้อมูลหรือป่าว แต่เนื่องจากบทความนี้ไม่ได้โฟกัสที่เรื่องนั้นแล้ว ดังนั้นขอเปลี่ยนโค้ดในคลาส FakeNetwork ให้กลับมาเป็นปกติสุขก่อนเนอะ

// FakeNetwork.kt
object FakeNetwork {
    fun getFakeOrderDetail(callback: (orderDetail: OrderDetail) -> Unit) {
        Handler(Looper.getMainLooper()).postDelayed({
            callback.invoke(createFakeOrderDetail())
        }, 2000)
    }

    private fun createFakeOrderDetail(): OrderDetail {
        val fakeJson = "{\"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}]}"
        return Gson().fromJson(fakeJson, OrderDetail::class.java)
    }
}

เตรียม Resource ที่จำเป็น (ที่ไม่จำเป็น…)

(สามารถข้ามขั้นตอนนี้ได้เลย)

เพื่อให้การกดค้างมี Feedback ที่ผู้ใช้รับรู้ได้ ดังนั้นเจ้าของบล็อกขอเพิ่ม Selector ให้กับพื้นหลังของข้อมูลแบบ Order Type เสียหน่อย เวลากดเลือกที่ข้อมูลนั้นๆจะให้เปลี่ยนสีพื้นหลัง จะได้รู้ว่ากดเลือกอยู่

<!-- shape_order_background_normal.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/angel_white" />
</shape>

<!-- shape_order_background_pressed.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/little_light_gray" />
    <stroke
        android:width="@dimen/selector_stroke_width"
        android:color="@color/angel_white" />
</shape>

<!-- shape_order_background_focused.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="@color/angel_white" />
    <stroke
        android:width="@dimen/selector_stroke_width"
        android:color="@color/seasonal_orange" />
</shape>
<!-- selector_order_background.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/shape_order_background_pressed" android:state_pressed="true" />
    <item android:drawable="@drawable/shape_order_background_focused" android:state_focused="true" />
    <item android:drawable="@drawable/shape_order_background_normal" />
</selector>

จากนั้นก็กำหนดให้ LinearLayout ใน view_order.xml ซะ

<!-- view_order.xml -->
<LinearLayout ...
    android:background="@drawable/selector_order_background">
    <!-- ... -->
</LinearLayout>

เพิ่มคำสั่งให้ View ของ Order Type เพื่อให้ลบข้อมูลได้

ถ้ายังจำกันได้ เจ้าของบล็อกสร้าง ButtonViewHolder แล้วให้ส่ง Click Event ไปให้ Adapter เพื่อส่งต่อไปให้ Activity อีกที

ซึ่งในกรณีนี้ก็จะทำแบบนั้นเลย แต่ทำเป็น Long Click Event แทน

// OrderViewHolder.kt
class OrderViewHolder(private val binding: ViewOrderBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(item: OrderDetailItem.Order, onLongClicked: () -> Unit) {
        /* .. */
        binding.root.setOnLongClickListener {
            onLongClicked.invoke()
            true
        }
    }
}

แล้ว OrderDetailAdapter ก็จะรับจาก OrderViewHolder ก็จะส่งต่อออกไปให้ Activity

// OrderDetailAdapter.kt
class OrderDetailAdapter(
    /* ... */
    private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    /* ... */
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        orderDetailItems.getOrNull(position)?.let { item ->
            when {
                /* ... */
                holder is OrderViewHolder && item is OrderDetailItem.Order -> 
                    holder.bind(item) { onOrderRemoveClicked.invoke(item) }
                /* ... */
            }
        }
    }
    /* ... */
}

จะเห็นว่า Long Click Event ที่ Adapter ส่งออกไปจะแนบข้อมูล Order ไปด้วย เพื่อให้ Activity สามารถเอาไปคำนวณหาข้อมูลที่จะลบจริงๆได้นั่นเอง

val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit
ถ้าเป็นข้อมูลที่มี Unique ID ด้วยจะง่ายต่อการทำในขั้นตอนนี้มากๆ เพราะสามารถส่งแค่ ID ออกไปได้เลย

ผู้ที่หลงเข้ามาอ่านบางคนอาจจะเพิ่มคำสั่งให้ลบข้อมูลที่ Adapter ถือไว้โดยตรงเลย แต่เจ้าของบล็อกไม่ค่อยแนะนำให้ไปลบข้อมูลที่อยู่ใน Adapter ซักเท่าไรนัก เพราะข้อมูลต้นฉบับจะอยู่ที่ Activity

ดังนั้นเพื่อให้ Flow ถูกต้อง จะต้องส่ง Event ไปบอก Activity เพื่อให้ที่ Activity จะได้ลบข้อมูลที่เลือกทิ้งแล้วอัปเดตข้อมูลใน Adapter ใหม่ซะ ตาม Flow ที่แปะไว้ในตอนแรกของบทความนั่นเอง

และนั่นหมายความว่าที่ Activity ก็จะต้องเพิ่ม Function สำหรับ onOrderRemoveClicked(...) ที่ OrderDetailAdapter ส่งออกมานั่นเอง

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */
    private fun setupView() {
        adapter = OrderDetailAdapter(onPositiveButtonClicked, onNegativeButtonClicked, onOrderRemoveClicked)
        /* ... */
    }
    /* ... */
    private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit = { item ->
        
    }
}

สำหรับการลบข้อมูลออกจาก OrderDetail นั้นจะต้องเอาข้อมูลใน Order มาเทียบว่าตรงกับข้อมูลในไหน ซึ่งแน่นอน Order เป็นได้ทั้งข้อมูลของ Food, Book และ Music ดังนั้นจึงต้องเอามาเทียบกับทุกตัวว่าเป็นของตัวไหนด้วยคำสั่ง removeAll(...) ของ Collection

// MainActivity.kt
private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit = { item ->
    orderDetail?.let { newOrderDetail ->
        newOrderDetail.foodList?.removeAll { it.orderName == item.name && "x${it.amount}" == item.detail }
        newOrderDetail.bookList?.removeAll { it.bookName == item.name && it.author == item.detail }
        newOrderDetail.musicList?.removeAll { it.album == item.name && it.artist == item.detail }
        updateOrderDetailItems(listOf(), convertToOrderDetailItems(newOrderDetail))
    }
}
จะเห็นว่าถ้าข้อมูลมี Unique ID จะเช็คได้ง่ายมาก เพราะ name กับ detail เป็นข้อมูลที่ถูกแปลงเพื่อใช้แสดงผลแล้ว ทำให้การเทียบข้อมูลจะทำได้ยากกว่า

เมื่อลบข้อมูลเสร็จแล้วก็เอาไปแปลงให้เป็น OrderDetailItem ใหม่อีกครั้งแล้วโยนเข้า updateOrderDetailItems(...) เพื่อให้ Adapter อัปเดตข้อมูลใหม่อีกครั้ง

เพียงเท่านี้ก็สามารถลบข้อมูลได้แล้ว

เพิ่ม Animation ตอนลบข้อมูลกันเถอะ

โค้ดที่เจ้าของบล็อกใช้ในตอนนี้จะให้ผลลัพธ์แบบนี้

ถึงแม้ว่าดูเหมือนจะเสร็จเรียบร้อยแล้ว แต่เอาเข้าจริงถ้าลองสังเกตดีๆจะเห็นว่าตอนที่ลบข้อมูลนั้น สิ่งที่เกิดขึ้นนั้นคือในข้อมูลจะหายไปทันที โดยไม่มี Animation ใดๆเลย

ส่วนหนึ่งที่เจ้าของบล็อกชอบ RecyclerView นั่นก็เพราะว่า Adapter มี ItemAnimator ให้ด้วย และมีคำสั่งต่างๆเพื่ออัปเดตพร้อมกับ Animation เวลาข้อมูลมีการ เพิ่ม, แก้ไข, ย้าย หรือลบ ซึ่งจะต้องเรียกใช้คำสั่งเหล่านี้

  • notifyItemChanged
  • notifyItemMoved
  • notifyItemRemoved
  • notifyItemRangeChanged
  • notifyItemRangeInserted
  • notifyItemRangeRemoved

ดังนั้นจากเดิมที่ใช้คำสั่ง notifyDataSetChanged() ก็จะเปลี่ยนมาใช้คำสั่งเหล่านี้แทน แต่คำสั่งเหล่านี้จะต้องกำหนด Item ที่ต้องการให้ทำงาน เช่น

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

ฟังดูยุ่งยากกว่าเดิมหรือป่าวนะ

ใช่ฮะ ยุ่งยากกว่าเดิมแน่นอน เพื่อให้ข้อมูลใน RecyclerView เปลี่ยนแปลงแบบมี Animation ดังนั้นเวลาที่ข้อมูลมีการเปลี่ยนแปลงก็จะต้องมีโค้ดเทียบข้อมูลระหว่างข้อมูลชุดเก่ากับชุดใหม่ว่ามีอะไรเปลี่ยนแปลงบ้าง และข้อมูลตัวไหนควรใช้คำสั่งแบบไหน

ก็บอกแล้วว่านี่คือการใช้งาน Recycler View กับงานจริงๆ

ถ้าคิดว่าการใช้คำสั่งเหล่านั้นเป็นเรื่องที่ยุ่งยาก อย่าลืมว่า RecyclerView ของเจ้าของบล็อกก็ไม่ได้แสดงข้อมูลแบบธรรมดาๆเช่นกัน

ดังนั้นจะเกิดอะไรขึ้นถ้าข้อมูลตัวหนึ่งถูกลบออกไปแล้วข้อมูลที่เกี่ยวข้องต้องอัปเดตตามด้วย

บันเทิงละสิ

เพราะว่าถ้าผู้ใช้ลบข้อมูลในส่วนของ Music ที่มีข้อมูลอยู่ตัวเดียว ก็จะต้องลบ Section และ Summary ของ Music ทิ้งด้วย และก็ต้องมีการคำนวณราคาสินค้าทั้งหมดใหม่ด้วย ซึ่งเมื่อก่อนการทำอะไรแบบนี้ถือว่ายุ่งยากและเสียเวลามากๆ

แต่สำหรับตอนนี้

ขอแนะนำให้รู้จักกับ DiffUtil ตัวช่วยใหม่ใน Recycler View

คลาส DiffUtil จะมาช่วยให้นักพัฒนาจัดการกับข้อมูลใน RecyclerView ที่ซับซ้อนและมีการเปลี่ยนแปลงเมื่อไรก็ได้

โดยจะเทียบข้อมูล 2 ชุดที่เป็นประเภทเดียวกันว่ามีการเปลี่ยนแปลงอย่างไร หรือพูดง่ายๆก็คือถ้าโยนข้อมูลชุดเก่าและชุดใหม่เข้าไป คลาสตัวนี้จะบอกให้ว่าข้อมูลตัวไหนเปลี่ยนแปลงอย่างไรนั่นเอง อาจจะฟังดูเจ๋ง แต่บางอย่างก็ต้องเขียนเพิ่มเข้าไปเองอยู่นะ

วิธีการเรียกใช้งาน DiffUtil จะต้องสร้างคลาสขึ้นมาแบบนี้

// OrderDetailDiffCallback
class OrderDetailDiffCallback(
    private val oldItems: List<OrderDetailItem>?,
    private val newItems: List<OrderDetailItem>?
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = /* ... */

    override fun getNewListSize(): Int = /* ... */

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return /* ... */
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return /* ... */
    }
}

เนื่องจากหน้าที่ของมันคือเปรียบเทียบระหว่างข้อมูล 2 ชุด จึงต้องรับข้อมูลที่เป็น OrderDetailItem แบบ List มา 2 ชุดด้วยกัน โดยตัวนึงคือข้อมูลชุดเก่า และอีกตัวคือข้อมูลชุดใหม่

สำหรับคำสั่ง getOldListSize() กับ getNewListSize() คงเดาได้ไม่ยาก เพราะเอาไว้เช็คจำนวนข้อมูลของแต่ละตัว และจะเหลืออีก 2 คำสั่ง คือ areItemsTheSame(...) กับ areContentsTheSame(...) สำหรับเปรียบเทียบข้อมูลที่อยู่ในแต่ละตัว ซึ่งมีหน้าที่ดังนี้

  • areItemsTheSame เอาไว้เช็คว่าข้อมูลตัวเก่ากับข้อมูลตัวใหม่นั้นคือตัวเดียวกันหรือป่าว โดยควรจะเช็คจาก Unique ID ของข้อมูลตัวนั้นๆ
  • areContentsTheSame เอาไว้เช็คว่าข้อมูลตัวเก่ากับข้อมูลตัวใหม่มีข้อมูลข้างในเหมือนกันหรือป่าว เพื่อที่ว่าจะได้รู้ว่าข้อมูลตัวนั้นๆมีการเปลี่ยนแปลงค่าข้างในบางอย่างหรือป่าว

ซึ่งเงื่อนไขการเช็คข้อมูลตัวเก่ากับตัวใหม่เป็นหน้าที่ของนักพัฒนานั่นเอง ที่จะต้องใส่โค้ดเพิ่มเข้าไปเพื่อเช็คข้อมูลแล้ว Return ผลลัพธ์ให้ถูกต้อง

ข้อมูลทั้ง 2 ตัวนั้นเป็นข้อมูลตัวเดียวกันหรือป่าว?

คำสั่ง areItemsTheSame(...) มีไว้เช็คว่าข้อมูลมีการเปลี่ยนแปลงหรือไม่ (สำหรับกรณี Remove, Insert หรือ Move)

โดยปกติแล้วการเช็คว่าข้อมูลเป็นตัวเดียวกันหรือไม่นั้น ควรจะใช้วิธีเช็คจาก Unique ID ของข้อมูลแต่ละตัว แต่เนื่องจากเจ้าของบล็อกลืมกำหนด ID ให้กับข้อมูลที่สมมติขึ้นมา (งานชุ่ยชะมัด..) ดังนั้นในกรณีของเจ้าของบล็อกก็จะใช้วิธีเปรียบเทียบว่าเป็นตัวเดียวกันหรือไม่โดยอิงจากชื่อของข้อมูล

// OrderDetailDiffCallback.kt
class OrderDetailDiffCallback(
    private val oldItems: List<OrderDetailItem>?,
    private val newItems: List<OrderDetailItem>?
) : DiffUtil.Callback() {

    override fun getOldListSize(): Int = oldItems?.size ?: 0

    override fun getNewListSize(): Int = newItems?.size ?: 0

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldItems?.getOrNull(oldItemPosition) === newItems?.getOrNull(newItemPosition)
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldItems?.getOrNull(oldItemPosition) == newItems?.getOrNull(newItemPosition)
    }
}

โดย areItemsTheSame(...) จะเทียบข้อมูลด้วย === ส่วน areContentsTheSame(...) เทียบข้อมูลด้วย ==

DiffUtil พร้อมแล้ว! เรียกใช้งานกันเถอะ

เมื่อคลาส OrderDetailDiffCallback พร้อมใช้งานแล้ว ให้กลับมาดูโค้ดใน Activity ก่อน เพราะต้องมีการปรับบางอย่างเพื่อให้ใช้งาน DiffUtil ได้ ซึ่งก็คือตอนที่อัปเดตข้อมูลใน Adapter นั่นเอง

จากเดิมที่คำสั่ง updateOrderDetailItems(...) เป็นแบบนี้

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */
    private fun updateOrderDetailItems(newItems: List<OrderDetailItem>) {
        adapter.orderDetailItems = newItems
        adapter.notifyDataSetChanged()
    }
}

ก็เปลี่ยนมาเป็นแบบนี้แทน

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */
    private fun updateOrderDetailItems(oldItems: List<OrderDetailItem>, newItems: List<OrderDetailItem>) {
        adapter.orderDetailItems = newItems
        DiffUtil.calculateDiff(OrderDetailDiffCallback(oldItems, newItems))
            .dispatchUpdatesTo(adapter)
    }
}

โดยรับข้อมูลเก่าเพิ่มเข้ามาด้วย เพื่อเทียบกับข้อมูลชุดใหม่ด้วย DiffUtil แล้วสั่งให้ Adapter ทำ Animation เพื่ออัปเดตข้อมูลด้วยคำสั่ง dispatchUpdatesTo(...)

เมื่อคำสั่ง updateOrderDetailItems(...) มีการเปลี่ยนแปลง ก็ต้องไปเปลี่ยนคำสั่งที่เรียกใช้งานคำสั่งนี้ด้วยเช่นกัน

เริ่มจากคำสั่งอยู่ใน loadOrderDetail() ซึ่งในกรณีนี้จะถือว่ายังไม่มีข้อมูลเก่า จึงใช้วิธีสร้างข้อมูลเปล่าๆด้วย listOf() แล้วโยนเข้าไปเป็นข้อมูลชุดเก่าแทน

// MainActivity.kt
private fun loadOrderDetail() {
    FakeNetwork.getFakeOrderDetail { orderDetail ->
        this.orderDetail = orderDetail
        val convertedItems = convertToOrderDetailItems(orderDetail)
        updateOrderDetailItems(listOf(), convertedItems)
    }
}

อีกที่นึงก็คือตอนใช้คำสั่งลบข้อมูลออกจาก OrderDetail หรือใน onOrderRemoveClicked นั่นเอง แต่เจ้าของบล็อกอยากให้ลองดูคำสั่งเก่าก่อน

// MainActivity.kt
private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit = { item ->
    orderDetail?.let { newOrderDetail ->
        newOrderDetail.foodList?.removeAll { it.orderName == item.name && "x${it.amount}" == item.detail }
        newOrderDetail.bookList?.removeAll { it.bookName == item.name && it.author == item.detail }
        newOrderDetail.musicList?.removeAll { it.album == item.name && it.artist == item.detail }
        updateOrderDetailItems(listOf(), convertToOrderDetailItems(newOrderDetail))
    }
}

แล้วจะเอาข้อมูลเก่ามาจากไหน? ในเมื่อเจ้าของบล็อกแก้ไขข้อมูลที่อยู่ใน OrderDetail ตัวนั้นไปแล้ว

เพราะในกรณีนี้เจ้าของบล็อกถือข้อมูลเก็บไว้ใน Activity ไม่ได้เก็บไว้ใน Database หรือ Fetch จาก Web Server มาใหม่ ทำให้ตัวแปรนั้นยังเป็นตัวเดิมอยู่ และเมื่อแก้ไขข้อมูลใดๆก็จะทำให้ข้อมูลในนั้นเปลี่ยนแปลงไปในทันที

เจ้าของบล็อกจึงต้องทำการ Copy ข้อมูลชุดนั้นเก็บไว้ก่อนที่จะลบข้อมูลที่อยู่ข้างในออกไป แต่ก็จะพบปัญหาต่อก็คือการ Copy ข้อมูลก็จะไม่สามารถใช้คำสั่งอย่าง copy() ได้ทันที เพราะเป็น Swallow Copy ที่จะไม่ได้ Copy ทั้งหมดขึ้นมาใหม่จริงๆ

ดังนั้นการ Copy ข้อมูลที่สะดวกรวดเร็วที่สุดสำหรับกรณีนี้ก็คือการใช้ Gson เพื่อแปลงข้อมูลให้กลายเป็น JSON String แล้วแปลงกลับเป็น OrderDetail ซะ เท่านี้ก็จะได้ข้อมูลก้อนใหม่แล้ว

// build.gradle
implementation 'com.google.code.gson:gson:2.8.6'

ให้ทำการ Copy ข้อมูลด้วย Gson เก็บไว้ก่อนที่จะลบข้อมูลออก จากนั้นก็จะได้ข้อมูลชุดเก่าและชุดใหม่เพื่อส่งเข้าไปในคำสั่ง updateOrderDetailItems(...) แล้ว

private val onOrderRemoveClicked: (OrderDetailItem.Order) -> Unit = { item ->
    orderDetail?.let { newItems ->
        val gson = Gson()
        val oldItems = gson.fromJson(gson.toJson(newItems), OrderDetail::class.java)
        newItems.foodList?.removeAll { it.orderName == item.name && "x${it.amount}" == item.detail }
        newItems.bookList?.removeAll { it.bookName == item.name && it.author == item.detail }
        newItems.musicList?.removeAll { it.album == item.name && it.artist == item.detail }
        updateOrderDetailItems(convertToOrderDetailItems(oldItems), convertToOrderDetailItems(newItems))
    }
}

เพียงเท่านี้ก็เสร็จเรียบร้อย

มาดูผลลัพธ์ที่ได้จากการใช้ DiffUtil กันเถอะ

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

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

เมื่อคำสั่งทั้งหมดพร้อมทำงานแล้ว ผลลัพธ์ที่เกิดขึ้นก็จะได้ออกมาเป็นแบบนี้

นั่นล่ะครับ Animation ที่เกิดขึ้นเมื่อข้อมูลมีการถูกลบออกไป จะเห็นว่าเมื่อข้อมูลมีการเปลี่ยนแปลงก็อัปเดตผลลัพธ์บน RecyclerView โดยมี Animation แสดงด้วย ซึ่งเป็นผลประโยชน์จากคลาส DiffUtil ล้วนๆเลย ไม่ต้องนั่งทำเองแล้วววววว

แต่ถ้าอยากจะเปลี่ยนรูปแบบของ Animation ก็สามารถกำหนด ItemAnimator ของ Adapter ตัวนั้นๆได้ทันที ไม่มีผลอะไรกับ DiffUtil เพราะ DiffUtil ทำหน้าที่เช็คการเปลี่ยนแปลงของข้อมูลแล้วสั่งให้เกิด Animation เท่านั้น ส่วนรูปแบบ Animation ที่เกิดขึ้นก็เป็นหน้าที่ของ Item Animator อยู่เหมือนเดิม

ถ้าอยากจะเพิ่มหรือแก้ไขข้อมูลจะต้องทำยังไง?

เดาว่าน่าจะมีผู้ที่หลงเข้ามาอ่านบางคนที่ยังสงสัยว่าถ้าเปลี่ยนจากการ “ลบข้อมูล” เป็น “เพิ่มข้อมูล” หรือ “แก้ไขข้อมูลที่อยู่ข้างใน” ล่ะ จะต้องทำยังไง

คำตอบก็คือหัวใจสำคัญทั้งหมดอยู่ที่คลาส OrderDetail ที่เป็นข้อมูลต้นทาง ดังนั้นการลบ, เพิ่ม หรือแก้ไขข้อมูลนั้นไม่ได้ต่างกันเลย สุดท้ายมันก็แค่ข้อมูลที่อยู่ใน List มีการเปลี่ยนแปลงเท่านั้นเอง ซึ่งในเนื้อหาในบทความนี้ก็ครอบคลุมกับการเอาไปใช้ในกรณีเพิ่มหรือแก้ไขข้อมูลอยู่แล้ว ที่ต้องทำก็แค่ใส่โค้ดในส่วนที่จะทำให้ข้อมูล List เปลี่ยนแปลงตามที่ต้องการให้ได้ก็พอ

สรุป

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