From cf90b3d59419f5e44abb053340a8b67ce84c0005 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Sat, 27 Dec 2025 03:33:03 +0300 Subject: [PATCH 1/5] =?UTF-8?q?Step=202,=20>=2010=20=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=B2=20=D0=B3=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/codeStyles/Project.xml | 117 ++++++++++++++++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .../coins/feature/CoinListFragment.kt | 8 +- .../coins/feature/CoinListViewModel.kt | 29 +++-- .../cryptosample/coins/feature/CoinState.kt | 3 +- .../coins/feature/adapter/CoinsAdapter.kt | 58 +++++++-- .../coins/feature/adapter/CoinsAdapterItem.kt | 13 +- .../feature/adapter/HorizontalCoinsAdapter.kt | 53 ++++++++ .../adapter/HorizontalCoinsRowViewHolder.kt | 26 ++++ .../main/res/layout/fragment_coin_list.xml | 6 +- app/src/main/res/layout/item_carousel.xml | 7 ++ app/src/main/res/values/strings.xml | 3 + 12 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt create mode 100644 app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt create mode 100644 app/src/main/res/layout/item_carousel.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..4bec4ea --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,117 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file 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..093960e 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 @@ -59,14 +60,15 @@ class CoinListFragment : Fragment() { } private fun setupRecyclerView() { - coinsAdapter = CoinsAdapter() + coinsAdapter = CoinsAdapter(RecyclerView.RecycledViewPool()) 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 + 1 -> 1 // Coin item spans half-width + 2 -> 2 // The horizontal row spans full width else -> 1 } } @@ -99,7 +101,7 @@ class CoinListFragment : Fragment() { } private fun renderState(state: CoinsScreenState) { - coinsAdapter.setData(state.categories) + coinsAdapter.setData(state.categories, state.showAll) } override fun onDestroyView() { 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..1550e0f 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,27 @@ class CoinListViewModel( } private fun updateUiState() { - var processedCategories = if (showAll) { - fullCategories - } else { - fullCategories.map { category -> - category.copy(coins = category.coins.take(4)) - } - } + var processedCategories = fullCategories +// 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) - }) + 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..483bbb8 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,7 +2,8 @@ package ru.otus.cryptosample.coins.feature data class CoinsScreenState( val categories: List = emptyList(), -) + val showAll: Boolean = true, + ) data class CoinCategoryState( val id: String, 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..cfe4a53 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 @@ -4,41 +4,62 @@ import android.view.LayoutInflater import android.view.ViewGroup 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( + private val sharedPool: RecyclerView.RecycledViewPool +) : RecyclerView.Adapter() { + companion object { private const val VIEW_TYPE_CATEGORY = 0 private const val VIEW_TYPE_COIN = 1 + private const val VIEW_TYPE_HORIZONTAL_ROW = 2 } - + private var items = listOf() - - fun setData(categories: List) { + +// private var showAll: Boolean = true + + fun setData(categories: List, showAll: Boolean) { val adapterItems = mutableListOf() - + + categories.forEach { category -> adapterItems.add(CoinsAdapterItem.CategoryHeader(category.name)) - category.coins.forEach { coin -> - adapterItems.add(CoinsAdapterItem.CoinItem(coin)) + + val coins = category.coins + val shouldUseHorizontalRow = coins.size > 10 && !showAll + + if (shouldUseHorizontalRow) { + adapterItems.add( + CoinsAdapterItem.HorizontalCoinsRow( + categoryName = category.name, + coins = coins + ) + ) + } else { + coins.forEach { coin -> + adapterItems.add(CoinsAdapterItem.CoinItem(coin)) + } } } - + items = adapterItems notifyDataSetChanged() } - + override fun getItemCount(): Int = items.size - + override fun getItemViewType(position: Int): Int { return when (items[position]) { is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN + is CoinsAdapterItem.HorizontalCoinsRow -> VIEW_TYPE_HORIZONTAL_ROW } } - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( @@ -55,10 +76,18 @@ class CoinsAdapter : RecyclerView.Adapter() { false ) ) + VIEW_TYPE_HORIZONTAL_ROW -> HorizontalCoinsRowViewHolder( + 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 -> { @@ -67,6 +96,9 @@ class CoinsAdapter : RecyclerView.Adapter() { is CoinsAdapterItem.CoinItem -> { (holder as CoinViewHolder).bind(item.coin) } + + is CoinsAdapterItem.HorizontalCoinsRow -> + (holder as HorizontalCoinsRowViewHolder).bind(item.coins) } } } \ 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..e867b08 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,14 @@ 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 interface CoinsAdapterItem { + + data class CategoryHeader(val categoryName: String) : CoinsAdapterItem + + data class CoinItem(val coin: CoinState) : CoinsAdapterItem + + data class HorizontalCoinsRow( + val categoryName: String, + val coins: List + ) : CoinsAdapterItem } \ No newline at end of file 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..d903f46 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt @@ -0,0 +1,53 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.roundToInt +import ru.otus.cryptosample.coins.feature.CoinState +import ru.otus.cryptosample.databinding.ItemCoinBinding + +class HorizontalCoinsAdapter : RecyclerView.Adapter() { + + companion object { + private const val VISIBLE_ITEMS_COUNT = 2.25f + } + + private var items: List = emptyList() + + fun submitData(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinViewHolder { + val binding = ItemCoinBinding.inflate(LayoutInflater.from(parent.context), parent, false) + + val rv = parent as? RecyclerView + val parentWidthPx = rv?.width?.takeIf { it > 0 } + ?: parent.measuredWidth.takeIf { it > 0 } + ?: parent.resources.displayMetrics.widthPixels + + val paddingStart = rv?.paddingStart ?: 0 + val paddingEnd = rv?.paddingEnd ?: 0 + val availableWidth = (parentWidthPx - paddingStart - paddingEnd).coerceAtLeast(0) + + val itemWidth = (availableWidth / VISIBLE_ITEMS_COUNT).roundToInt().coerceAtLeast(1) + + val lp = (binding.root.layoutParams as? RecyclerView.LayoutParams) + ?: RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT) + + lp.width = itemWidth + lp.height = RecyclerView.LayoutParams.WRAP_CONTENT + binding.root.layoutParams = lp + + return CoinViewHolder(binding) + + } + + override fun onBindViewHolder(holder: CoinViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt new file mode 100644 index 0000000..95eb00b --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt @@ -0,0 +1,26 @@ +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 HorizontalCoinsRowViewHolder( + private val binding: ItemCarouselBinding, + sharedPool: RecyclerView.RecycledViewPool +) : RecyclerView.ViewHolder(binding.root) { + + private val adapter = HorizontalCoinsAdapter() + + init { + binding.horizontalRecyclerView.apply { + layoutManager = LinearLayoutManager(itemView.context, LinearLayoutManager.HORIZONTAL, false) + setRecycledViewPool(sharedPool) + this.adapter = this@HorizontalCoinsRowViewHolder.adapter + } + } + + fun bind(coins: List) { + adapter.submitData(coins) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_coin_list.xml b/app/src/main/res/layout/fragment_coin_list.xml index b222909..4547ad8 100644 --- a/app/src/main/res/layout/fragment_coin_list.xml +++ b/app/src/main/res/layout/fragment_coin_list.xml @@ -12,7 +12,7 @@ android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_marginEnd="16dp" - android:text="Coins" + android:text="@string/coins" android:textSize="20sp" android:textStyle="bold" android:textColor="?attr/colorOnSurface" @@ -36,7 +36,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingHorizontal="6dp" - android:text="Highlight movers" + android:text="@string/highlight_movers" app:chipBackgroundColor="@color/chip_background_color" /> 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..ec2ad37 --- /dev/null +++ b/app/src/main/res/layout/item_carousel.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f2b821..b04c69d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,7 @@ CryptoSample Coins Main + Coins + Highlight movers + Show All \ No newline at end of file From dc416087477946f0060a221a7e9b8e6cc2e4d9ad Mon Sep 17 00:00:00 2001 From: AleksVira Date: Sat, 27 Dec 2025 03:50:40 +0300 Subject: [PATCH 2/5] Step 3, DiffUtil --- .../coins/feature/adapter/CoinsAdapter.kt | 47 ++++++++++++++----- .../feature/adapter/HorizontalCoinsAdapter.kt | 22 +++++---- .../adapter/HorizontalCoinsRowViewHolder.kt | 4 +- 3 files changed, 50 insertions(+), 23 deletions(-) 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 cfe4a53..37e52ec 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,6 +2,8 @@ package ru.otus.cryptosample.coins.feature.adapter import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import ru.otus.cryptosample.coins.feature.CoinCategoryState import ru.otus.cryptosample.databinding.ItemCarouselBinding @@ -16,16 +18,40 @@ class CoinsAdapter( private const val VIEW_TYPE_CATEGORY = 0 private const val VIEW_TYPE_COIN = 1 private const val VIEW_TYPE_HORIZONTAL_ROW = 2 - } - private var items = listOf() + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CoinsAdapterItem, + newItem: CoinsAdapterItem + ): Boolean { + return 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 -// private var showAll: Boolean = true + oldItem is CoinsAdapterItem.HorizontalCoinsRow && newItem is CoinsAdapterItem.HorizontalCoinsRow -> + oldItem.categoryName == newItem.categoryName + + else -> false + } + } + + override fun areContentsTheSame( + oldItem: CoinsAdapterItem, + newItem: CoinsAdapterItem + ): Boolean { + return oldItem == newItem + } + } + } + + private val differ = AsyncListDiffer(this, DIFF_CALLBACK) fun setData(categories: List, showAll: Boolean) { val adapterItems = mutableListOf() - categories.forEach { category -> adapterItems.add(CoinsAdapterItem.CategoryHeader(category.name)) @@ -46,14 +72,13 @@ class CoinsAdapter( } } - items = adapterItems - notifyDataSetChanged() + differ.submitList(adapterItems) } - override fun getItemCount(): Int = items.size + override fun getItemCount(): Int = differ.currentList.size override fun getItemViewType(position: Int): Int { - return when (items[position]) { + return when (differ.currentList[position]) { is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN is CoinsAdapterItem.HorizontalCoinsRow -> VIEW_TYPE_HORIZONTAL_ROW @@ -89,16 +114,16 @@ class CoinsAdapter( } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = items[position]) { + when (val item = differ.currentList[position]) { is CoinsAdapterItem.CategoryHeader -> { (holder as CategoryHeaderViewHolder).bind(item.categoryName) } is CoinsAdapterItem.CoinItem -> { (holder as CoinViewHolder).bind(item.coin) } - - is CoinsAdapterItem.HorizontalCoinsRow -> + is CoinsAdapterItem.HorizontalCoinsRow -> { (holder as HorizontalCoinsRowViewHolder).bind(item.coins) + } } } } \ No newline at end of file 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 index d903f46..f8adbcb 100644 --- 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 @@ -2,22 +2,27 @@ 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 kotlin.math.roundToInt import ru.otus.cryptosample.coins.feature.CoinState import ru.otus.cryptosample.databinding.ItemCoinBinding -class HorizontalCoinsAdapter : RecyclerView.Adapter() { +class HorizontalCoinsAdapter : ListAdapter(DIFF_CALLBACK) { companion object { private const val VISIBLE_ITEMS_COUNT = 2.25f - } - private var items: List = emptyList() + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CoinState, newItem: CoinState): Boolean { + return oldItem.id == newItem.id + } - fun submitData(newItems: List) { - items = newItems - notifyDataSetChanged() + override fun areContentsTheSame(oldItem: CoinState, newItem: CoinState): Boolean { + return oldItem == newItem + } + } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinViewHolder { @@ -42,12 +47,9 @@ class HorizontalCoinsAdapter : RecyclerView.Adapter() { binding.root.layoutParams = lp return CoinViewHolder(binding) - } override fun onBindViewHolder(holder: CoinViewHolder, position: Int) { - holder.bind(items[position]) + holder.bind(getItem(position)) } - - override fun getItemCount(): Int = items.size } \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt index 95eb00b..bcfb79b 100644 --- a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsRowViewHolder.kt @@ -6,7 +6,7 @@ import ru.otus.cryptosample.coins.feature.CoinState import ru.otus.cryptosample.databinding.ItemCarouselBinding class HorizontalCoinsRowViewHolder( - private val binding: ItemCarouselBinding, + binding: ItemCarouselBinding, sharedPool: RecyclerView.RecycledViewPool ) : RecyclerView.ViewHolder(binding.root) { @@ -21,6 +21,6 @@ class HorizontalCoinsRowViewHolder( } fun bind(coins: List) { - adapter.submitData(coins) + adapter.submitList(coins) } } \ No newline at end of file From 0f24ed3726b5ff312e0677e323fbde53c1e415c9 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Sat, 27 Dec 2025 15:15:09 +0300 Subject: [PATCH 3/5] Step 4, payload --- .../coins/feature/adapter/CoinViewHolder.kt | 19 +++++++++ .../coins/feature/adapter/CoinsAdapter.kt | 40 +++++++++++++++++-- .../feature/adapter/HorizontalCoinsAdapter.kt | 17 ++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) 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..8d91e60 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 @@ -12,6 +12,10 @@ class CoinViewHolder( private val binding: ItemCoinBinding ) : RecyclerView.ViewHolder(binding.root) { + sealed class Payload { + data class HighlightChanged(val isHighlighted: Boolean) : Payload() + } + fun bind(coin: CoinState) { with(binding) { coinName.text = coin.name @@ -33,4 +37,19 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } + + fun bind(coin: CoinState, payloads: List) { + if (payloads.isEmpty()) { + bind(coin) + return + } + + payloads.forEach { payload -> + when (payload) { + is Payload.HighlightChanged -> { + binding.fireBadge.isVisible = payload.isHighlighted + } + } + } + } } 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 37e52ec..6d1c42b 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 @@ -24,14 +24,14 @@ class CoinsAdapter( oldItem: CoinsAdapterItem, newItem: CoinsAdapterItem ): Boolean { - return when { - oldItem is CoinsAdapterItem.CategoryHeader && newItem is CoinsAdapterItem.CategoryHeader -> + return when (oldItem) { + is CoinsAdapterItem.CategoryHeader if newItem is CoinsAdapterItem.CategoryHeader -> oldItem.categoryName == newItem.categoryName - oldItem is CoinsAdapterItem.CoinItem && newItem is CoinsAdapterItem.CoinItem -> + is CoinsAdapterItem.CoinItem if newItem is CoinsAdapterItem.CoinItem -> oldItem.coin.id == newItem.coin.id - oldItem is CoinsAdapterItem.HorizontalCoinsRow && newItem is CoinsAdapterItem.HorizontalCoinsRow -> + is CoinsAdapterItem.HorizontalCoinsRow if newItem is CoinsAdapterItem.HorizontalCoinsRow -> oldItem.categoryName == newItem.categoryName else -> false @@ -44,6 +44,21 @@ class CoinsAdapter( ): Boolean { return oldItem == newItem } + + override fun getChangePayload( + oldItem: CoinsAdapterItem, + newItem: CoinsAdapterItem + ): Any? { + if (oldItem is CoinsAdapterItem.CoinItem && newItem is CoinsAdapterItem.CoinItem) { + val oldCoin = oldItem.coin + val newCoin = newItem.coin + if (oldCoin.highlight != newCoin.highlight && + oldCoin.copy(highlight = newCoin.highlight) == newCoin) { + return CoinViewHolder.Payload.HighlightChanged(newCoin.highlight) + } + } + return null + } } } @@ -126,4 +141,21 @@ class CoinsAdapter( } } } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + when (val item = differ.currentList[position]) { + is CoinsAdapterItem.CoinItem -> { + (holder as CoinViewHolder).bind(item.coin, payloads) + } + else -> super.onBindViewHolder(holder, position, payloads) + } + } + } } \ No newline at end of file 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 index f8adbcb..bd56590 100644 --- 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 @@ -22,6 +22,15 @@ class HorizontalCoinsAdapter : ListAdapter(DIFF_CALLB override fun areContentsTheSame(oldItem: CoinState, newItem: CoinState): Boolean { return oldItem == newItem } + + override fun getChangePayload(oldItem: CoinState, newItem: CoinState): Any? { + return if (oldItem.highlight != newItem.highlight && + oldItem.copy(highlight = newItem.highlight) == newItem) { + CoinViewHolder.Payload.HighlightChanged(newItem.highlight) + } else { + null + } + } } } @@ -52,4 +61,12 @@ class HorizontalCoinsAdapter : ListAdapter(DIFF_CALLB 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 { + holder.bind(getItem(position), payloads) + } + } } \ No newline at end of file From 835fc34aeab18e1170c2ef12736a609345092ff4 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Sat, 27 Dec 2025 16:15:48 +0300 Subject: [PATCH 4/5] Step 5, Animation --- .../coins/feature/CoinListFragment.kt | 10 +++ .../coins/feature/CoinListViewModel.kt | 51 +++++++++++-- .../coins/feature/adapter/CoinItemAnimator.kt | 71 +++++++++++++++++++ .../main/res/layout/fragment_coin_list.xml | 34 +++++++-- app/src/main/res/values/strings.xml | 2 + 5 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinItemAnimator.kt 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 093960e..400c686 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 @@ -18,6 +18,7 @@ import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapter import ru.otus.cryptosample.coins.feature.di.DaggerCoinListComponent import ru.otus.cryptosample.databinding.FragmentCoinListBinding import javax.inject.Inject +import ru.otus.cryptosample.coins.feature.adapter.CoinItemAnimator class CoinListFragment : Fragment() { @@ -77,6 +78,15 @@ class CoinListFragment : Fragment() { binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + itemAnimator = CoinItemAnimator() + } + + binding.btnAddCoin.setOnClickListener { + viewModel.addRandomCoin() + } + + binding.btnRemoveCoins.setOnClickListener { + viewModel.removeRandomCoin() } } 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 1550e0f..2897d60 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 @@ -38,6 +38,50 @@ class CoinListViewModel( updateUiState() } + fun addRandomCoin() { + if (fullCategories.isEmpty()) return + + val allCoins = fullCategories.flatMap { it.coins } + if (allCoins.isEmpty()) return + + val templateCoin = allCoins.random() + + val newCoinId = "new_${System.currentTimeMillis()}" + val newCoin = templateCoin.copy( + id = newCoinId, + name = "New Coin ${(1..100).random()}", + price = "$${(1000..50000).random()}", + ) + + val randomCategory = fullCategories.random() + + fullCategories = fullCategories.map { category -> + if (category.id == randomCategory.id) { + category.copy(coins = category.coins + newCoin) + } else { + category + } + } + updateUiState() + } + + fun removeRandomCoin() { + if (fullCategories.isEmpty()) return + + val allCoins = fullCategories.flatMap { it.coins } + if (allCoins.isEmpty()) return + + val coinsToRemove = allCoins.shuffled().take(5.coerceAtMost(allCoins.size)) + val coinsToRemoveIds = coinsToRemove.map { it.id }.toSet() + + fullCategories = fullCategories.map { category -> + category.copy( + coins = category.coins.filter { coin -> coin.id !in coinsToRemoveIds } + ) + } + updateUiState() + } + private fun requestCoins() { consumeCoinsUseCase().map { categories -> categories.map { category -> coinsStateFactory.create(category) } @@ -55,13 +99,6 @@ class CoinListViewModel( private fun updateUiState() { var processedCategories = fullCategories -// var processedCategories = if (showAll) { -// fullCategories -// } else { -// fullCategories.map { category -> -// category.copy(coins = category.coins.take(4)) -// } -// } processedCategories = processedCategories.map { category -> category.copy( diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinItemAnimator.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinItemAnimator.kt new file mode 100644 index 0000000..d3c8957 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/CoinItemAnimator.kt @@ -0,0 +1,71 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.view.View +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class CoinItemAnimator : DefaultItemAnimator() { + + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + holder.itemView.alpha = 0f + holder.itemView.translationX = -holder.itemView.width.toFloat() + + val fadeIn = ObjectAnimator.ofFloat(holder.itemView, View.ALPHA, 0f, 1f) + val slideIn = ObjectAnimator.ofFloat(holder.itemView, View.TRANSLATION_X, -holder.itemView.width.toFloat(), 0f) + + val animatorSet = AnimatorSet() + animatorSet.playTogether(fadeIn, slideIn) + animatorSet.duration = addDuration + animatorSet.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + dispatchAddStarting(holder) + } + + override fun onAnimationEnd(animation: Animator) { + dispatchAddFinished(holder) + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + } + + override fun onAnimationCancel(animation: Animator) { + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + } + }) + animatorSet.start() + + return false + } + + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + val fadeOut = ObjectAnimator.ofFloat(holder.itemView, View.ALPHA, 1f, 0f) + val slideOut = ObjectAnimator.ofFloat(holder.itemView, View.TRANSLATION_X, 0f, holder.itemView.width.toFloat()) + + val animatorSet = AnimatorSet() + animatorSet.playTogether(fadeOut, slideOut) + animatorSet.duration = removeDuration + animatorSet.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + dispatchRemoveStarting(holder) + } + + override fun onAnimationEnd(animation: Animator) { + dispatchRemoveFinished(holder) + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + } + + override fun onAnimationCancel(animation: Animator) { + holder.itemView.alpha = 1f + holder.itemView.translationX = 0f + } + }) + animatorSet.start() + + return false + } +} diff --git a/app/src/main/res/layout/fragment_coin_list.xml b/app/src/main/res/layout/fragment_coin_list.xml index 4547ad8..5936f40 100644 --- a/app/src/main/res/layout/fragment_coin_list.xml +++ b/app/src/main/res/layout/fragment_coin_list.xml @@ -1,7 +1,6 @@ @@ -13,22 +12,43 @@ android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:text="@string/coins" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" android:textStyle="bold" - android:textColor="?attr/colorOnSurface" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + app:layout_constraintTop_toBottomOf="@+id/btnAddCoin"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b04c69d..358603a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ Coins Highlight movers Show All + Add Coin + Remove Coins \ No newline at end of file From ea44a7134421e5ce96c20dd8d0c1fa8bbed42c87 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Sat, 27 Dec 2025 16:50:58 +0300 Subject: [PATCH 5/5] Step 6, Layout as ID --- .../coins/feature/CoinListFragment.kt | 7 ++-- .../coins/feature/adapter/CoinsAdapter.kt | 36 ++++++------------- .../feature/adapter/ViewBindingExtensions.kt | 19 ++++++++++ 3 files changed, 33 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ViewBindingExtensions.kt 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 400c686..3a40740 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 @@ -18,6 +18,7 @@ import ru.otus.cryptosample.coins.feature.adapter.CoinsAdapter import ru.otus.cryptosample.coins.feature.di.DaggerCoinListComponent import ru.otus.cryptosample.databinding.FragmentCoinListBinding import javax.inject.Inject +import ru.otus.cryptosample.R import ru.otus.cryptosample.coins.feature.adapter.CoinItemAnimator class CoinListFragment : Fragment() { @@ -67,9 +68,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 - 2 -> 2 // The horizontal row spans full width + R.layout.item_category_header -> 2 // Category header spans full width + R.layout.item_coin -> 1 // Coin item spans half-width + R.layout.item_carousel -> 2 // The horizontal row spans full width else -> 1 } } 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 6d1c42b..158e478 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,10 +1,10 @@ package ru.otus.cryptosample.coins.feature.adapter -import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import ru.otus.cryptosample.R import ru.otus.cryptosample.coins.feature.CoinCategoryState import ru.otus.cryptosample.databinding.ItemCarouselBinding import ru.otus.cryptosample.databinding.ItemCategoryHeaderBinding @@ -15,10 +15,6 @@ class CoinsAdapter( ) : RecyclerView.Adapter() { companion object { - private const val VIEW_TYPE_CATEGORY = 0 - private const val VIEW_TYPE_COIN = 1 - private const val VIEW_TYPE_HORIZONTAL_ROW = 2 - private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: CoinsAdapterItem, @@ -94,34 +90,22 @@ class CoinsAdapter( override fun getItemViewType(position: Int): Int { return when (differ.currentList[position]) { - is CoinsAdapterItem.CategoryHeader -> VIEW_TYPE_CATEGORY - is CoinsAdapterItem.CoinItem -> VIEW_TYPE_COIN - is CoinsAdapterItem.HorizontalCoinsRow -> VIEW_TYPE_HORIZONTAL_ROW + is CoinsAdapterItem.CategoryHeader -> R.layout.item_category_header + is CoinsAdapterItem.CoinItem -> R.layout.item_coin + is CoinsAdapterItem.HorizontalCoinsRow -> R.layout.item_carousel } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { - VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( - ItemCategoryHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + R.layout.item_category_header -> CategoryHeaderViewHolder( + parent.inflateBinding(ItemCategoryHeaderBinding::inflate) ) - VIEW_TYPE_COIN -> CoinViewHolder( - ItemCoinBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + R.layout.item_coin -> CoinViewHolder( + parent.inflateBinding(ItemCoinBinding::inflate) ) - VIEW_TYPE_HORIZONTAL_ROW -> HorizontalCoinsRowViewHolder( - ItemCarouselBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ), + R.layout.item_carousel -> HorizontalCoinsRowViewHolder( + parent.inflateBinding(ItemCarouselBinding::inflate), sharedPool ) else -> throw IllegalArgumentException("Unknown view type: $viewType") diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ViewBindingExtensions.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ViewBindingExtensions.kt new file mode 100644 index 0000000..2700714 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/ViewBindingExtensions.kt @@ -0,0 +1,19 @@ +package ru.otus.cryptosample.coins.feature.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.viewbinding.ViewBinding + +/** + * Extension-функция для упрощения inflate ViewBinding в адаптерах. + * + * Пример использования: + * ``` + * parent.inflateBinding(ItemCoinBinding::inflate) + * ``` + */ +inline fun ViewGroup.inflateBinding( + crossinline bindingInflater: (LayoutInflater, ViewGroup, Boolean) -> T +): T { + return bindingInflater(LayoutInflater.from(context), this, false) +}