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..c12681c 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.ItemAnimator 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 + CoinsAdapter.VIEW_TYPE_CATEGORY -> 2 // Category header spans full width + CoinsAdapter.VIEW_TYPE_COIN -> 1 // Coin item spans half width + CoinsAdapter.VIEW_TYPE_CAROUSEL -> 2 else -> 1 } } @@ -75,6 +77,8 @@ class CoinListFragment : Fragment() { binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + itemAnimator = ItemAnimator() + 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..17fa87f --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselAdapter.kt @@ -0,0 +1,61 @@ +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(CarouselDiff) { + + 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 getItemViewType(position: Int): Int = CoinsAdapter.VIEW_TYPE_COIN + + 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 CarouselDiff : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(old: CoinState, new: CoinState) = old.id == new.id + + override fun areContentsTheSame(old: CoinState, new: CoinState) = old == new + + override fun getChangePayload(oldItem: CoinState, newItem: CoinState): Any? = + if (oldItem.highlight != newItem.highlight) { + Payload.HOT_MOVER_CHANGED + } else null + + object Payload { + const val HOT_MOVER_CHANGED = "HOT_MOVER_CHANGED" + } +} + 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..a10f29f --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CarouselViewHolder.kt @@ -0,0 +1,30 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import androidx.recyclerview.widget.DefaultItemAnimator +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.recyclerView.apply { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = carouselAdapter + setRecycledViewPool(sharedPool) + isNestedScrollingEnabled = false + setHasFixedSize(true) + itemAnimator = ItemAnimator() + } + } + + 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..7f0f2c7 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 @@ -33,4 +33,12 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } + + fun bind(coin: CoinState, payloads: List) { + if (CoinDiff.Payload.HOT_MOVER_CHANGED in payloads) { + binding.fireBadge.isVisible = coin.highlight + } else { + bind(coin) + } + } } 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..d5b9017 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,43 +2,50 @@ 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.ItemCarouselBinding import ru.otus.cryptosample.databinding.ItemCategoryHeaderBinding import ru.otus.cryptosample.databinding.ItemCoinBinding -class CoinsAdapter : RecyclerView.Adapter() { - +class CoinsAdapter : ListAdapter(CoinDiff) { + 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)) + if (category.coins.size <= 10) { + category.coins.forEach { coin -> + adapterItems.add(CoinsAdapterItem.CoinItem(coin)) + } + } else { + adapterItems.add(CoinsAdapterItem.Carousel(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]) { + return when (getItem(position)) { is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN + is CoinsAdapterItem.Carousel -> VIEW_TYPE_CAROUSEL } } - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( @@ -48,25 +55,105 @@ 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]) { + 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.Carousel -> { + (holder as CarouselViewHolder).bind(item.coins) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { + when (val item = getItem(position)) { + is CoinsAdapterItem.CategoryHeader -> { + (holder as CategoryHeaderViewHolder).bind(item.categoryName) + } + is CoinsAdapterItem.CoinItem -> { + if (payloads.isEmpty()) { + (holder as CoinViewHolder).bind(item.coin) + } else { + (holder as CoinViewHolder).bind(item.coin, payloads) + } + + } + is CoinsAdapterItem.Carousel -> { + (holder as CarouselViewHolder).bind(item.coins) + } + } + } +} + +object CoinDiff : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean = + when { + oldItem is CoinsAdapterItem.CategoryHeader && + newItem is CoinsAdapterItem.CategoryHeader -> + oldItem.categoryName == newItem.categoryName + + oldItem is CoinsAdapterItem.CoinItem && + newItem is CoinsAdapterItem.CoinItem -> + oldItem.coin.id == newItem.coin.id + + oldItem is CoinsAdapterItem.Carousel && + newItem is CoinsAdapterItem.Carousel -> + // каждая карусель привязана к своей категории и стоит на том же месте + oldItem.coins.firstOrNull()?.id == newItem.coins.firstOrNull()?.id + + else -> false + } + + override fun areContentsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean = oldItem == newItem + + override fun getChangePayload(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Any? { + return when (oldItem) { + is CoinsAdapterItem.CoinItem -> { + val oldCoin = oldItem.coin + val newCoin = (newItem as CoinsAdapterItem.CoinItem).coin + if (oldCoin.highlight != newCoin.highlight) { + Payload.HOT_MOVER_CHANGED + } else { + null + } + } + else -> null } } -} \ No newline at end of file + + object Payload { + const val HOT_MOVER_CHANGED = "HOT_MOVER_CHANGED" + } +} 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..163b141 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() -} \ No newline at end of file + data class Carousel(val coins: List) : CoinsAdapterItem() +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ItemAnimator.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ItemAnimator.kt new file mode 100644 index 0000000..5bf6bb6 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ItemAnimator.kt @@ -0,0 +1,59 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.View +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class ItemAnimator : DefaultItemAnimator() { + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + val view: View = holder.itemView + addDuration = when (holder) { + is CoinViewHolder -> 400L + is CategoryHeaderViewHolder -> 50L + else -> 200L + } + + view.alpha = 0f + view.translationX = view.width * -1f + view.scaleX = 0.9f + view.scaleY = 0.9f + + val animator = view.animate() + .alpha(1f) + .translationX(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(addDuration) + .setListener(null) + + animator.withStartAction { + dispatchAddStarting(holder) + } + + animator.withEndAction { + view.alpha = 1f + view.translationX = 0f + view.scaleX = 1f + view.scaleY = 1f + dispatchAddFinished(holder) + view.animate().setListener(null) + } + + animator.start() + return true + } + + override fun runPendingAnimations() { + super.runPendingAnimations() + } + + override fun endAnimation(item: RecyclerView.ViewHolder) { + item.itemView.animate().cancel() + super.endAnimation(item) + } + + override fun endAnimations() { + super.endAnimations() + } +} 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..22386c3 --- /dev/null +++ b/app/src/main/res/layout/item_carousel.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/item_coin.xml b/app/src/main/res/layout/item_coin.xml index 33462f3..791ef3a 100644 --- a/app/src/main/res/layout/item_coin.xml +++ b/app/src/main/res/layout/item_coin.xml @@ -5,6 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="4dp" + android:maxWidth="0dp" app:cardElevation="2dp" app:cardCornerRadius="8dp">