REDI-92: Views/XML renderer for the characters list (same MVI ViewModel)

Add :feature:characters:presentation-views — a classic Fragment + ViewBinding +
RecyclerView/DiffUtil renderer driving the SAME CharacterListViewModel as the Compose
screen (obtained via Koin's by viewModel()), proving the presentation logic is truly
UI-agnostic. State is observed with viewLifecycleOwner.repeatOnLifecycle(STARTED),
one-time Events are collected, UiText is resolved via Context, and the binding is nulled
in onDestroyView. Coil loads avatars into ImageView with a circle-crop transform; the
module has no Compose dependency.

Paging scroll listener guards the empty-list case (lastVisible >= 0), uses a safe
layoutManager cast, and is removed in onDestroyView.
This commit is contained in:
2026-06-10 13:44:53 +02:00
parent 5f2792002b
commit e230aa77d8
8 changed files with 376 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
package com.example.architecture.feature.characters.presentation.views
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import coil3.request.crossfade
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import com.example.architecture.feature.characters.presentation.views.databinding.ItemCharacterBinding
/**
* RecyclerView adapter over the SAME [CharacterUi] presentation model the Compose renderer uses.
* [DiffUtil] computes minimal updates; Coil loads avatars straight into the `ImageView`.
*/
internal class CharacterListAdapter(
private val onItemClick: (Int) -> Unit,
) : ListAdapter<CharacterUi, CharacterListAdapter.CharacterViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder {
val binding = ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return CharacterViewHolder(binding)
}
override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class CharacterViewHolder(
private val binding: ItemCharacterBinding,
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.root.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onItemClick(getItem(position).id)
}
}
}
fun bind(item: CharacterUi) {
val context = binding.root.context
binding.name.text = item.name
binding.statusSpecies.text =
context.getString(item.status.labelRes()) + " · " + item.species
ViewCompat.setBackgroundTintList(
binding.statusDot,
ColorStateList.valueOf(item.status.indicatorColor()),
)
binding.avatar.contentDescription =
context.getString(R.string.cd_character_avatar, item.name)
binding.avatar.load(item.imageUrl) {
crossfade(true)
transformations(CircleCropTransformation())
}
}
}
private companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<CharacterUi>() {
override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,127 @@
package com.example.architecture.feature.characters.presentation.views
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.architecture.core.presentation.asString
import com.example.architecture.feature.characters.presentation.CharacterListAction
import com.example.architecture.feature.characters.presentation.CharacterListEvent
import com.example.architecture.feature.characters.presentation.CharacterListState
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
import com.example.architecture.feature.characters.presentation.views.databinding.FragmentCharacterListBinding
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
* the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
*
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches
* the Compose NavController, so this module stays decoupled from navigation.
*/
class CharacterListFragment : Fragment() {
var onCharacterClick: (Int) -> Unit = {}
var onNavigateBack: () -> Unit = {}
private var _binding: FragmentCharacterListBinding? = null
private val binding get() = _binding!!
private val viewModel: CharacterListViewModel by viewModel()
private lateinit var listAdapter: CharacterListAdapter
private var pagingListener: RecyclerView.OnScrollListener? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentCharacterListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listAdapter = CharacterListAdapter(
onItemClick = { id -> viewModel.onAction(CharacterListAction.OnCharacterClick(id)) },
)
val scrollListener = pagingScrollListener()
pagingListener = scrollListener
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = listAdapter
addOnScrollListener(scrollListener)
}
binding.toolbar.setNavigationOnClickListener { onNavigateBack() }
binding.retryButton.setOnClickListener {
viewModel.onAction(CharacterListAction.OnRetry)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.state.collect(::render) }
launch { viewModel.events.collect(::handleEvent) }
}
}
}
private fun render(state: CharacterListState) {
listAdapter.submitList(state.characters)
val showFullScreenError = state.error != null && state.characters.isEmpty()
binding.progressBar.isVisible = state.isLoading
binding.nextPageProgress.isVisible = state.isLoadingNextPage
binding.errorContainer.isVisible = showFullScreenError
binding.recyclerView.isVisible = !state.isLoading && !showFullScreenError
if (showFullScreenError) {
binding.errorMessage.text = state.error?.asString(requireContext())
}
}
private fun handleEvent(event: CharacterListEvent) {
when (event) {
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
is CharacterListEvent.ShowSnackbar -> Snackbar.make(
binding.root,
event.message.asString(requireContext()),
Snackbar.LENGTH_SHORT,
).show()
}
}
private fun pagingScrollListener() = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisible = layoutManager.findLastVisibleItemPosition()
// `lastVisible >= 0` skips the empty-list case (findLastVisibleItemPosition() == -1),
// mirroring the Compose renderer's `total > 0` guard. The ViewModel still guards against
// duplicate / end-reached / already-loading requests.
if (lastVisible >= 0 && lastVisible >= layoutManager.itemCount - 1) {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
}
}
}
override fun onDestroyView() {
// Remove the scroll listener and detach the adapter before nulling the binding so neither
// the RecyclerView nor this Fragment is leaked.
pagingListener?.let { binding.recyclerView.removeOnScrollListener(it) }
pagingListener = null
binding.recyclerView.adapter = null
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,24 @@
package com.example.architecture.feature.characters.presentation.views
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
* renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer
* owns its own resources, so the small label duplication across modules is expected.
*/
@StringRes
internal fun CharacterStatus.labelRes(): Int = when (this) {
CharacterStatus.ALIVE -> R.string.characters_views_status_alive
CharacterStatus.DEAD -> R.string.characters_views_status_dead
CharacterStatus.UNKNOWN -> R.string.characters_views_status_unknown
}
@ColorInt
internal fun CharacterStatus.indicatorColor(): Int = when (this) {
CharacterStatus.ALIVE -> 0xFF4CAF50.toInt()
CharacterStatus.DEAD -> 0xFFE53935.toInt()
CharacterStatus.UNKNOWN -> 0xFF9E9E9E.toInt()
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Solid white oval, tinted per status at bind time via setBackgroundTintList. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/white" />
</shape>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationContentDescription="@string/cd_back"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/characters_views_title" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/errorContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/errorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<Button
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/characters_views_retry" />
</LinearLayout>
<ProgressBar
android:id="@+id/nextPageProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="16dp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:id="@+id/statusDot"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_status_dot" />
<TextView
android:id="@+id/statusSpecies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<resources>
<string name="characters_views_title">Characters (Views)</string>
<string name="characters_views_retry">Retry</string>
<string name="cd_back">Back</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="characters_views_status_alive">Alive</string>
<string name="characters_views_status_dead">Dead</string>
<string name="characters_views_status_unknown">Unknown</string>
</resources>