당겨서 새로고침(Pull to refresh)
Riverpod은 선언적 성격 덕분에 당겨서 새로고침(pull-to-refresh)를 기본적으로 지원합니다.
일반적으로 당겨서 새로고침(pull-to-refresh)는 해결해야 할 문제가 많기 때문에 복잡할 수 있습니다:
- 페이지에 처음 들어갔을 때 스피너(spinner)를 표시하고 싶습니다. 하지만 새로 고침 중에는 대신 갱신표시기(refresh indicator)를 표시하고 싶습니다. 갱신표시기(refresh indicator)와 스피너(spinner)를 모두 표시해서는 안 됩니다.
- 새로 고침이 보류 중인 동안 이전 데이터/오류를 표시하고 싶습니다.
- 새로 고침이 진행되는 동안 갱신표시기(refresh indicator)를 표시해야 합니다.
Riverpod를 사용하여 이 문제를 해결하는 방법을 살펴봅시다.
이를 위해 사용자에게 임의의 액티비티를 추천하는 간단한 예제를 만들어 보겠습니다.
그리고 당겨서 새로고침(pull-to-refresh)를 수행하면 새로운 제안이 트리거됩니다:
기본(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 ?? ''),
],
),
),
);
}
}
이제 사용자가 화면을 아래로 내릴 수 있습니다. 하지만 데이터는 아직 새로 고쳐지지 않았습니다.
새로 고침 로직 추가
사용자가 화면을 아래로 내리면 RefreshIndicator
가 onRefresh
콜백을 호출합니다.
이 콜백을 사용해 데이터를 새로 고칠 수 있습니다.
여기서 ref.refresh
를 사용하여 선택한 provider를 새로 고칠 수 있습니다.
참고: onRefresh
는 Future
를 반환할 것으로 예상됩니다.
그리고 새로 고침이 완료될 때 그 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에서는 value
가 valueOrNull
처럼 동작하도록 변경될 예정입니다.
하지만 지금은 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);
}