diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListFragment.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListFragment.kt index 093b01f..aedd3f2 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListFragment.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListFragment.kt @@ -14,6 +14,7 @@ import androidx.recyclerview.widget.GridLayoutManager import kotlinx.coroutines.launch import ru.otus.cryptosample.CoinsSampleApp import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapter +import ru.otus.cryptosample.coins.feature.adapter.CustomItemAnimator import ru.otus.cryptosample.coins.feature.di.DaggerCoinListComponent import ru.otus.cryptosample.databinding.FragmentCoinListBinding import javax.inject.Inject @@ -67,6 +68,7 @@ class CoinListFragment : Fragment() { return when (coinsAdapter.getItemViewType(position)) { 0 -> 2 // Category header spans full width 1 -> 1 // Coin item spans half width + 2 -> 2 // Carousel spans full width else -> 1 } } @@ -75,6 +77,8 @@ class CoinListFragment : Fragment() { binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + itemAnimator = CustomItemAnimator() + setRecycledViewPool(CoinsAdapter.sharedPool) } } diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselAdapter.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselAdapter.kt new file mode 100644 index 0000000..d157013 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselAdapter.kt @@ -0,0 +1,43 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCoinBinding + +class CarouselAdapter : ListAdapter(CarouselDiffUtil) { + + override fun getItemCount(): Int = currentList.size + + override fun getItemViewType(position: Int): Int = CoinsAdapter.VIEW_TYPE_COIN + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinViewHolder { + val binding = ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val params = binding.root.layoutParams + ?: RecyclerView.LayoutParams( + RecyclerView.LayoutParams.WRAP_CONTENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ) + val screenWidth = parent.resources.displayMetrics.widthPixels + params.width = (screenWidth / 2) - 16 + + binding.root.layoutParams = params + return CoinViewHolder(binding) + } + + override fun onBindViewHolder(holder: CoinViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: CoinViewHolder, position: Int, payloads: List) { + holder.bind(getItem(position), payloads) + } +} + +object CarouselDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: CoinState, new: CoinState) = old.id == new.id + override fun areContentsTheSame(old: CoinState, new: CoinState) = old == new +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselViewHolder.kt new file mode 100644 index 0000000..362191f --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselViewHolder.kt @@ -0,0 +1,29 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCarouselBinding + +class CarouselViewHolder( + binding: ItemCarouselBinding, + sharedPool: RecyclerView.RecycledViewPool +) : RecyclerView.ViewHolder(binding.root) { + + private val carouselAdapter = CarouselAdapter() + + init { + binding.carouselRecyclerView.apply { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = carouselAdapter + setRecycledViewPool(sharedPool) + setHasFixedSize(true) + isNestedScrollingEnabled = false + itemAnimator = CustomItemAnimator() + } + } + + fun bind(coins: List) { + carouselAdapter.submitList(coins) + } +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinViewHolder.kt index 729978e..27a6f8a 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinViewHolder.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinViewHolder.kt @@ -1,5 +1,6 @@ package ru.otus.cryptosample.coins.feature.adapter +import android.os.Bundle import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -33,4 +34,19 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } + + fun bind(coin: CoinState, payloads: List) { + if (payloads.isNotEmpty()) { + val bundle = payloads[0] as Bundle + if (bundle.containsKey("highlight")) { + updateHighlight(bundle.getBoolean("highlight")) + } + } else { + bind(coin) + } + } + + private fun updateHighlight(highlight: Boolean) { + binding.fireBadge.isVisible = highlight + } } diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapter.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapter.kt index 9d6ab4f..100d752 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapter.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapter.kt @@ -1,44 +1,58 @@ package ru.otus.cryptosample.coins.feature.adapter +import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import ru.otus.cryptosample.coins.feature.CoinCategoryState +import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapterItem.CarouselItems +import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapterItem.CategoryHeader +import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapterItem.CoinItem +import ru.otus.cryptosample.databinding.ItemCarouselBinding import ru.otus.cryptosample.databinding.ItemCategoryHeaderBinding import ru.otus.cryptosample.databinding.ItemCoinBinding -class CoinsAdapter : RecyclerView.Adapter() { - +class CoinsAdapter : ListAdapter(CoinDiffUtil) { + companion object { - private const val VIEW_TYPE_CATEGORY = 0 - private const val VIEW_TYPE_COIN = 1 + const val VIEW_TYPE_CATEGORY = 0 + const val VIEW_TYPE_COIN = 1 + const val VIEW_TYPE_CAROUSEL = 2 + + val sharedPool = RecyclerView.RecycledViewPool() } - - private var items = listOf() - + fun setData(categories: List) { val adapterItems = mutableListOf() - + categories.forEach { category -> - adapterItems.add(CoinsAdapterItem.CategoryHeader(category.name)) - category.coins.forEach { coin -> - adapterItems.add(CoinsAdapterItem.CoinItem(coin)) + adapterItems.add(CategoryHeader(category.name)) + when (category.coins.size) { + in 0..10 -> { + category.coins.forEach { coin -> + adapterItems.add(CoinItem(coin)) + } + } + + else -> adapterItems.add(CarouselItems(category.coins)) } } - - items = adapterItems - notifyDataSetChanged() + + submitList(adapterItems) } - - override fun getItemCount(): Int = items.size - + + override fun getItemCount(): Int = currentList.size + override fun getItemViewType(position: Int): Int { - return when (items[position]) { - is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY - is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN + return when (getItem(position)) { + is CategoryHeader -> VIEW_TYPE_CATEGORY + is CoinItem -> VIEW_TYPE_COIN + is CarouselItems -> VIEW_TYPE_CAROUSEL } } - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( @@ -48,25 +62,100 @@ class CoinsAdapter : RecyclerView.Adapter() { false ) ) - VIEW_TYPE_COIN -> CoinViewHolder( - ItemCoinBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + + VIEW_TYPE_COIN -> { + val binding = + ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val params = binding.root.layoutParams + ?: RecyclerView.LayoutParams( + RecyclerView.LayoutParams.WRAP_CONTENT, + RecyclerView.LayoutParams.WRAP_CONTENT + ) + val screenWidth = parent.resources.displayMetrics.widthPixels + params.width = (screenWidth / 2) - 16 + + binding.root.layoutParams = params + CoinViewHolder(binding) + } + + VIEW_TYPE_CAROUSEL -> { + CarouselViewHolder( + ItemCarouselBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + sharedPool ) - ) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") } } - + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = items[position]) { - is CoinsAdapterItem.CategoryHeader -> { + when (val item = getItem(position)) { + is CategoryHeader -> { (holder as CategoryHeaderViewHolder).bind(item.categoryName) } - is CoinsAdapterItem.CoinItem -> { + + is CoinItem -> { (holder as CoinViewHolder).bind(item.coin) } + + is CarouselItems -> { + (holder as CarouselViewHolder).bind(item.coins) + } } } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + when (val item = getItem(position)) { + is CategoryHeader -> { + (holder as CategoryHeaderViewHolder).bind(item.categoryName) + } + + is CoinItem -> { + if (payloads.isNotEmpty()) { + (holder as CoinViewHolder).bind(item.coin, payloads) + } else { + (holder as CoinViewHolder).bind(item.coin) + } + } + + is CarouselItems -> { + (holder as CarouselViewHolder).bind(item.coins) + } + } + } +} + +object CoinDiffUtil : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: CoinsAdapterItem, + newItem: CoinsAdapterItem + ): Boolean = when (oldItem) { + is CategoryHeader if newItem is CategoryHeader -> oldItem.categoryName == newItem.categoryName + is CoinItem if newItem is CoinItem -> oldItem.coin.id == newItem.coin.id + is CarouselItems if newItem is CarouselItems -> oldItem.coins.size == newItem.coins.size + else -> false + } + + override fun areContentsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean = + oldItem == newItem + + override fun getChangePayload(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Any? { + if (oldItem is CoinItem && newItem is CoinItem) { + val diffBundle = Bundle() + if (oldItem.coin.highlight != newItem.coin.highlight) { + diffBundle.putBoolean("highlight", newItem.coin.highlight) + } + return if (diffBundle.size() > 0) diffBundle else null + } else return super.getChangePayload(oldItem, newItem) + } } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapterItem.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapterItem.kt index c483d5e..a5e19c9 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapterItem.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinsAdapterItem.kt @@ -5,4 +5,5 @@ import ru.otus.cryptosample.coins.feature.CoinState sealed class CoinsAdapterItem { data class CategoryHeader(val categoryName: String) : CoinsAdapterItem() data class CoinItem(val coin: CoinState) : CoinsAdapterItem() + data class CarouselItems(val coins: List) : CoinsAdapterItem() } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CustomItemAnimator.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CustomItemAnimator.kt new file mode 100644 index 0000000..0ae3644 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CustomItemAnimator.kt @@ -0,0 +1,75 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class CustomItemAnimator : DefaultItemAnimator() { + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + + holder.itemView.alpha = 0f + holder.itemView.translationX = holder.itemView.width.toFloat() + + val animatorSet = AnimatorSet() + val fadeIn = ObjectAnimator.ofFloat( + holder.itemView, "alpha", 0f, 1f + ) + val slideIn = ObjectAnimator.ofFloat( + holder.itemView, "translationX", holder.itemView.width.toFloat(), 0f + ) + + animatorSet.playTogether(fadeIn, slideIn) + + animatorSet.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + dispatchAddStarting(holder) + } + + override fun onAnimationEnd(animation: Animator) { + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + dispatchAddFinished(holder) + } + + override fun onAnimationCancel(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + }) + + animatorSet.start() + return true + } + + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + + val animatorSet = AnimatorSet() + val fadeOut = ObjectAnimator.ofFloat( + holder.itemView, "alpha", 1f, 0f + ) + val slideOut = ObjectAnimator.ofFloat( + holder.itemView, "translationX", 0f, holder.itemView.width.toFloat() + ) + + animatorSet.playTogether(fadeOut, slideOut) + + animatorSet.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + dispatchRemoveStarting(holder) + } + + override fun onAnimationEnd(animation: Animator) { + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + dispatchRemoveFinished(holder) + } + + override fun onAnimationCancel(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + }) + + animatorSet.start() + return true + } +} diff --git a/app/src/main/res/layout/item_carousel.xml b/app/src/main/res/layout/item_carousel.xml new file mode 100644 index 0000000..44d4ac4 --- /dev/null +++ b/app/src/main/res/layout/item_carousel.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file