จัดการ Fragment Back Stack อย่างไรให้เหมาะสม

เดี๋ยวนี้ Fragment ถือว่าเป็นเรื่องปกติที่นักพัฒนาแอนดรอยด์จะต้องเรียนรู้และใช้งานกัน เพราะว่า Activity เพียงอย่างเดียวไม่ได้ตอบโจทย์ความสามารถบางอย่างได้ ดังนั้น Fragment จึงถูกสร้างขึ้นมาเพื่อชดเชยความสามารถที่ขาดไปใน Activity นั่นเอง แต่ทว่า Fragment ก็มาพร้อมกับการทำงานที่ซับซ้อนมากขึ้นรวมไปถึง Back Stack ด้วยเช่นกัน

ผู้ที่หลงเข้ามาอ่านอาจจะคุ้นเคยกับ Back Stack ของ Activity ที่เรียบง่ายและเข้าใจได้ไม่ยากมากนัก ซึ่ง Fragment ก็มี Back Stack เหมือนกันนะ แต่จะแตกต่างจาก Activity อยู่เล็กน้อย ซึ่งจะขึ้นอยู่กับการเขียนโค้ดของนักพัฒนาว่าอยากจะให้มันทำงานออกมาในรูปแบบไหน

ปกติแล้วเวลาอยากจะแปะ Fragment ซักตัวก็จะเป็นแบบนี้

<fragment
    android:id="@+id/awesomeFragment"
    android:name="com.akexorcist.awesomeapp.AwesomeFragment"
    android:layout_width="..."
    android:layout_height="..."
    ... />

วิธีนี้จะเป็นการแปะ Fragment ผ่าน XML โดยตรง และ Fragment จะไม่ถูกเก็บลงไว้ใน Back Stack

ซึ่งมีค่าเท่ากับการแปะ Fragment ผ่านโค้ดแบบนี้

<!-- XML -->
<FrameLayout
    android:id="@+id/layoutFragmentContainer"
    android:layout_width="..."
    android:layout_height="..."
    .../>
// Kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        if (savedInstanceState == null) {
            supportFragmentManager.beginTransaction()
                .add(R.id.layoutFragmentContainer, AwesomeFragment.newInstance())
                .commit()
        }
    }
}

ดังนั้นถ้าอยากจะให้ Fragment เพิ่มเข้าไปใน Back Stack ด้วยก็จะต้องกำหนดผ่านโค้ดด้วยเพิ่มคำสั่ง addToBackStack(...) เข้าไปด้วยแบบนี้

// Kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        if (savedInstanceState == null) {
            supportFragmentManager.beginTransaction()
                .add(R.id.layoutFragmentContainer, AwesomeFragment.newInstance())
                .addToBackStack(null)
                .commit()
        }
    }
}

และคำสั่ง addToBackStack(…) นั้นสามารถกำหนดชื่อของ Fragment ที่จะเก็บลงใน Back Stack ได้ด้วย เผื่อว่าอยากจะทำอะไรบางอย่างกับ Back Stack แล้วต้องใช้ชื่อดังกล่าวในการสั่งงาน แต่เนื่องจากส่วนใหญ่ไม่ค่อยได้ยุ่งเรื่องแบบนี้กัน ก็เลยใส่เป็น null ไว้แทน

แล้ว Back Stack ของ Fragment มีไว้ทำอะไร?

เหตุผลหลักๆที่ผู้ที่หลงเข้ามาอ่านต้องเพิ่ม Fragment ที่ต้องการลงไปใน Back Stack ก็เพราะว่าอยากจะให้มันกด Back แล้วย้อนกลับไปยัง Fragment ก่อนหน้านั้นเอง

บ่อยครั้งก็จำเป็นต้องสร้างทั้งหน้าด้วย Fragment เนอะ และเวลาเปลี่ยนหน้าก็จะเป็นการเอา Fragment ตัวอื่นมาแปะทับแทน ก็เลยต้องทำให้กดปุ่ม Back แล้วย้อนกลับไป Fragment ก่อนหน้าได้ แทนที่จะปิด Activity ตัวนั้นไปเฉยๆ

เพราะผู้ใช้ไม่จำเป็นต้องรู้ว่าอันไหนคือ Activity อันไหนคือ Fragment รู้แค่ว่าเวลาเปลี่ยนหน้าก็ต้องกดปุ่ม Back เพื่อย้อนกลับไปได้สิ

ทำอะไรกับ Back Stack ของ Fragment ได้บ้าง?

คำสั่งที่เกี่ยวกับ Back Stack ของ Fragment นั้นจะอยู่ใน Fragment Manager ถ้าใช้ Fragment ของ Support Library ก็ให้เรียกคำสั่งจาก Fragment Manager ของ Support Library แต่ถ้ายังใช้ Fragment Manager ของเก่าอยู่ ก็แนะนำให้เปลี่ยนไปใช้ของ Support Library เถอะ…

จำนวน Fragment ที่มีอยู่ใน Back Stack

ถ้าอยากรู้ว่าใน Back Stack มี Fragment อยู่กี่ตัวก็ให้ใช้คำสั่งดังนี้

val count = supportFragmentManager.backStackEntryCount

เนื่องจากคำสั่งของ Fragment Transaction นั้นทำงานแบบ Asynchronous ทำให้ตอนที่เพิ่ม Fragment เข้าไปใน Back Stack จะต้องรอจนกว่า Fragment ถูกสร้างขึ้นมาจริงๆเท่านั้นถึงจะได้ค่าล่าสุดของจำนวน Fragment ใน Back Stack

Event Listener สำหรับ Back Stack ของ Fragment

เมื่ออยากจะรู้ว่าตอนไหนที่มี Fragment ถูกเปลี่ยนแปลงใน Back Stack ก็ให้ใช้คำสั่งดังนี้

supportFragmentManager?.addOnBackStackChangedListener {
    // Do something
}

แต่จะไม่มี Parameter อะไรส่งมาให้นะ ทำได้แค่รู้ว่า Fragment ใน Back Stack มีการเปลี่ยนแปลงเท่านั้น

ย้อนกลับไปยัง Fragment ก่อนหน้าที่อยู่ใน Back Stack (Pop Back Stack)

นอกเหนือจากการกดปุ่ม Back เพื่อย้อนกลับไป Fragment ก่อนหน้านี้ที่อยู่ใน Back Stack แล้ว ผู้ที่หลงเข้ามาอ่านยังสามารถสั่งผ่านโค้ดได้ด้วยคำสั่งเหล่านี้

supportFragmentManager.popBackStack()
supportFragmentManager.popBackStack(id: Int, flags: Int)
supportFragmentManager.popBackStack(name: String?, flags: Int)
supportFragmentManager.popBackStackImmediate()
supportFragmentManager.popBackStackImmediate(id: Int, flags: Int)
supportFragmentManager.popBackStackImmediate(name: String?, flags: Int)

ซึ่งการ Pop ก็จะเป็นไปตาม Stack Concept นั่นเอง Fragment ตัวไหนที่อยู่ข้างบนของ Stack ก็จะโดนลบออกไปจาก Stack

  • popBackStack จะรอจนกว่า Fragment Transaction ทำงานเสร็จก่อนถึงจะทำงาน
  • popBackStackImmediate จะทำงานทันทีโดยไม่สนใจว่า Fragment Transaction จะทำงานอยู่หรือไม่

ในกรณีที่ใช้คำสั่ง popBackStack() หรือ popBackStackImmediate() ก็จะมีผลกับ Fragment ที่อยู่ชั้นบนสุดของ Back Stack

ถ้าอยากจะสั่งไปที่ Fragment ตัวที่ต้องการโดยตรงก็ให้กำหนดด้วย ID ที่ได้มาจากตอนเรียกคำสั่ง commit() ของ Fragment Manager

val id = supportFragmentManager.beginTransaction()
    .add(R.id.layoutFragmentContainer, PageFragment.newInstance(3))
    .addToBackStack(null)
    .commit()
/* ... */
supportFragmentManager.popBackStack(id, 0)

แต่อาจจะรู้สึกลำบากเล็กน้อยเพราะว่าต้องมานั่งเก็บ ID ตอนที่สร้าง Fragment นั้นๆไว้ ดังนั้นที่นิยมกันจริงๆคือการกำหนดชื่อของ Fragment ที่เก็บไว้ใน Back Stack แทน แบบนั้นจะสะดวกกว่าเพราะสามารถตั้งเป็นชื่ออะไรก็ได้ตามใจชอบ แล้วเวลาจะสั่ง Pop ก็ให้กำหนดด้วยชื่อนั้นๆลงไปได้เลย

val name = "PageFragment"
/* ... */
supportFragmentManager.beginTransaction()
    .add(R.id.layoutFragmentContainer, PageFragment.newInstance(3))
    .addToBackStack(name)
    .commit()
/* ... */
supportFragmentManager.popBackStack(name, 0)

เมื่อใช้คำสั่ง popBackStack(...) หรือ popBackStackImmediate(...) ไม่ว่าจะกำหนดด้วย ID หรือชื่อของ Fragment ก็ตาม จะต้องมีการกำหนดค่า Flag ด้วยเสมอ ซึ่งในตัวอย่างข้างบน เจ้าของบล็อกกำหนดค่าเป็น 0 ไว้ ซึ่งจริงๆแล้วสามารถกำหนดค่าได้ 2 แบบด้วยกันคือ 0 กับ FragmentManager.POP_BACK_STACK_INCLUSIVE (มีค่าเป็น 1)

ความต่างกันก็คือตำแหน่งของ Fragment ที่จะทำการ Pop โดย 0 จะเป็นการ Pop เฉพาะ Fragment ที่ Stack อยู่ข้างบน Fragment ตัวที่ระบุ แต่ถ้ากำหนดเป็น 1 หรือ FragmentManager.POP_BACK_STACK_INCLUSIVE จะ Pop ตั้งแต่ Fragment ตัวที่ระบุไว้

เมื่อกด Back จน Back Stack ว่างเปล่า

อันนี้เป็นปัญหายอดนิยมสำหรับนักพัฒนาแอนดรอยด์ที่จะพบว่าตอนท่ีเพิ่ม Fragment เข้าไปใน Back Stack แล้วมีการกด Back จนไม่มี Fragment หลงเหลืออยู่ใน Back Stack

แล้วกลายเป็นว่า Activity จะแสดงหน้าเปล่าๆขึ้นมา (ขั้นตอนที่ 4) เพราะ Layout ทุกอย่างถูกใส่ไว้ใน Fragment ที่ถูก Pop ออกไปเรียบร้อยแล้ว แล้วพอกด Back อีกครั้งก็ถึงจะปิด Activity ตัวนั้นไป (ขั้นตอนที่ 5)

ทั้งๆที่จริงมันควรจะปิด Activity ตัวนั้นๆทันทีเมื่อไม่เหลือ Fragment ใน Back Stack มากกว่าเนอะ

สาเหตุที่เป็นแบบนี้ก็เพราะว่า Fragment ทั้งหมดถูกใส่ไว้ใน Back Stack นั่นเอง และตามธรรมชาติของ Back Stack นั้นก็จะทำการ Pop สิ่งที่อยู่ข้างใน Back Stack ของ Fragment ออกมาให้หมดก่อน แล้วค่อยทำการ Pop ที่ Back Stack ของ Activity อีกทีหนึ่ง

ดังนั้นวิธีที่ดีที่สุดสำหรับการแก้ปัญหานี้คือตอนที่แปะ Fragment ตัวแรกลงใน Activity ไม่ต้องเพิ่มเข้าไปใน Back Stack นั้นเอง

supportFragmentManager.beginTransaction()
        .add(R.id.layoutFragmentContainer, FistAwesomeFragment.newInstance())
        .commit()
/* ... */
supportFragmentManager.beginTransaction()
        .add(R.id.layoutFragmentContainer, SecondAwesomeFragment.newInstance())
        .addToBackStack(null)
        .commit()
/* ... */
supportFragmentManager.beginTransaction()
        .add(R.id.layoutFragmentContainer, ThirdAwesomePageFragment.newInstance())
        .addToBackStack(null)
        .commit()
/* ... */

ส่วน Fragment ตัวอื่นๆหลังจากนั้นก็ให้เพิ่มลงไปใน Back Stack ตามปกติ

แต่ถ้าจำเป็นต้องเพิ่ม Fragment ลงใน Back Stack ทุกตัวและไม่สามารถเช็คได้ว่าตัวไหนจะเป็นตัวแรกสุด ก็ให้ดักที่ onBackPressed() ของ Activity ไว้แบบนี้

override fun onBackPressed() {
    if (supportFragmentManager.backStackEntryCount <= 1) {
        finish()
    } else {
        super.onBackPressed()
    }
}

ซึ่งจะเห็นการเช็คว่าใน Back Stack ของ Fragment ที่อยู่ใน Activity นั้นๆมีน้อยกว่าหรือเท่ากับ 1 ตัวหรือป่าว ถ้าใช่ก็จะสั่งปิด Activity นั้นๆไปเลย แต่ถ้าไม่ใช่ ก็ให้ทำการเรียก super.onBackPressed() ตามปกติ

แต่ชีวิตไม่ได้ง่ายขนาดนั้น เพราะยังมีสิ่งที่เรียกว่า Nested Fragment อยู่!!

ในกรณีที่มี Fragment แปะอยู่บน Activity ถือว่าเป็นการทำงานที่เรียบง่ายมาก ดังนั้นการจัดการกับ Back Stack ของ Fragment จึงแทบไม่ต้องทำอะไรมากมาย

แต่ทว่าในโลกของความจริงกับแอปฯที่ลึกลับซับซ้อนนั้น ไม่สามารถเลี่ยงความซับซ้อนของการใช้งาน Fragment ได้เลย เพราะใน Fragment ก็อาจจะมี Fragment ซ้อนอยู่ในนั้นก็เป็นได้

สิ่งที่ต้องทำให้ได้ก็คือทำให้มันกด Back แล้วย้อนกลับไปทีละหน้าให้ถูกต้อง

แต่ชีวิตมันเศร้าตรงที่แอนดรอยด์ไม่ได้จัดการอะไรแบบนี้ให้เลย ก็เลยต้องมานั่งเขียนโค้ดดักเองว่ามี Nested Fragment อยู่หรือไม่ ถ้ามีก็ให้เช็คข้างในอีกว่ามี Nested Fragment อยู่ข้างในมั้ย เพื่อให้ทำการ Pop เฉพาะ Fragment ที่อยู่ชั้นบนสุดออกมาทีละชั้น

โชคดีที่โค้ดที่จะต้องจัดการความซับซ้อนทั้งหมดนี้จะอยู่ที่ Activity เท่านั้น ไม่ต้องใส่โค้ดอะไรไว้ใน Fragment เลยซักนิด

override fun onBackPressed() {
    val isFragmentPopped = handleNestedFragmentBackStack(supportFragmentManager)
    if (!isFragmentPopped) {
        super.onBackPressed()
    }
}

private fun handleNestedFragmentBackStack(fragmentManager: FragmentManager): Boolean {
    val childFragmentList = fragmentManager.fragments
    if (childFragmentList.size > 0) {
        for (index in childFragmentList.size - 1 downTo 0) {
            val fragment = childFragmentList[index]
            val isPopped = handleNestedFragmentBackStack(fragment.childFragmentManager)
            return when {
                isPopped -> true
                fragmentManager.backStackEntryCount > 0 {
                    fragmentManager.popBackStack()
                    true
                }
                else -> false
            }
        }
    }
    return false
}

โดยคำสั่งนี้จะทำงานถูกต้องก็ต่อเมื่อ Fragment ตัวแรกสุดไม่ได้อยู่ใน Back Stack เท่านั้นนะ ไม่ว่าจะเป็น Fragment ตัวแรกสุดใน Activity หรือ Fragment ตัวแรกสุดใน Fragment ก็ตาม

ดังนั้นกรณีที่ต้องทำให้ Fragment ทุกตัวอยู่ใน Back Stack ก็จะต้องเปลี่ยนคำสั่งเล็กน้อย

override fun onBackPressed() {
    val isFragmentPopped = handleNestedFragmentBackStack(supportFragmentManager)
    if (!isFragmentPopped && supportFragmentManager.backStackEntryCount <= 1) {
        finish()
    } else if (!isFragmentPopped) {
        super.onBackPressed()
    }
}

private fun handleNestedFragmentBackStack(fragmentManager: FragmentManager): Boolean {
    val childFragmentList = fragmentManager.fragments
    if (childFragmentList.size > 0) {
        for (index in childFragmentList.size - 1 downTo 0) {
            val fragment = childFragmentList[index]
            val isPopped = handleNestedFragmentBackStack(fragment.childFragmentManager)
            return when {
                isPopped -> true
                fragmentManager.backStackEntryCount > 1 -> {
                    fragmentManager.popBackStack()
                    true
                }
                else -> false
            }
        }
    }
    return false
}

เพียงเท่านี้ผู้ที่หลงเข้ามาอ่านก็สามารถจัดการกับ Back Stack ของ Fragment ที่น่าปวดหัวกันได้แล้ว

เหนือกว่านั้นคือสิ่งที่เรียกว่า View Pager!!

ในความเป็นจริงนั้นมีแอปฯอีกมากมายที่ต้องใช้ View Pager เพื่อแปะ Fragment ลงบนนั้นแทนที่จะแปะลงใน Activity ตรงๆ และ Fragment ในแต่ละตัวก็อาจจะมี Fragment ซ้อนอยู่ข้างในก็เป็นได้

แล้วถ้าแบบนั้นจะจัดการกับ Back Stack ยังไงล่ะ? คำตอบก็คือต้องเขียนโค้ดเพิ่มเข้าไปสำหรับ View Pager เองจ้าาาาาาาา (น้ำตาจะไหล)

เพราะว่า Fragment ที่อยู่ใน View Pager แต่ละหน้านั้นสามารถมี Fragment ซ้อนอยู่ข้างใน ก็ควรจะมอง Back Stack แยกกันใช่มั้ยล่ะ? ดังนั้นเวลาผู้ใช้กดปุ่ม Back ก็ควรจัดการกับ Back Stack ของ Fragment ที่แสดงผลอยู่เท่านั้น

ส่วน Fragment ที่ไม่ได้แสดงอยู่ ณ ตอนนั้น ต่อให้มันมี Fragment ซ้อนอยู่ข้างในมากแค่ไหน ก็ไม่ควรจะไปยุ่งอะไรกับมันเนอะ

แต่ชีวิตมันไม่ได้ง่ายขนาดนั้นน่ะสิ…

ก่อนจะพูดถึงวิธีการจัดการกับ Back Stack ของ Fragment ที่อยู่ใน View Pager เจ้าของบล็อกมีหลายๆอย่างที่อยากจะบอกให้รู้ก่อนว่า Fragment ที่อยู่บน View Pager มันพิเศษกว่าการแปะลงบน Activity อย่างไร

Fragment Manager มองเห็น Fragment เท่าที่ View Pager สร้างขึ้นมาเท่านั้น

ถ้าผู้ที่หลงเข้ามาอ่านเคยใช้ View Pager มาบ้างแล้ว ก็จะรู้ว่ามันมีค่าที่เรียกว่า Offscreen Page Limit อยู่ด้วย ซึ่งจะเป็นการบอกให้ View Pager ทำการโหลด Fragment ข้างๆเตรียมไว้ล่วงหน้า ซึ่งปกติจะกำหนดค่า Default ไว้เป็น 1​ ซึ่งผู้ที่หลงเข้ามาอ่านกำหนดได้ตามใจชอบ (แต่ค่าต่ำสุดคือ 1)

สมมติว่า View Pager มีทั้งหมด 6 หน้า และ Offscreen Page มีค่าเป็น 1 สมมติว่าเจ้าของบล็อกเลื่อนไปหน้าที่ 3 (Index = 2) สิ่งที่เกิดขึ้นคือ View Pager จะสร้าง Fragment ของหน้า 2 กับ 4 เตรียมไว้ล่วงหน้าให้

สมมติว่าเจ้าของบล็อกใช้คำสั่งใน Fragment Manager แบบนี้

val fragmentList: List<Fragment> = supportFragmentManager.fragments

สิ่งที่เกิดขึ้นคือใน fragmentList จะมี Fragment อยู่แค่ 3 ตัวเท่านั้น คือ Fragment ของหน้าที่ 2 ถึง 4 นั่นเอง เพราะนอกเหนือจากนั้น View Pager จะทำลายทิ้งหรือยังไม่ได้สร้างขึ้นมา จึงทำให้ Fragment Manager มองเห็นเท่าที่สร้างขึ้นมาเท่านั้น

อ้าว ทำไมต้องใช้ Fragment Manager ล่ะ? ดึง Fragment ผ่าน Adapter อย่าง FragmentStatePagerAdapter หรือ FragmentPagerAdapter โดยตรงไปเลยจะไม่ง่ายกว่าหรือ? Fragment ตัวไหนแสดงผลอยู่ก็ดึงจาก Adapter ไปเลยสิ

val currentFragment = adapter.getItem(viewPager.currentItem)

สาเหตุที่เจ้าของบล็อกไม่ทำแบบนี้ก็เพราะว่า…​ (โปรดอ่านข้อต่อไป)

Fragment ที่อยู่ใน View Pager จะมีค่า isAdded() และ isVisible() เพี้ยนไปจากปกติ

โดยปกติแล้วเวลาแปะ Fragment ลงใน Activity ถ้าอยากรู้ว่า Fragment นั้นแสดงผลหรือทำงานอยู่หรือไม่ มักจะเช็คผ่านคำสั่ง isAdded() หรือ isVisible()

แต่เมื่อแปะ Fragment ลงใน View Pager แทน สิ่งที่เกิดขึ้นคือ Fragment ทุกตัวที่ View Pager สร้างขึ้นมาแล้ว isAdded() จะมีค่าเป็น False เสมอ และ isVisible() จะเป็น True เสมอ ถึงแม้ว่าจะไม่ได้แสดงผลอยู่ก็ตาม

เมื่อ isAdded() เป็น False ก็จะทำให้ใช้คำสั่ง Pop Back Stack ไม่ได้เลย

currentFragment.childFragmentManager.popBackStack()

เพราะคำสั่ง popBackStack() เป็นคำสั่งที่สามารถใช้งานได้ก็ต่อเมื่อ Fragment ตัวนั้นมีค่า isAdded() เป็น true เท่านั้น ไม่เช่นนั้นจะเกิด Exception แบบนี้

java.lang.IllegalStateException: Fragment has not been attached yet.
    at android.support.v4.app.Fragment.instantiateChildFragmentManager(Fragment.java:2383)
    at android.support.v4.app.Fragment.getChildFragmentManager(Fragment.java:845)
    at com.akexorcist.fragmentbackstack.MainActivity.handleViewPagerBackStack(MainActivity.kt:92)
    at com.akexorcist.fragmentbackstack.MainActivity.onBackPressed(MainActivity.kt:77)
    at android.app.Activity.onKeyUp(Activity.java:2193)
    at android.view.KeyEvent.dispatch(KeyEvent.java:2664)
    at android.support.v4.view.KeyEventDispatcher.activitySuperDispatchKeyEventPre28(KeyEventDispatcher.java:137)
    at android.support.v4.view.KeyEventDispatcher.dispatchKeyEvent(KeyEventDispatcher.java:87)
    at android.support.v4.app.SupportActivity.dispatchKeyEvent(ComponentActivity.java:126)
    ...

และอย่าคิดว่าจะลักไก่ด้วยการใช้คำสั่ง onAttach(context: Context) ได้นะ ฝันไปเถอะ

val currentFragment = adapter.getItem(viewPager.currentItem)
currentFragment.onAttach(context)
currentFragment.childFragmentManager?.popBackStack()
// พังอยู่ดี

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

ถ้าเป็น Fragment ที่เรียกผ่าน Fragment Manager กลับทำได้!!

ทำแบบนี้

val currentFragment = supportFragmentManager.fragments[0]
currentFragment.childFragmentManager.popBackStack()

อะไรวะ ทำไมมันถึงได้วะเนี่ย มันต่างจาก Fragment ที่ดึงผ่าน Pager Adapter ยังไงเนี่ยยยยย

Fragment Manager คือทางออก แต่มันกลับไม่สัมพันธ์กับ Position ของ View Pager

อย่างที่บอกไว้ในตอนแรกว่า Fragment Manager จะเก็บ Array ของ Fragment ที่ถูกสร้างขึ้นมาไว้เท่านั้น ดังนั้นอย่าถามหา Fragment ที่ถูกทำลายทิ้งหรือยังไม่ได้สร้าง มันไม่มีอยู่ในนี้!!!

เจ้าของบล็อกต้องดึง Fragment ที่แสดงอยู่ใน View Pager ณ ตอนนั้นเพื่อใช้คำสั่ง popBackStack() ให้กับ Fragment ตัวนั้น ถ้าแบบนั้นเจ้าของบล็อกใช้คำสั่งแบบนี้ก็ได้นี่นา

val currentFragment: Fragment? = supportFragmentManager.fragments.find { fragment ->
    fragment == adapter.getItem(viewPager.currentItem)
}

เฮ้ย ก็ง่ายอยู่นะ แค่เอา Fragment จากใน Pager Adapter มาเช็คเทียบกับใน Fragment Manager ว่าตัวไหนมีค่าเท่ากัน เพียงเท่านี้ก็จะได้เป็น Fragment จาก Fragment Manager ที่แสดงผลอยู่แล้ว เย้!

แต่คำสั่งแบบนี้กลับใช้ไม่ได้ เพราะว่า Fragment ที่ได้จาก Fragment Manager น่าจะเป็นตัวเดียวกันกลับ Fragment ที่ได้จาก Pager Adapter ก็จริง เมื่อลองเช็คดูก็จะพบว่ามันคือคนละตัวกันซะงั้น

// Fragment จาก Fragment Manager
PageFragment{afac9c88 #0 id=0x7f070094}

// Fragment จาก Pager Adapter
PageFragment{afafbc38}

สาบานจริงๆว่ามันคือ Fragment ตัวเดียวกัน…

ทำให้วิธีแบบนี้ไม่ได้ผลเพราะได้ผลลัพธ์เป็น null ตลอด แทนที่จะเป็น Fragment ตัวที่แสดงผลอยู่

เพื่อให้สามารถใช้คำสั่ง popBackStack() ได้ จึงต้องใช้ทางออกที่อยากที่สุด นั่นก็คือ…

ต้องเขียนโค้ดคำนวณค่าจาก View Pager เพื่อหา Fragment ที่อยู่ใน Fragment Manager ด้วยตัวเอง

ถ้าเป็นไปได้ก็ไม่อยากทำหรอก… แต่ในเมื่อปัญหาที่ผ่านมามันรุมเร้าและบีบบังคับให้เจ้าของบล็อกต้องทำ ก็คงเลี่ยงไม่ได้แล้วล่ะ

ก็เลยได้โค้ดที่ออกมามีหน้าตาเป็นแบบนี้

private fun getCurrentFragment(fragmentManager: FragmentManager, viewPager: ViewPager): Fragment? {
    val fragmentList = fragmentManager.fragments
    val currentSize = fragmentList.size
    val maxSize = viewPager.adapter?.count ?: 0
    val lastPosition = if (maxSize > 0) maxSize - 1 else 0
    val position = viewPager.currentItem
    val offset = viewPager.offscreenPageLimit
    return when {
        maxSize == 0 -> null
        position <= offset -> fragmentList[position]
        position == lastPosition -> fragmentList[currentSize - 1]
        position > lastPosition - offset -> fragmentList[maxSize - offset]
        currentSize < maxSize -> fragmentList[offset]
        currentSize >= maxSize -> fragmentList[position]
        else -> null
    }
}

เพียงแค่โยน Fragment Manager และ View Pager เข้าไป สิ่งที่ได้ออกมาก็คือ Fragment ที่แสดงอยู่ใน View Pager นั่นเองงงงงงงงงง สะดวกสุดๆ

เมื่อได้ Fragment ที่ต้องการแล้วก็เอาไปเข้าคำสั่งเดิมเพื่อเช็คว่ามี Fragment อยู่ข้างในหรือไม่ ถ้ามีก็ให้ทำการ Pop จาก Back Stack ของ Fragment ที่อยู่ข้างในจนหมดก่อน แล้วค่อยทำใน Back Stack ของ Activity ซะ

จึงทำให้โค้ดทั้งหมดสำหรับจัดการกับ Back Stack เมื่อใช้ View Pager กลายเป็นแบบนี้แทน

val viewPager = /* ... */
/* ... */
override fun onBackPressed() {
    val isFragmentPopped = handleViewPagerBackStack()
    if (!isFragmentPopped) {
        super.onBackPressed()
    }
}

private fun handleViewPagerBackStack(): Boolean {
    val fragment: Fragment? = getCurrentFragment(supportFragmentManager, viewPager)
    return fragment?.let {
        handleNestedFragmentBackStack(fragment.childFragmentManager)
    } ?: run {
        false
    }
}

private fun getCurrentFragment(fragmentManager: FragmentManager, viewPager: ViewPager): Fragment? {
    val fragmentList = fragmentManager.fragments
    val currentSize = fragmentList.size
    val maxSize = viewPager.adapter?.count ?: 0
    val lastPosition = if (maxSize > 0) maxSize - 1 else 0
    val position = viewPager.currentItem
    val offset = viewPager.offscreenPageLimit
    return when {
        maxSize == 0 -> null
        position <= offset -> fragmentList[position]
        position == lastPosition -> fragmentList[currentSize - 1]
        position > lastPosition - offset -> fragmentList[maxSize - offset]
        currentSize < maxSize -> fragmentList[offset]
        currentSize >= maxSize -> fragmentList[position]
        else -> null
    }
}

private fun handleNestedFragmentBackStack(fragmentManager: FragmentManager): Boolean {
    val childFragmentList = fragmentManager.fragments
    if (childFragmentList.size > 0) {
        for (index in childFragmentList.size - 1 downTo 0) {
            val fragment = childFragmentList[index]
            val isPopped = handleNestedFragmentBackStack(fragment.childFragmentManager)
            return when {
                isPopped -> true
                fragmentManager.backStackEntryCount > 1 -> {
                    fragmentManager.popBackStack()
                    true
                }
                else -> false
            }
        }
    }
    return false
}

กลายเป็นว่านอกจากโค้ดสำหรับ Nested Fragment แล้ว ก็จะมีโค้ดสำหรับ View Pager ครอบอยู่อีกชั้นด้วย แต่ก็ยังดีที่โค้ดทั้งหมดยังคงอยู่ใน Activity นะเออ

สรุป

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

แล้วผู้ที่หลงเข้ามาอ่านล่ะ? จัดการกับ Back Stack ของ Fragment ในโปรเจคที่ทำกันอยู่อย่างถูกต้องแล้วหรือยัง?