주요 콘텐츠로 건너뛰기

당겨서 새로고침(Pull to refresh)

Riverpod은 선언적 성격 덕분에 당겨서 새로고침(pull-to-refresh)를 기본적으로 지원합니다.

일반적으로 당겨서 새로고침(pull-to-refresh)는 해결해야 할 문제가 많기 때문에 복잡할 수 있습니다:

  • 페이지에 처음 들어갔을 때 스피너(spinner)를 표시하고 싶습니다. 하지만 새로 고침 중에는 대신 갱신표시기(refresh indicator)를 표시하고 싶습니다. 갱신표시기(refresh indicator) 스피너(spinner)를 모두 표시해서는 안 됩니다.
  • 새로 고침이 보류 중인 동안 이전 데이터/오류를 표시하고 싶습니다.
  • 새로 고침이 진행되는 동안 갱신표시기(refresh indicator)를 표시해야 합니다.

Riverpod를 사용하여 이 문제를 해결하는 방법을 살펴봅시다.
이를 위해 사용자에게 임의의 액티비티를 추천하는 간단한 예제를 만들어 보겠습니다.
그리고 당겨서 새로고침(pull-to-refresh)를 수행하면 새로운 제안이 트리거됩니다:

A gif of the previously described application working

기본(bare-bones) 애플리케이션 만들기

당겨서 새로고침(pull-to-refresh) 기능을 구현하기 전에 먼저 새로고침할 무언가가 필요합니다.
Bored API를 사용하여 사용자에게 임의의 액티비티를 제안하는 간단한 애플리케이션을 만들 수 있습니다.

먼저 Activity 클래스를 정의하겠습니다:


class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;

factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}

이 클래스는 제안된 액티비티를 타입에 안전한(type-safe) 방식으로 표현하고 JSON 인코딩/디코딩을 처리합니다.
Freezed/json_serializable을 반드시 사용해야 하는 것은 아니지만 권장합니다.

이제 단일 액티비티를 가져오기 위해 HTTP GET 요청을 하는 provider를 정의하겠습니다:


Future<Activity> activity(Ref ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);

final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}

이제 이 provider를 사용하여 임의의 액티비티를 표시할 수 있습니다.
지금은 로딩/오류 상태를 처리하지 않고 사용 가능한 경우 액티비티만 표시합니다:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: Center(
// activity가 있으면 표시하고, 그렇지 않으면 대기합니다.
child: Text(activity.valueOrNull?.activity ?? ''),
),
);
}
}

RefreshIndicator 추가

이제 간단한 애플리케이션을 만들었으니 여기에 RefreshIndicator를 추가하면 됩니다.
이 위젯은 사용자가 화면을 아래로 내릴 때 새로고침 표시기(refresh indicator)를 표시하는 공식 머티리얼(Material) 위젯입니다.

RefreshIndicator를 사용하려면 스크롤 가능한 표면(surface)이 필요합니다. 하지만 지금까지는 하나도 없습니다. ListView/GridView/SingleChildScrollView/등을 사용하면 이 문제를 해결할 수 있습니다:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () async => print('refresh'),
child: ListView(
children: [
Text(activity.valueOrNull?.activity ?? ''),
],
),
),
);
}
}

이제 사용자가 화면을 아래로 내릴 수 있습니다. 하지만 데이터는 아직 새로 고쳐지지 않았습니다.

새로 고침 로직 추가

사용자가 화면을 아래로 내리면 RefreshIndicatoronRefresh 콜백을 호출합니다. 이 콜백을 사용해 데이터를 새로 고칠 수 있습니다. 여기서 ref.refresh를 사용하여 선택한 provider를 새로 고칠 수 있습니다.

참고: onRefreshFuture를 반환할 것으로 예상됩니다. 그리고 새로 고침이 완료될 때 그 future가 완료되는 것이 중요합니다.

이러한 future를 얻으려면 provider의 '.future' 속성을 읽으면 됩니다. 그러면 provider가 해결(resolved)될 때 완료되는 future가 반환됩니다.

따라서 RefreshIndicator를 다음과 같이 업데이트할 수 있습니다:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
// "activityProvider.future"를 새로 고치고 해당 결과를 반환합니다,
// 새 활동을 가져올 때까지 새로 고침 표시기가 계속 표시됩니다.
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
Text(activity.valueOrNull?.activity ?? ''),
],
),
),
);
}
}

초기 로드 및 오류 처리 중에만 스피너를 표시합니다.

현재 저희 UI는 오류/로딩 상태를 처리하지 않습니다.
대신 로딩/새로 고침이 완료되면 데이터가 마술처럼 나타납니다.

이러한 상태를 우아하게 처리하여 이를 변경해 보겠습니다. 두 가지 경우가 있습니다:

  • 초기 로드 중에는 전체 화면 스피너를 표시하고 싶습니다.
  • 새로 고침 중에는 새로 고침 표시기와 이전 데이터/오류를 표시하고 싶습니다.

다행히도 Riverpod에서 비동기 provider를 수신할 때, Riverpod는 필요한 모든 것을 제공하는 AsyncValue를 제공합니다.

AsyncValue는 다음과 같이 Dart 3.0의 패턴 일치(pattern matching)와 사용할 수 있습니다:

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
// 일부 데이터를 사용할 수 있는 경우 해당 데이터를 표시합니다.
// 새로 고침 중에도 데이터를 계속 사용할 수 있다는 점에 유의하세요.
AsyncValue<Activity>(:final valueOrNull?) => Text(valueOrNull.activity),
// 오류를 사용할 수 있으므로 렌더링합니다.
AsyncValue(:final error?) => Text('Error: $error'),
// 데이터/오류가 없으므로 로딩 상태입니다.
_ => const CircularProgressIndicator(),
},
],
),
),
);
}
}
주의

여기서는 현재와 같이 valueOrNull을 사용하여 에러/로딩 상태인 경우 value를 사용합니다.

Riverpod 3.0에서는 valuevalueOrNull처럼 동작하도록 변경될 예정입니다. 하지만 지금은 valueOrNull을 고수하겠습니다.

패턴 매칭에서 :final valueOrNull? 구문이 사용된 것을 주목하세요. 이 구문은 activityProvider가 널이 아닌 Activity를 반환할 때만 사용할 수 있습니다.

데이터가 null일 수 있는 경우, 대신 AsyncValue(hasData: true, :final valueOrNull)를 사용할 수 있습니다. 이렇게 하면 몇 개의 문자가 추가되는 대신 데이터가 null인 경우를 올바르게 처리할 수 있습니다.

마무리: 전체 애플리케이션

지금까지 다룬 모든 내용을 정리한 소스는 다음과 같습니다:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'codegen.g.dart';
part 'codegen.freezed.dart';

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(home: ActivityView());
}
}

class ActivityView extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pull to refresh')),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue<Activity>(:final valueOrNull?) =>
Text(valueOrNull.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const CircularProgressIndicator(),
},
],
),
),
);
}
}


Future<Activity> activity(Ref ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);

final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}


class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;

factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}