Epoxy: RecyclerView won't update view after data change.

Created on 23 Oct 2018  路  6Comments  路  Source: airbnb/epoxy

Hi,

So what I tried to do is I set a controller to EpoxyRecyclerView, set a data to the controller and call requestModelBuild(). But, the problem is that the EpoxyRecyclerView won't update its UI for some reason.

This is what my code looks like.

CategorizedPromotionsFragment.kt

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        fragment_promotions_categorized_recycler_view.setController(promotionController)
        fragment_promotions_categorized_recycler_view.isNestedScrollingEnabled = false

        if (categoryId == null || categoryName == null) {
            throw IllegalStateException("categoryId and categoryName must be specified.")
        }

        getData()

        fragment_promotions_categorized_scrollview.setOnScrollChangeListener(this)
    }

    private fun getData() {
        Http.get("get/promotion/$categoryId").responseObject<List<PromotionResponse>> { _, response, result ->
            if (response.isSuccessful) {
                setUpRecyclerViewUi(result.get())
            }
        }
    }

    @SuppressLint("SetTextI18n")
    private fun setUpRecyclerViewUi(data: List<PromotionResponse>) {
        promotionController.setData(data) // The UI won't update

        fragment_promotions_categorized_header.text = "$categoryName Promotions"
        fragment_promotions_categorized_number_result.text = "Found ${data.size} Promotions"
    }

    override fun onScrollChange(v: NestedScrollView?, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) {
        if (scrollY == (v?.getChildAt(0)?.measuredHeight?.minus(v.measuredHeight))) {
            promotionController.loadMore()
        }
    }

PromotionController.kt

class PromotionController : EpoxyController() {
    private var promotionsBuffer: List<PromotionResponse> = listOf()
    private var allPromotions: List<PromotionResponse> = listOf()

    private var currentMaxSize: Int = 5

    override fun buildModels() {
        if (currentMaxSize > allPromotions.size) {
            currentMaxSize = allPromotions.size
        }

        if (promotionsBuffer.size == allPromotions.size) {
            return
        }

        promotionsBuffer = allPromotions.take(currentMaxSize)

        Log.d("PromotionController", "allPromotions Size: ${allPromotions.size}")
        Log.d("PromotionController", "promotionsBuffer Size: ${promotionsBuffer.size}")

        // The UI won't update
        for (promotion in promotionsBuffer) {
            popPromo {
                id(promotion.Id)
                promoName(promotion.promotion_name)
                promoDesc(promotion.promotion_detail)
                promoExp(promotion.promotion_end_date)
                promoPoint(promotion.reward?.get(0)?.reward_point_cost ?: 0)
                promoPicUrl(promotion.image?.get(0)?.ImageName ?: "")
                promoStoreName(promotion.company_name)
            }
        }
    }

    fun setData(data: List<PromotionResponse>) {
        allPromotions = data
        currentMaxSize = 5
        requestModelBuild()
    }

    fun loadMore() {
        Log.d("PromotionController", "Loading More.. BRRRRRRRRR")
        currentMaxSize += 5
        requestModelBuild()
    }
}

And the model:

@EpoxyModelClass
abstract class PopPromoModel : EpoxyModelWithHolder<PopPromoModel.PopPromoViewHolder>() {
    @EpoxyAttribute
    var promoPicUrl: String = ""

    @EpoxyAttribute
    var promoPoint: Int = 0

    @EpoxyAttribute
    var promoName: String = ""

    @EpoxyAttribute
    var promoStoreName: String = ""

    @EpoxyAttribute
    var promoDesc: String = ""

    @EpoxyAttribute
    var promoExp: String = ""

    @JvmField
    @EpoxyAttribute
    var isBig: Boolean = true

    override fun getDefaultLayout(): Int {
        return R.layout.model_pop_promo
    }

    @SuppressLint("SetTextI18n")
    override fun bind(holder: PopPromoViewHolder) {
        Picasso.get()
                .load("${FuelManager.instance.basePath}/Images/$promoPicUrl")
                .fit()
                .centerCrop()
                .placeholder(R.drawable.placeholder)
                .error(R.drawable.placeholder)
                .into(holder.promoPic)

        holder.promoPoint.text = promoPoint.toString()
        holder.promoName.text = promoName
        holder.promoDesc.text = promoDesc
        holder.promoStoreName.text = promoStoreName
        holder.promoExp.text = "Valid Until: ${promoExp.formatDbDate("d MMM yyyy")}"

        if (!isBig) {
            holder.pointContainer.visibility = View.GONE

            val pointText = "$promoPoint Point"
            val spannablePointText = SpannableString(pointText)

            spannablePointText.setSpan(
                    ForegroundColorSpan(Color.rgb(170, 170, 170)),
                    pointText.indexOf("Point"), spannablePointText.length,
                    Spannable.SPAN_INCLUSIVE_INCLUSIVE
            )

            spannablePointText.setSpan(
                    StyleSpan(Typeface.BOLD),
                    pointText.indexOf(promoPoint.toString()), promoPoint.toString().length,
                    Spannable.SPAN_INCLUSIVE_INCLUSIVE
            )

            holder.promoPointSmall.text = spannablePointText
            holder.promoPointSmall.visibility = View.VISIBLE
        }
    }

    inner class PopPromoViewHolder : KotlinHolder() {
        val promoPic        by bind<RoundedImageView>(R.id.model_pop_promo_pic)
        val promoPoint      by bind<TextView>(R.id.model_pop_promo_point_text)
        val promoName       by bind<TextView>(R.id.model_pop_team_epic) // yes
        val promoDesc       by bind<TextView>(R.id.model_pop_promo_desc)
        val promoStoreName  by bind<TextView>(R.id.model_pop_promo_store_text)
        val promoExp        by bind<TextView>(R.id.model_pop_promo_exp)

        val pointContainer  by bind<LinearLayout>(R.id.model_pop_promo_point_right_container)
        val promoPointSmall by bind<TextView>(R.id.model_pop_promo_point_text_small)
    }
}

However, it gets weirder.

If I set controller to recyclerview after the data has been loaded from API like this:

private fun setUpRecyclerViewUi(data: List<PromotionResponse>) {
    fragment_promotions_categorized_recycler_view.setController(promotionController)
    fragment_promotions_categorized_recycler_view.isNestedScrollingEnabled = false

    promotionController.setData(data)

    fragment_promotions_categorized_header.text = "$categoryName Promotions"
    fragment_promotions_categorized_number_result.text = "Found ${data.size} Promotions"
}

The UI will update with initial 5 rows as expected. But, if the user scroll to bottom of the nestedScrollView, the fragment will call loadMore() method inside PromotionController, which will then call requestModelBuild(). But, the UI won't update with newly appended data.

Any idea what might be causing this?

Also note that PopPromoModel has been used with other fragment / controller and it works perfectly, so I'm quite sure that the model is not what causing this problem.

UPDATE 0: Ok, I tried logging out the itemCount from recyclerView's adapter. It is clear the item count keep increasing as expected, but the UI itself won't update.

UPDATE 1: I also logged out models using interceptor, and as expected, it also has all the data as expected, but, again, the UI won't update.

Most helpful comment

I was facing the same issue, where I had vertical RecyclerView with each item containing optional vertical RecyclerViews. Whenever an item in the inner RecyclerView was being added, the list wasn't being updated. And as it turns out, I too, was passing the object's "id" field in the id() method.

So what I did was passed object.toString() instead of object.id (in your case, it would be replacing promotion.Id with promotion.toString() in the id() method) and voila! It started updating the views.

All 6 comments

UPDATE 2: Yep, I got it working by swapping out EpoxyRecyclerView with normal RecyclerView. I think the main issue is that EpoxyRecyclerView somehow did not get notify about data change. I'm gonna change the issue name for now.

This is what my working code looks like.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    if (categoryId == null || categoryName == null) {
        throw IllegalStateException("categoryId and categoryName must be specified.")
    }

    fragment_promotions_categorized_recycler_view.layoutManager = LinearLayoutManager(context)
    fragment_promotions_categorized_recycler_view.adapter = promotionController.adapter
    fragment_promotions_categorized_recycler_view.isNestedScrollingEnabled = false

    fragment_promotions_categorized_scrollview.setOnScrollChangeListener(this)
    getData()
}

private fun getData() {
    Http.get("get/promotion/$categoryId").responseObject<List<PromotionResponse>> { _, response, result ->
        if (response.isSuccessful) {
            setUpRecyclerViewUi(result.get())
        }
    }
}

@SuppressLint("SetTextI18n")
private fun setUpRecyclerViewUi(data: List<PromotionResponse>) {
    promotionController.setData(data)
    fragment_promotions_categorized_header.text = "$categoryName Promotions"
    fragment_promotions_categorized_number_result.text = "Found ${data.size} Promotions"
}

override fun onScrollChange(v: NestedScrollView?, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) {
    if (scrollY == (v?.getChildAt(0)?.measuredHeight?.minus(v.measuredHeight))) {
        promotionController.loadMore()
    }
}

I was facing the same issue, where I had vertical RecyclerView with each item containing optional vertical RecyclerViews. Whenever an item in the inner RecyclerView was being added, the list wasn't being updated. And as it turns out, I too, was passing the object's "id" field in the id() method.

So what I did was passed object.toString() instead of object.id (in your case, it would be replacing promotion.Id with promotion.toString() in the id() method) and voila! It started updating the views.

Thank you! It's working as intended now!

I wouldn't recommend using object.toString() as the model id - that is likely a slow operation and inefficient when building models.

It sounds like your problem is that you have duplicate ids that aren't unique, that should be addressed instead.

Generally I would recommend using EpoxyController's setFilterDuplicates and setGlobalExceptionHandler or onExceptionSwallowed to be alerted to these issues more clearly

Will do. Thanks a bunch.

I wouldn't recommend using object.toString() as the model id - that is likely a slow operation and inefficient when building models.

It sounds like your problem is that you have duplicate ids that aren't unique, that should be addressed instead.

Generally I would recommend using EpoxyController's setFilterDuplicates and setGlobalExceptionHandler or onExceptionSwallowed to be alerted to these issues more clearly

Hi, is there any other workaround besides the things you mentioned? I need to update the title of items inside a horizontal recyclerview (the id-s stay the same, its just the titles that need to change - to be more precise I rely on the id-s because they are hard coded). When I update the titles just the ones that are not visible are being updated, when I scroll the vertical list eventually all items change the title. When I set the title as the id it of the model itworks as intended. Is there some workaround to force epoxy to redraw the horizontal recyclerview in these partical situations when I change the titles?

Thank you

Was this page helpful?
0 / 5 - 0 ratings

Related issues

fredo- picture fredo-  路  4Comments

carlosmuvi picture carlosmuvi  路  3Comments

saurabhdtu picture saurabhdtu  路  6Comments

TonnyL picture TonnyL  路  5Comments

Sergiyss picture Sergiyss  路  6Comments