BottomSheet 内的 RecyclerView onRestoreInstanceState 不工作

RecyclerView onRestoreInstanceState Inside BottomSheet Not Working

总结这似乎是将底部 Sheet 内部的 RecyclerView 作为父片段托管的问题 HomeFragment 承载子片段 ContentFragment 的另一个实例,该实例未嵌套在底部 Sheet 中,并且 onRestoreInstanceState 按预期执行。

预期

FragmentonSaveInstanceStateonViewStateRestored 方法中保存和返回 RecyclerView LayoutManager 的状态时,预期结果是RecyclerView 显示在与配置更改之前相同的位置。

观察到

在屏幕配置更改时,RecyclerView 有时会显示在位置 0 而不是配置更改之前的 RecyclerView 位置。在某些情况下,它还成功地保留了预期的布局状态。由于随机性,这似乎涉及生命周期 + 底部 Sheet 问题。

实施

层次结构

ContentFragmentHomeFragmentfragment_home 布局中名为 bottomSheetBottomSheet 片段中托管。 ContentFragmentfragment_content 布局包含 contentRecyclerView.

正在加载保存的状态

onRestoreInstanceStateSAVED.name 的情况下,在数据加载到 observeContentUpdated 中的 Adapter 之后调用。实例状态在 onRestoreInstanceState 之后设置为 null,因为 RecyclerView 中的单元格是可关闭的,将导致数据再次加载。这确保恢复仅在配置更改后发生一次。

HomeFragment.kt

initSavedBottomSheet 创建包含保存的片段 ContentFragment.

的底部 Sheet
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

contentRecyclerView 填充在 initializeAdapters 方法中。

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>

作为保存 RecyclerView 状态的变通方法,位置可以保存在实例状态中。

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)
        }
}

为确保保存的索引没有越界,需要进行检查。此外,由于 RecyclerView 项目被关闭,因此清除保存的索引位置很重要,这样 RecyclerView 不会在项目被关闭后更新,因为这代码片段包含在 LiveData 观察者中。

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