0

I have a RecyclerView that has many ViewHolder types. One of those ViewHolder (GoalCardViewHolder) holds a View that is just a container for displaying a Fragment. In my chat app this ViewHolder is used by two different ViewTypes, this is for UserInput and ChatItem view types.

The UserInput type represents when the user needs to act. User Input

The ChatItemType represent any of the other elements in the Chat, when the user inputs a new goal, a ChatItemType.GoalCard is created: ChatItem.GoalCard

Note that both types use the same ViewHolder.

The problem

When the user is trying to create a new Goal , I expect a new UserInput to be created at the bottom of the chat. However, the previous GoalCardViewHolder is being re-used(this happens a few times, until the list grows long enough that the ViewHolder gets recycled, when that happens the new view is added at the bottom as expected).

The source code Apologies for the lengthy pasting but the Adapter and MainChatFragment are very complex (please do let me know if you need anything else):

MainChatFragment.kt

class MainChatFragment(
    private val viewModel: MainChatViewModel,
    private val chatAdapter: ChatAdapter,
    private val chatToolbar: ChatToolbar,
    private val chatDaySelector: ChatDaySelector,
    private val dayOfStudyProvider: DayOfStudyProvider,
    private val subjectMenuFragment: SubjectMenuFragment,
    private val chatSpeedHelper: ChatSpeedHelper,
) : Fragment() {
    private lateinit var dayOfStudySelectorView: DayOfStudySelectorView
    private lateinit var binding: FragmentChatBinding
    private var scrollDistance: Int = 0
    private val cancelOnPauseScope = CancelOnPauseCoroutineScope(this)
    private val bufferedChatItems = mutableListOf<IChatItem>()

    private val arguments: MainChatFragmentArgs by navArgs()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentChatBinding.inflate(inflater)
        return binding.root
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        dayOfStudySelectorView = binding.dashboardView
        dayOfStudySelectorView.init(dayOfStudyProvider, chatDaySelector)
        setupChatItemList()
        subjectMenuFragment.closeMenuCallback = { shouldDiscardItems ->
            if (!shouldDiscardItems) {
                bufferedChatItems.forEach {
                    addChatItem(it)
                }
            }
            bufferedChatItems.clear()
        }

        chatAdapter.removeLastUserInput = this::removeLastUserInput
        chatAdapter.subjectMenuButtonOnClick = this::openSubjectMenuFragment

        chatToolbar.setMenuButtonOnClickListener {
            openSubjectMenuFragment()
        }
    }

    private fun removeLastUserInput() {
        viewModel.removeLastUserInputAndLogMessage()
    }

    private fun openSubjectMenuFragment() {
        if (!subjectMenuFragment.isAdded) {
            subjectMenuFragment.show(requireActivity().supportFragmentManager, "subjectMenu")
        }
    }

    override fun onResume() {
        super.onResume()
        observeChatItemActions() // Should be last entry in here
    }

    @Suppress("IMPLICIT_CAST_TO_ANY")
    private fun observeChatItemActions() {
        cancelOnPauseScope.launch {
            viewModel.chatItemActionFlow()
                .onStart { viewModel.onUserEnteredChat(arguments.hasJustActivated) }
                .transform {
                    if (it is ChatItemAction.AddChatItemAction) {
                        val delayMs = chatSpeedHelper.itemDelay(it.chatItem)
                        if (delayMs > 0) {
                            delay(delayMs)
                        }
                        if (subjectMenuFragment.isVisible) {
                            bufferedChatItems.add(it.chatItem)
                            return@transform
                        }
                    }
                    emit(it)
                }
                .collect {
                    if (!isVisible) {
                        return@collect
                    }
                    when (it) {
                        is ChatItemAction.AddChatItemAction -> {
                            addChatItem(it.chatItem)
                        }
                        is ChatItemAction.SetChatItemsAction -> {
                            chatAdapter.showWaitingBubble = false
                            binding.emptyChatText.isVisible =
                                it.chatItems.isEmpty() && !it.chatEnabled
                            chatAdapter.items = it.chatItems.map { item -> ChatViewItem(item) }
                        }
                        is ChatItemAction.RemoveAllItemsAction -> {
                            chatAdapter.removeAllItems()
                        }
                        is ChatItemAction.RemoveLastItemAction -> {
                            chatAdapter.conditionallyRemoveLastChatItem(it.predicate)
                        }
                    }.let { } // Make when exhaustive
                }
        }
    }

    private fun addChatItem(chatItem: IChatItem) {
        if (chatItem is ChatItem) {
            chatItem.chatBackgroundAction?.let { chatBackgroundAction ->
                viewModel.launchChatBackgroundAction(chatBackgroundAction)
            }
        }
        val chatViewItem = ChatViewItem(chatItem)
        chatAdapter.showWaitingBubble = chatViewItem.shouldShowPendingBubble()
        chatAdapter.addChatItem(chatViewItem)
        binding.emptyChatText.isVisible = false
    }

    private fun setupChatItemList() {
        val linearLayoutManager = LinearLayoutManager(requireView().context)
        linearLayoutManager.stackFromEnd = true
        with(binding.chatList) {
            layoutManager = linearLayoutManager
            adapter = chatAdapter
            addItemDecoration(
                DividerItemDecoration(
                    requireView().context,
                    linearLayoutManager.orientation
                ).apply {
                    setDrawable(
                        ContextCompat.getDrawable(
                            requireView().context,
                            R.drawable.divider_chat
                        )!!
                    )
                })

            addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    scrollDistance += dy
                    if (dy < 0 &&
                        (scrollDistance.absoluteValue >= measuredHeight || !binding.chatList.canScrollVertically(
                            -1
                        ))
                    ) {
                        dayOfStudySelectorView.revealDaySelector()
                    } else if (dy > 0) {
                        dayOfStudySelectorView.hideDaySelector()
                    }
                }
            })
        }
    }
}

ChatAdapter.kt

class ChatAdapter(
    private val widgetHandler: WidgetHandler,
    private val coroutineScope: CoroutineScope,
) : RecyclerView.Adapter<BaseChatItemViewHolder>() {

    private lateinit var context: Context
    private lateinit var layoutInflater: LayoutInflater

    private var recyclerView: RecyclerView? = null
    private val lastDivider = ChatViewItem(ChatItemType.LastDivider)
    private val pendingMessage = ChatViewItem(ChatItemType.PendingMessage)
    private val staticPendingView = false

    var showWaitingBubble: Boolean = true
        set(value) {
            if (value != showWaitingBubble) {
                setShowingWaitingBubble(value)
            }
            field = value
        }

    var removeLastUserInput: () -> Unit = {}
    var subjectMenuButtonOnClick: () -> Unit = {}
    lateinit var onGoalSettingStarted: (GoalCardProgressListener) -> Unit

    private fun setShowingWaitingBubble(value: Boolean): Int {
        var updatedIndex = -1
        if (_items.isNotEmpty()) {
            if (!value) {
                _items.lastIndexOf(pendingMessage)
                    .takeIf { it >= 0 }
                    ?.let {
                        _items.removeAt(it)
                        updatedIndex = it
                    }
            } else {
                if (!hasPendingBubble() && shouldShowPendingBubble(getLastRealChatItem().second)) {
                    val divider = hasLastDivider()
                    updatedIndex = Math.max(0, _items.size - divider.toBitInteger())
                    _items.add(updatedIndex, pendingMessage)
                }
            }
        }
        if (updatedIndex != -1) {
            scrollToBottom()
        }
        return updatedIndex
    }

    private var _items = ArrayList<ChatViewItem>()
    var items: List<ChatViewItem>
        get() = _items
        set(value) {
            _items.clear()
            _items.addAll(value)
            if (value.isNotEmpty()) {
                setShowingWaitingBubble(showWaitingBubble)
            }
            _items.add(lastDivider)
            notifyDataSetChanged()
            scrollToBottom()
        }

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        super.onAttachedToRecyclerView(recyclerView)
        this.recyclerView = recyclerView
        context = recyclerView.context
        layoutInflater = LayoutInflater.from(recyclerView.context)
        recyclerView.setItemViewCacheSize(20)
    }

    override fun getItemCount(): Int = items.size

    override fun getItemViewType(position: Int): Int {
        val chatViewItem = items[position]
        return when (chatViewItem.type) {
            ChatItemType.UserInput, ChatItemType.DisabledUserInput -> {
                val inputType = chatViewItem.inputType ?: npe("chatViewItem.inputType")
                when (inputType) {
                    is InputType.GoalSettingCard -> R.layout.goal_card
                    else -> -1 // deleted other viewTypes for stackOverflow
                }
            }
            ChatItemType.GoalCard -> R.layout.goal_card
            else -> -1 // deleted other viewTypes for stackOverflow
        }
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onBindViewHolder(holder: BaseChatItemViewHolder, position: Int) {
        val chatViewItem = items[position]
        when (chatViewItem.type) {
            ChatItemType.UserInput, ChatItemType.DisabledUserInput -> {
                val inputType = chatViewItem.inputType ?: npe("chatViewItem.inputType")
                when (inputType) {
                    is InputType.GoalSettingCard -> {
                        val goalCardFragment = GoalCardFragment()
                        goalCardFragment.initialProgress =
                            GoalBuilder() // Fixme use goal builder from topic interactor
                        onGoalSettingStarted.invoke(goalCardFragment)
                        goalCardFragment.arguments =
                            bundleOf(GoalCardFragment.FLOW_TYPE_EXTRA to inputType.flowType)
                        goalCardFragment.onCanceledCallback =
                            (holder as GoalCardViewHolder).canceledListener
                        goalCardFragment.onCompletedCallback = holder.completedListener

                        if (inputType.flowType == FlowType.COMPLETE_GOAL) {
                            context.activity().supportFragmentManager
                                .beginTransaction()
                                .replace(
                                    holder.binding.goalCardContainer.id,
                                    goalCardFragment,
                                    GOAL_CARD_FRAGMENT_AS_USER_INPUT
                                )
                                .commit()
                        } else {
                            TODO("Need implementation")
                        }
                    }
                }
            }
            ChatItemType.GoalCard -> {
                gson.fromJson(chatViewItem.text, GoalCardParameters::class.java)
                GoalCardFragment().let {
                    if (chatViewItem.data is GoalSettingData) {
                        it.initialProgress = (chatViewItem.data as GoalSettingData).goalBuilder
                    }
                    context.activity().supportFragmentManager.beginTransaction()
                        .replace(
                            (holder as GoalCardViewHolder).binding.goalCardContainer.id,
                            it as GoalCardFragment,
                            GOAL_CARD_FRAGMENT_AS_CHAT_ITEM
                        )
                        .commit()
                }
            }
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        @LayoutRes viewType: Int
    ): BaseChatItemViewHolder {
        val itemView = layoutInflater.inflate(viewType, parent, false)
        return when (viewType) {
            R.layout.goal_card -> {
                GoalCardViewHolder(itemView).apply {
                    setOnCanceledListener {
                        coroutineScope.launch {
                            removeLastUserInput()
                            removeFragmentByTag(GOAL_CARD_FRAGMENT_AS_USER_INPUT)
                            widgetHandler.onWidgetDone(
                                userResponse = UserResponse(
                                    WIDGET_DISMISSED_WITH_VALUE + "cancelled"
                                )
                            )
                        }
                    }
                    setOnCompletedListener {
                        coroutineScope.launch {
                            val goalData = UserResponseData.GoalData(it)
                            removeLastUserInput()
                            removeFragmentByTag(GOAL_CARD_FRAGMENT_AS_USER_INPUT)
                            widgetHandler.onWidgetDone(
                                userResponse = UserResponse(
                                    WIDGET_DISMISSED_WITH_VALUE + "completed", data = goalData
                                )
                            )

                            // TODO api
                        }
                    }
                }
            }
            else -> throw IllegalStateException("View type not mapped to a view holder")
        }
    }

    private fun removeFragmentByTag(tag: String) {
        context.activity().supportFragmentManager.apply {
            findFragmentByTag(tag)?.let {
                beginTransaction()
                    .remove(it)
                    .commit()
            }
        }
    }

    fun addChatItem(chatItemView: ChatViewItem) {
        val shouldShowPendingBubble = shouldShowPendingBubble(chatItemView)
        setShowingWaitingBubble(shouldShowPendingBubble)
        val hasPendingBubble = hasPendingBubble()
        val insertIndex = Math.max(
            0,
            itemCount - hasLastDivider().toBitInteger() - hasPendingBubble.toBitInteger()
        )
        _items.add(insertIndex, chatItemView)
        // sending notifyItemRangeChanged instead of notifyItemInserted is for a feeling of
        // scrolling also the pendingBubble, otherwise it looks like it's static view and it's not part of scrolling content
        if (staticPendingView && shouldShowPendingBubble) {
            notifyItemInserted(insertIndex)
        } else {
            notifyItemRangeChanged(insertIndex, 1 + hasPendingBubble.toBitInteger())
        }

        when (chatItemView.inputType) {
            is InputType.FreeText,
            is InputType.FreeTextWithLimit ->
                // for some reason RecyclerView scrolls up little bit when EditText is shown
                // basically aligns to bottom of line, not bottom of EditText =>
                // let RV do this nonsense and scroll down little bit later
                // however we have to do it sooner, we try to show the keyboard then
                recyclerView?.postDelayed(UiConst.keyboardHandlingDelay / 2) {
                    scrollToBottom()
                }
            else -> scrollToBottom()
        }
    }

    override fun onViewAttachedToWindow(holder: BaseChatItemViewHolder) {
        super.onViewAttachedToWindow(holder)
        holder.onAttached()
    }

    override fun onViewDetachedFromWindow(holder: BaseChatItemViewHolder) {
        super.onViewDetachedFromWindow(holder)
        holder.onDetached()
    }

    fun removeAllItems() {
        _items.clear()
        notifyDataSetChanged()
    }

    fun notifyItemChanged(chatViewItem: ChatViewItem) {
        notifyItemChanged(items.indexOf(chatViewItem))
    }

    fun conditionallyRemoveLastChatItem(predicate: (ChatViewItem) -> Boolean) {
        val (index, item) = getLastRealChatItem()
        if (item != null && predicate(item)) {
            _items.removeAt(index)
            notifyItemRemoved(index)
        }
    }

    private fun hasLastDivider(): Boolean = _items.lastOrNull()?.type == ChatItemType.LastDivider
    private fun hasPendingBubble(): Boolean {
        return (items.lastOrNull(1)?.type == ChatItemType.PendingMessage) ||
                (items.lastOrNull(0)?.type == ChatItemType.PendingMessage)
    }

    private fun getLastRealChatItem(): Pair<Int, ChatViewItem?> {
        if (!items.isEmpty()) {
            (items.size - 1 downTo 0).forEach { i ->
                val item = items[i]
                when (item.type) {
                    ChatItemType.LastDivider,
                    ChatItemType.PendingMessage -> {
                        /*ignore*/
                    }
                    else -> return Pair(i, item)
                }
            }
        }
        return Pair(-1, null)
    }

    private val skipPendingBubbleFor = listOf(
        ChatItemType.UserInput,
        ChatItemType.LastBotMessage
    )

    private fun shouldShowPendingBubble(chatItemView: ChatViewItem?): Boolean {
        return showWaitingBubble && !skipPendingBubbleFor.contains(chatItemView?.type)
    }

    private fun scrollToBottom() {
        (recyclerView?.layoutManager as? LinearLayoutManager)?.scrollToPosition(itemCount - 1)
    }

    companion object {
        const val GOAL_CARD_FRAGMENT_AS_USER_INPUT = "goal-card-fragment-user-input"
        const val GOAL_CARD_FRAGMENT_AS_CHAT_ITEM = "goal-card-fragment-chat-item"
    }
}

abstract class BaseChatItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    open fun onAttached() {}

    open fun onDetached() {}

    open fun bind(chatViewItem: ChatViewItem) {}

    abstract val binding: ViewBinding?
}

abstract class BaseTextChatItemViewHolder(itemView: View) : BaseChatItemViewHolder(itemView) {
    abstract val chatText: TextView
}

class GoalCardViewHolder(itemView: View) : BaseChatItemViewHolder(itemView) {
    lateinit var canceledListener: (() -> Unit)
    lateinit var completedListener: ((GoalBuilder) -> Unit)

    fun setOnCanceledListener(canceledListener: () -> Unit) {
        this.canceledListener = canceledListener
    }

    fun setOnCompletedListener(completedListener: (GoalBuilder) -> Unit) {
        this.completedListener = completedListener
    }

    override val binding: GoalCardBinding = GoalCardBinding.bind(itemView)
}

goal_card.xml

<?xml version="1.0" encoding="utf-8"?>
<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="match_parent"
    android:layout_height="wrap_content">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/goal_card_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
REM Code
  • 115
  • 6
  • I would suggest you create a simpler version of your code in order to increase the chances of getting an answer to your issue. You've added a lot of code with a very specific context - It is hard to follow. On another note, it considered a bad practice to pass parameters to a Fragment using the Fragment's constructor. Check https://stackoverflow.com/questions/9245408/best-practice-for-instantiating-a-new-android-fragment – gioravered Sep 17 '21 at 12:20
  • try setting ```recyclerView.setItemViewCacheSize(20)``` to 0 instead of 20. – Nataraj KR Sep 17 '21 at 14:20

1 Answers1

0

The issue and lesson learned was 'never use a Fragment in a RecyclerView.ViewHolders

I converted my fragment to a custom view and the problem is no longer happening.

I think it must have been an issue with the Fragment's lifecycle.

REM Code
  • 115
  • 6