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..b717748 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,15 +11,32 @@ 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 +import ru.otus.cryptosample.coins.feature.animator.CoinsItemAnimator import ru.otus.cryptosample.coins.feature.di.DaggerCoinListComponent import ru.otus.cryptosample.databinding.FragmentCoinListBinding import javax.inject.Inject +/** + * Фрагмент для отображения списка криптовалют. + * + * Реализует отображение категорий с монетами, поддерживая сеточное отображение + * и вложенные горизонтальные списки для категорий с большим количеством элементов. + */ class CoinListFragment : Fragment() { + companion object { + /** Количество колонок в основной сетке */ + private const val SPAN_COUNT = 2 + /** Размер элемента на всю ширину сетки */ + private const val FULL_SPAN = 2 + /** Размер элемента в одну колонку */ + private const val SINGLE_SPAN = 1 + } + private var _binding: FragmentCoinListBinding? = null private val binding get() = _binding!! @@ -29,6 +46,12 @@ class CoinListFragment : Fragment() { private val viewModel: CoinListViewModel by viewModels { factory } private lateinit var coinsAdapter: CoinsAdapter + + /** + * Общий пул для переиспользования холдеров между основным списком и горизонтальными лентами. + * Позволяет значительно экономить память и сглаживать прокрутку. + */ + private val sharedViewPool = RecyclerView.RecycledViewPool() override fun onAttach(context: Context) { super.onAttach(context) @@ -58,16 +81,21 @@ class CoinListFragment : Fragment() { subscribeUI() } + /** + * Настраивает основной RecyclerView: устанавливает GridLayoutManager, + * адаптирует SpanSizeLookup под типы данных, подключает кастомный аниматор и общий ViewPool. + */ private fun setupRecyclerView() { - coinsAdapter = CoinsAdapter() + coinsAdapter = CoinsAdapter(sharedViewPool) - val gridLayoutManager = GridLayoutManager(requireContext(), 2) + val gridLayoutManager = GridLayoutManager(requireContext(), SPAN_COUNT) 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 + CoinsAdapter.VIEW_TYPE_CATEGORY, + CoinsAdapter.VIEW_TYPE_HORIZONTAL_LIST -> FULL_SPAN + CoinsAdapter.VIEW_TYPE_COIN_GRID -> SINGLE_SPAN + else -> SINGLE_SPAN } } } @@ -75,6 +103,10 @@ class CoinListFragment : Fragment() { binding.recyclerView.apply { layoutManager = gridLayoutManager adapter = coinsAdapter + // Установка кастомного аниматора для обработки добавлений и удалений + itemAnimator = CoinsItemAnimator() + // Установка общего пула для всех вложенных списков + setRecycledViewPool(sharedViewPool) } } @@ -88,20 +120,19 @@ class CoinListFragment : Fragment() { } } + /** + * Подписывается на поток состояний из ViewModel для обновления списка. + */ private fun subscribeUI() { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { state -> - renderState(state) + coinsAdapter.setData(state.categories) } } } } - private fun renderState(state: CoinsScreenState) { - coinsAdapter.setData(state.categories) - } - override fun onDestroyView() { super.onDestroyView() _binding = null 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..4d1d176 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 @@ -8,10 +8,18 @@ import ru.otus.cryptosample.R import ru.otus.cryptosample.coins.feature.CoinState import ru.otus.cryptosample.databinding.ItemCoinBinding +/** + * ViewHolder для отображения информации о конкретной монете. + */ class CoinViewHolder( private val binding: ItemCoinBinding ) : RecyclerView.ViewHolder(binding.root) { + /** + * Привязывает данные монеты к вью. + * + * @param coin Состояние монеты для отображения. + */ fun bind(coin: CoinState) { with(binding) { coinName.text = coin.name @@ -33,4 +41,14 @@ class CoinViewHolder( fireBadge.isVisible = coin.highlight } } -} + + /** + * Частичное обновление: меняет только видимость бейджика "горячей" монеты. + * Используется при обработке payloads для избежания полной перерисовки элемента. + * + * @param highlight Нужно ли отображать бейджик 🔥. + */ + fun updateHighlight(highlight: Boolean) { + binding.fireBadge.isVisible = highlight + } +} \ No newline at end of file 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..ba8b48b 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,145 @@ 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.ItemHorizontalCoinsBinding + +/** + * Основной адаптер для отображения списка криптовалют. + * Поддерживает отображение заголовков, карточек монет в сетке и вложенных горизонтальных лент. + * + * @param viewPool Пул вью для переиспользования холдеров монет между основным и вложенными списками. + */ +class CoinsAdapter( + private val viewPool: RecyclerView.RecycledViewPool +) : ListAdapter(CoinsDiffCallback()) { -class CoinsAdapter : RecyclerView.Adapter() { - companion object { - private const val VIEW_TYPE_CATEGORY = 0 - private const val VIEW_TYPE_COIN = 1 + /** Тип представления для заголовка категории */ + const val VIEW_TYPE_CATEGORY = 10 + /** Тип представления для карточки монеты в основной сетке */ + const val VIEW_TYPE_COIN_GRID = 11 + /** Тип представления для контейнера горизонтального списка */ + const val VIEW_TYPE_HORIZONTAL_LIST = 12 + /** Тип представления для карточки монеты в горизонтальной ленте */ + const val VIEW_TYPE_COIN_HORIZONTAL = 13 + + /** Ширина карточки монеты в горизонтальном списке в dp */ + const val COIN_ITEM_WIDTH_DP = 160 + /** Порог количества монет, после которого категория отображается горизонтально */ + const val HORIZONTAL_THRESHOLD = 10 } - - private var items = listOf() - + + /** + * Преобразует иерархические данные категорий в плоский список элементов для адаптера. + * Если в категории более [HORIZONTAL_THRESHOLD] монет, она отображается как горизонтальная лента. + * + * @param categories Список категорий с монетами. + */ 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 > HORIZONTAL_THRESHOLD) { + adapterItems.add(CoinsAdapterItem.HorizontalCoinsItem(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.CoinItem -> VIEW_TYPE_COIN_GRID + is CoinsAdapterItem.HorizontalCoinsItem -> VIEW_TYPE_HORIZONTAL_LIST } } - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_CATEGORY -> CategoryHeaderViewHolder( - ItemCategoryHeaderBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + ItemCategoryHeaderBinding.inflate(inflater, parent, false) + ) + VIEW_TYPE_COIN_GRID -> CoinViewHolder( + ItemCoinBinding.inflate(inflater, parent, false) ) - VIEW_TYPE_COIN -> CoinViewHolder( - ItemCoinBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + VIEW_TYPE_HORIZONTAL_LIST -> HorizontalCoinsViewHolder( + ItemHorizontalCoinsBinding.inflate(inflater, parent, false), + viewPool ) - else -> throw IllegalArgumentException("Unknown view type: $viewType") + else -> throw IllegalArgumentException("Неизвестный тип вью: $viewType") } } - + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = items[position]) { - is CoinsAdapterItem.CategoryHeader -> { - (holder as CategoryHeaderViewHolder).bind(item.categoryName) + 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.HorizontalCoinsItem -> (holder as HorizontalCoinsViewHolder).bind(item.coins) + } + } + + /** + * Обработка частичного обновления через payloads для минимизации перерисовок. + */ + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + val item = getItem(position) + if (item is CoinsAdapterItem.CoinItem && holder is CoinViewHolder) { + payloads.forEach { payload -> + if (payload == HorizontalCoinsAdapter.PAYLOAD_HIGHLIGHT) { + // Обновляем только статус подсветки (бейдж 🔥) + holder.updateHighlight(item.coin.highlight) + } + } + } else { + onBindViewHolder(holder, position) + } + } + } + + /** + * Callback для вычисления разницы между элементами списка (DiffUtil). + */ + class CoinsDiffCallback : 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 + oldItem is CoinsAdapterItem.HorizontalCoinsItem && newItem is CoinsAdapterItem.HorizontalCoinsItem -> + oldItem.categoryId == newItem.categoryId + else -> false } - is CoinsAdapterItem.CoinItem -> { - (holder as CoinViewHolder).bind(item.coin) + } + + 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) { + // Если изменилось только состояние подсветки, возвращаем payload для частичного обновления + if (oldItem.coin.highlight != newItem.coin.highlight) { + return HorizontalCoinsAdapter.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..42a052e 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,27 @@ package ru.otus.cryptosample.coins.feature.adapter import ru.otus.cryptosample.coins.feature.CoinState +/** + * Типы элементов для адаптера списка монет. + */ sealed class CoinsAdapterItem { + /** + * Заголовок категории. + * @property categoryName Название категории. + */ data class CategoryHeader(val categoryName: String) : CoinsAdapterItem() + + /** + * Элемент отдельной монеты для отображения в сетке. + * @property coin Данные о монете. + */ data class CoinItem(val coin: CoinState) : CoinsAdapterItem() + + /** + * Элемент для горизонтального списка монет. + * Используется, когда в категории более 10 элементов. + * @property categoryId Идентификатор категории. + * @property coins Список монет в категории. + */ + data class HorizontalCoinsItem(val categoryId: 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..92ec0f1 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsAdapter.kt @@ -0,0 +1,83 @@ +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 + +/** + * Адаптер для горизонтального списка монет. + * Используется для отображения ленты монет в категориях с количеством элементов более 10. + */ +class HorizontalCoinsAdapter : ListAdapter(CoinDiffCallback()) { + + override fun getItemViewType(position: Int): Int { + // Используем специфичный тип для горизонтального списка. + // Это предотвращает переиспользование холдеров из сетки (match_parent) в ленте через общий ViewPool. + return CoinsAdapter.VIEW_TYPE_COIN_HORIZONTAL + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoinViewHolder { + val binding = ItemCoinBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + + // Устанавливаем фиксированную ширину из константы CoinsAdapter.COIN_ITEM_WIDTH_DP + val density = parent.context.resources.displayMetrics.density + val width = (CoinsAdapter.COIN_ITEM_WIDTH_DP * density).toInt() + + val layoutParams = binding.root.layoutParams + layoutParams.width = width + 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 { + payloads.forEach { payload -> + if (payload == PAYLOAD_HIGHLIGHT) { + // Частичное обновление: только бейдж огня + holder.updateHighlight(getItem(position).highlight) + } + } + } + } + + /** + * Сравнение элементов для оптимизации обновления списка через DiffUtil. + */ + 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? { + // Если изменилось только состояние подсветки, возвращаем соответствующий payload + return if (oldItem.highlight != newItem.highlight) { + PAYLOAD_HIGHLIGHT + } else { + null + } + } + } + + companion object { + /** Ключ для частичного обновления (подсветка монеты) */ + const val PAYLOAD_HIGHLIGHT = "PAYLOAD_HIGHLIGHT" + } +} diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsViewHolder.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsViewHolder.kt new file mode 100644 index 0000000..43041b6 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/adapter/HorizontalCoinsViewHolder.kt @@ -0,0 +1,41 @@ +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.ItemHorizontalCoinsBinding + +/** + * ViewHolder для отображения горизонтального списка монет внутри основного вертикального списка. + * Используется для категорий, содержащих более 10 элементов. + * + * @param binding Привязка макета для горизонтального списка. + * @param viewPool Общий пул вью для оптимизации производительности вложенного списка. + */ +class HorizontalCoinsViewHolder( + private val binding: ItemHorizontalCoinsBinding, + private val viewPool: RecyclerView.RecycledViewPool +) : RecyclerView.ViewHolder(binding.root) { + + private val adapter = HorizontalCoinsAdapter() + + init { + binding.horizontalRecyclerView.apply { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = this@HorizontalCoinsViewHolder.adapter + // Устанавливаем общий пул вью для переиспользования холдеров + setRecycledViewPool(viewPool) + // Оптимизация: фиксированный размер, так как высота списка обычно не меняется + setHasFixedSize(true) + } + } + + /** + * Обновляет данные во вложенном адаптере. + * + * @param coins Список монет для отображения. + */ + fun bind(coins: List) { + adapter.submitList(coins) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/otus/cryptosample/coins/feature/animator/CoinsItemAnimator.kt b/app/src/main/java/ru/otus/cryptosample/coins/feature/animator/CoinsItemAnimator.kt new file mode 100644 index 0000000..333e8a1 --- /dev/null +++ b/app/src/main/java/ru/otus/cryptosample/coins/feature/animator/CoinsItemAnimator.kt @@ -0,0 +1,281 @@ +package ru.otus.cryptosample.coins.feature.animator + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +/** + * Кастомный аниматор для элементов списка криптовалют. + * + * Данный класс расширяет [DefaultItemAnimator], переопределяя логику добавления и удаления элементов. + * Это позволяет добавить визуальные эффекты (смещение и прозрачность), сохраняя при этом + * стандартное поведение для перемещения (Move) и изменения (Change) элементов. + * + * Основные эффекты: + * - Добавление: Элемент "выплывает" справа (смещение по X) и плавно проявляется (изменение alpha). + * - Удаление: Элемент плавно исчезает, "уплывая" влево. + */ +class CoinsItemAnimator : DefaultItemAnimator() { + + companion object { + /** Смещение по горизонтали в dp для анимаций */ + private const val TRANSLATION_X_DP = 100f + + /** Значение смещения в состоянии покоя */ + private const val IDLE_TRANSLATION_X = 0f + + /** Значение прозрачности для невидимого элемента */ + private const val ALPHA_INVISIBLE = 0f + + /** Значение прозрачности для полностью видимого элемента */ + private const val ALPHA_VISIBLE = 1f + } + + /** + * Список ViewHolder-ов, для которых была запрошена анимация добавления, + * но которые еще не начали анимироваться. + */ + private val pendingAdditions = mutableListOf() + + /** + * Список ViewHolder-ов, для которых была запрошена анимация удаления, + * но которые еще не начали анимироваться. + */ + private val pendingRemovals = mutableListOf() + + /** + * Список ViewHolder-ов, которые в данный момент активно анимируются на добавление. + */ + private val addAnimations = mutableListOf() + + /** + * Список ViewHolder-ов, которые в данный момент активно анимируются на удаление. + */ + private val removeAnimations = mutableListOf() + + /** + * Вспомогательный метод для перевода dp в пиксели для текущего устройства. + */ + private fun getTranslationX(holder: RecyclerView.ViewHolder): Float { + return TRANSLATION_X_DP * holder.itemView.context.resources.displayMetrics.density + } + + /** + * Вызывается RecyclerView, когда в адаптер добавляется новый элемент. + * Здесь мы не запускаем анимацию сразу, а только подготавливаем начальное состояние вью. + * + * @param holder ViewHolder нового элемента. + * @return true, чтобы сообщить RecyclerView, что мы берем на себя выполнение анимации. + */ + override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean { + // Сначала сбрасываем все текущие анимации для этого холдера + resetAnimation(holder) + + // Устанавливаем начальное состояние: полностью прозрачный и смещен вправо (в пикселях, расчет через DP) + holder.itemView.alpha = ALPHA_INVISIBLE + holder.itemView.translationX = getTranslationX(holder) + + // Добавляем в очередь на запуск + pendingAdditions.add(holder) + return true + } + + /** + * Внутренний метод для запуска фактической анимации добавления. + * Использует [android.view.ViewPropertyAnimator] для плавного изменения свойств. + */ + private fun performAnimateAdd(holder: RecyclerView.ViewHolder) { + addAnimations.add(holder) + val view = holder.itemView + val animation = view.animate() + + animation.alpha(ALPHA_VISIBLE) + .translationX(IDLE_TRANSLATION_X) + .setDuration(addDuration) // Используем длительность из настроек базового класса + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + // Уведомляем систему о начале анимации (важно для корректной работы RV) + dispatchAddStarting(holder) + } + + override fun onAnimationCancel(animator: Animator) { + // В случае отмены принудительно устанавливаем финальное состояние + view.alpha = ALPHA_VISIBLE + view.translationX = IDLE_TRANSLATION_X + } + + override fun onAnimationEnd(animator: Animator) { + animation.setListener(null) + // Уведомляем систему, что анимация добавления для этого холдера завершена + dispatchAddFinished(holder) + addAnimations.remove(holder) + // Проверяем, не была ли это последняя активная анимация в системе + dispatchFinishedWhenDone() + } + }).start() + } + + /** + * Вызывается RecyclerView, когда элемент удаляется из списка. + * + * @param holder ViewHolder удаляемого элемента. + * @return true, так как мы будем анимировать удаление самостоятельно. + */ + override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean { + resetAnimation(holder) + pendingRemovals.add(holder) + return true + } + + /** + * Внутренний метод для запуска фактической анимации удаления. + */ + private fun performAnimateRemove(holder: RecyclerView.ViewHolder) { + removeAnimations.add(holder) + val view = holder.itemView + val animation = view.animate() + + // Смещение влево при исчезновении (в пикселях, расчет через DP) + animation.alpha(ALPHA_INVISIBLE) + .translationX(-getTranslationX(holder)) + .setDuration(removeDuration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animator: Animator) { + dispatchRemoveStarting(holder) + } + + override fun onAnimationCancel(animator: Animator) { + view.alpha = ALPHA_VISIBLE + view.translationX = IDLE_TRANSLATION_X + } + + override fun onAnimationEnd(animator: Animator) { + animation.setListener(null) + // ПЕРЕД завершением возвращаем прозрачность в 1, + // чтобы при следующем использовании этого холдера (переиспользовании) он не был невидимым. + view.alpha = ALPHA_VISIBLE + view.translationX = IDLE_TRANSLATION_X + + dispatchRemoveFinished(holder) + removeAnimations.remove(holder) + dispatchFinishedWhenDone() + } + }).start() + } + + /** + * Метод, который запускает выполнение всех отложенных анимаций. + * RecyclerView вызывает его один раз за цикл отрисовки. + */ + override fun runPendingAnimations() { + val removalsNotEmpty = pendingRemovals.isNotEmpty() + val additionsNotEmpty = pendingAdditions.isNotEmpty() + + // Сначала запускаем базовые анимации (перемещения, изменения), если они были запланированы + super.runPendingAnimations() + + if (!removalsNotEmpty && !additionsNotEmpty) return + + // Логика запуска: сначала удаляем старые элементы, потом добавляем новые + + // Обработка удалений + if (removalsNotEmpty) { + val removals = ArrayList(pendingRemovals) + pendingRemovals.clear() + removals.forEach { performAnimateRemove(it) } + } + + // Обработка добавлений + if (additionsNotEmpty) { + val additions = ArrayList(pendingAdditions) + pendingAdditions.clear() + additions.forEach { performAnimateAdd(it) } + } + } + + /** + * Прерывает все активные и отложенные анимации для конкретного ViewHolder. + * Вызывается, если RV нужно срочно переиспользовать вью. + */ + override fun endAnimation(item: RecyclerView.ViewHolder) { + item.itemView.animate().cancel() + + if (pendingRemovals.remove(item)) { + resetAnimation(item) + dispatchRemoveFinished(item) + } + if (pendingAdditions.remove(item)) { + resetAnimation(item) + dispatchAddFinished(item) + } + if (removeAnimations.remove(item)) { + resetAnimation(item) + dispatchRemoveFinished(item) + } + if (addAnimations.remove(item)) { + resetAnimation(item) + dispatchAddFinished(item) + } + super.endAnimation(item) + } + + /** + * Проверяет, занят ли аниматор какими-либо задачами в данный момент. + * Важно для RV, чтобы понимать, когда можно считать список стабильным. + */ + override fun isRunning(): Boolean { + return pendingAdditions.isNotEmpty() || + pendingRemovals.isNotEmpty() || + addAnimations.isNotEmpty() || + removeAnimations.isNotEmpty() || + super.isRunning() + } + + /** + * Мгновенно завершает вообще все текущие и запланированные анимации. + */ + override fun endAnimations() { + // Проходим по спискам в обратном порядке и сбрасываем состояние + for (i in pendingRemovals.indices.reversed()) { + val item = pendingRemovals[i] + resetAnimation(item) + dispatchRemoveFinished(item) + pendingRemovals.removeAt(i) + } + for (i in pendingAdditions.indices.reversed()) { + val item = pendingAdditions[i] + resetAnimation(item) + dispatchAddFinished(item) + pendingAdditions.removeAt(i) + } + for (i in removeAnimations.indices.reversed()) { + val item = removeAnimations[i] + item.itemView.animate().cancel() + removeAnimations.removeAt(i) + } + for (i in addAnimations.indices.reversed()) { + val item = addAnimations[i] + item.itemView.animate().cancel() + addAnimations.removeAt(i) + } + super.endAnimations() + } + + /** + * Вспомогательный метод для сброса трансформаций вью к стандартным значениям. + */ + private fun resetAnimation(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = ALPHA_VISIBLE + holder.itemView.translationX = IDLE_TRANSLATION_X + } + + /** + * Финальное уведомление RecyclerView, вызывается когда все списки анимаций пусты. + */ + private fun dispatchFinishedWhenDone() { + if (!isRunning) { + dispatchAnimationsFinished() + } + } +} diff --git a/app/src/main/res/layout/item_horizontal_coins.xml b/app/src/main/res/layout/item_horizontal_coins.xml new file mode 100644 index 0000000..2a3d672 --- /dev/null +++ b/app/src/main/res/layout/item_horizontal_coins.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/hr_result.webm b/hr_result.webm new file mode 100644 index 0000000..1a2c118 Binary files /dev/null and b/hr_result.webm differ