material-components / material-components-android

Modular and customizable Material Design UI components for Android
Apache License 2.0
16.34k stars 3.06k forks source link

[TabLayout] TabLayoutMediator crashes if page count decreases #616

Closed gmk57 closed 5 years ago

gmk57 commented 5 years ago

Description: TabLayoutMediator crashes if ViewPager2 adapter item count decreases when last tab was selected.

Expected behavior: Tabs are updated reflecting adapter state (new last tab is selected).

Stack trace:

java.lang.NullPointerException: Attempt to invoke virtual method 'void com.google.android.material.tabs.TabLayout$Tab.select()' on a null object reference
    at com.google.android.material.tabs.TabLayoutMediator.populateTabsFromPagerAdapter(TabLayoutMediator.java:165)
    at com.google.android.material.tabs.TabLayoutMediator$PagerAdapterObserver.onChanged(TabLayoutMediator.java:265)
    at androidx.recyclerview.widget.RecyclerView$AdapterDataObservable.notifyChanged(RecyclerView.java:12244)
    at androidx.recyclerview.widget.RecyclerView$Adapter.notifyDataSetChanged(RecyclerView.java:7345)

Source code: See sample project

Android API version: 25, 28

Material Library version: 1.1.0-alpha10 ViewPager2 1.0.0-beta04

Device: Wileyfox Swift, Android Emulator

alexandrosla commented 5 years ago

Same problem here!

Until we have a proper fix for this issue, you could use a CustomTabLayoutMediator instead, and check tabLayout.getTabAt(currItem)?.select()

below you can find a full CustomTabLayoutMediator in Kotlin.

import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.*
import com.google.android.material.tabs.TabLayout
import java.lang.ref.WeakReference

class CustomTabLayoutMediator(
    private val tabLayout: TabLayout,
    private val viewPager: ViewPager2,
    private val autoRefresh: Boolean,
    private val tabConfigurationStrategy: TabConfigurationStrategy
) {
    private var adapter: RecyclerView.Adapter<*>? = null
    private var attached: Boolean = false

    private var onPageChangeCallback: TabLayoutOnPageChangeCallback? = null
    private var onTabSelectedListener: TabLayout.OnTabSelectedListener? = null
    private var pagerAdapterObserver: RecyclerView.AdapterDataObserver? = null

    /**
     * A callback interface that must be implemented to set the text and styling of newly created
     * tabs.
     */
    interface TabConfigurationStrategy {
        /**
         * Called to configure the tab for the page at the specified position. Typically calls [ ][TabLayout.Tab.setText], but any form of styling can be applied.
         *
         * @param tab The Tab which should be configured to represent the title of the item at the given
         * position in the data set.
         * @param position The position of the item within the adapter's data set.
         */
        fun onConfigureTab(tab: TabLayout.Tab, position: Int)
    }

    constructor(
        tabLayout: TabLayout,
        viewPager: ViewPager2,
        tabConfigurationStrategy: TabConfigurationStrategy
    ) : this(tabLayout, viewPager, true, tabConfigurationStrategy) {
    }

    /**
     * Link the TabLayout and the ViewPager2 together. Must be called after ViewPager2 has an adapter
     * set. To be called on a new instance of TabLayoutMediator or if the ViewPager2's adapter
     * changes.
     *
     * @throws IllegalStateException If the mediator is already attached, or the ViewPager2 has no
     * adapter.
     */
    fun attach() {
        check(!attached) { "TabLayoutMediator is already attached" }
        adapter = viewPager.adapter
        checkNotNull(adapter) { "TabLayoutMediator attached before ViewPager2 has an " + "adapter" }
        attached = true

        // Add our custom OnPageChangeCallback to the ViewPager
        onPageChangeCallback = TabLayoutOnPageChangeCallback(tabLayout).apply {
            viewPager.registerOnPageChangeCallback(this)
        }

        // Now we'll add a tab selected listener to set ViewPager's current item
        onTabSelectedListener = ViewPagerOnTabSelectedListener(viewPager).apply {
            tabLayout.addOnTabSelectedListener(this)
        }

        // Now we'll populate ourselves from the pager adapter, adding an observer if
        // autoRefresh is enabled
        if (autoRefresh) {
            // Register our observer on the new adapter
            pagerAdapterObserver = PagerAdapterObserver().apply {
                adapter?.registerAdapterDataObserver(this)
            }
        }

        populateTabsFromPagerAdapter()

        // Now update the scroll position to match the ViewPager's current item
        tabLayout.setScrollPosition(viewPager.currentItem, 0f, true)
    }

    /**
     * Unlink the TabLayout and the ViewPager. To be called on a stale TabLayoutMediator if a new one
     * is instantiated, to prevent holding on to a view that should be garbage collected. Also to be
     * called before [.attach] when a ViewPager2's adapter is changed.
     */
    fun detach() {

        pagerAdapterObserver?.let {
            adapter?.unregisterAdapterDataObserver(it)
        }

        onTabSelectedListener?.let {
            tabLayout.removeOnTabSelectedListener(it)
        }

        onPageChangeCallback?.let {
            viewPager.unregisterOnPageChangeCallback(it)
        }

        pagerAdapterObserver = null
        onTabSelectedListener = null
        onPageChangeCallback = null
        adapter = null
        attached = false
    }

    internal fun populateTabsFromPagerAdapter() {
        tabLayout.removeAllTabs()

        adapter?.itemCount?.let { adapterCount ->

            for (i in 0 until adapterCount) {
                val tab = tabLayout.newTab()
                tabConfigurationStrategy.onConfigureTab(tab, i)
                tabLayout.addTab(tab, false)
            }

            // Make sure we reflect the currently set ViewPager item
            if (adapterCount > 0) {
                val currItem = viewPager.currentItem
                if (currItem != tabLayout.selectedTabPosition) {
                    tabLayout.getTabAt(currItem)?.select()
                }
            }
        }
    }

    /**
     * A [ViewPager2.OnPageChangeCallback] class which contains the necessary calls back to the
     * provided [TabLayout] so that the tab position is kept in sync.
     *
     *
     * This class stores the provided TabLayout weakly, meaning that you can use [ ][ViewPager2.registerOnPageChangeCallback] without removing the
     * callback and not cause a leak.
     */
    private class TabLayoutOnPageChangeCallback internal constructor(tabLayout: TabLayout) :
        ViewPager2.OnPageChangeCallback() {
        private val tabLayoutRef: WeakReference<TabLayout>
        private var previousScrollState: Int = 0
        private var scrollState: Int = 0

        init {
            tabLayoutRef = WeakReference(tabLayout)
            reset()
        }

        override fun onPageScrollStateChanged(state: Int) {
            previousScrollState = scrollState
            scrollState = state
        }

        override fun onPageScrolled(
            position: Int,
            positionOffset: Float,
            positionOffsetPixels: Int
        ) {
            tabLayoutRef.get()?.let { tabLayout ->
                // Only update the text selection if we're not settling, or we are settling after
                // being dragged
                val updateText =
                    scrollState != SCROLL_STATE_SETTLING || previousScrollState == SCROLL_STATE_DRAGGING
                // Update the indicator if we're not settling after being idle. This is caused
                // from a setCurrentItem() call and will be handled by an animation from
                // onPageSelected() instead.
                val updateIndicator =
                    !(scrollState == SCROLL_STATE_SETTLING && previousScrollState == SCROLL_STATE_IDLE)
                tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator)
            }
        }

        override fun onPageSelected(position: Int) {
            tabLayoutRef.get()?.let { tabLayout ->
                if (tabLayout.selectedTabPosition != position
                    && position < tabLayout.tabCount
                ) {
                    // Select the tab, only updating the indicator if we're not being dragged/settled
                    // (since onPageScrolled will handle that).
                    val updateIndicator =
                        scrollState == SCROLL_STATE_IDLE || scrollState == SCROLL_STATE_SETTLING && previousScrollState == SCROLL_STATE_IDLE
                    tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator)
                }
            }
        }

        internal fun reset() {
            scrollState = SCROLL_STATE_IDLE
            previousScrollState = scrollState
        }
    }

    /**
     * A [TabLayout.OnTabSelectedListener] class which contains the necessary calls back to the
     * provided [ViewPager2] so that the tab position is kept in sync.
     */
    private class ViewPagerOnTabSelectedListener internal constructor(private val viewPager: ViewPager2) :
        TabLayout.OnTabSelectedListener {

        override fun onTabSelected(tab: TabLayout.Tab) {
            viewPager.setCurrentItem(tab.position, true)
        }

        override fun onTabUnselected(tab: TabLayout.Tab) {
            // No-op
        }

        override fun onTabReselected(tab: TabLayout.Tab) {
            // No-op
        }
    }

    private inner class PagerAdapterObserver internal constructor() :
        RecyclerView.AdapterDataObserver() {

        override fun onChanged() {
            populateTabsFromPagerAdapter()
        }

        override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
            populateTabsFromPagerAdapter()
        }

        override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
            populateTabsFromPagerAdapter()
        }

        override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
            populateTabsFromPagerAdapter()
        }

        override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
            populateTabsFromPagerAdapter()
        }

        override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
            populateTabsFromPagerAdapter()
        }
    }
}
ldjcmu commented 5 years ago

Thanks for the report and testing on the latest version. We'll take a look.