ConcatAdapter มันก็สะดวกดีนะ แต่ถ้าอยากให้มี LayoutManager หลายๆแบบด้วยล่ะ?

ในโลกของการพัฒนาแอปแอนดรอยด์จะต้องมีการใช้ RecyclerView อยู่เป็นประจำ เพราะ RecyclerView เป็นรูปแบบในการแสดงผลพื้นฐานของแอปส่วนใหญ่ในทุกวันนี้

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

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

ถึงแม้ว่า RecyclerView ที่มีรูปแบบซับซ้อนอาจจะเลี่ยงโค้ดที่เยอะและยุ่งยากไม่ได้ แต่การเอาข้อมูลมาเรียงต่อกันแบบนี้จะมีวิธีไหนที่ง่ายกว่าการทำ Multiple View Type มั้ยนะ?

และนั่นก็คือที่มาของ ConcatAdapter ที่ถูกเพิ่มเข้ามาใน RecyclerView 1.2.0 นั่นเอง

เอา Adapter ของ RecyclerView มาเรียงต่อกันได้ง่ายๆด้วย ConcatAdapter

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

นั่นหมายความว่านักพัฒนาสามารถสร้าง Adapter สำหรับข้อมูลแต่ละชุดแยกกัน แล้วเอามารวมไว้ใน ConcatAdapter เพื่อนำไปกำหนดให้กับ RecyclerView

val context: Context = /* ... */
val recyclerView: RecyclerView = /* ... */
recyclerView.layoutManager = LinearLayoutManager(context)

val adapter1: Adapter1 = /* ... */
val adapter2: Adapter2 = /* ... */
val adapter3: Adapter3 = /* ... */
val concatAdapter = ConcatAdapter(adapter1, adapter2, adapter3)
recyclerView.adapter = concatAdapter

คือมันง่ายมากกกกก ง่ายจนคิดว่าทำไมเพิ่งจะมี ConcatAdapter ให้ใช้กันนะ เพราะนักพัฒนาแอนดรอยด์นั้นทุกทนทรมานกับ Multiple View Type ใน RecyclerView กันมานานหลายปี 😂

ถ้าอยากให้ Adapter แต่ละตัวใน ConcatAdapter มี LayoutManager ต่างกันล่ะ?

จากตัวอย่างโค้ดก่อนหน้าจะเห็นว่าเราสามารถกำหนด LayoutManager ได้แค่แบบเดียวเท่านั้น เพราะ LayoutManager จะต้องกำหนดไว้ใน RecyclerView ไม่ใช่ Adapter

แล้วถ้าต้องการทำแบบนี้ล่ะ?

โดยให้ RecyclerView เป็น Vertical Scroll แต่ว่ามีข้อมูลใน Adapter บางตัวเป็น Horizontal Scroll และในขณะเดียวกัน ข้อมูลใน Adapter อีกตัวก็แสดงข้อมูลเป็นแบบ Grid แทน

ยังใช้ ConcatAdapter ได้อยู่มั้ยนะ ในเมื่อ RecyclerView สามารถกำหนด LayoutManager ได้แค่เพียงแบบเดียวเท่านั้น 🤔

แต่ก็ไม่จำเป็นต้องใช้ RecyclerView แค่ตัวเดียวนี่นา?

เพื่อให้มี Adapter บางตัวที่เป็น Horizontal Scroll อยู่ใน ConcatAdapter ด้วย จึงจำเป็นต้องสร้าง RecyclerView เพิ่มเข้ามาอีกตัวเพื่อทำหน้าที่นี้โดยเฉพาะ

โดยให้ RecyclerView ตัวที่อยู่ข้างนอกสุดใช้ GridLayoutManager (Vertical) และ RecyclerView ตัวที่อยู่ข้างใน Adapter 2 ใช้ LinearLayoutManager (Horizontal) นั่นเอง

• RecyclerView - GridLayoutManager (Vertical)
  • ConcatAdapter
    • Adapter 1
    • Adapter 2
      • RecyclerView - LinearLayoutManager (Horizontal)
        • Adapter 4
    • Adapter 3

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

Adapter 2 ทำหน้าที่เป็น Wrapper เพื่อครอบ Recycler และ Adapter 4 ไว้ข้างในอีกชั้นนั่นเอง

ทำไมต้องใช้ GridLayoutManager?

เนื่องจาก Adapter 1 จะต้องแสดงข้อมูลแถวละ 1 ตัว แต่ใน Adapter 3 แสดงข้อมูลแถวละ 2 ตัว การใช้ GridLayoutManager จึงเป็นตัวเลือกที่เหมาะสมที่สุด เพราะมี spanSizeLookup ที่สามารถกำหนดจำนวนคอลัมน์ที่จะให้แสดงผลได้

ที่ใช้ 12 Column เพราะว่าเป็นตัวเลขที่สามารถหารด้วย 1, 2, 3, 4, 6 หรือ 12 ได้ลงตัว จึงง่ายต่อการเปลี่ยนจำนวน Column ในอนาคต

ส่วน Adapter 2 นั้นจะไม่ได้แสดงข้อมูลโดยตรง แต่จะแสดง RecyclerView ข้างในอีกที ดังนั้นจึงต้องกำหนดเป็น 1 คอลัมน์ไว้

RecyclerView ตัวข้างในจะยังรองรับการ Recycle อยู่หรือไม่?

รองรับ จึงทำให้ ViewHolder ที่อยู่ข้างใน Adapter ทุกตัวนั้นรองรับการ Recycle ของ RecyclerView ได้อย่างถูกต้อง

มาเริ่มกันเลยดีกว่า

เพื่อให้เห็นภาพและเข้าใจมากขึ้น เริ่มจากการเปลี่ยนชื่อเรียก Adapter ต่างๆก่อนเป็นอย่างแรก โดยที่

  • Adapter 1 : OneColumnAdapter
  • Adapter 2 : HorizontalWrapperAdapter
  • Adapter 3 : TwoColumnAdapter
  • Adapter 4 : HorizontalAdapter
• RecyclerView - GridLayoutManager (Vertical)
  • ConcatAdapter
    • OneColumnAdapter
    • HorizontalWrapperAdapter
      • RecyclerView - LinearLayoutManager (Horizontal)
        • HorizontalAdapter
    • TwoColumnAdapter
ในตัวอย่างนี้จะใช้ข้อมูลเป็นแบบ String ทั้งหมด ในความเป็นจริงเราจะใช้แบบไหนก็ได้ ใช้เป็น Data Class ก็ได้ เพราะยังไงข้อมูลใน Adapter แต่ละตัวจะแยกกันอย่างอิสระอยู่แล้ว
ในบทความนี้จะใช้ ViewBinding โดยส่ง Binding เข้ามาให้ ViewHolder เรียกใช้งาน

OneColumnAdapter

สำหรับ OneColumnAdapter จะแสดงข้อมูลแบบ List ทั่วๆไป จึงสามารถสร้าง Adapter และ ViewHolder ขึ้นมาตามปกติได้เลย

// OneColumnViewHolder.kt
class OneColumnViewHolder(
    private val binding: ViewOneColumnBinding
) : RecyclerView.ViewHolder(binding.root) {
    /* ... */
}
ViewOneColumnBinding ถูกสร้างด้วย ViewBinding จาก Layout ที่ชื่อว่า view_one_column.xml
// OneColumnAdapter.kt
class OneColumnAdapter(
    /* ... */
) : RecyclerView.Adapter<OneColumnViewHolder>() {
    /* ... */
}

TwoColumnAdapter

ถึงแม้จะเป็นการแสดงข้อมูลแบบ 2 คอลัมน์ แต่ก็ยังคงเป็นการแสดงข้อมูลรูปแบบเดียวกับ OneColumnAdapter อยู่ดี นั่นก็คือใช้ Vertical Scrolling จึงสร้างขึ้นมาด้วยรูปแบบเดียวกัน ส่วนการแบ่งคอลัมน์ให้เป็นหน้าที่ของ GridLayoutManager แทน

// TwoColumnViewHolder.kt
class TwoColumnViewHolder(
    private val binding: ViewTwoColumnBinding
) : RecyclerView.ViewHolder(binding.root) {
    /* ... */
}
ViewTwoColumnBinding ถูกสร้างด้วย ViewBinding จาก Layout ที่ชื่อว่า view_two_column.xml
// TwoColumnAdapter.kt
class TwoColumnAdapter(
    /* ... */
) RecyclerView.Adapter<TwoColumnViewHolder>() {
    /* ... */
}

HorizontalAdapter  และ HorizontalWrapperAdapter

อย่างที่บอกไปว่าสำหรับ Adapter ที่ต้องการ Horizontal Scrolling จะต้องสร้าง Adapter เพิ่มขึ้นมาอีกตัวเพื่อทำเป็น Wrapepr ดังนั้นจึงสร้าง HorizontalWrapperAdapter เพิ่มเข้ามาเพื่อทำหน้าที่นี้

โดยเริ่มจากการสร้าง HorizontalAdapter ก่อน ซึ่งจะเป็นข้อมูลแบบ List เหมือนกัน แต่ว่าจะถูกนำไปใช้แสดงผลเป็น Horizontal Scrolling

// HorizontalViewHolder.kt
class HorizontalViewHolder(
    private val binding: ViewHorizontalCardBinding
) : RecyclerView.ViewHolder(binding.root) {
    /* ... */
}
ViewHorizontalCardBinding ถูกสร้างด้วย ViewBinding จาก Layout ที่ชื่อว่า view_horizontal.xml
// HorizontalAdapter.kt
class HorizontalAdapter(
    /* ... */
) : RecyclerView.Adapter<HorizontalViewHolder>() {
    /* ... */
}

ส่วน HorizontalWrapperAdapter จะสร้างขึ้นมาโดยข้างในมี ViewHolder เพียงตัวเดียว ที่มี RecyclerView อยู่ข้างในเท่านั้น

<!-- view_horizontal_wrapper.xml -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

และใน RecyclerView ตัวนี้ก็แสดงข้อมูลจาก HorizontalAdapter นั่นเอง

// HorizontalWrapperViewHolder.kt
class HorizontalWrapperViewHolder(
    private val binding: ViewHorizontalWrapperBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(adapter: HorizontalAdapter) {
        val context = binding.root.context
        binding.recyclerView.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
        binding.recyclerView.adapter = adapter
    }
}
ViewHorizontalWrapperBinding ถูกสร้างด้วย ViewBinding จาก Layout ที่ชื่อว่า view_horizontal_wrapper.xml

จะเห็นว่า HorizontalAdapter ที่ใช้กำหนดให้กับ RecyclerView ไม่ได้ถูกสร้างขึ้นมาจากข้างใน ViewHolder แต่จะถูกส่งเข้ามาผ่านทาง bind(adapter: HorizontalAdapter) แทน

เพราะว่า HorizontalAdapter จะถูกสร้างจาก Activity หรือ Fragment แล้วโยนค่าเข้ามาแทน เพื่อให้สะดวกต่อการจัดการและเรียกใช้งาน แทนการใส่ Logic Code ไว้ใน Adapter โดยตรง (ซึ่งจัดการยากกว่า)

// HorizontalWrapperAdapter.kt
class HorizontalWrapperAdapter(
    private val adapter: HorizontalAdapter
) : RecyclerView.Adapter<HorizontalWrapperViewHolder>() {
    /* ... */
    override fun onBindViewHolder(holder: HorizontalWrapperViewHolder, position: Int) {
        holder.bind(adapter)
    }
    
    override fun getItemCount(): Int = 1
}

โดยให้ Activity หรือ Fragment สร้าง HorizontalAdapter แล้วโยนเข้ามาเป็น Constructor Parameter ของ HorizontalWrapperAdapter เพื่อส่งต่อผ่านคำสั่ง bind(...) ของ HorizontalWrapperViewHolder ในตอน onBindViewHolder(...)

สร้าง ConcatAdapter เพื่อรวม Adapter ทั้งหมดเข้าด้วยกัน

เมื่อเตรียม Adapter ครบหมดทุกตัวแล้ว ก็สร้างแต่ละตัวขึ้นมาเพื่อกำหนดให้กับ ConcatAdapter

val oneColumnAdapter = OneColumnAdapter(/* ... */)
val horizontalAdapter = HorizontalAdapter(/* ... */)
val horizontalWrapperAdapter = HorizontalWrapperAdapter(horizontalAdapter)
val twoColumnAdapter = TwoColumnAdapter(/* ... */)
val concatAdapter = ConcatAdapter(oneColumnAdapter, horizontalWrapperAdapter, twoColumnAdapter)

จากนั้นก็กำหนด LayoutManager และ Adapter ให้ RecyclerView ที่อยู่ใน Activity หรือ Fragment

val context: Context = /* ... */
val recyclerView: RecyclerView = /* ... */
val concatAdapter: ConcatAdapter = /* ... */
val gridLayoutManager = GridLayoutManager(context, 12)
with(recyclerView) {
    layoutManager = gridLayoutManager
    adapter = concatAdapter
}

กำหนดจำนวนคอลัมน์ให้กับ GridLayoutManager ด้วย SpanSizeLookup

เพื่อให้ข้อมูลใน TwoColumnAdapter แสดงข้อมูลเป็น 2 คอลัมน์ส่วน Adapter ตัวอื่นแสดงข้อมูลแค่ 1 คอลัมน์จึงต้องกำหนดค่า SpanSizeLookup เพื่อใช้กำหนดจำนวนคอลัมน์ให้กับ RecyclerView

val gridLayoutManager: GridLayoutManager = /* ... */
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return /* Number of span size */
    }
}

แล้วจะรู้ได้ไงว่า position ที่ส่งเข้ามาใน getSpanSize(...) นั้นเป็นของ Adapter ตัวไหน? เพราะว่า Item Position ที่ส่งเข้ามาใน Override Method ตัวนี้จะคิดจากข้อมูลของ Adapter ทุกตัวรวมกัน

เพื่อให้ง่ายต่อการเขียนโค้ด เจ้าของบล็อกขอแนะนำให้ใช้คำสั่ง getItemViewType(position: Int): Int ของ ConcatAdapter แทน เพื่อนำค่า View Type ไปเช็คว่าเป็นของ Adapter ตัวไหน

val concatAdapter: ConcatAdapter = /* ... */
override fun getSpanSize(position: Int): Int {
    return when(concatAdapter.getItemViewType(position)) {
          /* Is OneColumnAdapter */ -> 12
          /* Is TwoColumnAdapter */ -> 6
          /* Is HorizontalWrapperAdapter */ -> 12
          else -> 12
    }
}

แล้วจะรู้ได้ไงว่า Adapter ตัวไหนมีค่า View Type เท่าไร?

โดยปกติแล้ว ConcatAdapter จะส่งค่าจากคำสั่ง getItemViewType(...) กลับมาเป็นตัวเลขที่เริ่นต้นจาก 0 และเรียงลำดับไปเรื่อยๆตามลำดับของ Adapter ที่กำหนดไว้

val oneColumnAdapter: OneColumnAdapter = /* ... */
val horizontalWrapperAdapter: HorizontalWrapperAdapter = /* ... */
val twoColumnAdapter: TwoColumnAdapter = /* ... */
val concatAdapter = ConcatAdapter(oneColumnAdapter, horizontalWrapperAdapter, twoColumnAdapter)

เมื่อย้อนกลับไปดูโค้ดก่อนหน้าจะเห็นว่า Adapter ที่กำหนดให้กับ ConcatAdapter เรียงลำดับตามนี้

  • OneColumnAdapter : มีค่าเป็น 0
  • HorizontalWrapperAdapter : มีค่าเป็น 1
  • TwoColumnAdapter : มีค่าเป็น 2
เพราะ HorizontalAdapter อยู่ข้างใน HorizontalWrapperAdapter อีกที จึงไม่มีผลต่อการคำนวณในตรงนี้

ค่าของ View Type ที่แปรผันตามลำดับของ Adapter ที่กำหนดให้กับ ConcatAdapter นั้นไม่ใช่เรื่องที่ดีซักเท่าไร เพราะถ้าวันนึงมีการสลับลำดับในการแสดงผลของ Adapter ก็อาจจะทำให้ค่า View Type เปลี่ยนแปลงได้

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

  • OneColumnAdapter : กำหนดให้ View Type เป็น 1111
  • HorizontalWrapperAdapter : กำหนดให้ View Type เป็น 2222
  • TwoColumnAdapter : กำหนดให้ View Type เป็น 3333

โดยการกำหนดค่า View Type ให้กับ Adapter ใน ConcatAdapter จะต้องสร้าง Config เพื่อกำหนดให้ปิดใช้งาน Isolate View Type ของ ConcatAdapter เสียก่อน

val oneColumnAdapter: OneColumnAdapter = /* ... */
val horizontalWrapperAdapter: HorizontalWrapperAdapter = /* ... */
val twoColumnAdapter: TwoColumnAdapter = /* ... */

val config = ConcatAdapter.Config.Builder().apply {
    setIsolateViewTypes(false)
}.build()
val concatAdapter = ConcatAdapter(config, oneColumnAdapter, horizontalWrapperAdapter, twoColumnAdapter)
ConcatAdapter.Config จะเป็น Constructor Parameter ตัวแรกสุด และตัวถัดไปจะเป็น Adapter ใดๆ

ใน Adapter ทั้ง 3 ตัวก็ให้เพิ่ม Override Method ที่ชื่อ getItemViewType(position: Int): Int แล้วกำหนดค่าให้ครบทุกตัว

// OneColumnAdapter.kt
class OneColumnAdapter(
    /* ... */
) : RecyclerView.Adapter<OneColumnViewHolder>() {
    /* ... */
    companion object {
        const val VIEW_TYPE = 1111
    }
    
    override fun getItemViewType(position: Int) = VIEW_TYPE
}

// HorizontalWrapperAdapter.kt
class HorizontalWrapperAdapter(
    /* ... */
) : RecyclerView.Adapter<HorizontalWrapperViewHolder>() {
    /* ... */
    companion object {
        const val VIEW_TYPE = 2222
    }
    
    override fun getItemViewType(position: Int) = VIEW_TYPE
}

// TwoColumnAdapter.kt
class TwoColumnAdapter(
    /* ... */
) : RecyclerView.Adapter<TwoColumnViewHolder>() {
    /* ... */
    companion object {
        const val VIEW_TYPE = 3333
    }
    
    override fun getItemViewType(position: Int) = VIEW_TYPE
}

เพียงเท่านี้ก็จะสามารถกำหนด Span Size ให้กับ Adapter แต่ละตัวโดยแยกจาก View Type ได้แล้ว

val gridLayoutManager: GridLayoutManager = /* ... */
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return when (adapter.getItemViewType(position)) {
            OneColumnAdapter.VIEW_TYPE -> 12
            TwoColumnAdapter.VIEW_TYPE -> 6
            HorizontalWrapperAdapter.VIEW_TYPE -> 12
            else -> 12
        }
    }
}

ปัญหา RecyclerView ที่อยู่ใน ViewHolder ไม่จำ Scroll Position หลังจากถูก Recycle

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

ซึ่งเป็นเรื่องที่เกิดขึ้นได้ปกติ เพราะว่า RecyclerView ที่อยู่ข้างใน ViewHolder จำ State เดิมของตัวเองก่อนถูกทำลายแบบ RecyclerView ที่อยู่บน Activity หรือ Fragment ให้โดยอัตโนมัติไม่ได้ได้อยู่แล้ว

เพื่อแก้ปัญหานี้ นักพัฒนาจะต้องเก็บ Scroll Position ไว้ และเมื่อ ViewHolder ของ RecyclerView ตัวนี้กลับมาแสดงบนหน้าจออีกครั้งก็ให้สั่งเลื่อนไปยังตำแหน่งล่าสุด และเนื่องจาก ViewHolder ตัวดังกล่าวเป็นของ HorizontalWrapperAdapter ที่แสดง ViewHolder แค่เพียงตัวเดียวอยู่แล้ว ดังนั้นเจ้าของบล็อกจึงสามารถเก็บ Scroll Position ไว้ข้างในนี้ได้เลย

ดังนั้นใน HorizontalWrapperViewHolder จะเพิ่มคำสั่ง getScrollXPosition() เพื่อใช้ดึงค่า Scroll Position เพื่อเก็บไว้ใน Adapter ก่อนที่จะถูกทำลาย แล้วในคำสั่ง bind(...) ก็จะส่งค่า Scroll Position ที่เก็บไว้ล่าสุดเพื่อกำหนดให้กับ RecyclerView ด้วย

// HorizontalWrapperViewHolder.kt
class HorizontalWrapperViewHolder(
    private val binding: ViewHorizontalWrapperBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(adapter: HorizontalAdapter, lastScrollX: Int) {
        /* ... */
        binding.recyclerView.scrollBy(lastScrollX, 0)
    }

    fun getScrollXPosition() = binding.recyclerView.computeHorizontalScrollOffset()
}

ในขณะที่ HorizontalWrapperAdapter จะเพิ่ม Override Method ที่ชื่อว่า onViewRecycled(holder: HorizontalWrapperViewHolder) เพื่อเก็บค่าดังกล่าวไว้เป็น Global Variable ก่อนที่ ViewHolder จะถูก Recycle และส่งกลับเข้าไปใน bind(...) ตอนที่ onBindViewHolder(...) ทำงานอีกครั้ง

// HorizontalWrapperAdapter.kt
class HorizontalWrapperAdapter(
    private val adapter: HorizontalAdapter
) : RecyclerView.Adapter<HorizontalWrapperViewHolder>() {
    private var lastScrollX = 0
    /* ... */
    override fun onBindViewHolder(holder: HorizontalWrapperViewHolder, position: Int) {
        holder.bind(adapter, lastScrollX)
    }
    
    override fun onViewRecycled(holder: HorizontalWrapperViewHolder) {
        lastScrollX = holder.getScrollXPosition()
        super.onViewRecycled(holder)
    }
}

เพียงเท่านี้ RecyclerView ที่อยู่ข้างในนี้ก็จะจำ Scroll Position ได้แล้ว

แต่ Scroll Position ก็หายไปตอนที่เกิด Configuration Changes หรือ Activity Recreation อยู่ดี

เพราะตอนที่เกิด Config Changes หรือ Activity Recreation ค่าที่อยู่ใน Adapter จะไม่ได้ถูกเก็บไว้ จึงทำให้ค่าหายไปได้ และส่งผลให้ Scroll Position ของ RecyclerView  กลับมาอยู่ตั้งต้นอีกครั้ง

ดังนั้นในกรณีนี้ก็อาจจะต้องใช้คำสั่ง addOnScrollListener(...) ของ RecycleView เพื่ออัปเดตค่าเก็บไว้ใน HorizontalWrapperAdapter อยู่ตลอดเวลา แล้วเวลาที่เกิด Config Changes หรือ Activity Recreation ก็ให้ดึงค่าดังกล่าวไป Save และ Restore ให้เรียบร้อย

// HorizontalWrapperViewHolder.kt
class HorizontalWrapperViewHolder(
    private val binding: ViewHorizontalWrapperBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(adapter: HorizontalAdapter, lastScrollX: Int, onScrolled: (Int) -> Unit) {
        /* ... */
        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                onScrolled(recyclerView.computeHorizontalScrollOffset())
            }
        })
    }
}

ทำให้จากเดิมที่ HorizontalWrapperAdapter จะต้องเก็บค่า Scroll Position ตอน onViewRecycled(...) ก็จะเปลี่ยนมาเป็น Listener ในตอนที่เรียกคำสั่ง bind(...) แทน

// HorizontalWrapperAdapter.kt
class HorizontalWrapperAdapter(
    private val adapter: HorizontalAdapter
) : RecyclerView.Adapter<HorizontalWrapperViewHolder>() {
    private var lastScrollX = 0
    /* ... */
    override fun onBindViewHolder(holder: HorizontalWrapperViewHolder, position: Int) {
        holder.bind(adapter, lastScrollX) { x ->
            lastScrollX = x
        }
    }
}

และสำหรับการ Save และ Restore UI State ของ Activity หรือ Fragment นั้น จะต้องทำการเก็บค่าดังกล่าวด้วย แต่ทว่าการโยนค่าตัวนี้ออกไปให้ Activity หรือ Fragment ไปเลยก็คงจะไม่ค่อยเหมาะซักเท่าไร เนื่องจากเป็นค่าที่ใช้งานเฉพาะใน Adapter ตัวนี้เท่านั้น

ดังนั้นในกรณีนี้แนะนำให้ Activity หรือ Fragment เป็นคนเรียกคำสั่งข้างใน Adapter เพื่อ Save และ Restore UI State แทนดีกว่า

// HorizontalWrapperAdapter.kt
class HorizontalWrapperAdapter(
    /* ... */
) : RecyclerView.Adapter<HorizontalWrapperViewHolder>() {
    private var lastScrollX = 0
    
    companion object {
        private const val KEY_SCROLL_X = "horizontal.wrapper.adapter.key_scroll_x"
        /* ... */
    }
    
    fun onSaveState(outState: Bundle) {
        outState.putInt(KEY_SCROLL_X, lastScrollX)
    }

    fun onRestoreState(savedState: Bundle) {
        lastScrollX = savedState.getInt(KEY_SCROLL_X)
    }
    /* ... */
}
เนื่องจาก Activity หรือ Fragment ที่เรียกใช้งาน Adapter ตัวนี้ก็อาจจะต้อง Save และ Restore UI State เช่นกัน ดังนั้นจึงควรตั้ง Key ให้ Unique มากที่สุดเพื่อป้องกันการเผลอเก็บข้อมูลทับกันเพราะ Key บังเอิญตรงกัน

โดยในตัวอย่างจะใช้ Key ว่า "horizontal.wrapper.adapter.key_scroll_x"

โดยคำสั่ง onSaveState(...) และ onRestoreState(...) จะรับค่าเป็น Bundle เข้ามา เพื่อจัดการกับข้อมูลตอน Save และ Restore UI State

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    /* ... */
    private val horizontalWrapperAdapter: HorizontalWrapperAdapter = /* ... */
    
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        horizontalWrapperAdapter.onSaveState(outState)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        super.onRestoreInstanceState(savedInstanceState)
        horizontalWrapperAdapter.onRestoreState(savedInstanceState)
    }
}

และนอกจากนี้การกำหนดค่า Scroll Position ให้ RecyclerView หลังจากที่ Restore UI State จะไม่สามารถกำหนดค่าได้ทันที จะต้องใช้ doOnPreDraw(...) ของ Core KTX เข้ามาช่วย เพื่อรอให้ RecyclerView พร้อมสำหรับการแสดงผลถึงจะกำหนด Scroll Position ให้

// HorizontalWrapperViewHolder.kt
class HorizontalWrapperViewHolder(
    private val binding: ViewHorizontalWrapperBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(adapter: HorizontalAdapter, lastScrollX: Int, onScrolled: (Int) -> Unit) {
        /* ... */
        binding.recyclerView.doOnPreDraw {
            binding.recyclerView.scrollBy(lastScrollX, 0)
        }
    }
}

เพียงเท่านี้ RecyclerView ที่อยู่ข้างใน HorizontalWrapperAdapter ก็จะสามารถจำ Scroll Position ได้ ไม่ว่าจะถูก Recycle,  Config Changes หรือ Activity Recreation ก็ตาม

ในที่สุดก็ได้ ConcatAdapter ที่สามารถใช้ LayoutManager หลายๆแบบได้แล้ว!

จะเห็นว่านอกจาก ConcatAdapter จะช่วยให้นักพัฒนาสามารถรวม Adapter หลายๆตัวมารวมอยู่ด้วยกัน แทนการสร้าง Multiple View Type ใน Adapter เพียงตัวเดียวที่จะทำให้โค้ดมีความซับซ้อน และมาแก้ไขภายหลังได้ยาก เพราะต้องทำความเข้าใจกับ View Type และ Logic ที่อยู่ใน Adapter เพียงตัวเดียว

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

และขั้นตอนจัดการกับ Scroll Position ของ Nested RecyclerView ก็เป็นเรื่องปกติที่ต้องจัดการอยู่แล้ว ต่อให้ใช้ Adapter ที่เป็น Multiple View Type ก็ตาม ดังนั้น Cost ของโค้ดในส่วนนี้ก็จะเท่าเดิม

สำหรับผู้ที่หลงเข้ามาอ่านคนใดอยากเห็นโค้ดทั้งหมดของบทความนี้ สามารถตามกันไปดูได้ที่

akexorcist/ConcatAdapterMultipleLayoutManager
[Android] Using ConcatAdapter with multiple LayoutManager in single RecyclerView - akexorcist/ConcatAdapterMultipleLayoutManager

แหล่งข้อมูลอ้างอิง