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>