Search code examples
androidkotlinandroid-recyclerviewnestedrecyclerview

Android how to interact with nested recyclerView from fragment


How does one properly send data to child adapter in a fragment? I'm basically trying to implement an Instagram like comments-section, e.g. a bunch of comments that can each have more comments (replies).

To do that, I use one main recyclerView + main adapter, which instances are retained in my fragment, and within the main adapter I bind the children comments (recyclerView + adapter).

Adding comments to the main adapter is easy since the object is always available in the fragment, so I just call mainAdapter.addComments(newComments):

MainAdapter

fun addComments(newComments: List<Comment>){
    comments.addAll( 0, newComments) //loading comments or previous comments go to the beginning
    notifyItemRangeInserted(0, newComments.size)
}

But how to call addComments of one particular nested-rV? I read I should not save the adapter instances and only use positions. I'm trying to do that in my Fragment as follows:

val item = rVComments.findViewHolderForItemId(mAdapter.itemId)!!.itemView
val adapt = item.rVReplies.adapter as ChildCommentsAdapter
adapt.addComment(it.data.comment)

But that doesn't work very well: since we have only RecyclerViews, that particular ViewHolder is often already recycled if the user scrolled after posting or fetching items, which leads to a NullPointerException. Hence the initial question: how does one properly interact with nested recyclerviews and their adapter? If the answer is via Interface, please provide an example as I've tried it without success since I shouldn't save adapter objects.


Solution

  • Using @Gil Goldzweig's suggestion, here is what I did: in case of an Instagram like comments' system with replies, I did use a nested recyclerView system. It just makes it easier to add and remove items. However, as for the question How does one properly send data to child adapter in a fragment? You don't. It gets super messy. From my fragment, I sent the data to my mainAdapter, which in turn sent the data to the relevant childAdapter. The key to make it smooth is using notifyItemRangeInserted when adding a comment to the mainAdapter and then notifyItemChanged when adding replies to a comment. The second event will allow sending data to the child adapter using the payload. Here's the code in case other people are interested:

    Fragment

    class CommentsFragment : androidx.fragment.app.Fragment(), Injectable,
        SendCommentButton.OnSendClickListener, CommentsAdapter.Listener {
    
    
    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory
    
    private val viewModel by lazy {
        ViewModelProviders.of(requireActivity(), viewModelFactory).get(CommentsViewModel::class.java)
    }
    private val searchViewModel by lazy {
        ViewModelProviders.of(requireActivity(), viewModelFactory).get(SearchViewModel::class.java)
    }
    
    private val mAdapter = CommentsAdapter(this)
    private var contentid: Int = 0 //store the contentid to process further posts or requests for more comments
    private var isLoadingMoreComments: Boolean = false //used to check if we should fetch more comments
    
    
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
    
    
        return inflater.inflate(R.layout.fragment_comments, container, false)
    
    }
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
    
        //hide the action bar
        activity?.findViewById<BottomNavigationView>(R.id.bottomNavView)?.visibility = View.GONE
    
    
        contentid = arguments!!.getInt("contentid") //argument is mandatory, since comment is only available on content
    
        ivBackArrow.setOnClickListener{ activity!!.onBackPressed() }
    
        viewModel.initComments(contentid) //fetch comments
    
        val layoutManager = LinearLayoutManager(this.context)
        layoutManager.stackFromEnd = true
        rVComments.layoutManager = layoutManager
        mAdapter.setHasStableIds(true)
        rVComments.adapter = mAdapter
    
    
    
        setupObserver() //observe initial comments response
        setupSendCommentButton()
        post_comment_text.setSearchViewModel(searchViewModel)
        setupScrollListener(layoutManager) //scroll listener to load more comments
    
        iVCancelReplyTo.setOnClickListener{
            //reset ReplyTo function
            resetReplyLayout()
        }
    
    
    }
    
    private fun loadMoreComments(){
        viewModel.fetchMoreComments(contentid, mAdapter.itemCount)
        setupObserver()
    }
    
    /*
    1.check if not already loading
    2.check scroll position 0
    3.check total visible items != total recycle items
    4.check itemcount to make sure we can still make request
     */
    private fun setupScrollListener(layoutManager: LinearLayoutManager){
        rVComments.addOnScrollListener(object: RecyclerView.OnScrollListener(){
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val visibleItemCount = rVComments.childCount
                val totalItemCount = layoutManager.itemCount
                val pos = layoutManager.findFirstCompletelyVisibleItemPosition()
                if(!isLoadingMoreComments && pos==0 && visibleItemCount!=totalItemCount && mAdapter.itemCount%10==0){
                    //fetch more comments
                    isLoadingMoreComments = true
                    loadMoreComments()
                }
            }
    
        })
    }
    
    
    
    
    
    private fun setupSendCommentButton() {
        btnSendComment.setOnSendClickListener(this)
    }
    
    override fun onSendClickListener(v: View?) {
        if(isInputValid(post_comment_text.text.toString())) {
            val isReply = mAdapter.commentid!=null
            viewModel.postComment(post_comment_text.text.toString(), mAdapter.commentid?: contentid, isReply) //get reply ID, otherwise contentID
            observePost()
            post_comment_text.setText("")
            btnSendComment.setCurrentState(SendCommentButton.STATE_DONE)
        }
    }
    
    override fun postCommentAsReply(username: String) {
        //main adapter method to post a reply
        val replyText = "${getString(R.string.replyingTo)} $username"
        tVReplyTo.text = replyText
        layoutReplyTo.visibility=View.VISIBLE
        post_comment_text.requestFocus()
    }
    
    override fun fetchReplies(commentid: Int, commentsCount: Int) {
        //main adapter method to fetch replies
        if(!isLoadingMoreComments){ //load one series at a time
            isLoadingMoreComments = true
            viewModel.fetchReplies(commentid, commentsCount)
            viewModel.replies.observe(this, Observer<Resource<List<Comment>>> {
                if (it?.data != null) when (it.status) {
                    Resource.Status.LOADING -> {
                        //showProgressBar(true)
                    }
                    Resource.Status.ERROR -> {
                        //showProgressBar(false)
                        isLoadingMoreComments = false
                    }
                    Resource.Status.SUCCESS -> {
                        isLoadingMoreComments = false
                        mAdapter.addReplies(mAdapter.replyCommentPosition!!, it.data)
                        rVComments.scrollToPosition(mAdapter.replyCommentPosition!!)
                    }
                }
            })
        }
    
    }
    
    
    private fun isInputValid(text: String): Boolean = text.isNotEmpty()
    
    private fun observePost(){
        viewModel.postComment.observe(this, Observer<Resource<PostCommentResponse>> {
            if (it?.data != null) when (it.status) {
                Resource.Status.LOADING -> {
                    //showProgressBar(true)
                }
                Resource.Status.ERROR -> {
                    //showProgressBar(false)
                }
                Resource.Status.SUCCESS -> {
                    if(it.data.asReply){
                        //dispatch comment to child adapter via main adapter
                        mAdapter.addReply(mAdapter.replyCommentPosition!!, it.data.comment)
                        rVComments.scrollToPosition(mAdapter.replyCommentPosition!!)
                    }else{
                        mAdapter.addComment(it.data.comment)
                    }
                    resetReplyLayout()
                    //showProgressBar(false)
                }
            }
        })
    }
    
    
    
    private fun setupObserver(){
        viewModel.comments.observe(this, Observer<Resource<List<Comment>>> {
            if (it?.data != null) when (it.status) {
                Resource.Status.LOADING -> {
                    //showProgressBar(true)
                }
                Resource.Status.ERROR -> {
                    isLoadingMoreComments = false
                    //showProgressBar(false)
                }
                Resource.Status.SUCCESS -> {
                    mAdapter.addComments(it.data)
                    isLoadingMoreComments = false
                    //showProgressBar(false)
                }
            }
        })
    }
    
    private fun resetReplyLayout(){
        layoutReplyTo.visibility=View.GONE
        mAdapter.replyCommentPosition = null
        mAdapter.commentid = null
    }
    
    
    
    override fun onStop() {
        super.onStop()
        activity?.findViewById<BottomNavigationView>(R.id.bottomNavView)?.visibility = View.VISIBLE
    }
    
    }
    

    MainAdapter

    class CommentsAdapter(private val listener: Listener) : RecyclerView.Adapter<CommentsAdapter.ViewHolder>(), ChildCommentsAdapter.ChildListener {
    
    //method from child adapter
    override fun postChildReply(replyid: Int, username: String, position: Int) {
        commentid = replyid
        replyCommentPosition = position
        listener.postCommentAsReply(username)
    }
    
    interface Listener {
        fun postCommentAsReply(username: String)
        fun fetchReplies(commentid: Int, commentsCount: Int=0)
    }
    
    
    class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
    
    private var comments = mutableListOf<Comment>()
    private var repliesVisibility = mutableListOf<Boolean>() //used to store visibility state for replies
    var replyCommentPosition: Int? = null //store the main comment's position
    var commentid: Int? = null //used to indicate which comment is replied to
    
    
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
       val view = LayoutInflater.from(parent.context)
               .inflate(R.layout.item_comment, parent, false)
    
        return ViewHolder(view)
    }
    
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    
        val comment = comments[position]
    
        with(holder.view) {
            //reset visibilities (rebinding purpose)
            rVReplies.visibility = View.GONE
            iVMoreReplies.visibility = View.GONE
            tVReplies.visibility = View.GONE
    
            content.loadUserPhoto(comment.avatarThumbnailURL)
            text.setCaptionText(comment.username!!, comment.comment)
            tvTimestamp.setTimeStamp(comment.timestamp!!)
    
            val child = ChildCommentsAdapter(
                //we pass parent commentid and position to child to be able to pass it again on click
                this@CommentsAdapter, comments[holder.adapterPosition].id!!, holder.adapterPosition
            )
            val layoutManager = LinearLayoutManager(this.context)
            rVReplies.layoutManager = layoutManager
            rVReplies.adapter = child
    
    
            //initial visibility block when binding the viewHolder
            val txtMore = this.resources.getString(R.string.show_more_replies)
            if(comment.repliesCount>0) {
                tVReplies.visibility = View.VISIBLE
                if (repliesVisibility[position]) {
                    //replies are to be shown directly
                    rVReplies.visibility = View.VISIBLE
                    child.addComments(comment.replies!!)
                    tVReplies.text = resources.getString(R.string.hide_replies)
    
                    if (comment.repliesCount > comment.replies!!.size) {
                        //show the load more replies arrow if we can fetch more replies
                        iVMoreReplies.visibility = View.VISIBLE
                    }
                } else {
                    //replies all hidden
                    val txt = txtMore + " (${comment.repliesCount})"
                    tVReplies.text = txt
                }
            }
    
            //second visibility block when toggling with the show more/hide textView
            tVReplies.setOnClickListener{
                //toggle child recyclerView visibility and change textView text
                if(holder.view.rVReplies.visibility == View.GONE){
                    //show stuff
                    if(comment.replies!!.isEmpty()){
                        Timber.d(holder.adapterPosition.toString())
                        //fetch replies if none were fetched yet
                        replyCommentPosition = holder.adapterPosition
                        listener.fetchReplies(comments[holder.adapterPosition].id!!)
                    }else{
                        //load comments into adapter if not already
                        if(comment.replies!!.size>child.comments.size){child.addComments(comment.replies!!)}
                    }
    
                    repliesVisibility[position] = true
                    holder.view.rVReplies.visibility = View.VISIBLE
                    holder.view.tVReplies.text = holder.view.resources.getString(R.string.hide_replies)
                    if (comment.repliesCount > comment.replies!!.size && comment.replies!!.isNotEmpty()) {
                        //show the load more replies arrow if we can fetch more replies
                        iVMoreReplies.visibility = View.VISIBLE
                    }
                }else{
                    //hide replies and change text
                    repliesVisibility[position] = false
                    holder.view.rVReplies.visibility = View.GONE
                    holder.view.iVMoreReplies.visibility = View.GONE
                    val txt = txtMore + " (${comment.repliesCount})"
                    holder.view.tVReplies.text = txt
                }
            }
    
    
    
            tvReply.setOnClickListener{
                replyCommentPosition = holder.adapterPosition
                commentid = comments[holder.adapterPosition].id!!
                listener.postCommentAsReply(comments[holder.adapterPosition].username!!)
            }
    
            iVMoreReplies.setOnClickListener{
                replyCommentPosition = holder.adapterPosition
                listener.fetchReplies(comments[holder.adapterPosition].id!!, layoutManager.itemCount) //pass amount of replies too
            }
    
    
        }
    }
    
    @Suppress("UNCHECKED_CAST")
    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
        if(payloads.isNotEmpty()){
            //add reply to child adapter
            with(holder.view){
                Timber.d(payloads.toString())
                val adapter = rVReplies.adapter as ChildCommentsAdapter
                if(payloads[0] is Comment){
                    adapter.addComment(payloads[0] as Comment)
                }else{
                    //will be of type List<Comment>
                    adapter.addComments(payloads[0] as List<Comment>)
                    val comment = comments[position]
                    if (comment.repliesCount > comment.replies!!.size) {
                        //show the load more replies arrow if we can fetch more replies
                        iVMoreReplies.visibility = View.VISIBLE
                    }else{
                        iVMoreReplies.visibility = View.GONE
                    }
                }
            }
        }else{
            super.onBindViewHolder(holder,position, payloads) //delegate to normal binding process
        }
    }
    
    
    override fun getItemCount(): Int = comments.size
    
    
    //add multiple replies to child adapter at pos 0
    fun addReplies(position: Int, newComments: List<Comment>){
        comments[position].replies!!.addAll(0, newComments)
        notifyItemChanged(position, newComments)
    }
    
    //add a single reply to child adapter at last position
    fun addReply(position: Int, newComment: Comment){
        comments[position].replies!!.add(newComment)
        comments[position].repliesCount += 1 //update replies count in case viewHolder gets rebinded
        notifyItemChanged(position, newComment)
    }
    
    //add a new comment to main adapter at last position
    fun addComment(comment: Comment){
        comments.add(comment) //new comment just made goes to the end
        repliesVisibility.add(false)
        notifyItemInserted(itemCount-1)
    }
    
    //add multiple new comments to main adapter at pos 0
    fun addComments(newComments: List<Comment>){
        comments.addAll( 0, newComments) //loading comments or previous comments go to the beginning
        repliesVisibility.addAll(0, List(newComments.size) { false })
        notifyItemRangeInserted(0, newComments.size)
    }
    
    
    
    }
    

    The childAdapter is very basic and has nearly 0 logic.