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.
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.
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.
contentRecyclerView.layoutManager!!.onSaveInstanceState()
logged as not null on onSaveInstanceState
. savedRecyclerLayoutState
logged as not null on onViewStateRestored
.savedRecyclerLayoutState
logged as not null after the adapter
is loaded with data in the SAVED.name
case in observeContentUpdated
below.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>
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
}