1

I have a RecyclerView for a list of languages which can be selected using checkboxes. It uses LiveData, so that when the user selects or deselects a language from another client the change is automatically reflected in all of them.

Every time I click on the checkbox, the record for the language is selected and deselected in the room database, and on the server too. Other devices in which the user is logged in are automatically updated too, so I am using LiveData. I am clashing with the RecyclerView bug that pushes the list back to the top every time it is updated, regardless of the current scroll position.

So, say that I have two phones with the language screen open, I scroll them both all the way down to the bottom, and select or deselect the last language. The RecyclerView on both devices scrolls back to the top, but I would like it to stay still.

I have seen plenty of workaround to fix this behaviour, but I am relatively new to Android and Kotlin, and all the patches suggested are not explained well enough for me to be able to implement them. What do I need to do, and where should I do it?

The RecyclerView is in a Fragment:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/languages_container"
    tools:context=".ui.members.languages.LanguagesFragment">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/languages_recycler_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="10dp"
            android:paddingBottom="15dp"
            app:layoutManager="LinearLayoutManager"
            app:layout_constraintTop_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:context="com.example.flashcardmallard.ui.members.languages.LanguagesFragment"
            tools:listitem="@layout/fragment_languages_cards" />

</FrameLayout>

And this is the list item:

<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="40dp"
    android:orientation="horizontal">

    <CheckBox
        android:id="@+id/language_selection"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

The fragment (LANGUAGES_SCREEN_VIEWS is a static reference in a companion object for the elements of the screen I need to use in other classes, such as the ViewModel):

class LanguagesFragment : Fragment()
{
    override fun onCreateView ( inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle? ) : View
    {
        LANGUAGES_SCREEN_VIEWS = FragmentLanguagesBinding.inflate ( inflater, container, false )
        val languagesViewModel = ViewModelProvider ( this ).get ( LanguagesViewModel::class.java )
        LANGUAGES_FRAGMENT = this

        languagesViewModel.getLanguagesForUser().observe ( viewLifecycleOwner, { languages ->
            LANGUAGES_SCREEN_VIEWS.languagesRecyclerView.adapter = LanguagesRecyclerViewAdapter ( languages ) } )

        return LANGUAGES_SCREEN_VIEWS.root
    }
}

This is the adapter:

class LanguagesRecyclerViewAdapter ( private val languages: List < LanguagesEntity > ) : RecyclerView.Adapter < LanguagesRecyclerViewAdapter.ViewHolder > ()
{
    override fun onCreateViewHolder ( viewGroup: ViewGroup, i: Int ): ViewHolder
    {
        return ViewHolder ( LayoutInflater.from ( viewGroup.context ).inflate ( R.layout.fragment_languages_cards, viewGroup, false ) )
    }

    override fun onBindViewHolder ( viewHolder: ViewHolder, index: Int )
    {
        val language = languages [ index ]
        viewHolder.languageCard.text      = language.name
        viewHolder.languageCard.isChecked = language.spoken
    }

    override fun getItemCount() = languages.size

    inner class ViewHolder ( itemView: View ) : RecyclerView.ViewHolder ( itemView )
    {
        val languageCard: CheckBox = itemView.findViewById ( R.id.language_selection )

        init
        {
            languageCard.setOnClickListener {

                LANGUAGE_RECYCLER_VIEW_STATE = LANGUAGES_SCREEN_VIEWS.languagesRecyclerView.layoutManager!!.onSaveInstanceState()!!

                GlobalScope.launch {
                    // Save to local database
                    LanguageRepository ().updateLanguage ( languageCard )

                // Delete on server, pop up if not possible
                var dataSync = DataSyncBean ( languages = arrayListOf ( LanguageBean ( addedOrDeleted = if (
                    languageCard.isChecked ) ADDED else DELETED, name = languageCard.text.toString() ) ) )
                try
                {
                    dataSync = Retrofit.Builder().baseUrl ( ConstantsUtil.BASE_URL ).addConverterFactory ( GsonConverterFactory.create () ).client (
                    OkHttpClient.Builder().cookieJar ( CookieUtil () ).build() ).build().create (
                        RestfulCalls::class.java ).updateUserLanguageMapping ( dataSync )
                }
                catch ( e : Exception )
                {
                    dataSync.syncOutcome = ConstantsUtil.SERVER_UNREACHABLE
                    withContext ( Dispatchers.Main ) {
                        val alertDialog : AlertDialog.Builder = AlertDialog.Builder ( LANGUAGES_FRAGMENT.context )
                        alertDialog.setTitle                  ( R.string.no_server_connection )
                        alertDialog.setMessage                ( R.string.changes_local_only   )
                            alertDialog.setPositiveButton         ( R.string.ok ) { _, _ -> }
                            val alert: AlertDialog = alertDialog.create()
                            alert.setCanceledOnTouchOutside       ( true )
                            alert.show()
                        }
                    }
                }
            }
        }
    }
}

Some of the solutions I found mention using AsyncData, which I understand is deprecated, so I will not consider those. I am using Flow < List < LanguagesEntity > > in the DAO.

The solution of adding a Layout didn't work:

RecyclerView notifyDataSetChanged scrolls to top position

val linearLayoutManager = LinearLayoutManager ( context, RecyclerView.VERTICAL, false )
LANGUAGES_SCREEN_VIEWS.languagesRecyclerView.setLayoutManager(linearLayoutManager)

Another solution said the bug depended on the layout's height being "wrap content" and suggested a fixed height, but despite having a stupidly high number for the height, the RecyclerView didn't scroll at all.

A workaround suggested included.

This page offers a few solutions:

Refreshing data in RecyclerView and keeping its scroll position

One being

// Save state
private Parcelable recyclerViewState;
recyclerViewState = recyclerView.getLayoutManager().onSaveInstanceState();

// Restore state
recyclerView.getLayoutManager().onRestoreInstanceState(recyclerViewState);

But I would have absolutely no idea on where to put those lines in my files.

I guess I should intercept the moment before the data is updated (it can't be on click because the database could be updated by synchronisation from other device) and the moment during which the recycler view is being updated. I don't understand where this happens.

halfer
  • 18,701
  • 13
  • 79
  • 158
Dan
  • 448
  • 5
  • 21
  • 1
    Why are you calling `setAdapter()` every time your Observer changes rather than updating your existing adapter? The scroll position is tied to the specific adapter instance. – ianhanniballake Feb 05 '21 at 00:08
  • @ianhanniballake The honest question is that I altered an example which was doing exactly that and I thought worked fine, until I had realised it would cause that problem. So I haven't got as far as fully understanding what it does, and how I could possibly fix it... I understand the concept of what you are saying, but no idea how to actually do it. – Dan Feb 05 '21 at 00:18
  • @ianhanniballake Hey, thank you for your previous comment! It did point me in the right direction and allowed me to create a more specific post which helped me find the answer :-) – Dan Feb 05 '21 at 02:37
  • 1
    Glad you could find a solution! – ianhanniballake Feb 05 '21 at 03:45

1 Answers1

1

The solution was given by Teo in this more specific question:

Problem with LiveData observer changing rather than staying the same

Here is his solution:

// STEP 1 - make a function to notify the variable

internal fun setLanguage(lang: List<LanguagesEntity>) {
   languages = lang
   notifyDataSetChanged()    
}

// STEP 2 - setup recyclerview before everything

val languages = mutableListOf < LanguagesEntity > ()
var languagesAdapter = LanguagesRecyclerViewAdapter(languages)
  LANGUAGES_SCREEN_VIEWS.languagesRecyclerView.adapter = languagesAdapter 

// STEP 3 - set new value to adapter

languagesViewModel.getLanguagesForUser().observe( requireActivity(), { languages ->

languagesAdapter.setLanguage(language)
} )
halfer
  • 18,701
  • 13
  • 79
  • 158
Dan
  • 448
  • 5
  • 21