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..a7c51b7 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 @@ -65,8 +66,9 @@ class CoinListFragment : Fragment() { gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (coinsAdapter.getItemViewType(position)) { - 0 -> 2 // Category header spans full width - 1 -> 1 // Coin item spans half width + 0 -> 2 + 1 -> 1 + 2 -> 2 else -> 1 } } @@ -75,6 +77,10 @@ class CoinListFragment : Fragment() { binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + itemAnimator = CustomItemAnimator().apply { + addDuration = 300 + removeDuration = 300 + } } } diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CategoryHeaderViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CategoryHeaderViewHolder.kt index aad45c1..4b8e9d9 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CategoryHeaderViewHolder.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CategoryHeaderViewHolder.kt @@ -8,6 +8,11 @@ class CategoryHeaderViewHolder( ) : RecyclerView.ViewHolder(binding.root) { fun bind(categoryName: String) { + itemView.alpha = 1f + itemView.scaleX = 1f + itemView.scaleY = 1f + itemView.translationY = 0f + binding.categoryTitle.text = categoryName } } \ No newline at end of file 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..b88a3f6 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 @@ -13,6 +13,11 @@ class CoinViewHolder( ) : RecyclerView.ViewHolder(binding.root) { fun bind(coin: CoinState) { + itemView.alpha = 1f + itemView.scaleX = 1f + itemView.scaleY = 1f + itemView.translationY = 0f + with(binding) { coinName.text = coin.name coinPrice.text = coin.price @@ -33,4 +38,8 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } + + fun bindPayload(coin: CoinState) { + binding.fireBadge.isVisible = coin.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..c6e33d8 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 @@ -2,71 +2,122 @@ 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.CoinCategoryState import ru.otus.cryptosample.databinding.ItemCategoryHeaderBinding import ru.otus.cryptosample.databinding.ItemCoinBinding +import ru.otus.cryptosample.databinding.ItemHorizontalCategoryBinding -class CoinsAdapter : RecyclerView.Adapter() { +class CoinsAdapter : ListAdapter(ItemDiffCallback) { 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_HORIZONTAL_CATEGORY = 2 + const val PAYLOAD_HIGHLIGHT = "PAYLOAD_HIGHLIGHT" } - - private var items = listOf() - + + private val sharedViewPool = RecyclerView.RecycledViewPool() + 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(CoinsAdapterItem.CategoryHeader(category.id, category.name)) + if (category.coins.size > 10) { + adapterItems.add(CoinsAdapterItem.HorizontalCategory(category.id, category.coins)) + } else { + category.coins.forEach { coin -> + adapterItems.add(CoinsAdapterItem.CoinItem(coin)) + } } } - items = adapterItems - notifyDataSetChanged() + submitList(adapterItems) } - override fun getItemCount(): Int = items.size - override fun getItemViewType(position: Int): Int { - return when (items[position]) { + return when (getItem(position)) { is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN + is CoinsAdapterItem.HorizontalCategory -> VIEW_TYPE_HORIZONTAL_CATEGORY } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( - ItemCategoryHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + ItemCategoryHeaderBinding.inflate(layoutInflater, parent, false) ) VIEW_TYPE_COIN -> CoinViewHolder( - ItemCoinBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + ItemCoinBinding.inflate(layoutInflater, parent, false) + ) + VIEW_TYPE_HORIZONTAL_CATEGORY -> HorizontalCategoryViewHolder( + ItemHorizontalCategoryBinding.inflate(layoutInflater, parent, false), + sharedViewPool ) else -> throw IllegalArgumentException("Unknown view type: $viewType") } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = items[position]) { + when (val item = getItem(position)) { is CoinsAdapterItem.CategoryHeader -> { (holder as CategoryHeaderViewHolder).bind(item.categoryName) } is CoinsAdapterItem.CoinItem -> { (holder as CoinViewHolder).bind(item.coin) } + is CoinsAdapterItem.HorizontalCategory -> { + (holder as HorizontalCategoryViewHolder).bind(item.coins) + } + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + val item = getItem(position) + if (payloads.contains(PAYLOAD_HIGHLIGHT)) { + when (item) { + is CoinsAdapterItem.CoinItem -> { + (holder as CoinViewHolder).bindPayload(item.coin) + } + is CoinsAdapterItem.HorizontalCategory -> { + (holder as HorizontalCategoryViewHolder).bind(item.coins) + } + else -> Unit + } + } + } + } + + object ItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Any? { + if (oldItem is CoinsAdapterItem.CoinItem && newItem is CoinsAdapterItem.CoinItem) { + if (oldItem.coin.copy(highlight = newItem.coin.highlight) == newItem.coin) { + return PAYLOAD_HIGHLIGHT + } + } else if (oldItem is CoinsAdapterItem.HorizontalCategory && newItem is CoinsAdapterItem.HorizontalCategory) { + return PAYLOAD_HIGHLIGHT + } + 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..ef694b3 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 @@ -2,7 +2,8 @@ package ru.otus.cryptosample.coins.feature.adapter import ru.otus.cryptosample.coins.feature.CoinState -sealed class CoinsAdapterItem { - data class CategoryHeader(val categoryName: String) : CoinsAdapterItem() - data class CoinItem(val coin: CoinState) : CoinsAdapterItem() +sealed class CoinsAdapterItem(val id: String) { + data class CategoryHeader(val categoryId: String, val categoryName: String) : CoinsAdapterItem(categoryId) + data class CoinItem(val coin: CoinState) : CoinsAdapterItem(coin.id) + data class HorizontalCategory(val categoryId: String, val coins: List) : CoinsAdapterItem(categoryId + "_horizontal") } \ 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..d7acfce --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CustomItemAnimator.kt @@ -0,0 +1,95 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.view.animation.AnticipateInterpolator +import android.view.animation.OvershootInterpolator +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class CustomItemAnimator : DefaultItemAnimator() { + + private val customAnimators = mutableMapOf() + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + holder.itemView.alpha = 0f + holder.itemView.scaleX = 0f + holder.itemView.scaleY = 0f + holder.itemView.translationY = 100f + return super.animateAdd(holder) + } + + override fun onAddStarting(item: RecyclerView.ViewHolder) { + super.onAddStarting(item) + + val pvhScaleX = PropertyValuesHolder.ofFloat("scaleX", 1f) + val pvhScaleY = PropertyValuesHolder.ofFloat("scaleY", 1f) + val pvhTranslationY = PropertyValuesHolder.ofFloat("translationY", 0f) + + val anim = ObjectAnimator.ofPropertyValuesHolder(item.itemView, pvhScaleX, pvhScaleY, pvhTranslationY) + anim.duration = addDuration + anim.interpolator = OvershootInterpolator(1.5f) + anim.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + customAnimators.remove(item) + } + }) + customAnimators[item] = anim + anim.start() + } + + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + return super.animateRemove(holder) + } + + override fun onRemoveStarting(item: RecyclerView.ViewHolder) { + super.onRemoveStarting(item) + + val pvhScaleX = PropertyValuesHolder.ofFloat("scaleX", 0f) + val pvhScaleY = PropertyValuesHolder.ofFloat("scaleY", 0f) + val pvhTranslationY = PropertyValuesHolder.ofFloat("translationY", 100f) + + val anim = ObjectAnimator.ofPropertyValuesHolder(item.itemView, pvhScaleX, pvhScaleY, pvhTranslationY) + anim.duration = removeDuration + anim.interpolator = AnticipateInterpolator(1.5f) + anim.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + customAnimators.remove(item) + } + }) + customAnimators[item] = anim + anim.start() + } + + override fun endAnimation(item: RecyclerView.ViewHolder) { + super.endAnimation(item) + customAnimators[item]?.cancel() + customAnimators.remove(item) + resetState(item) + } + + override fun endAnimations() { + super.endAnimations() + customAnimators.values.toList().forEach { it.cancel() } + customAnimators.clear() + } + + override fun onAddFinished(item: RecyclerView.ViewHolder) { + super.onAddFinished(item) + resetState(item) + } + + override fun onRemoveFinished(item: RecyclerView.ViewHolder) { + super.onRemoveFinished(item) + resetState(item) + } + + private fun resetState(item: RecyclerView.ViewHolder) { + item.itemView.alpha = 1f + item.itemView.scaleX = 1f + item.itemView.scaleY = 1f + item.itemView.translationY = 0f + } +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCategoryViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCategoryViewHolder.kt new file mode 100644 index 0000000..7ce9147 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCategoryViewHolder.kt @@ -0,0 +1,32 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemHorizontalCategoryBinding + +class HorizontalCategoryViewHolder( + private val binding: ItemHorizontalCategoryBinding, + viewPool: RecyclerView.RecycledViewPool +) : RecyclerView.ViewHolder(binding.root) { + + private val adapter = HorizontalCoinsAdapter() + + init { + binding.horizontalRecyclerView.layoutManager = androidx.recyclerview.widget.LinearLayoutManager( + binding.root.context, + RecyclerView.HORIZONTAL, + false + ) + binding.horizontalRecyclerView.setRecycledViewPool(viewPool) + binding.horizontalRecyclerView.adapter = adapter + } + + fun bind(coins: List) { + itemView.alpha = 1f + itemView.scaleX = 1f + itemView.scaleY = 1f + itemView.translationY = 0f + + adapter.submitList(coins) + } +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt new file mode 100644 index 0000000..63f3b66 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt @@ -0,0 +1,60 @@ +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 ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCoinBinding + +class HorizontalCoinsAdapter : ListAdapter(CoinDiffCallback) { + + companion object { + const val PAYLOAD_HIGHLIGHT = "PAYLOAD_HIGHLIGHT" + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinViewHolder { + val binding = ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val layoutParams = binding.root.layoutParams + + layoutParams.width = (140 * parent.context.resources.displayMetrics.density).toInt() + binding.root.layoutParams = layoutParams + + return CoinViewHolder(binding) + } + + override fun onBindViewHolder(holder: CoinViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder( + holder: CoinViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + if (payloads.contains(PAYLOAD_HIGHLIGHT)) { + holder.bindPayload(getItem(position)) + } + } + } + + object CoinDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CoinState, newItem: CoinState): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CoinState, newItem: CoinState): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: CoinState, newItem: CoinState): Any? { + if (oldItem.copy(highlight = newItem.highlight) == newItem) { + return PAYLOAD_HIGHLIGHT + } + return super.getChangePayload(oldItem, newItem) + } + } +} diff --git a/app/src/main/res/layout/item_horizontal_category.xml b/app/src/main/res/layout/item_horizontal_category.xml new file mode 100644 index 0000000..26f42cd --- /dev/null +++ b/app/src/main/res/layout/item_horizontal_category.xml @@ -0,0 +1,8 @@ + +