Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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!!

Expand All @@ -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)
Expand Down Expand Up @@ -58,23 +81,32 @@ 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
}
}
}

binding.recyclerView.apply {
layoutManager = gridLayoutManager
adapter = coinsAdapter
// Установка кастомного аниматора для обработки добавлений и удалений
itemAnimator = CoinsItemAnimator()
// Установка общего пула для всех вложенных списков
setRecycledViewPool(sharedViewPool)
}
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,4 +41,14 @@ class CoinViewHolder(
fireBadge.isVisible = coin.highlight
}
}
}

/**
* Частичное обновление: меняет только видимость бейджика "горячей" монеты.
* Используется при обработке payloads для избежания полной перерисовки элемента.
*
* @param highlight Нужно ли отображать бейджик 🔥.
*/
fun updateHighlight(highlight: Boolean) {
binding.fireBadge.isVisible = highlight
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinsAdapterItem, RecyclerView.ViewHolder>(CoinsDiffCallback()) {

class CoinsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

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<CoinsAdapterItem>()


/**
* Преобразует иерархические данные категорий в плоский список элементов для адаптера.
* Если в категории более [HORIZONTAL_THRESHOLD] монет, она отображается как горизонтальная лента.
*
* @param categories Список категорий с монетами.
*/
fun setData(categories: List<CoinCategoryState>) {
val adapterItems = mutableListOf<CoinsAdapterItem>()

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<Any>) {
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<CoinsAdapterItem>() {
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinState>) : CoinsAdapterItem()
}
Loading