Galeria obrazów- kontrolka RecyclerView
Temat dotyczy tworzenia galerii zdjęć w aplikacji mobilnej, wykorzystując komponent RecyclerView. RecyclerView jest narzędziem Androida, które umożliwia wyświetlanie dużych zbiorów danych w sposób wydajny i estetyczny. Służy do pracy z dużymi i dynamicznymi listami danych. Materiał omawia, jak zbudować listę lub siatkę elementów, z których każdy zawiera obraz i podpis. Zostanie również omówiona rola adaptera, który odpowiada za łączenie danych z widokiem, oraz wykorzystanie biblioteki Coil do wygodnego ładowania grafik z zasobów lub internetu.
Coil (Coroutine Image Loader) to nowoczesna biblioteka przeznaczona dla Androida, stworzona specjalnie z myślą o języku Kotlin. Umożliwia ona szybkie i łatwe ładowanie obrazów do komponentu ImageView, zarówno z zasobów aplikacji (drawable), jak i z internetu (np. przez URL). Coil działa bardzo wydajnie, wykorzystując współbieżność opartą na Kotlin Coroutines, a przy tym jest lekka, intuicyjna i w pełni zgodna z nowoczesnym podejściem do tworzenia aplikacji. Dzięki niej możemy z łatwością wyświetlać obrazy z placeholderem.
Placeholdery (z ang. placeholder) to tymczasowe obrazy lub treści, które są wyświetlane zamiast właściwej zawartości, dopóki ta się nie załaduje.
Zapobiega to "mruganiu" lub pustym miejscom, dając użytkownikowi poczucie, że coś się dzieje (czyli UX się poprawia).
Pozwala obsłużyć błędy ładowania, a także stosować animacje przejścia (crossfade), co znacząco poprawia estetykę i wygodę korzystania z aplikacji.
Dzięki takiemu podejsciu stworzymy nowoczesny interfejs aplikacji przypominający galerię zdjęć lub katalog produktów.
Krótki opis biblioteki Coil
Coil to nowoczesna biblioteka do ładowania obrazów w aplikacjach Android, napisana specjalnie z myślą o języku Kotlin. Jej pełna nazwa to Coil ? Coroutine Image Loader.
Co robi Coil?
Coil pozwala:
- ładować obrazy z internetu (np. z URL),
- wyświetlać obrazy lokalne (drawable, file://, content://),
- automatycznie buforować obrazy (cache),
- obsługiwać placeholdery i błędy (np. brak sieci),
- działać wydajnie dzięki współpracy z Kotlin Coroutines i Jetpack.
Zalety Coil
- Lekka i szybka ? dużo lżejsza niż Glide czy Picasso.
- Napisał ją Chris Banes ? twórca wielu bibliotek Jetpack.
- Zgodna z Jetpack Compose i klasycznym Androidem (ImageView).
- W pełni napisana w Kotlinie ? idiomatyczna, łatwa w użyciu.
- Obsługuje animacje przejść (crossfade) i modyfikacje bitmap (np. przycięcie, zaokrąglenia).
Implementacja biblioteki Coil
Przejdź do pliku Gradle Scripts/ build.gradle.kts (modul app) i dodaj io.coil-kt:coil:2.6.0
Wskazówka:
dependencies {
implementation("io.coil-kt:coil:2.6.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
Wykonaj synchronizację z projektem- wybierz Sync Now
W manifeście aplikacji dopiszemy uprawnienia do korzystania z Internetu
Wskazówka:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
Layout aplikacji
W aplikacjach wykorzystujących listy lub siatki elementów, takich jak galeria zdjęć, konieczne jest przygotowanie dwóch oddzielnych layoutów. Pierwszy z nich to layout ekranu głównego (activity_main.xml), który zawiera komponent RecyclerView ? czyli kontener odpowiedzialny za wyświetlanie całej listy lub siatki elementów. Drugi to tzw. layout kafelka (item_obraz.xml), który definiuje wygląd pojedynczego elementu listy, np. obrazka z podpisem.
Dzięki takiemu podziałowi możliwe jest oddzielenie struktury całego ekranu od wyglądu pojedynczych obiektów, co pozwala na lepszą organizację kodu i ponowne wykorzystanie szablonów elementów. RecyclerView automatycznie tworzy i recyklinguje kafelki na podstawie właśnie tego drugiego layoutu ? co zapewnia zarówno elastyczność, jak i wydajność aplikacji.
Główny układ widoku

Zawartość pliku activity_main.xml
Wskazówka:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="12dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Układ widoku pojedynczego elementu
Układ widoku pojedynczego elementu utworzymy przez dodanie w hierarchii projektu nowego widoku res/ layout/ element_obrazu.xml, w którym element źródła (Root) przypiszemy na
com.google.android.material.card.MaterialCardView
Patrz poniższy zrzut ekranu

W widoku osadź:
- LinearLayout (vertical)
- ImageView
- TextView

Przypisane parametry widoczne są w zawartości pliku element_obraz.xml
Wskazówka:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
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="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="16dp"
app:cardUseCompatPadding="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/obraz"
android:layout_width="match_parent"
android:layout_height="180dp"
android:scaleType="centerCrop"
android:adjustViewBounds="true" />
<TextView
android:id="@+id/tytul"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:maxLines="1"
android:ellipsize="end"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
Model danych
Utwórz nowy pakiet dla danych. W hierarchii projektu kliknij prawym przyciskiem myszy na nazwę tworzonej aplikacji i wybierz New/ Package

Przypisz nazwę pakietowi (modelDanych). W hierarchii projektu utworzy się nowa struktura będąca folderem dla nowego pakietu. Tu utworzymy klasę danych reprezentującą pojedynczy element wczytywany adapterem do listy. Wybierz New/ Kotlin Class File a następnie Data class. Przypisz nazwę DaneObrazu

Początkowo w pliku klasy danych będzie zgłaszany błąd, dopóki klasy danych nie wypełnimy kodem. W klasie danych zapiszemy cztery pola tak jak poniżej
Wskazówka:
package pl.afizyka.galeriazobrazw.modelDanych
import androidx.annotation.DrawableRes
data class DaneObrazu(
//identyfikator itemu z listy
val id: Long,
val tytul: String,
val url: String? = null,
//identyfikator grafiki z zasobu
@DrawableRes val resId: Int? = null
)
Odświeżanie elementów listy
Do przeglądania listy obrazów lub innych dużych kolekcji przygotowano specjalną klasę o nazwie DiffUtil do zastosowania w adapterach dynamicznych list. Klasa Adapter z DiffUtil odgrywa kluczową rolę w aplikacjach korzystających z komponentu RecyclerView, ponieważ odpowiada za łączenie danych z widokiem. Adapter tworzy nowe elementy widoku (ViewHoldery) i wypełnia je danymi ? w tym przypadku obrazem i podpisem. Aby lista działała wydajnie, nie powinna odświeżać się cała przy każdej zmianie danych ? i tu wkracza DiffUtil.
DiffUtil to narzędzie, które inteligentnie porównuje starą i nową listę danych, znajdując tylko te elementy, które faktycznie się zmieniły. Dzięki temu RecyclerView może np. płynnie animować dodanie, usunięcie czy zmianę pozycji bez marnowania zasobów na przerysowanie całej listy.
Połączenie ListAdapter z DiffUtil pozwala zbudować wydajną, dynamiczną i nowoczesną listę, która sprawnie reaguje na zmiany danych ? nawet w dużych zbiorach, takich jak galerie zdjęć.
Tworzymy nowa klasę w projekcie (New/ Kotlin Class File). Nadajemy jej nazwę AdapterObrazow.

Poniżej kod
Uwaga! Zaimportuj bibliotekę coil.load. Pozostałe importy kompilator podpowie automatycznie.
Wskazówka:
package pl.afizyka.galeriazobrazw
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import pl.afizyka.galeriazobrazw.modelDanych.DaneObrazu
class AdapterObrazow (
private val onClick: (DaneObrazu) -> Unit
) : ListAdapter<DaneObrazu, AdapterObrazow.VH>(Diff) {
object Diff : DiffUtil.ItemCallback<DaneObrazu>() {
override fun areItemsTheSame(old: DaneObrazu, new: DaneObrazu) = old.id == new.id
override fun areContentsTheSame(old: DaneObrazu, new: DaneObrazu) = old == new
}
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
//odowłania do kontrolek w widoku element_obraz.xml
private val imgView = itemView.findViewById<ImageView>(R.id.obraz)
private val txtView = itemView.findViewById<TextView>(R.id.tytul)
fun bind(item: DaneObrazu) {
txtView.text = item.tytul
val request = when {
item.url != null -> item.url
item.resId != null -> item.resId
else -> null
}
// Coil: placeholder + błąd
if (request != null) {
imgView.load(request) {
placeholder(android.R.color.darker_gray)
error(android.R.color.holo_red_light)
crossfade(true)
}
} else {
imgView.setImageResource(android.R.color.darker_gray)
}
itemView.setOnClickListener { onClick(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val v = LayoutInflater.from(parent.context).inflate(R.layout.element_obraz, parent, false)
return VH(v)
}
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position))
}
Mechanizm tworzenia dynamicznej listy
W głównym pliku aplikacji MainActivity.kt zdefiniujemy dwie zmienne na kontrolkę RecyclerView i na adapter.Wskazówka:
class MainActivity : AppCompatActivity() {
private lateinit var recycler: RecyclerView
private val adapter = AdapterObrazow { item ->
Toast.makeText(this, "Klik: ${item.tytul}", Toast.LENGTH_SHORT).show()
// Do zrobienia: przejście do ekranu pełnoekranowego jak dalsze rozwinięcie projeku
}
override fun onCreate(savedInstanceState: Bundle?) {
Do folderu res/ drawable skopiuj dowolne pliki graficzne, które będą dodane do listy. W celach dekoracyjnych przygotujemy prostą klasę definiującą odstępy między kafelkami i zaokrąglającą rogi.
Klasę umieść w pliku MainActivity.kt na samym końcu poza nawiasem klamrowym zamykającym zawartość pliku.
Wskazówka:
/** Prosta dekoracja odstępów między kafelkami */
class odstepyKafelkow(private val spaceDp: Int) : RecyclerView.ItemDecoration() {
private fun Int.dpToPx(v: View) = (this * v.resources.displayMetrics.density).roundToInt()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val px = spaceDp.dpToPx(view)
outRect.set(px, px, px, px)
}
}
Dodaj funkcję w klasieMainActivity.kt która w możliwie prosty sposób doda elementy do listy
Wskazówka:
private fun listaDanych(): List<DaneObrazu> = listOf(
DaneObrazu(1, "Góry", resId = R.drawable.gory),
DaneObrazu(2, "Las", resId = R.drawable.las),
DaneObrazu(3, "Morze",
url = "https://www.epomoce.pl/ladujfotke.php?p1=Endeavour&p2=32"),
DaneObrazu(4, "Miasto",
url = "https://www.epomoce.pl/ladujfotke.php?p1=rw&p2=125"),
// ...dopełnij według potrzeb
)
Zmodyfikuj metodę onCreate() do poniższej postaci
Wskazówka:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
recycler = findViewById(R.id.recycler)
// Siatka: 2 kolumny w pionie, 3 w poziomie
val span = if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 2 else 3
recycler.layoutManager = GridLayoutManager(this, span)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
recycler.addItemDecoration(odstepyKafelkow(12))
adapter.submitList(listaDanych())
}
Skompiluj aplikacje i sprawdź efekt działania.

A aplikacja ładuje dostępne obrazy w liście. Sposób dodawania obrazów można zmodyfikować na dynamiczny. Można dodać podgląd ładowanego obrazu po kliknięciu itp.
Pełny kod pliku MainActivity.kt
Wskazówka:
package pl.afizyka.galeriazobrazw
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import pl.afizyka.galeriazobrazw.modelDanych.DaneObrazu
import kotlin.math.roundToInt
class MainActivity : AppCompatActivity() {
private lateinit var recycler: RecyclerView
private val adapter = AdapterObrazow { item ->
Toast.makeText(this, "Klik: ${item.tytul}", Toast.LENGTH_SHORT).show()
// Do zrobienia: przejście do ekranu pełnoekranowego jak dalsze rozwinięcie projektu
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
recycler = findViewById(R.id.recycler)
// Siatka: 2 kolumny w pionie, 3 w poziomie
val span = if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) 2 else 3
recycler.layoutManager = GridLayoutManager(this, span)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
recycler.addItemDecoration(odstepyKafelkow(12))
adapter.submitList(listaDanych())
}
private fun listaDanych(): List<DaneObrazu> = listOf(
DaneObrazu(1, "Góry", resId = R.drawable.gory),
DaneObrazu(2, "Las", resId = R.drawable.las),
DaneObrazu(3, "Morze",
url = "https://www.epomoce.pl/ladujfotke.php?p1=Endeavour&p2=32"),
DaneObrazu(4, "Miasto",
url = "https://www.epomoce.pl/ladujfotke.php?p1=rw&p2=125"),
// ...dopełnij według potrzeb
)
}
/** Prosta dekoracja odstępów między kafelkami */
class odstepyKafelkow(private val spaceDp: Int) : RecyclerView.ItemDecoration() {
private fun Int.dpToPx(v: View) = (this * v.resources.displayMetrics.density).roundToInt()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val px = spaceDp.dpToPx(view)
outRect.set(px, px, px, px)
}
}
Pełny kod pliku AdapterObrazow.kt
Wskazówka:
package pl.afizyka.galeriazobrazw
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import pl.afizyka.galeriazobrazw.modelDanych.DaneObrazu
class AdapterObrazow (
private val onClick: (DaneObrazu) -> Unit
) : ListAdapter<DaneObrazu, AdapterObrazow.VH>(Diff) {
//obiek klasy DiffUtil usprawniającej prace z dużymi kolekcjami
object Diff : DiffUtil.ItemCallback<DaneObrazu>() {
override fun areItemsTheSame(old: DaneObrazu, new: DaneObrazu) = old.id == new.id
override fun areContentsTheSame(old: DaneObrazu, new: DaneObrazu) = old == new
}
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
//odowłania do kontrolek w widoku element_obraz.xml
private val imgView = itemView.findViewById<ImageView>(R.id.obraz)
private val txtView = itemView.findViewById<TextView>(R.id.tytul)
//załaduj
fun bind(item: DaneObrazu) {
txtView.text = item.tytul
val request = when {
item.url != null -> item.url
item.resId != null -> item.resId
else -> null
}
// Coil: placeholder + błąd
if (request != null) {
imgView.load(request) {
placeholder(android.R.color.darker_gray)
error(android.R.color.holo_red_light)
crossfade(true)
}
} else {
imgView.setImageResource(android.R.color.darker_gray)
}
itemView.setOnClickListener { onClick(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val v = LayoutInflater.from(parent.context).inflate(R.layout.element_obraz, parent, false)
return VH(v)
}
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(getItem(position))
}
Pełny kod pliku DaneObrazu.kt
Wskazówka:
package pl.afizyka.galeriazobrazw.modelDanych
import androidx.annotation.DrawableRes
data class DaneObrazu(
//identyfikator itemu z listy
val id: Long,
val tytul: String,
val url: String? = null,
//identyfikator z zasobu
@DrawableRes val resId: Int? = null
)