diff --git a/Result.wmv b/Result.wmv new file mode 100644 index 0000000..a566786 Binary files /dev/null and b/Result.wmv differ diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinDiffCallback.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinDiffCallback.kt new file mode 100644 index 0000000..9afc94a --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinDiffCallback.kt @@ -0,0 +1,54 @@ +package ru.otus.cryptosample.coins.feature + +import androidx.recyclerview.widget.DiffUtil +import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapterItem +import ru.otus.cryptosample.coins.feature.adapter.HighlightPayload + +class CoinDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem): Boolean { + return when { + oldItem is CoinsAdapterItem.CoinItem && newItem is CoinsAdapterItem.CoinItem -> oldItem.coin.id == newItem.coin.id + oldItem is CoinsAdapterItem.CategoryHeader && newItem is CoinsAdapterItem.CategoryHeader -> oldItem.categoryId == newItem.categoryId + oldItem is CoinsAdapterItem.HorizontalCategory && newItem is CoinsAdapterItem.HorizontalCategory -> oldItem.categoryId == newItem.categoryId + else -> false + } + } + + 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.highlight != newItem.coin.highlight) return "highlight" + } + return null + } +} + +//class 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.highlight != newItem.highlight) { +// return HighlightPayload +// } +// return null +// } +//} 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..491bff5 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 @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.launch import ru.otus.cryptosample.CoinsSampleApp import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapter @@ -28,6 +29,15 @@ class CoinListFragment : Fragment() { private val viewModel: CoinListViewModel by viewModels { factory } + private val sharedViewPool = RecyclerView.RecycledViewPool() + + companion object { + private const val VIEW_TYPE_CATEGORY = 0 + private const val VIEW_TYPE_COIN = 1 + private const val VIEW_TYPE_HORIZONTAL = 2 + + } + private lateinit var coinsAdapter: CoinsAdapter override fun onAttach(context: Context) { @@ -59,25 +69,32 @@ class CoinListFragment : Fragment() { } private fun setupRecyclerView() { - coinsAdapter = CoinsAdapter() + + coinsAdapter = CoinsAdapter(sharedViewPool) val gridLayoutManager = GridLayoutManager(requireContext(), 2) - 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 - else -> 1 + + gridLayoutManager.spanSizeLookup = + object : GridLayoutManager.SpanSizeLookup() { + + override fun getSpanSize(position: Int): Int { + return when (coinsAdapter.getItemViewType(position)) { + VIEW_TYPE_CATEGORY -> 2 + VIEW_TYPE_HORIZONTAL -> 2 + VIEW_TYPE_COIN -> 1 + else -> 1 + } } } - } binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + itemAnimator = FadeSlideItemAnimator() } } + private fun setupChipToggle() { binding.highlightChip.setOnCheckedChangeListener { _, isChecked -> viewModel.onHighlightMoversToggled(isChecked) diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListViewModel.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListViewModel.kt index 6851a32..3fc265d 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListViewModel.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinListViewModel.kt @@ -54,20 +54,15 @@ class CoinListViewModel( } private fun updateUiState() { - var processedCategories = if (showAll) { - fullCategories - } else { - fullCategories.map { category -> - category.copy(coins = category.coins.take(4)) - } - } - - processedCategories = processedCategories.map { category -> - category.copy(coins = category.coins.map { coin -> - coin.copy(highlight = highlightMovers && coin.isHotMover) - }) + val processedCategories = fullCategories.map { category -> + category.copy( + coins = category.coins.map { coin -> + coin.copy(highlight = highlightMovers && coin.isHotMover) + } + ) } - _state.update { it.copy(categories = processedCategories) } + _state.update { it.copy(categories = processedCategories, showAll = showAll) } } + } diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinState.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinState.kt index cecf2b7..005ba38 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinState.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/CoinState.kt @@ -2,6 +2,7 @@ package ru.otus.cryptosample.coins.feature data class CoinsScreenState( val categories: List = emptyList(), + val showAll: Boolean = true ) data class CoinCategoryState( diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/FadeSlideItemAnimator.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/FadeSlideItemAnimator.kt new file mode 100644 index 0000000..dd4dd4f --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/FadeSlideItemAnimator.kt @@ -0,0 +1,43 @@ +package ru.otus.cryptosample.coins.feature + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class FadeSlideItemAnimator : DefaultItemAnimator() { + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + + dispatchAddStarting(holder) + + holder.itemView.apply { + alpha = 0f + translationX = -50f + animate() + .alpha(1f) + .translationX(0f) + .setDuration(250) + .withEndAction { + dispatchAddFinished(holder) + } + .start() + } + + return true + } + + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + + dispatchRemoveStarting(holder) + + holder.itemView.animate() + .alpha(0f) + .translationX(50f) + .setDuration(250) + .withEndAction { + dispatchRemoveFinished(holder) + } + .start() + + return true + } +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinDiffCallback.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinDiffCallback.kt new file mode 100644 index 0000000..de20c29 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinDiffCallback.kt @@ -0,0 +1,14 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import androidx.recyclerview.widget.DiffUtil +import ru.otus.cryptosample.coins.feature.CoinState + +class 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinHorizontalViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinHorizontalViewHolder.kt new file mode 100644 index 0000000..1364031 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinHorizontalViewHolder.kt @@ -0,0 +1,39 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import coil.load +import ru.otus.cryptosample.R +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCoinHorizontalBinding + +class CoinHorizontalViewHolder( + private val binding: ItemCoinHorizontalBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(coin: CoinState) { + with(binding) { + coinName.text = coin.name + coinPrice.text = coin.price + coinChange.text = coin.discount + + val changeColor = if (coin.goesUp) { + ContextCompat.getColor(itemView.context, android.R.color.holo_red_dark) + } else { + ContextCompat.getColor(itemView.context, android.R.color.holo_green_dark) + } + coinChange.setTextColor(changeColor) + + coinIcon.load(coin.image) { + placeholder(R.drawable.generic) + error(R.drawable.generic) + } + + updateHighlight(coin.highlight) + } + } + + fun updateHighlight(isHighlighted: Boolean) { + binding.fireBadge.visibility = if (isHighlighted) RecyclerView.VISIBLE else RecyclerView.GONE + } +} \ 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..aaf2ef3 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,8 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } + + 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..4bad629 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,58 @@ 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 -class CoinsAdapter : RecyclerView.Adapter() { - +class CoinsAdapter( + private val sharedViewPool: RecyclerView.RecycledViewPool +) : ListAdapter(CoinsDiffCallback()) { + + init { + setHasStableIds(true) + } + companion object { private const val VIEW_TYPE_CATEGORY = 0 private const val VIEW_TYPE_COIN = 1 + private const val VIEW_TYPE_HORIZONTAL = 2 } - - 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(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 getItemId(position: Int): Long { + return getItem(position).id.hashCode().toLong() + } + 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 + else -> throw java.lang.IllegalArgumentException("Unknown view type") } } - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( @@ -55,18 +70,74 @@ class CoinsAdapter : RecyclerView.Adapter() { false ) ) + VIEW_TYPE_HORIZONTAL -> HorizontalCategoryViewHolder( + parent, sharedViewPool + ) else -> throw IllegalArgumentException("Unknown view type: $viewType") } } - + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = items[position]) { - is CoinsAdapterItem.CategoryHeader -> { - (holder as CategoryHeaderViewHolder).bind(item.categoryName) + when (holder) { + is CoinViewHolder -> { + val item = getItem(position) as? CoinsAdapterItem.CoinItem ?: return + holder.bind(item.coin) + } + is CategoryHeaderViewHolder -> { + val item = getItem(position) as? CoinsAdapterItem.CategoryHeader ?: return + holder.bind(item.categoryName) } - is CoinsAdapterItem.CoinItem -> { - (holder as CoinViewHolder).bind(item.coin) + is HorizontalCategoryViewHolder -> { + val item = getItem(position) as? CoinsAdapterItem.HorizontalCategory ?: return + holder.bind(item.coins) } } } -} \ No newline at end of file + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isNotEmpty() && holder is CoinViewHolder && getItem(position) is CoinsAdapterItem.CoinItem) { + val coinItem = getItem(position) as CoinsAdapterItem.CoinItem + coinItem.coin.highlight.let { holder.updateHighlight(it) } + } else { + super.onBindViewHolder(holder, position, payloads) + } + } +} + +class CoinsDiffCallback : 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.highlight != newItem.coin.highlight) { + return HighlightPayload + } + } + return null + } +} + +object HighlightPayload \ 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..c0e6e55 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 @@ -3,6 +3,25 @@ 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() + abstract val id: String + + data class CategoryHeader( + val categoryId: String, + val categoryName: String + ) : CoinsAdapterItem() { + override val id: String = "header_$categoryId" + } + + data class CoinItem( + val coin: CoinState + ) : CoinsAdapterItem() { + override val id: String = coin.id + } + + data class HorizontalCategory( + val categoryId: String, + val coins: List + ) : CoinsAdapterItem() { + override val id: String = "h_category_$categoryId" + } } \ No newline at end of file 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..ce9927a --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCategoryViewHolder.kt @@ -0,0 +1,30 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cryptosample.R +import ru.otus.cryptosample.coins.feature.CoinState + +class HorizontalCategoryViewHolder( + parent: ViewGroup, + private val sharedViewPool: RecyclerView.RecycledViewPool +) : RecyclerView.ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.item_horizontal_category, parent, false) +) { + + private val recyclerView: RecyclerView = itemView.findViewById(R.id.horizontalRecyclerView) + private val adapter = HorizontalCoinsAdapter() + + init { + recyclerView.adapter = adapter + recyclerView.setRecycledViewPool(sharedViewPool) + recyclerView.layoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false) + recyclerView.setHasFixedSize(true) + } + + fun bind(coins: List) { + 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..c4fa710 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt @@ -0,0 +1,24 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCoinHorizontalBinding + +class HorizontalCoinsAdapter : ListAdapter(CoinDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinHorizontalViewHolder { + return CoinHorizontalViewHolder( + ItemCoinHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: CoinHorizontalViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_coin_horizontal.xml b/app/src/main/res/layout/item_coin_horizontal.xml new file mode 100644 index 0000000..5bddf86 --- /dev/null +++ b/app/src/main/res/layout/item_coin_horizontal.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..f544d7d --- /dev/null +++ b/app/src/main/res/layout/item_horizontal_category.xml @@ -0,0 +1,8 @@ + +