Progress 사용 이유
프로젝트(풀밭)에서 네트워크 통신을 하거나 혹은 시간이 걸리는 기능을 실행할 때 언제 기능이 종료되는지 모르기 때문에 만약 아무런 반응도 보여주지 않으면 사용자는 애플리케이션이 렉이 걸렸거나 혹은 기능 실행이 안되고 있다고 판단할 것이다. 그러면 유저의 입장에서는 좋지 않은 경험을 받기에 어플에 대한 안좋은 감정을 갖게 된다.
그렇다면 시간이 소요되는 작업을 할 때 어떠한 반응을 보여줘야 하냐면 우리가 흔히 볼 수 있는 로딩창 즉, progress를 보여주면 된다.
Progress 화면
Fluttter에는 CircularProgressIndicator가 존재한다.
이것을 사용하여 Data가 Loading 상황일때 CircularProgressIndicator를 보여주면 유저에게 현재 기능이 실행중인 것을 알릴 수 있다.
Loading 상태 확인
나는 이번 프로젝특에서 데이터 상태 관리를 Riverpod을 이용하여 상태 관리를 한다.
Riverpod을 사용하면 손쉽게 데이터 상태가 Loading, Data(성공), Error(실패)인지 알 수 있고 그것을 UI에 쉽게 업데이트 할 수 있다. 코드로 한 번 봐보자.
풀밭(프로젝트)는 로그인 뷰에서 카카오톡 혹은 애플 로그인을 하게 되면 바로 Firebase Auth에 등록이 되는 것이 아닌 설정까지 해줘야 Auth에 등록이 된다. 그러면 로그인의 실행 흐름을 봐보자.
위의 플로우를 보면 이해가 쉽다.
- 간편 로그인 (애플, 카카오)
- 초기 유저 정보 설정 (강의, 캠퍼스)
- Auth 생성 후 User Collection 생성 (회원가입 완료)
- 만약 3번을 하기전에 종료한다면 1번으로 돌아감.
이 과정을 하려면 간편 로그인 후 발급된 Token Data를 갖고 있어야 하는 것이다.
코드를 보면서 어떻게 데이터 상태를 관리하는지 보자.
Riverpod을 사용하여 provider 파일 만들기
part 'login_view_model.g.dart';
@Riverpod(keepAlive: true)
class LoginViewModel extends _$LoginViewModel {
@override
Future<Token> build() async {
return Token();
}
Future<Result<Token, Exception>> signInWithKakao() async {
state = AsyncLoading();
try {
final result = await ref.read(loginRepositoryProvider).signInWithKakao();
switch (result) {
case Success(value: final token):
state = AsyncData(token); // Token 객체를 상태로 저장
return Success(token); // Token 객체 반환
case Failure(exception: final e):
// 실패 시 에러를 AsyncError에 전달
state = AsyncError(e, StackTrace.current);
return Failure(Exception('카카오 로그인 실패: $e'));
}
} catch (e, stackTrace) {
// 예외가 발생한 경우 AsyncError에 에러와 스택 트레이스를 전달
state = AsyncError(e, stackTrace);
return Failure(Exception('카카오 로그인 중 예외 발생: $e'));
}
}
Future<Result<Token, Exception>> signInWithApple() async {
state = AsyncLoading();
final result = await ref.read(loginRepositoryProvider).signInWithApple();
switch (result) {
case Success(value: final token):
state = AsyncData(token);
return Success(token);
case Failure(exception: final e):
state = AsyncError(e, StackTrace.current);
return Failure(Exception('애플 로그인 실패: $e'));
}
}
}
그러면 LoginViewModel을 봐보자. Riverpod을 어노테이션하여 loginViewModelProvider를 만들었다.
정확히는 클래스로 만들었으니 NotifierProvider이다. 이 차이를 알려면 공식문서에서 확인할 수 있다.
LoginViewModel을 이제 Provider로 변환하는 과정은 아래 구문을 적어준다.
part 'login_view_model.g.dart';
그리고 어노테이션후extends _$LoginViewModel을 해준다.
@Riverpod(keepAlive: true)
class LoginViewModel extends _$LoginViewModel {
마지막으로 아래 명령어를 터미널에 실행하면 된다.
flutter pub run build_runner build -d
그러면 자동으로 loginViewModelProvider 파일이 생성된다.
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_view_model.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$loginViewModelHash() => r'a704b93cfa47a2928e23edbbce1826ba59c17053';
/// See also [LoginViewModel].
@ProviderFor(LoginViewModel)
final loginViewModelProvider =
AsyncNotifierProvider<LoginViewModel, Token>.internal(
LoginViewModel.new,
name: r'loginViewModelProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$loginViewModelHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LoginViewModel = AsyncNotifier<Token>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
LoginView에서 LoginViewModelProvider 인스턴스화 하여 사용하기
provider를 사용하여 UI를 관리할 경우 ConsumerStatefulWidgetr과 ConsumerState를 사용해야 한다.
이 둘에 대한 내용은 공식문서에서 알아보자.
class LoginView extends ConsumerStatefulWidget {
LoginView({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _LoginViewState();
}
class _LoginViewState extends ConsumerState<LoginView> {
@override
Widget build(BuildContext context) {
final loginState = ref.watch(loginViewModelProvider);
## 생략 ##
AsyncLoading() 사용
Riverpod에서는 데이터를 로딩할때 사용하는 메서드가 있다.
바로 isLoading()과 AsyncLoading()이다.
아래 카카오 로그인 메서드를 보면 제일 처음 코드가 state를 AsyncLoading()으로 업데이트 해주는 코드이다.
그 후 로그인에 성공한다면 token을 실패한다면 error를 state에 반영한다.
Future<Result<Token, Exception>> signInWithKakao() async {
state = AsyncLoading();
try {
final result = await ref.read(loginRepositoryProvider).signInWithKakao();
switch (result) {
case Success(value: final token):
state = AsyncData(token); // Token 객체를 상태로 저장
return Success(token); // Token 객체 반환
case Failure(exception: final e):
// 실패 시 에러를 AsyncError에 전달
state = AsyncError(e, StackTrace.current);
return Failure(Exception('카카오 로그인 실패: $e'));
}
} catch (e, stackTrace) {
// 예외가 발생한 경우 AsyncError에 에러와 스택 트레이스를 전달
state = AsyncError(e, stackTrace);
return Failure(Exception('카카오 로그인 중 예외 발생: $e'));
}
}
isLoading() 사용
이제 state를 업데이트 하는 기능은 만들었으니 UI에 보여주는 코드를 작성하면 된다.
어떻게 작성하는지 코드를 통해 보자. 아래 코드는 loginViewModelProvider를 watch해서 loginState가 값이 변경될때마다 새로운 UI를 반영하게 된다. 기존에는 setState를 사용해서 화면 업데이트를 계속 갱신하는 코드를 작성해야하였는데 이제는 그것도 작성할 필요가 없게 되는 것이다.
final loginState = ref.watch(loginViewModelProvider);
그러면 어떻게 쓰는지 확인해보자.
아래 코드는 LoginView를 ConsumerStatefulWidget과 ConsumerState를 extends하였다.
그리고 loginState를 만들었고 loginState.isLoading에 따라 프로그래스 인디케이터를 보여준다.
class LoginView extends ConsumerStatefulWidget {
LoginView({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _LoginViewState();
}
class _LoginViewState extends ConsumerState<LoginView> {
@override
Widget build(BuildContext context) {
final loginState = ref.watch(loginViewModelProvider);
return Scaffold(
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
loginState.isLoading ? const CircularProgressIndicator() : const SizedBox.shrink(
UI를 보면 버튼이 눌려 네트워크 통신을 할때마다 CircularProgressIndicator가 작동되는 것을 볼 수 있다.
Lottie 적용하기
위의 화면으로 유저에게 현재 기능이 실행중인것을 보여줄 수 있지만 뭔가 아쉽다.
그래서 나는 Lottie에서 내가 원하는 파일을 갖고와서 보여줄것이다.
찾아보니 잎에 관련된 로딩 애니메이션 파일이 있었고 Lottie JSON을 다운받아서 저장했다.
ProgressIndicator가 작동되는 것을 볼 수 있다.
json파일을 원하는 디렉토리 위치에 저장하고 pubspec.yaml의 assets에도 경롤를 추가해주자
이제 Lottie Package를 다운받아서 Lottie JSON 파일을 쉽게 다루고 화면에 보여줄 수 있다.
나는 컴포넌트로 만들어서 시간이 소요가 되는 네트워크 통신이 걸릴때마다 로딩 컴포넌트를 보여줄 것 이다.
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
class GreenFieldLoadingWidget extends StatelessWidget {
final double size;
const GreenFieldLoadingWidget({super.key, this.size = 100});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
color: Colors.black.withOpacity(0.2),
),
Center(
child: Container(
width: size,
height: size,
color: Colors.transparent,
child: Lottie.asset(
'assets/lotties/loading.json',
repeat: true,
animate: true,
),
),
),
],
);
}
}
이제 컴포넌트를 만들었으니 화면에 보여줘야 한다.
class _LoginViewState extends ConsumerState<LoginView> {
@override
Widget build(BuildContext context) {
final loginState = ref.watch(loginViewModelProvider);
return Scaffold(
body: Stack(
children: [
SafeArea(...),
loginState.isLoading
? GreenFieldLoadingWidget()
: SizedBox.shrink(),
],
),
);
}
}
위의 코드를 보면 Stack에 맨 밑에 loginState.isLoading일때 GreenFieldLoadingWidget을 보여주는 것으로 작성했다. 이제 한 번 결과물을 확인해보자. 비교를 해보면 확실히 더 좋은 UX라고 느껴진다.
Skeleton 적용하기
만약 소요 시간이 긴 네트워크 요청이 간단한 Get 요청 같은 네트워크 요청은 어떻게 잘 보여줄 수 있을까?
여러 방법이 있지만 대부분의 애플리케이션에서 Skeleton 애니메이션을 적용하여 보여준다.
Skeleton 애니메이션이 무엇인지 먼저 확인해보자. 아래 Gif 파일을 보면 바로 이해할것이다.
나는 이러한 애니메이션을 skeletonizer package를 사용하여 텍스트나 이미지를 네트워크에서 가져올 때 줄것이다. 자세한 사용법은 skeletonizer package에서 확인할 수 있다.
어떻게 사용했는지 코드를 봐보자.
Skeletonizer(
enabled: onboardingState.isLoading,
effect: ShimmerEffect(
baseColor: Theme.of(context).appColors.gfMainBackGroundColor,
highlightColor: Theme.of(context).appColors.gfWhiteColor,
duration: const Duration(seconds: 2),
),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Image.asset(
AppIcons.profile,
width: 40,
height: 40,
),
title: Text(
onboardingState.value?.name != null && onboardingState.value!.name.isNotEmpty
? onboardingState.value!.name
: '(익명)',
style: AppTextsTheme.main().gfTitle1.copyWith(
color: Theme.of(context).appColors.gfBlackColor,
),
),
subtitle: Text(
onboardingState.value != null
? '${onboardingState.value!.campus} 캠퍼스 ${onboardingState.value!.course}'
: '서비스를 이용하려면 로그인해주세요.', // 실패 시 기본 메시지
style: AppTextsTheme.main().gfBody5.copyWith(
color: Theme.of(context).appColors.gfGray400Color,
),
), // 서브타이틀 텍스트
),
),
Skeletonizer에는 다양한 프로퍼티들이 있다. enabled는 bool값에 따라 Skeleton UI를 보여줄 것 인지 판별한다. final onboardingState = ref.watch(onboardingViewModelProvider);
즉 onboardingState에서 loading 상태를 관리하기에 enabled를 onboardingState.isLoading으로 작성해주었다. 어떻게 작동되는지 아래 화면을 보고 확인할 수 있다.
그전에 일반적인 인터넷 속도로 테스트한다면 너무 빨라 볼 수 없기에 네트워크 속도를 조정하여 테스트 하겠다.
Network Link Conditioner를 다운받아서 실행한다면 아래 화면을 볼 수 있는데 Profile를 100% Loss를 설정한다면 정말 느린 네트워크 환경으로 테스트 할 수 있다.
그러면 실제 화면을 봐보자. 매우 느리게 설정했기에 네트워크 요청을 해서 Data를 가져오는데 시간이 오래 걸린다.
'Flutter' 카테고리의 다른 글
[Flutter] RiverPod - Data 상태 관리하기 (0) | 2025.01.09 |
---|---|
[Flutter] Riverpod 학습 - Performing side effects & Passing arguments to your requests (0) | 2025.01.07 |
[Flutter] RiverPod - provider 알아보기(with 네트워크 요청) (0) | 2025.01.06 |
[Flutter] RiverPod 학습 - State management (0) | 2025.01.03 |
[Flutter] [Error Handling] Result Pattern (간편로그인 적용) (0) | 2024.12.26 |