Najważniejsze
Pull-to-Refresh — wzorzec interfejsu użytkownika, w którym użytkownik przeciąga (pull down) listę lub obszar przewijany w dół w celu odświeżenia zawartości. Wizualnie gestowi towarzyszy wskaźnik ładowania (spinner), który pojawia się w górnej części ekranu i znika po otrzymaniu danych. Wzorzec został spopularyzowany przez aplikację Tweetie dla iPhone (2008), a następnie zestandaryzowany przez Apple (iOS 6 — UIRefreshControl) i Google (Android Support Library — SwipeRefreshLayout).
Z technicznego punktu widzenia Pull-to-Refresh to kombinacja przesuwania (śledzenie przesunięcia palca) i wyzwalacza po osiągnięciu progu. Użytkownik przeciąga listę w dół, pokonując opór (rezystywny overscroll), a po przekroczeniu progu (~80px w iOS, ~64dp w Android) uruchamiana jest animacja wskaźnika i asynchroniczne ładowanie. Jeśli użytkownik puści palec przed progiem — lista wraca do pozycji wyjściowej bez odświeżania.
Według Material Design Guidelines Pull-to-Refresh nie powinien być używany do nawigacji lub przełączania zakładek — jego jedynym przeznaczeniem jest odświeżanie danych. W IT Sectr stosujemy Pull-to-Refresh w kanałach informacyjnych, listach zamówień i czatach, gdzie świeżość danych jest kluczowa dla doświadczenia użytkownika.
UIRefreshControl — standardowy element sterujący iOS dla Pull-to-Refresh, dostępny od iOS 6. UIRefreshControl jest dodawany do UITableViewController przez właściwość refreshControl (iOS 10+) lub jako subview tabeli we wcześniejszych wersjach. Zawiera wbudowany spinner z konfigurowalnym kolorem (tintColor), atrybutem title i atrybutowanym stringiem z etykietą (np. „Odświeżanie...").
UIRefreshControl działa przez mechanizm target-action: po aktywacji gestu wywoływana jest wskazana metoda (np. refresh(_:)). Wewnątrz metody wykonywane jest asynchroniczne ładowanie danych. Po zakończeniu wywoływane jest endRefreshing(), które ukrywa wskaźnik z animacją. UIRefreshControl automatycznie zarządza czułością gestu — uruchamia się tylko w górnym położeniu tabeli (contentOffset.y <= 0).
Właściwość tintColor ustawia kolor spinnera. Atrybuty title pozwalają wyświetlać tekst „Odświeżono 2 minuty temu" po zakończeniu. Od iOS 10, UIRefreshControl obsługuje niestandardowe animacje przez UIActivityIndicatorView lub trwałe niestandardowe widoki. W IT Sectr dostosowujemy tintColor do marki i wyświetlamy czas ostatniego odświeżenia przez attributedTitle — zwiększa to zaufanie użytkowników do danych.
SwipeRefreshLayout — ViewGroup z Android Support Library (androidx.swiperefreshlayout), otaczająca przewijalną treść (RecyclerView, NestedScrollView, ListView) i dodająca funkcjonalność Pull-to-Refresh. W przeciwieństwie do UIRefreshControl (który jest kontrolką, a nie kontenerem), SwipeRefreshLayout to kontener, który przechwytuje zdarzenia dotykowe dziecka i uruchamia wskaźnik odświeżania po przekroczeniu progu.
SwipeRefreshLayout używa okrągłego wskaźnika postępu Material Design z konfiguracją kolorów przez setColorSchemeColors(). Metoda setOnRefreshListener ustawia callback onRefresh(), w którym wykonywane jest asynchroniczne ładowanie. Po zakończeniu wywoływane jest setRefreshing(false) w celu ukrycia wskaźnika. Ważne: setRefreshing(true) wywołuje onRefresh() ponownie — dlatego do programowego uruchomienia odświeżania użyj flagi lub metody post.
Właściwość setProgressBackgroundColorSchemeResource zmienia tło wskaźnika. setSize(SwipeRefreshLayout.LARGE) — rozmiar spinnera. W XML-układzie SwipeRefreshLayout otacza RecyclerView: swipe_refresh_layout → recycler_view. Według Google I/O 2024, SwipeRefreshLayout jest używany w 85% aplikacji Android z kanałami treści. W IT Sectr otaczamy w SwipeRefreshLayout wszystkie ekrany z asynchronicznie ładowanymi listami — zapewnia to jednolity UX na wszystkich wersjach Android.
Od Android 12 (Material You), Google zaleca używanie nowego Material Pull-to-Refresh z biblioteki material-1.6.0+ (androidx.compose.material3.pulltorefresh dla Compose). Nowe API używa animowanego wskaźnika z obsługą spring-animacji i adaptacyjnego koloru na podstawie tapety. SwipeRefreshLayout pozostaje kompatybilny dla wersji poniżej Android 12.
Pull-to-Refresh — prosty w implementacji wzorzec, ale zawiera kilka typowych błędów, obniżających UX. Omówmy je i sposoby zapobiegania.
W IT Sectr dodaliśmy sprawdzanie isRefreshing w każdym projekcie po odkryciu zduplikowanych żądań w logach serwera testowego — okazało się, że użytkownicy z szybkimi palcami uruchamiali odświeżanie do 3 razy z rzędu.
Dodaje Pull-to-Refresh do UITableViewController z niestandardowym kolorem spinnera i attributed title. Po załadowaniu danych wskaźnik jest ukrywany.
import UIKit
class FeedTableViewController: UITableViewController {
private var items: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.refreshControl = UIRefreshControl()
refreshControl?.tintColor = .systemBlue
refreshControl?.attributedTitle = NSAttributedString(
string: “Przeciągnij, aby odświeżyć”
)
refreshControl?.addTarget(
self,
action: #selector(refreshData),
for: .valueChanged
)
}
@objc private func refreshData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.items = FeedService().fetchLatest()
self.tableView.reloadData()
self.refreshControl?.endRefreshing()
}
}
}
Właściwość tableView.refreshControl (iOS 10+) ustawia UIRefreshControl. addTarget ze zdarzeniem .valueChanged uruchamia się przy aktywacji gestu. endRefreshing() jest obowiązkowy — bez niego wskaźnik będzie kręcił się w nieskończoność. Asynchroniczne ładowanie jest symulowane przez DispatchQueue.main.asyncAfter — w rzeczywistym projekcie byłoby to URLSession lub async/await.
Otacza RecyclerView w SwipeRefreshLayout z niestandardowymi kolorami wskaźnika. onRefresh uruchamia ładowanie i ukrywa wskaźnik po zakończeniu.
class FeedFragment : Fragment() {
private var _binding: FragmentFeedBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentFeedBinding.inflate(inflater, container, false)
binding.swipeRefreshLayout.setColorSchemeColors(
resources.getColor(R.color.brand_blue, null),
resources.getColor(R.color.brand_green, null)
)
binding.swipeRefreshLayout.setOnRefreshListener {
loadData()
}
return binding.root
}
private fun loadData() {
viewModelScope.launch {
try {
val result = repository.getLatestFeed()
adapter.submitList(result)
} finally {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
setColorSchemeColors ustawia kolory obracającego się wskaźnika Material Design. isRefreshing = false jest obowiązkowo wywoływane w finally, aby ukryć wskaźnik nawet przy błędzie ładowania. ViewModelScope.launch wykonuje coroutine w cyklu życia fragmentu — przy zniszczeniu fragmentu coroutine jest automatycznie anulowana, zapobiegając wyciekowi pamięci.
Nowoczesny SwiftUI udostępnia modyfikator .refreshable, który automatycznie dodaje Pull-to-Refresh do List lub ScrollView.
import SwiftUI
struct FeedView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.refreshable {
items = await FeedService().fetchLatestAsync()
}
}
}
Modyfikator .refreshable przyjmuje async-closure, wykonywany przy Pull-to-Refresh. SwiftUI automatycznie pokazuje i ukrywa wskaźnik odświeżania, zarządza wyścigiem stanu (nie uruchamia ponownego ładowania przed zakończeniem bieżącego) i dostosowuje animację do platformy. Dla iOS 15+ jest to preferowany sposób implementacji Pull-to-Refresh w SwiftUI.
Często zadawane pytania
Tak, SwiftUI udostępnia modyfikator .refreshable dla List lub ScrollView, dostępny od iOS 15. Wewnątrz closure wykonywany jest async-kod ładowania danych. SwiftUI automatycznie zarządza wskaźnikiem odświeżania i blokuje ponowne uruchomienia do zakończenia bieżącego ładowania — to standardowe recommended podejście dla nowych projektów.
Użyj flagi isRefreshing: ustaw true na starcie ładowania i false po zakończeniu. W iOS UIRefreshControl automatycznie blokuje ponowny wywołanie, dopóki nie zostanie wywołane endRefreshing(). W Android sprawdzaj SwipeRefreshLayout.isRefreshing na początku onRefresh(): jeśli true — return. Gwarantuje to jeden żądanie na jeden gest.
UIRefreshControl i SwipeRefreshLayout uruchamiają się tylko w górnym położeniu listy (contentOffset == 0). Architektura wyklucza konflikt: dopóki lista jest przewinięta choćby o 1px, gest Pull-to-Refresh nie aktywuje się. Jeśli konflikt wystąpi — sprawdź nestedScrollingEnabled w Android lub obecność niestandardowych GestureRecognizer przechwytujących dotyk.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również