-
[Flutter] 무한 스크롤 구현해보기 feat. cursor based pagination #2Flutter/project 2024. 2. 9. 17:26
*이 글의 페이지네이션 로직은 코드팩토리님의 강의 내용을 응용하여 제작되었습니다.
모바일 환경에서 무한스크롤을 사용하는 이유가 뭘까? feat. cursor based pagination #1
유저에게 보여줘야할 데이터 목록이 많은 경우, 한번에 모든 데이터를 보여주는 것은 DB 조회비용도 많이 들 뿐더러서버 및 클라이언트에 부하를 일으켜 성능저하로 이어집니다. 그렇기때문에
nomal-dev.tistory.com
무한 스크롤은 앱 개발시, 대용량 데이터를 불러올때 무조건 사용되는 로직입니다. 이전 글에서 페이지 기반 페이지네이션과 비교하며 무한 스크롤을 사용하는 이유와 무한 스크롤을 구현할때 백엔드에서 사용되는 커서기반 페이지네이션에 대해서도 알아봤습니다. 이번에는 실제로 flutter를 이용해서 클라이언트에서 어떻게 구현되는지 알아보도록 하겠습니다.
✓ Infinity scrolling (무한 스크롤) 제작 과정 with Cursor based pagination
서버에서 제공되는 커서 기반 페이지네이션 api는 보통 클라이언트에게 take와 cursor값을 파라미터로 요구하고 그에 맞게 데이터를 조회해서 다음과 같은 형식으로 반환해주는 것이 일반적입니다.
{ "data": [ { "storeId": 1, "storeName": "가게 1", "status": 1, "rating": 4.5, "curPickupTime": 15, "storeThumbImgUrl": "imageUrl", "createdAt": "2023-08-25 14:44:48.461454", "likeCount": 100, "reviewCount": 50, "dist": "xxxx.xxxxxxx", "tags": "태그1, 태그2" } ... ], "meta": { "take": 10, "hasNextData": true, "customCursor": "0000000000000000" } }
- data
- 조회한 데이터들이 take의 수만큼 List형태로 날라옴
- meta
- take: 클라이언트에서 요구한 데이터 갯수
- hasNextData(hasMore): 마지막 데이터 여부를 알 수 있다.
- customCursor: 다음 데이터를 조회할때 사용되는 커서 값
✍🏼 "data" model code (예시 모델)
@JsonSerializable() class StoreModel implements IModelWithId { @JsonKey( fromJson: Formats.convertMetersToKilometers, ) final double dist; @override @JsonKey(name: 'storeId') // 페이지 네이션 일반화를 위해 모든 Model의 ~id를 id로 통일 final int id; final String storeName; final double rating; @JsonKey( name: 'createdAt', fromJson: Formats.isWithinOneWeek, ) final bool isNew; // client에서 계산된 것 final String storeThumbImgUrl; final int curPickupTime; final int status; final int likeCount; final int reviewCount; @JsonKey( name: 'tags', fromJson: Formats.parsingTags, ) final List<String> tagList; StoreModel({ required this.id, required this.storeName, required this.status, required this.rating, required this.curPickupTime, required this.storeThumbImgUrl, required this.likeCount, required this.reviewCount, required this.dist, required this.tagList, required this.isNew, }); factory StoreModel.fromJson(Map<String, dynamic> json) => _$StoreModelFromJson(json); }
성공적으로 데이터가 넘어왔을때 받을 데이터를 그대로 model로 만든 것입니다.
- @JsonKey(name: 'storeId') // 페이지 네이션 일반화를 위해 모든 Model의 ~id를 id로 통일
final int id;
이부분은 뒤에서 다시 설명하겠습니다.
✍🏼 "meta" code
@JsonSerializable() class PaginationMeta { final String take; final bool hasNextData; final String? customCursor; PaginationMeta({ required this.take, required this.hasNextData, this.customCursor, }); PaginationMeta copyWith({ String? take, bool? hasNextData, String? customCursor, }) { return PaginationMeta( take: take ?? this.take, hasNextData: hasNextData ?? this.hasNextData, customCursor: customCursor ?? this.customCursor, ); } factory PaginationMeta.fromJson(Map<String, dynamic> json) => _$PaginationMetaFromJson(json); }
📌 Pagination State
Pagination은 다양한 종류의 페이징 상태가 나타나게 됩니다. 상태는 class로 구성하기때문에 가장먼저 해야할 일은 모든 클래스를 그룹화 해 줄 추상클래스를 정의하고 기본적인 네트워크 상태(Error, Loading, Data)에 대해 정의하는 일입니다.
배달의민족 앱에서도 볼 수 있듯이 무한스크롤에서는 데이터가 있는 상태(Pagination 상태)일때도 데이터를 불러오는 경우가 있습니다.
- fetching more
- 스크롤이 맨 하단까지 도달하여 다음 데이터를 불러오는 상태로, 데이터를 이어서 보여주는 경우에 사용된다.
- 무한 스크롤에 반드시 필요한 요소
- refetching
- 커서값을 초기화하여 다시 처음부터 데이터를 가져오는 상태
- 이미 이전에 노출한 페이지의 최신 데이터를 보여주기위해 필요함
여기까지가 기본적으로 무한스크롤을 구현할때 필요한 요소들입니다.
상태가 더 필요하다면 PaginationBase를 부모로 두고 추가해서 사용하면 됩니다.
❗️주의: '미리' 프로젝트 중 페이지네이션 api를 만들 때, 초기에는 data가 없는 경우엔 에러로 반환했던 적이 있습니다.
그땐 아래와같이 에러 상태를 하나 더 만들어서 관리했었습니다. 앞으로 이어질 글은 이 부분이 포함되었다고 가정하고 진행됩니다.
[Project] 맛있는 외식의 시작, '미리'
✓ What Project? "지역 소상공인 식음료매장들의 당일 재고 상품을 파격적인 할인가로 제공하는 O2O 커머스 서비스" 코로나로 인해 전국의 학교들이 비대면수업을 하면서 지역 경제가 침체되던 때
nomal-dev.tistory.com
✍🏼 "pagination State" code
abstract class PaginationBase {} class PaginationError extends PaginationBase { final ErrorMapper mapper; PaginationError({ required this.mapper, }); } class PaginationLoading extends PaginationBase {} class PaginationNothing extends PaginationBase { final ErrorMapper mapper; PaginationNothing({ required this.mapper, }); } // Pagination(Data 상태) @JsonSerializable(genericArgumentFactories: true) class Pagination<T> extends PaginationBase { final List<T> data; final PaginationMeta meta; Pagination({ required this.data, required this.meta, }); Pagination copyWith({ List<T>? data, PaginationMeta? meta, }) { return Pagination<T>( data: data ?? this.data, meta: meta ?? this.meta, ); } factory Pagination.fromJson( Map<String, dynamic> json, T Function(Object? json) fromJsonT) => _$PaginationFromJson(json, fromJsonT); } // 새로고침 할때 class PaginationRefetching<T> extends Pagination<T> { PaginationRefetching({ required PaginationMeta meta, required List<T> data, }) : super(data: data, meta: meta); } // 리스트의 맨 아래로 내려서 추가 데이터를 요청중 class PaginationFetchingMore<T> extends Pagination<T> { PaginationFetchingMore({ required PaginationMeta meta, required List<T> data, }) : super(data: data, meta: meta); }
- @JsonSerializable(genericArgumentFactories: true)
class Pagination<T> extends PaginationBase
무한스크롤을 사용하는 화면은 굉장히 많고 화면마다 조회하고 반환받은 데이터는 제각각인것을 생각해보면 data(Pagination)는 어떠한 모델도 받을 수 있도록 제네릭으로 구성해야합니다.
🤷🏻♂️ "제네릭화하면 어떤 Model을 data로 받을 지 모르잖아요? 그럼 예를들어 가게 목록중 하나를 클릭하면 그 가게의 상세화면으로 넘어가듯이, 데이터 목록 중 하나를 클릭해서 다른 화면으로 라우팅되는 로직은 어떻게 만드나요?"
상세화면으로 이동하려면 보통 받은 Json 형식의 데이터의 id값을 통해 GET요청을 하는 것이 일반적입니다. 그래서 id는 모든 페이지네이션 형태로 전달되는 model에 존재합니다.
그렇기때문에 클라이언트에서는 'id'가 모든 페이지네이션 조회 api으로 받게될 model에 있다는 것을 컴파일단계에서 알려주도록 하면 되고, 이는 추상화 클래스로 해결할 수 있습니다.
abstract class IModelWithId { final int? id; IModelWithId({required this.id}); }
이렇게하면 아래처럼 페이지네이션 view를 구성할때, model에서 id를 추출 해 사용할 수 있습니다.
view에 대한 이야기는 뒤쪽에 다시 등장합니다.
StorePaginationListView( tabController: _tabController, provider: storePaginationController, itemBuilder: <StoreModel>(_, index, model) { return GestureDetector( onTap: () { context.goNamed(StoreDetailScreen.routeName, pathParameters: {'rid': model.id.toString()}); // model의 id를 알고있음 }, child: StoreListCard.fromModel(model: model), ); }, ),
🤷🏻♂️ "Pagination class에 copyWith()이 왜 필요한가요?"
무한스크롤은 데이터를 받을때마다 계속 누적이 되어야합니다. 그렇기때문에 가장 처음에 받은 데이터에다가 계속 새로운 데이터를 뒤에 추가해주는 로직이 필요합니다. 이때 List에 요소를 추가하기위해 add()를 생각할수있으나 add()를 사용하면 이전 상태를 직접 수정하게되어 불변성을 보장 할 수 없습니다.
따라서 이전 상태 안에서 데이터를 변경하는 것이 않을려면, copyWith()을 사용해서 이전 상태의 데이터와 최신 데이터를 합친 새로운 List를 만들어 새로운 paginaition상태를 만들고 이를 새로운 상태로 설정하는 방법이 좋습니다.
✓ 전체 Cycle with Code
본 코드들은 리버팟 아키텍처대로 설계되어있습니다. 필요하시다면 아래 링크를 참고해주세요.
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#1 (Data, Domain)
창업 아이템이 정해지고 나서 Flutter를 학습했기 때문에 프로젝트의 모든 과정이 새로웠다. 특히 아키텍처의 도입은 볼륨이 커질수록 생각만큼 매끄럽게 적용하기 어려웠습니다. 이때 제가 겪었
nomal-dev.tistory.com
무한 스크롤 flow 📌 repository
@Riverpod(keepAlive: true) StoresRepository storesRepository(StoresRepositoryRef ref) { final dio = ref.watch(dioProvider); final String url = 'http://$ip/가게 목록 조회'; return StoresRepository(baseUrl: url, dio: dio); } class StoresRepository { StoresRepository({ required this.baseUrl, required this.dio, }); final String baseUrl; final Dio dio; Future<Result<Pagination<StoreModel>, CustomExceptions>> distPaginate( PaginationParams params, ) { return runCatchingExceptions(() async { final resp = await dio.get( "$baseUrl/거리순 조회", options: Options(headers: {'accessToken': 'true'}), queryParameters: params.toJson(), ); final Pagination<StoreModel> result = Pagination.fromJson(resp.data, (json) => StoreModel.fromJson(json as Map<String, dynamic>)); return result; }); } }
레포지토리에서 api를 연동할때, 무한스크롤이라고 다른점은 없습니다. 다른 api연동하듯이 작성해줍니다.
Future<Result<Pagination<StoreModel>, CustomExceptions>>에 적혀있는 'Result' 패턴에 대해 궁금하다면
아래 링크를 참고해주세요[Flutter] 에러 핸들링, Custom Exception 관리하기
에러가 일어나지 않는 애플리케이션은 세상에 존재할 수없다. 로그인할 때조차도 값을 잘못 입력하면 '비밀번호가 틀렸습니다' 라며 에러가 발생한다. 일반적으로 http통신을 사용해본 적이 있
nomal-dev.tistory.com
✍🏼 Pagination Params
위의 레포지토리의 메서드를 자세히보면 파라미터를 받을때 PaginationParms 라는 객체로 받는 것을 알 수 있습니다.
PaginationParms class는 다음과 같이 작성되어 있습니다.
@JsonSerializable() class PaginationParams { final int? take; final String? customCursor; const PaginationParams({ this.take, this.customCursor, }); PaginationParams copyWith({ int? take, String? customCursor, }) { return PaginationParams( customCursor: customCursor ?? this.customCursor, take: take ?? this.take, ); } factory PaginationParams.fromJson(Map<String, dynamic> json) => _$PaginationParamsFromJson(json); Map<String, dynamic> toJson() => _$PaginationParamsToJson(this); }
파라미터의 값인 take나 cursor는 스크롤 중에 계속 다른 값을 넣어줘야하는 부분입니다.
따라서 copyWith()을 사용할 수 있도록 파라미터를 class로 만들어 객체로 받을 수 있게 만들어줘야합니다.
+ 컴파일시, 타입불일치로 인한 오류도 방지할 수 있어 런타임 에러를 줄여주는 효과도 있습니다.
📌 Service
@Riverpod(keepAlive: true) MallService mallService(MallServiceRef ref) { final storeRepo = ref.watch(storesRepositoryProvider); final secureStorage = ref.watch(secureStorageProvider); return MallService( storesRepository: storeRepo, secureStorage: secureStorage); } class MallService implements IBasePaginationService<StoreModel>{ final StoresRepository storesRepository; final SecureStorage secureStorage; MallService({ required this.storesRepository, required this.secureStorage, }); // 📌 가게 리스트 페이지네이션: Result를 반환 @override Future<Result<Pagination<StoreModel>, CustomExceptions>> paginate( {PaginationParams paginationParams = const PaginationParams(), required PaginationType type}) async { if (type == PaginationType.storeDist) { return await storesRepository.distPaginate(paginationParams); } else if (type == PaginationType.storeLike) { return await storesRepository.likePaginate(paginationParams); } else if (type == PaginationType.storeRating) { return await storesRepository.ratingPaginate(paginationParams); } else { return await storesRepository.reviewPaginate(paginationParams); } } // 📌 가게 상세페이지 첫화면 (id == storeId) Future<StoreDetail> getStoreDetail({required int id}) async { final result = await storesRepository.getStoreDetail(id: id); final value = switch (result) { Success(value: final value) => StoreDetailData(value), Failure(exception: final e) => StoreDetailError(ErrorMapper(message: e.toString())), }; return value; } }
- class MallService implements IBasePaginationService<StoreModel>
✍🏼 IBasePaginationservice class
abstract class IBasePaginationService<T extends IModelWithId> { Future<Result<Pagination<T>, CustomExceptions>> paginate({ PaginationParams paginationParams = const PaginationParams(), required PaginationType type, }); }
paginate()는 모든 페이지네이션에 공통적으로 들어가는 함수입니다. 그렇기때문에 추상클래스를 따로 만들고 paginate()라는 추상메서드를 제작해 페이지네이션을 사용할 service에서는 일관된 인터페이스를 통해 paginate() 함수를 구현하도록 만들었습니다.
이는 코드의 재사용성과 유지보수 측면에서 장점이 있습니다.
📌 Contorller
class PaginationController<T extends IModelWithId, U extends IBasePaginationService<T>> extends StateNotifier<PaginationBase> { final U service; final PaginationType type; PaginationController({ required this.service, required this.type, }) : super(PaginationLoading()) { paginate(); } Future<void> paginate({ int fetchCount = 20, // 추가 데이터 가져오기 true: ++데이터, false: 새로고침(현재상태를 덮어씌움) bool fetchMore = false, // 강제 리로딩 bool forceRefetch = false, String firstCursor = '0000000000000000', }) async { // 페이지네이션 Data가 있고 새로고침을 한 것도 아닌 상태에서 받아올 데이터가 없을때 if (state is Pagination && !forceRefetch) { final pState = state as Pagination; if (!pState.meta.hasNextData) { return; } } final isLoading = state is PaginationLoading; final isRefetching = state is PaginationRefetching; final isFetchingMore = state is PaginationFetchingMore; // 데이터를 가져오는 상황에서 다른 요청이 들어온다면 추가처리를 하지않고 함수를 종료한다 if (fetchMore && (isLoading || isRefetching || isFetchingMore)) { return; } // 커서 재정비 if (type == PaginationType.storeDist || type == PaginationType.point || type == PaginationType.notice || type == PaginationType.myReview) { firstCursor = '0000000000000000'; } else { firstCursor = '9999999999999999'; } PaginationParams? paginationParams = PaginationParams( take: fetchCount, customCursor: firstCursor, ); // 데이터를 더 가져오는 상황 if (fetchMore) { final pState = state as Pagination<T>; state = PaginationFetchingMore( meta: pState.meta, data: pState.data, ); paginationParams = paginationParams.copyWith( customCursor: pState.meta.customCursor, ); } else { if (state is Pagination && !forceRefetch) { final pState = state as Pagination<T>; state = PaginationRefetching<T>( data: pState.data, meta: pState.meta, ); } else { state = PaginationLoading(); } } // 실제 네트워크 통신이 일어나는 부분 final result = await service.paginate(type: type, paginationParams: paginationParams); final value = switch (result) { Success(value: final value) => value, Failure(exception: final e) => e, }; //[ERROR] 에러가 발생했을 시, 페이지네이션 타입에따라 에러를 처리하는 과정 if (value is CustomExceptions) { value.maybeWhen( orElse: () { state = PaginationError(mapper: ErrorMapper()); }, // 페이지네이션 종류에따라 같은 에러도 다른 에러화면으로 나타내도록 함 notingStore: (message) { state = PaginationNothing( mapper: ErrorMapper( errorIllust: ImagePathIllust.illust09, message: '현재위치에서 영업중인 매장이 없어요', )); if (type == PaginationType.favorite) { state = PaginationNothing( mapper: ErrorMapper( errorIllust: ImagePathIllust.illust12, message: '아직 관심매장이 없어요', )); } }, ); } //[Data] 연속된 데이터를 보여주기위해 기존 데이터에 받은 데이터를 이어붙여줌 if (state is PaginationFetchingMore) { final pState = state as PaginationFetchingMore<T>; if (value is Pagination<T>) { state = value.copyWith(data: [ ...pState.data, ...value.data, ]); } } else { // 첫 페이지네이션 요청 if (value is Pagination<T>) { // 빈 배열에 대한 에러 화면을 구성하는 부분 if (value.data.isEmpty) { if (type == PaginationType.history) { state = PaginationNothing( mapper: ErrorMapper( errorIllust: ImagePathIllust.history_empty_illust, message: '아직 주문내역이 없어요')); } if (type == PaginationType.myReview) { state = PaginationNothing( mapper: ErrorMapper( errorIllust: ImagePathIllust.review_empty_illust, message: '작성한 리뷰가 없어요')); } return; } state = value; } } } }
무한 스크롤UI에 들어가는 데이터를 구성하는 부분입니다. 주석으로 설명이 부족한 부분들만 추가로 정리 해 보도록 하겠습니다.
- class PaginationController<T extends IModelWithId, U extends IBasePaginationService<T>> extends StateNotifier<PaginationBase>{ ... }
- "T"는 인터페이스를 구현한 어떤 모델이든 받기위한 제네릭 타입이다.
- "U"는 어떤 페이지네이션 service든 받기위한 제네릭타입이며 'T'타입의 모델을 처리해야한다.
- Future<void> paginate({
int fetchCount = 20,
bool fetchMore = false,
bool forceRefetch = false,
String firstCursor = '0000000000000000',
})- 페이지네이션은 보통 UI 진입과 동시에 실행되므로 메서드 파라미터의 기본값을 정의 해두어야한다.
- fetchMore: 한번 더 스크롤시 받아올 데이터가 있는지 여부
- forceRefetch: 유저가 새로고침을 실행 했는지 여부
- firstCursor: 초기 커서값
- if (state is Pagination && !forceRefetch) {
final pState = state as Pagination;
if (!pState.meta.hasNextData) {
return;
}
}- 페이지네이션 중에 새로고침을 한 것도 아닌 상태에서 받아올 데이터가 없다면 함수를 종료한다.
- if (type == PaginationType.storeDist ||
type == PaginationType.point ||
type == PaginationType.notice ||
type == PaginationType.myReview) {
firstCursor = '0000000000000000';
} else {
firstCursor = '9999999999999999';
}- 오름차순과 내림차순은 초기 커서값이 다르기때문에 로직이 진행되기전, 그에 맞게 설정을 해준다
PaginationType enum class
enum PaginationType { storeDist, storeLike, storeRating, storeReview, point, favorite, notice, history, myReview, }
- if (fetchMore) {
...
paginationParams = paginationParams.copyWith(
customCursor: pState.meta.customCursor,
);
} else {
if (state is Pagination && !forceRefetch) {
...
} else {
state = PaginationLoading();
}
}- fetchMore일때, 커서값을 이전 페이지의 meta의 커서값으로 바꿔준다.
- fetchMore가 아니면 현재 데이터들을 그대로 보여주면 되고,
현재 데이터가 없다면 PaginationLoading() 상태로 만들어 준다.
📌 Contorller 의존성 주입
final storePaginationController = StateNotifierProvider<StoreStateNotifier, PaginationBase>((ref) { final service = ref.watch(mallServiceProvider); final controller = ref.watch(storeDetailControllerProvider.notifier); final type = ref.watch(storePageTypeControllerProvider); final notifier = StoreStateNotifier( service: service, type: type, controller: controller, ); // 페이지마다 생성해야함 return notifier; }); class StoreStateNotifier extends PaginationController<StoreModel, MallService> { final StoreDetailController controller; StoreStateNotifier({ required this.controller, required super.service, required super.type, }); }
📌 View (페이지네이션 UI)
// BuildContext를 사용하여 페이지네이션 위젯의 빌더 함수를 정의하고 해당 위젯을 렌더링하는 방법을 지정함 typedef StorePaginationWidgetBuilder<T extends IModelWithId> = Widget Function( BuildContext context, int index, T model); class StorePaginationListView<T extends IModelWithId> extends ConsumerStatefulWidget { final StateNotifierProvider<PaginationController, PaginationBase> provider; final StorePaginationWidgetBuilder<T> itemBuilder; final TabController tabController; const StorePaginationListView({ required this.provider, required this.itemBuilder, required this.tabController, super.key, }); @override ConsumerState<StorePaginationListView> createState() => _StorePaginationListViewState<T>(); } class _StorePaginationListViewState<T extends IModelWithId> extends ConsumerState<StorePaginationListView> { final ScrollController controller = ScrollController(); @override void initState() { // 위치 변경시, 페이지네이션 리패치를 위한 부분(아래 설명 참고) ref .read(activeLocationForStoreListControllerProvider.notifier) .locationBasePaginate(); super.initState(); } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { final double width = MediaQuery.of(context).size.width; final double height = MediaQuery.of(context).size.height; final state = ref.watch(widget.provider); return CustomRefreshIndicator( // 당겨서 새로고침, forceRefetch: true로 만들고 pagianate()를 실행한다 onRefresh: () async => await ref.read(widget.provider.notifier).paginate( forceRefetch: true, ), child: CustomScrollView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics()), controller: controller, slivers: [ StoreListTabBarWidget( tabController: widget.tabController, ), //[Loading] - 첫 api 통신 if (state is PaginationLoading) const CustomLoadingCircleWidget(), //[Error] 상태 처리 if (state is PaginationNothing) StoreNothingWidget( errorMapper: state.mapper, ), //[Error] 상태 처리 if (state is PaginationError) SliverToBoxAdapter( child: CustomErrorWidget( errorMapper: state.mapper, errorFun: () async { return await ref .read(widget.provider.notifier) .paginate(forceRefetch: true); }, ), ), //[Data] if (state is Pagination<T>) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16.0), sliver: SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { // [Loading of Data] 유저가 데이터List를 끝까지 본 상황 if (index == state.data.length) { // Data가 있는 상황에서 최하단에 로딩 위젯을 보여주기 위한 로직 if (state is PaginationFetchingMore) { return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), child: Center( child: state is PaginationFetchingMore ? const CircularProgressIndicator( color: AchromaticColors .IMPACT_COLOR_LIGHT_GRAY, ) : null, ), ); // 불러올 데이터가 없음(데이터를 모두 불러옴) } else { return null; } } // [First or added Data] 불러온 데이터를 위젯에 업데이트하는 부분 final pItem = state.data[index]; return widget.itemBuilder( context, index, pItem, ); }, childCount: state.data.length + 1, ), ), ), ], ), ); } }
- @override
void initState() {
ref
.read(activeLocationForStoreListControllerProvider.notifier)
.locationBasePaginate();
super.initState();
}- 무한스크롤 화면에 진입할때, paginate()를 실행시켜줘야합니다.
다만 유저가 설정한 위치에따라 주위에 있는 가게 목록이 달라지는 '미리'앱의 경우, 홈에서 '위치'를 변경할때마다 '주문하기'화면에서 유저가 새로고침을 통해 새로 적용된 위치의 가게 목록을 불러오는 것이 아니라, 변경된 위치가 바로 반영된 가게 목록을 보여주기위해, 현재 위치 변경을 감지하여 ReFetch하는 컨트롤러를 제작했었습니다.
📌 View 사용시 작성 방법
StorePaginationListView( tabController: _tabController, provider: storePaginationController, itemBuilder: <StoreModel>(_, index, model) { return GestureDetector( onTap: () { context.goNamed(StoreDetailScreen.routeName, pathParameters: {'rid': model.id.toString()}); }, child: StoreListCard.fromModel(model: model), ); }, ),
다음은 무한 스크롤을 최적화하는 방법들에 대해 소개해보도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] API 요청 횟수를 줄여 네트워크 대역폭을 절약해보자! feat. 배민 북마크 버그 (0) 2024.02.14 [Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3 (0) 2024.02.13 [Flutter] JWT 토큰관리 및 자동로그인 구현하기 feat. Dio Interceptor, Social Login (2) 2024.02.06 [Flutter] 위젯, 뷰, 컴포넌트, 스크린 어떤 차이일까? UI 구조를 잡아보자! (0) 2024.01.30 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#3 (Presentation-Layer) (0) 2024.01.30 - data