How-To: Add Native Ads In a RecyclerView With Removable ViewHolder

Native Ads In RecyclerViews

✍️ cleverchuk

2024-06-15

section image
We recently updated our Doers app to transition away from Banner ads to Native ads in order to provide a more seamless Ads experience. We have a good number of RecyclerViews and most of them support deleting a row/ViewHolder. This caused an added complexity in implementing this change.
There are two problems that surfaced when we tried to add the Native ads without any remodeling or re-architecting. The first problem was the Ads ViewHolder being used for normal data ViewHolder or vice-versa when a ViewHolder is recycled after being scrolled offscreen. The second issue is when a ViewHolder is removed because the element count is computed a few times during rerender the RecyclerView crashes due to inconsistent indices.
The usual way of adding Native ads that we've seen on the internet is to do something of a virtual addressing scheme where the ads isn't part of the list of data being rendered by the RecyclerView and ViewHolder rather the ads are injected based on some arbitrary interval. Therefore, the Native ads are completely separate from the data.
This approach introduced the two issues mentioned earlier. The first issue can be solved by marking all ViewHolders as not recyclable by calling setIsRecyclable(false) on the ViewHolder during creation. Doing this defeats the benefit of using a RecyclerView. Hence, we opted out of this. A much better solution is to make the ads and the data be part of the same class hierarchy. This solution is much more palatable, so we went for it.
To put the data model and the ads under the same class hierarchy, we created a sealed abstract class, let's call it UiState with one abstract field named identifier. This is so the DiffUtil callback implementation is not too complex at least. Our data and ads models implement this class as shown below.
sealed class UiState {
    abstract var identifier: String
 }
  data class Task(val id: String, val name: String): UiState{
 override var identifier: String
    get() = id
    set(_) {}
 }  data class DoerAd(val nativeAds: NativeAd?) : UiState(){
    override var identifier: String
        get() = nativeAds.toString()
        set(_) {}
 }
These are abbreviated versions of the classes to convey enough context for understanding how we tackled this issue.
Creating the class hierarchy on the model is the first step in the right direction. Another class hierarchy for the ViewHolders is needed to complete the solution for the first issue. This one was fairly straightforward because we already have a class hierarchy for our ViewHolders. We only needed to create a special purpose ViewHolder that will render the ads. The code below is the code of the Ads viewholder.
inner class AdsViewHolder(itemView: View) : BaseVH<UiState>(itemView) {
    val binding = ItemAdsBinding.bind(itemView)
     private val adLoader = AdLoader.Builder(itemView.context, adId)
        .forNativeAd {
            if (isDestroyed) {
                it.destroy()
                return@forNativeAd
            }
             binding.adsTemplate.setNativeAd(it)
            ads.add(it)
        }.build()
     override fun bind(item: UiState) {
        super.bind(item)
        adLoader.loadAd(AdRequest.Builder().build())
    }
 }
The above code is an excerpt from the code base. The AdsViewHolder is an inner class in the AdsAdapter which we will talk about in a bit. As can be seen from the code, the viewholder has an AdLoader that's used to load the ads and it uses the Native ads Ui template provided by the wonderful people at GoogleAds and the community at large. The item it binds is of type UiState which is ignored. Doing this ensures that when it's recycled we always render the ads instead of the actual data helping address the first issue.
Since we have multiple Adapters and would like to monetize each, modifying each one of them to render the AdsViewHolder will be very cumbersome. Hence, we choose to use the decorator pattern and created another adapter AdsAdapter that handles this. The code below shows the implementation of this new Adapter.
class AdsAdapter<T : UiState, DelegateVH : BaseVH<T>>(
    private val delegate: RecyclerView.Adapter<DelegateVH>,
    val adId: String,
 ) :    ListAdapter<UiState, BaseVH<T>>(UiStateDiff), LifecycleEventObserver {
     private var isDestroyed = false
     private val ads: MutableList<NativeAd> = mutableListOf()
     private val adViewType = 1
     private val adViewInterval = 3
     inner class AdsViewHolder(itemView: View) : BaseVH<DoerAd>(itemView) {
        val binding = ItemAdsBinding.bind(itemView)
         private val adLoader = AdLoader.Builder(itemView.context, adId)
            .forNativeAd {
                if (isDestroyed) {
                    it.destroy()
                    return@forNativeAd
                }                 binding.adsTemplate.setNativeAd(it)
                ads.add(it)
            }.build()
         override fun bind(item: DoerAd) {
            super.bind(item)
            adLoader.loadAd(AdRequest.Builder().build())
        }
    }
     companion object UiStateDiff : DiffUtil.ItemCallback<UiState>() {
        override fun areItemsTheSame(oldItem: UiState, newItem: UiState) = oldItem.identifier == newItem.identifier
         override fun areContentsTheSame(oldItem: UiState, newItem: UiState) = oldItem == newItem
    }
     override fun getItemViewType(position: Int): Int {
        return if (isAdPosition(position)) adViewType else 0
    }
     private fun isAdPosition(position: Int): Boolean {
        return (position + 1) % adViewInterval == 0
    }
     override fun submitList(list: MutableList<UiState>?) {
        val states = mutableListOf<UiState>()
        list?.forEachIndexed { position, state ->
            if (isAdPosition(position)) {
                states.add(position, DoerAd(null))
            }
            states.add(state)
        }
        super.submitList(states)
    }
     @Suppress("UNCHECKED_CAST")
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseVH<T> {
        return if (viewType == adViewType) {
            AdsViewHolder(
                LayoutInflater.from(parent.context).inflate(
                    R.layout.item_ads, parent,
                    false
                )            ) as DelegateVH
         } else {
            delegate.onCreateViewHolder(parent, viewType)
        }
    }
     @Suppress("UNCHECKED_CAST")
    override fun onBindViewHolder(holder: BaseVH<T>, position: Int) {
        if (getItemViewType(position) == 1) {
            (holder as AdsAdapter<*, *>.AdsViewHolder).bind(currentList[position])
         } else {
            holder.bind(currentList[position] as T)
        }
    }
     override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        if (event == Lifecycle.Event.ON_DESTROY) {
            ads.forEach { it.destroy() }
            ads.clear()
        }
    }
 }
This adapter is quite complex. We added the method isAdposition which returns true when the current position is not zero and is divisible by adViewInterval. The value of adViewInterval is arbitrary and can be whatever number you wish depending on how many ads you want to show per real data. In our case, we're choosing to show ads every third data item. We modified submitList so we can insert the ads model ahead of time. Doing this, solves the second problem of inconsistent indices.
After the refactor, we realized we did not need all the notify* method calls of the parent ​​RecyclerView.Adapter because we are using room and LiveData. Hence, the UI will update automagically on any change to the underlying data.
With this change, the AdsAdapter can be used to replace any use of the other adapters by wrapping them with the AdsAdapter as shown below.
 adapter = OldAdapter()
 adsAdapter = AdsAdapter(adapter, adId)
 recyclerView.adapter = adsAdapter
We hope you've found this instructive and happy coding!

CleverCorp LLC © 2024

Mail us:
7901 4th St N STE 300
St. Petersburg
Florida, 33702