androidkotlinandroid-recyclerviewandroid-diffutils

Renaming item with DiffUtil not updating RecyclerView


For an app I am making I have a list in which I display pixel art creations, I do this with a RecyclerView and DiffUtil, here is the code:

package com.therealbluepandabear.pixapencil.adapters

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.therealbluepandabear.pixapencil.R
import com.therealbluepandabear.pixapencil.databinding.RecentCreationsLayoutBinding
import com.therealbluepandabear.pixapencil.enums.SnackbarDuration
import com.therealbluepandabear.pixapencil.extensions.setOnLongPressListener
import com.therealbluepandabear.pixapencil.extensions.showSnackbar
import com.therealbluepandabear.pixapencil.listeners.RecentCreationsListener
import com.therealbluepandabear.pixapencil.models.PixelArt
import com.therealbluepandabear.pixapencil.viewholders.PixelArtViewHolder


class PixelArtAdapter(
    private val snackbarView: View,
    private val listener: RecentCreationsListener,
    private val context: Context
) : ListAdapter<PixelArt, RecyclerView.ViewHolder>(diffCallback) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val binding = RecentCreationsLayoutBinding.inflate(LayoutInflater.from(parent.context))
        return PixelArtViewHolder(binding, context)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val pixelArt = getItem(position)

        if (holder is PixelArtViewHolder) {
            holder.bind(pixelArt)

            holder.binding.recentCreationsLayoutMaterialCardView.setOnClickListener {
                listener.onCreationTapped(pixelArt)
            }

            holder.binding.recentCreationsLayoutMaterialCardView.setOnLongPressListener {
                listener.onCreationLongTapped(pixelArt)
            }

            holder.binding.recentCreationsLayoutFavoriteButton.setOnClickListener {
                if (pixelArt.starred) {
                    pixelArt.starred = false
                    listener.onUnstarredTapped(pixelArt)

                    unFavouriteRecentCreation(snackbarView, pixelArt)
                    holder.bind(pixelArt)
                } else {
                    pixelArt.starred = true
                    listener.onStarredTapped(pixelArt)

                    favouriteRecentCreation(snackbarView, pixelArt)
                    holder.bind(pixelArt)
                }
            }
        }
    }

    private fun favouriteRecentCreation(contextView: View, pixelArt: PixelArt) { // move to listener
        contextView.showSnackbar(contextView.context.getString(R.string.snackbar_pixel_art_project_saved_to_starred_items_in_code_str, pixelArt.title), SnackbarDuration.Default)
        pixelArt.starred = true
    }

    private fun unFavouriteRecentCreation(contextView: View, pixelArt: PixelArt) {
        contextView.showSnackbar(contextView.context.getString(R.string.snackbar_pixel_art_project_removed_from_starred_items_in_code_str, pixelArt.title), SnackbarDuration.Default)
        pixelArt.starred = false
    }

    companion object {
        val diffCallback: DiffUtil.ItemCallback<PixelArt> = object : DiffUtil.ItemCallback<PixelArt>() {
            override fun areItemsTheSame(oldItem: PixelArt, newItem: PixelArt): Boolean {
                return oldItem.objId == newItem.objId
            }

            override fun areContentsTheSame(oldItem: PixelArt, newItem: PixelArt): Boolean {
                return oldItem == newItem
            }
        }
    }
}

ViewHolder:

class PixelArtViewHolder(val binding: RecentCreationsLayoutBinding, private val context: Context) : RecyclerView.ViewHolder(binding.root) {
    private fun loadPixelArtCoverImage(pixelArt: PixelArt) {
        val widthHeight = if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
             350
        } else {
            750
        }

        val requestOptions: RequestOptions = RequestOptions()
            .diskCacheStrategy(DiskCacheStrategy.NONE)
            .dontAnimate()
            .priority(Priority.IMMEDIATE)
            .encodeFormat(Bitmap.CompressFormat.PNG)
            .override(widthHeight, widthHeight)
            .centerInside()
            .format(DecodeFormat.DEFAULT)

        Glide.with(itemView.context)
            .setDefaultRequestOptions(requestOptions)
            .load(File(itemView.context.getFileStreamPath(pixelArt.coverBitmapFilePath).absolutePath))
            .transition(DrawableTransitionOptions.withCrossFade())
            .placeholder(R.drawable.transparent_placeholder)
            .into(binding.recentCreationsLayoutImageView)
    }

    private fun loadPixelArtTitle(pixelArt: PixelArt) {
        if (pixelArt.title.length > 6) {
            binding.recentCreationsLayoutTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
            binding.recentCreationsLayoutTitle.isSelected = true
            binding.recentCreationsLayoutTitle.isSingleLine = true
            (pixelArt.title + " ".repeat(10)).repeat(200).also {  binding.recentCreationsLayoutTitle.text = it }
        } else {
            binding.recentCreationsLayoutTitle.text = pixelArt.title
        }
    }

    private fun loadPixelArtStarred(pixelArt: PixelArt) {
        binding.recentCreationsLayoutFavoriteButton.setImageResource(
            if (pixelArt.starred) {
                R.drawable.ic_baseline_star_24
            } else {
                R.drawable.ic_baseline_star_border_24
            }
        )
    }

    fun bind(pixelArt: PixelArt){
        loadPixelArtCoverImage(pixelArt)
        binding.recentCreationsLayoutSubtitle.text = context.getString(R.string.recentCreationsLayoutSubtitle_str, pixelArt.width, pixelArt.height)
        loadPixelArtStarred(pixelArt)
        loadPixelArtTitle(pixelArt)
    }
}

Here is the result:

enter image description here

When the user long taps on a project, they get the following dialog:

enter image description here

When they press 'Rename', they get the following dialog where they can rename the project:

enter image description here

My issue is, that when the user types in a new name, and then presses OK, the data is not updating. Sometimes it takes twice to update, sometimes I need to restart the app for it to update, and sometimes it doesn't update at all.

Here is the code responsible for renaming:

fun MainActivity.extendedOnRenameTapped(pixelArt: PixelArt, bottomSheetDialog: BottomSheetDialog) {
    val inflatedActivity = activity()?.layoutInflater?.inflate(R.layout.save_file_under_new_name_alert, activity()?.findViewById(android.R.id.content),false)
    val textInput: TextInputLayout = inflatedActivity as TextInputLayout

    showDialog(
        getString(R.string.dialog_rename_title_in_code_str),
        null,
        getString(R.string.generic_ok_in_code_str), { _, _ ->
            val input: String = textInput.editText?.text.toString()

            if (input.isNotBlank()) {
                pixelArt.title = input
                pixelArtViewModel.update(pixelArt)
                adapter.submitList(pixelArtData)

                bottomSheetDialog.dismiss()
            }
        }, getString(R.string.generic_cancel_in_code_str), null, view = textInput, dimBackground = false
    )
}

I am following everything by the book, so I am confused why this is not working.

Edit

I tried to make it all 'val' and then add this:

 pixelArtViewModel.update(pixelArt.copy(title = input))
                pixelArtViewModel.getAll().observe(this) {
                    adapter.submitList(it)
                }

                bottomSheetDialog.dismiss()

Still not working.


Solution

  • I see that you are setting pixelArt.title, which means your PixelArt class is mutable (has var properties or val properties that reference mutable classes). DiffUtil is 100% incompatible with mutable classes, because they make it impossible to compare items in the old and new lists. It will see the old list as having the new value already so it will treat it as unchanged.

    Example with my imagined version of your PixelArt class.

    data class PixelArt(
        val objId: Long,
        val name: String,
        val starred: Boolean,
        val imageFilePath: String
    )
    
    // In ViewModel:
    
    // You probably have the list backed up to disk somehow. I'm just using
    // placeholder functions to represent working with the repo or files or 
    // whatever you use.
    val pixelArtLiveData = MutableLiveData<List<PixelArt>>().also {
        viewModelScope.launch { it.value = readThePersistedData() }
    }
    
    private fun modifyItem(oldItem: PixelArt, newItem: PixelArt) {
        pixelArtLiveData.value = pixelArtLiveData.value.orEmpty()
            .map { if (it == oldItem) newItem else it }
        // also update your persisted data here
    }
    
    fun renameItem(originalItem: PixelArt, newName: String) {
        modifyItem(originalItem, originalItem.copy(name = newName))
    }
    
    fun toggleItemStarred(originalItem: PixelArt) {
        modifyItem(originalItem, originalItem.copy(starred = !originalItem.starred))
    }
    
    // etc. or you could just make modifyItem public instead of making
    // all these helper functions
    

    Then in your adapter, you must call through to these ViewModel functions instead of directly modifying the items or the list or calling submitList. Since the adapter doesn't have direct access to the ViewModel, you probably use your RecentCreationsListener for this by adding appropriate actions to it that your various click listeners can call.

    Your Activity or Fragment would observe this LiveData and simply call submitList() with the observed value.