androidtwitterandroid-recyclerviewmopub

RecyclerView onRestoreInstanceState Inside BottomSheet Not Working


Summary: This appears to be an issue with hosting the RecyclerView inside of a Bottom Sheet as the parent Fragment HomeFragment hosts another instance of the child Fragment ContentFragment which is not nested within a Bottom Sheet and the onRestoreInstanceState performs as expected.

Expected

When saving and returning a RecyclerView LayoutManager's state in a Fragment's onSaveInstanceState and onViewStateRestored methods, the expected outcome is for the RecyclerView to display in the same position as prior to the configuration change.

Observed

Upon screen configuration change the RecyclerView is sometimes showing at position 0 rather than the RecyclerView position prior to the configuration change. It is also successfully retaining the layout state as expected in some cases. Because of the randomness this seems that a lifecycle + Bottom Sheet issue may be involved.

Implementation

Hierarchy

The ContentFragment is hosted by HomeFragment inside a BottomSheet Fragment named bottomSheet in the fragment_home layout. The ContentFragment's fragment_content layout contains the contentRecyclerView.

Loading Saved State

onRestoreInstanceState is called after data has been loaded to the Adapter in observeContentUpdated in the SAVED.name case. The instance state is set to null after onRestoreInstanceState because cells in the RecyclerView are dismissible and will cause data to load again. This ensures the restore only happens once after a config change.

HomeFragment.kt

initSavedBottomSheet creates the Bottom Sheet containing the saved Fragment ContentFragment.

class HomeFragment : Fragment() {

...

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putParcelable(USER_KEY, user)
    outState.putBoolean(APP_BAR_EXPANDED_KEY, isAppBarExpanded)
    outState.putBoolean(SAVED_CONTENT_EXPANDED_KEY, isSavedContentExpanded)
}

override fun onViewStateRestored(savedInstanceState: Bundle?) {
    super.onViewStateRestored(savedInstanceState)
    if (savedInstanceState != null) {
        if (savedInstanceState.getBoolean(APP_BAR_EXPANDED_KEY)) appBar.setExpanded(true)
        else appBar.setExpanded(false)
        if (savedInstanceState.getBoolean(SAVED_CONTENT_EXPANDED_KEY)) {
            swipeToRefresh.isEnabled = false
            bottomSheetBehavior.state = STATE_EXPANDED
            setBottomSheetExpanded()
        }
        updateAds()
    }
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    homeViewModel = ViewModelProviders.of(activity!!).get(HomeViewModel::class.java)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? {
    binding = FragmentHomeBinding.inflate(inflater, container, false)
    binding.setLifecycleOwner(this)
    binding.viewmodel = homeViewModel
    return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    user = homeViewModel.getCurrentUser()
    ...
    observeSignIn(savedInstanceState)
    initSavedBottomSheet(savedInstanceState)
    ...
    initSwipeToRefresh()
    ...
}

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    if (savedInstanceState == null
            && childFragmentManager.findFragmentByTag(PRICEGRAPH_FRAGMENT_TAG) == null
            && childFragmentManager.findFragmentByTag(CONTENT_FEED_FRAGMENT_TAG) == null) {
        childFragmentManager.beginTransaction()
                .replace(priceContainer.id, PriceFragment.newInstance(), PRICEGRAPH_FRAGMENT_TAG)
                .commit()
        childFragmentManager.beginTransaction().replace(contentContainer.id,
                ContentFragment.newInstance(Bundle().apply {
                    putString(FEED_TYPE_KEY, MAIN.name)
                }), CONTENT_FEED_FRAGMENT_TAG)
                .commit()
    }
}

...

private fun initSavedBottomSheet(savedInstanceState: Bundle?) {
    bottomSheetBehavior = from(bottomSheet)
    bottomSheetBehavior.isHideable = false
    bottomSheetBehavior.peekHeight = SAVED_BOTTOM_SHEET_PEEK_HEIGHT
    bottomSheet.layoutParams.height = getDisplayHeight(context!!)
    if (savedInstanceState == null && homeViewModel.user.value == null)
        childFragmentManager.beginTransaction().replace(
                R.id.savedContentContainer,
                SignInDialogFragment.newInstance(Bundle().apply {
                    putInt(SIGNIN_TYPE_KEY, FULLSCREEN.code)
                }))
                .commit()
    bottomSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
        override fun onStateChanged(bottomSheet: View, newState: Int) {
            if (newState == STATE_EXPANDED) {
                homeViewModel.bottomSheetState.value = STATE_EXPANDED
                setBottomSheetExpanded()
            }
            if (newState == STATE_COLLAPSED) {
                isSavedContentExpanded = false
                appBar.visibility = VISIBLE
                bottom_handle.visibility = VISIBLE
                bottom_handle_elevation.visibility = VISIBLE
            }
        }

        override fun onSlide(bottomSheet: View, slideOffset: Float) {}
    })
    ...
}

private fun setBottomSheetExpanded() {
    isSavedContentExpanded = true
    appBar.visibility = GONE
    bottom_handle.visibility = GONE
    bottom_handle_elevation.visibility = GONE
}

private fun initSavedContentFragment() {
    childFragmentManager.beginTransaction().replace(
            savedContentContainer.id,
            ContentFragment.newInstance(Bundle().apply { putString(FEED_TYPE_KEY, SAVED.name) }),
            SAVED_CONTENT_TAG).commit()
}

...

private fun observeSignIn(savedInstanceState: Bundle?) {
    homeViewModel.user.observe(this, Observer { user: FirebaseUser? ->
        this.user = user
        ...
        if (user != null) { // Signed in.
            ...
            if (savedInstanceState == null || savedInstanceState.getParcelable<FirebaseUser>(USER_KEY) == null) {
                initMainContent()
                initSavedContentFragment()
            }
        } else if (savedInstanceState == null)  /*Signed out.*/ initMainContent()
    })
}

private fun initMainContent() {
    (childFragmentManager.findFragmentById(R.id.contentContainer) as ContentFragment)
            .initMainContent(false)
}

fun initSwipeToRefresh() {
    homeViewModel.isSwipeToRefreshEnabled.observe(viewLifecycleOwner, Observer { isEnabled: Boolean ->
        ...
        (childFragmentManager.findFragmentById(R.id.priceContainer) as PriceFragment)
                .getPrices(false, false)
        if (homeViewModel.accountType.value == FREE) updateAds()
    }
}

private fun updateAds() {
    (childFragmentManager.findFragmentById(R.id.contentContainer) as ContentFragment)
            .updateAds(true)
    if (childFragmentManager.findFragmentById(R.id.savedContentContainer) as ContentFragment != null)
        (childFragmentManager.findFragmentById(R.id.savedContentContainer) as ContentFragment)
                .updateAds(true)
}

...
}

ContentFragment.kt

The contentRecyclerView is populated in the initializeAdapters method.

class ContentFragment : Fragment() {

...

private var savedRecyclerLayoutState: Parcelable? = null

companion object {
    @JvmStatic
    fun newInstance(contentBundle: Bundle) = ContentFragment().apply {
        arguments = contentBundle
    }
}

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
        if (contentRecyclerView != null)
                outState.putParcelable(CONTENT_RECYCLER_VIEW_STATE,
                        contentRecyclerView.layoutManager!!.onSaveInstanceState())
}

override fun onViewStateRestored(savedInstanceState: Bundle?) {
    super.onViewStateRestored(savedInstanceState)
    if (savedInstanceState != null) {
        savedRecyclerLayoutState = savedInstanceState.getParcelable(CONTENT_RECYCLER_VIEW_STATE)
    }
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    feedType = ContentFragmentArgs.fromBundle(arguments!!).feedType
    analytics = getInstance(FirebaseApp.getInstance()!!.applicationContext)
    contentViewModel = ViewModelProviders.of(this).get(ContentViewModel::class.java)
    homeViewModel = ViewModelProviders.of(activity!!).get(HomeViewModel::class.java)
    contentViewModel.feedType = feedType
    if (savedInstanceState == null) homeViewModel.isRealtime.observe(this, Observer { isRealtime: Boolean ->
        when (feedType) {
            SAVED.name, DISMISSED.name -> initCategorizedContent(feedType, homeViewModel.user.value!!.uid)
        }
    })
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? {
    analytics.setCurrentScreen(activity!!, feedType, null)
    binding = FragmentContentBinding.inflate(inflater, container, false)
    binding.setLifecycleOwner(this)
    binding.viewmodel = contentViewModel
    binding.actionbar.viewmodel = contentViewModel
    binding.emptyContent.viewmodel = contentViewModel
    return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    setToolbar()
    initializeAdapters()
}

override fun onDestroy() {
    moPubAdapter.destroy()
    compositeDisposable.dispose()
    super.onDestroy()
}

fun setToolbar() {
    when (feedType) {
        SAVED.name -> {
            binding.actionbar.toolbar.savedContentTitle.visibility = View.VISIBLE
        }
        DISMISSED.name -> {
            binding.actionbar.toolbar.title = getString(R.string.dismissed)
            (activity as AppCompatActivity).setSupportActionBar(binding.actionbar.toolbar)
            (activity as AppCompatActivity).supportActionBar!!.setDisplayHomeAsUpEnabled(true)
        }
    }
}

fun initMainContent(isRealtime: Boolean) {
    contentViewModel.initializeMainContent(isRealtime).observe(viewLifecycleOwner, Observer { status ->
        if (status == SUCCESS && homeViewModel.accountType.value == FREE) updateAds(true)
    })
}

fun initCategorizedContent(feedType: String, userId: String) {
    contentViewModel.initCategorizedContent(feedType, userId)
}

fun updateAds(toLoad: Boolean) {
    var toLoad = toLoad
    moPubAdapter.loadAds(AD_UNIT_ID)
    moPubAdapter.setAdLoadedListener(object : MoPubNativeAdLoadedListener {
        override fun onAdRemoved(position: Int) {}
        override fun onAdLoaded(position: Int) {
            if (toLoad) {
                moPubAdapter.notifyDataSetChanged()
                toLoad = false
            }
        }
    })
}

private fun initializeAdapters() {
    contentRecyclerView.layoutManager = LinearLayoutManager(context)
    populateAdapterType()
    observeContentUpdated()
    ...
}

private fun observeContentUpdated() {
    when (feedType) {
        MAIN.name -> {
            contentViewModel.getMainContentList().observe(viewLifecycleOwner, Observer { homeContentList ->
                adapter.submitList(homeContentList)
                if (homeContentList.isNotEmpty()) {
                    emptyContent.visibility = GONE
                    if (savedRecyclerLayoutState != null) {
                        contentRecyclerView.layoutManager?.onRestoreInstanceState(savedRecyclerLayoutState)
                        savedRecyclerLayoutState = null
                    }
                }
            })
        }
        SAVED.name, DISMISSED.name -> {
            contentViewModel.getCategorizedContentList(
                    if (feedType == SAVED.name) SAVED
                    else if (feedType == DISMISSED.name) DISMISSED
                    else NONE
            ).observe(viewLifecycleOwner, Observer { contentList ->
                adapter.submitList(contentList)
                if (!(contentList.size == 0 && (adapter.itemCount == 1 || adapter.itemCount == 0))) {
                    emptyContent.visibility = GONE
                    if (feedType == SAVED.name) {
                        if (savedRecyclerLayoutState != null) {
                            contentRecyclerView.layoutManager?.onRestoreInstanceState(savedRecyclerLayoutState)
                            savedRecyclerLayoutState = null
                        }
                    }
                    if (feedType == DISMISSED.name)
                        contentRecyclerView.layoutManager?.onRestoreInstanceState(savedRecyclerLayoutState)
                } 
            })
        }
    }
}

private fun populateAdapterType() {
    adapter = ContentAdapter(contentViewModel)
    // FREE
    if (homeViewModel.accountType.value!! == FREE) {
        moPubAdapter = MoPubRecyclerAdapter(activity!!, adapter,
                MoPubNativeAdPositioning.MoPubServerPositioning())
    ...            
        contentRecyclerView.adapter = moPubAdapter
        // Realtime, only need to set ads once.
        if (feedType == SAVED.name || feedType == DISMISSED.name) moPubAdapter.loadAds(AD_UNIT_ID)
    } /* PAID */ else contentRecyclerView.adapter = adapter
    ItemTouchHelper(homeViewModel).build(context!!, FREE, feedType, adapter, moPubAdapter, fragmentManager!!)
            .attachToRecyclerView(contentRecyclerView)
}

...

}

fragment_home.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
    <variable
        name="viewmodel"
        type="app.coinverse.home.HomeViewModel" />
</data>

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    android:id="@+id/swipeToRefresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appBar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@android:color/white">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fitsSystemWindows="true"
                app:layout_scrollFlags="scroll|snap">

                <androidx.appcompat.widget.Toolbar
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize">

                    <androidx.constraintlayout.widget.ConstraintLayout
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        android:paddingTop="@dimen/padding_small"
                        android:paddingRight="@dimen/padding_small">

                        <ImageView
                            android:id="@+id/profileButton"
                            android:layout_width="@dimen/toolbar_button_dimen"
                            android:layout_height="@dimen/toolbar_button_dimen"
                            android:layout_gravity="start"
                            android:contentDescription="@string/profile_content_description"
                            android:src="@drawable/ic_astronaut_color_accent_24dp"
                            app:layout_constraintLeft_toLeftOf="parent" />

                    </androidx.constraintlayout.widget.ConstraintLayout>

                </androidx.appcompat.widget.Toolbar>

                <FrameLayout
                    android:id="@+id/priceContainer"
                    android:name="app.carpecoin.PriceDataFragment"
                    android:layout_width="match_parent"
                    android:layout_height="@dimen/price_graph_height"
                    app:layout_collapseMode="parallax"
                    app:layout_constraintLeft_toLeftOf="parent"
                    app:layout_constraintRight_toRightOf="parent"
                    app:layout_constraintTop_toBottomOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

            </com.google.android.material.appbar.CollapsingToolbarLayout>

        </com.google.android.material.appbar.AppBarLayout>

        <FrameLayout
            android:id="@+id/contentContainer"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/bottomSheet"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:paddingBottom="@dimen/margin_large"
            app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

            <ImageView
                android:id="@+id/bottom_handle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@drawable/ic_bottom_sheet_handle"
                android:contentDescription="@string/saved_bottomsheet_handle_content_description"
                android:elevation="@dimen/bottom_sheet_elevation_height"
                android:src="@drawable/ic_save_planet_dark_48dp"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <ImageView
                android:id="@+id/bottom_handle_elevation"
                android:layout_width="0dp"
                android:layout_height="@dimen/bottom_sheet_elevation_height"
                android:background="@color/bottom_sheet_handle_elevation"
                android:contentDescription="@string/saved_bottomsheet_handle_content_description"
                app:layout_constraintBottom_toBottomOf="@id/bottom_handle"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent" />

            <FrameLayout
                android:id="@+id/savedContentContainer"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:background="@android:color/white"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintTop_toBottomOf="@id/bottom_handle_elevation" />

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

fragment_content.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<data>

    <variable
        name="viewmodel"
        type="app.coinverse.content.ContentViewModel" />

</data>


<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:id="@+id/contentFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <include
            android:id="@+id/actionbar"
            layout="@layout/toolbar"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/contentRecyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_below="@id/actionbar" />

        <include
            android:id="@+id/emptyContent"
            layout="@layout/empty_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_below="@id/actionbar" />

    </RelativeLayout
</androidx.coordinatorlayout.widget.CoordinatorLayout>

</layout>

Solution

  • As a workaround for saving the RecyclerView state, the position can be saved in the instance state.

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        if (contentRecyclerView != null)
            when (feedType) {
                MAIN.name, DISMISSED.name ->
                    outState.putParcelable(CONTENT_RECYCLER_VIEW_STATE,
                            contentRecyclerView.layoutManager!!.onSaveInstanceState())
                SAVED.name ->
                    outState.putInt(CONTENT_RECYCLER_VIEW_POSITION,
                            (contentRecyclerView.layoutManager as LinearLayoutManager)
                                    .findLastVisibleItemPosition())
            }
    }
    
    override fun onViewStateRestored(savedInstanceState: Bundle?) {
        super.onViewStateRestored(savedInstanceState)
        if (savedInstanceState != null)
            when (feedType) {
                MAIN.name, DISMISSED.name -> savedRecyclerLayoutState = savedInstanceState.getParcelable(CONTENT_RECYCLER_VIEW_STATE)
                SAVED.name -> savedRecyclerPosition = savedInstanceState.getInt(CONTENT_RECYCLER_VIEW_POSITION)
            }
    }
    

    To make sure the saved index is not out of bounds a check is required. Also, since RecyclerView item's are dismissed it is important to clear the saved index position so that the RecyclerView is not updated after an item is dismissed since this code snippet is contained in a LiveData observer.

    if (feedType == SAVED.name && savedRecyclerPosition != 0) {
                            val position: Int =
                                    if (savedRecyclerPosition >= adapter.itemCount) adapter.itemCount - 1
                                    else savedRecyclerPosition
                            contentRecyclerView.layoutManager?.scrollToPosition(position)
                            savedRecyclerPosition = 0
                        }