Android Coden
Android 5 min lesen

Pull to Refresh in Android

Pull to Refresh gibt Nutzern Kontrolle über neue Daten. Du lernst, wie du Refresh-Zustand sauber führst.

Pull to Refresh ist ein kleines UI-Muster mit großer Wirkung: Du gibst Nutzern die Möglichkeit, aktiv neue Daten anzufordern, während die App den bisherigen Inhalt sichtbar hält. Gerade bei Feeds, Listen, Nachrichten, Bestellungen oder Offline-First-Ansätzen ist das wichtig, weil Aktualität und Stabilität gleichzeitig zählen.

Was ist das?

Pull to Refresh bedeutet, dass eine scrollbare Ansicht durch eine Ziehbewegung nach unten eine Aktualisierung startet. Die Geste sagt der App: „Bitte prüfe, ob es neue Daten gibt.“ Wichtig ist dabei nicht nur die Animation am oberen Rand, sondern das fachliche Verhalten dahinter. Die App soll vorhandene Inhalte nicht sofort entfernen, sondern weiter anzeigen, während im Hintergrund neue Daten geladen oder synchronisiert werden.

Für Android-Lernende ist das mentale Modell entscheidend: Pull to Refresh ist kein Ersatz für korrektes Laden von Daten. Es ist eine zusätzliche Nutzeraktion, die eine bereits vorhandene Datenstrategie auslöst. Wenn deine App beim Start Daten aus einem Repository lädt, sollte Pull to Refresh meist denselben Datenfluss anstoßen, nur explizit durch den Nutzer. Die UI zeigt dann einen Refresh-Zustand, aber die eigentliche Arbeit liegt in ViewModel, Use Case oder Repository.

Im modernen Android-Kontext hängt das Muster eng mit State zusammen. In Jetpack Compose beschreibst du die Oberfläche aus Zustand: Ist gerade ein Refresh aktiv? Gibt es bereits Daten? Gibt es eine Fehlermeldung? Wurde zuletzt erfolgreich synchronisiert? Diese Fragen sollten in deinem UI-State sichtbar werden. So kann Compose zuverlässig darstellen, ob ein Indikator läuft, ob die Liste erhalten bleibt und welche Rückmeldung der Nutzer bekommt.

Wie funktioniert es?

Technisch besteht Pull to Refresh aus drei Teilen: einer Geste in der UI, einem Refresh-State und einer Datenoperation. Die UI erkennt die Ziehbewegung und ruft eine Funktion wie onRefresh() auf. Diese Funktion sollte nicht direkt Netzwerkcode in der Composable ausführen. Stattdessen delegierst du an das ViewModel. Das ViewModel aktualisiert seinen State und ruft die Datenebene auf.

Die Datenebene ist der passende Ort für die Entscheidung, was „frisch“ bedeutet. Ein Repository kann Daten aus dem Netzwerk holen, sie in einer lokalen Datenbank speichern und danach einen Flow mit den aktuellen lokalen Daten ausgeben. Bei Offline-First-Architektur ist das besonders nützlich: Die UI beobachtet lokale Daten, und ein Refresh startet nur eine Synchronisation. Wenn das Netzwerk langsam ist oder fehlschlägt, bleiben die vorhandenen Einträge sichtbar.

Ein häufiger Denkfehler ist, isLoading und isRefreshing gleichzusetzen. Beim ersten Laden gibt es vielleicht noch keinen Inhalt. Dann kann ein großer Ladezustand sinnvoll sein. Beim Refresh gibt es dagegen meist schon Inhalt. Du willst also eher einen dezenten Indikator zeigen und die Liste stabil halten. Deshalb lohnt sich ein UI-State mit getrennten Feldern, zum Beispiel items, isInitialLoading, isRefreshing und errorMessage.

Auch parallele Aktualisierungen solltest du bewusst behandeln. Wenn der Nutzer mehrfach zieht oder ein automatischer Sync gleichzeitig läuft, darf dein Repository nicht unkontrolliert mehrere identische Requests starten. Eine einfache Regel: Ein Refresh-Kommando sollte idempotent wirken. Entweder ignorierst du weitere Refreshs während eines laufenden Refreshs, oder du bündelst sie in deiner Datenebene. Das schützt Akku, Datenvolumen und Backend.

In der Praxis

Ein typischer Compose-Aufbau sieht so aus: Die Composable sammelt State aus dem ViewModel, zeigt die Liste an und meldet die Refresh-Geste zurück. Die fachliche Logik bleibt außerhalb der UI.

data class ArticlesUiState(
    val articles: List<Article> = emptyList(),
    val isInitialLoading: Boolean = false,
    val isRefreshing: Boolean = false,
    val errorMessage: String? = null
)

class ArticlesViewModel(
    private val repository: ArticlesRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ArticlesUiState(isInitialLoading = true))
    val uiState: StateFlow<ArticlesUiState> = _uiState.asStateFlow()

    init {
        observeArticles()
        refresh()
    }

    private fun observeArticles() {
        viewModelScope.launch {
            repository.articles.collect { articles ->
                _uiState.update {
                    it.copy(
                        articles = articles,
                        isInitialLoading = false
                    )
                }
            }
        }
    }

    fun refresh() {
        if (_uiState.value.isRefreshing) return

        viewModelScope.launch {
            _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }

            runCatching {
                repository.syncArticles()
            }.onFailure { error ->
                _uiState.update {
                    it.copy(errorMessage = error.message ?: "Aktualisierung fehlgeschlagen")
                }
            }

            _uiState.update { it.copy(isRefreshing = false, isInitialLoading = false) }
        }
    }
}

Die Composable könnte dann so angebunden werden:

@Composable
fun ArticlesScreen(
    viewModel: ArticlesViewModel
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    PullToRefreshBox(
        isRefreshing = uiState.isRefreshing,
        onRefresh = viewModel::refresh
    ) {
        LazyColumn {
            items(uiState.articles) { article ->
                ArticleRow(article = article)
            }
        }
    }

    uiState.errorMessage?.let { message ->
        SnackbarHost(hostState = remember { SnackbarHostState() })
    }
}

Das Beispiel zeigt die wichtigste Praxisregel: Lösche die Liste nicht, nur weil ein Refresh startet. Wenn du bei jedem Refresh articles = emptyList() setzt, entsteht ein unruhiges UI. Nutzer verlieren ihre Scrollposition, Inhalte flackern, und ein Netzwerkfehler fühlt sich an, als seien Daten verschwunden. Besser ist: Bestehende Daten bleiben sichtbar, isRefreshing zeigt die laufende Aktualisierung, und neue Daten ersetzen oder ergänzen die lokale Quelle erst nach erfolgreicher Synchronisation.

Für Code-Reviews kannst du dir drei Fragen stellen. Erstens: Wird der Refresh durch State gesteuert oder durch verstreute lokale Variablen? Zweitens: Bleibt vorhandener Content während der Aktualisierung erhalten? Drittens: Ist die Datenoperation im Repository oder ViewModel gekapselt, statt direkt in der Composable zu liegen? Wenn eine dieser Fragen mit Nein beantwortet wird, ist die Umsetzung wahrscheinlich zu eng an die UI gebunden.

Beim Testen kannst du das Verhalten gut mit Fake-Repositories prüfen. Simuliere eine langsame Synchronisation und stelle sicher, dass isRefreshing währenddessen true ist, die bestehende Liste aber unverändert bleibt. Simuliere danach einen Fehler und prüfe, dass die Fehlermeldung gesetzt wird, ohne Daten zu entfernen. Zusätzlich lohnt sich manuelles Debugging: Setze Breakpoints in refresh() und im Repository, ziehe mehrfach hintereinander, und prüfe, ob wirklich nur die erwarteten Operationen laufen.

Fazit

Pull to Refresh ist dann sauber umgesetzt, wenn es Nutzerkontrolle bietet, ohne den aktuellen Zustand der Oberfläche unnötig zu zerstören. Behandle die Geste als Auslöser für eine klare Refresh-Operation, führe den Refresh-Zustand explizit im UI-State und lasse die Datenebene entscheiden, wie neue Daten synchronisiert werden. Übe das Muster an einer Liste mit lokalem Cache: Starte mit vorhandenen Daten, simuliere langsames Netzwerk, prüfe Fehlerfälle und lass deinen Code gezielt darauf reviewen, ob Inhalt, Refresh-State und Datenfluss sauber getrennt sind.

Quellen (3)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.