[Flutter]무한 스크롤 구현하기
- -
최근 Flutter로 모바일 게시판을 개발하게 됐는데 페이징 처리를 고민하다가 무한 스크롤 방식으로 결정했습니다. 패키지 중에 infinite_scroll_pagination이 문서도 잘 나와있고 사용하기도 편했습니다.
infinite_scroll_pagination을 적용한 앱(농실농실 보러가기) 화면 중 공지사항 페이지를 예제로 소개합니다. 실제 개발중인 프로젝트의 코드를 가져왔기 때문에 중간중간 import가 불가능한 코드들이 존재합니다. 페이징 컨트롤러, 위젯의 생김새, _fetchPage 함수 부분들만 참고하시고 각자의 프로젝트에 맞게 적용해서 사용하시길 바랍니다.
개발 환경
- Flutter 3.16.5
- Dart 3.2.3
- Spring boot 2.7.5
1. 패키지 설치
flutter pub add infinite_scroll_pagination
2. 위젯 추가
2-1. NoticeList
상태 변경이 가능한 위젯 NoticeList를 추가합니다.
프로젝트에서 Riverpod을 사용하고 있어 ConsumerStatefulWidget을 사용했지만 StatefulWidget을 사용해도 괜찮습니다.
class NoticeList extends ConsumerStatefulWidget {
const NoticeList({
super.key,
});
@override
ConsumerState<NoticeList> createState() => _NoticeList();
}
class _NoticeList extends ConsumerState<NoticeList> {
final _perPage = 15;
final PagingController<int, Notice> _noticeController =
PagingController(firstPageKey: 1);
Future<void> _fetchPage(int page) async {
try {
List<Notice> noticeList =
await NoticeService().getNoticeList(page: page, perPage: _perPage);
final isLastPage = noticeList.length != _perPage;
if (isLastPage) {
_noticeController.appendLastPage(noticeList);
} else {
final nextPage = page + 1;
_noticeController.appendPage(noticeList, nextPage);
}
} catch (e) {
_noticeController.error = e;
}
}
@override
void initState() {
_noticeController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState();
}
@override
void dispose() {
_noticeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.of(context).size;
return Scaffold(
backgroundColor: Colors.white,
appBar: BoardHeader(
height: screen.height,
),
body: Container(
padding: EdgeInsets.symmetric(horizontal: screen.width * p15),
child: PagedListView<int, Notice>(
pagingController: _noticeController,
builderDelegate: PagedChildBuilderDelegate<Notice>(
firstPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
newPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
noItemsFoundIndicatorBuilder: (context) => const Center(
child: EmptyBox(
emptyText: '등록된 공지사항이 없습니다.',
),
),
itemBuilder: (context, item, index) {
Notice notice = item;
return NoticeItem(notice: notice);
}),
),
));
}
}
PagingController를 통해 스크롤이 마지막에 도달할 때 자동으로 페이징을 처리할 수 있게 됩니다.
_noticeController에 _fetchPage 함수를 등록하고 페이지 번호를 받아 공지사항 목록을 가져오게 됩니다.
final PagingController<int, Notice> _noticeController =
PagingController(firstPageKey: 1);
@override
void initState() {
_noticeController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState();
}
공지사항은 데이터를 15개씩 가져오도록 했습니다.(_perPage : 15) NoticeService().getNoticeList를 통해 가져온 공지사항 리스트는 _noticeController에 추가됩니다. 이 때, 데이터 개수가 15보다 작으면 마지막 페이지로 간주하고 그게 아니라면 다음 페이지 번호를 설정합니다.
Future<void> _fetchPage(int page) async {
try {
List<Notice> noticeList =
await NoticeService().getNoticeList(page: page, perPage: _perPage);
final isLastPage = noticeList.length != _perPage;
if (isLastPage) {
_noticeController.appendLastPage(noticeList);
} else {
final nextPage = page + 1;
_noticeController.appendPage(noticeList, nextPage);
}
} catch (e) {
_noticeController.error = e;
}
}
2-2. NoticeItem
리스트의 각 아이템을 정의할 NoticeItem 위젯을 추가합니다. NoticeItem은 ConsumerWidget(Stateless)을 상속받습니다.
class NoticeItem extends ConsumerWidget {
final Notice notice;
const NoticeItem({
super.key,
required this.notice,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screen = MediaQuery.of(context).size;
String createTime = FormatUtil.dateFormat(notice.createDate, 'time');
return AccordionHeaderItem(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: screen.width * p10, horizontal: screen.width * p15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyTextbox(
textField: notice.noticeTitle,
fontSize: screen.width * f16,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 5.0),
MyTextbox(
textField: createTime,
fontSize: screen.width * f12,
fontWeight: FontWeight.w400,
fontColor: grayFontColor,
),
],
)),
children: [
AccordionItem(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: screen.width * p15, horizontal: screen.width * p20),
child: MyTextbox(
textField: notice.noticeContent,
fontSize: screen.width * f14,
),
)),
],
);
}
}
여기에는 simple_accordion이 사용됐는데 아래와 같은 UI 개발시 유용합니다.
3. 서비스 추가
데이터를 조회하는 서비스를 등록합니다.
class NoticeService {
final noticeApi = '${dotenv.env['NONGSIL_API_URL']}/notice';
/// 공지사항 리스트 조회
Future<List<Notice>> getNoticeList({
required int page,
required int perPage,
}) async {
List<Notice> resultList = [];
try {
final response = await ApiService().callApi(
'GET',
'$noticeApi/list?page=$page&perPage=$perPage',
{},
);
await Future.delayed(const Duration(milliseconds: 800));
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
List<dynamic> dataList = resultData['result'];
if (dataList.isNotEmpty) {
dataList.forEach((data) {
resultList.add(Notice.fromMap(data));
});
}
logger.d(':: 공지사항 리스트 조회 :: $dataList');
} else {
throw Exception(":: 공지사항 리스트 조회 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return resultList;
}
}
Notice 모델은 다음과 같습니다.
class Notice {
final int noticeSeq;
final String noticeTitle;
final String noticeContent;
final String createDate;
Notice({
required this.noticeSeq,
required this.noticeTitle,
required this.noticeContent,
required this.createDate,
});
...
데이터 연결 부분은 List 타입을 반환하게 하여 본인 프로젝트에 맞도록 수정해서 사용하시면 됩니다.
중간에 Future.delayed를 주는 것은 페이지 처리 중 보이는 로딩창이 보일 수 있도록 하기 위해서입니다. 데이터를 가져오는 시간이 오래 걸린다면 로딩창이 보이겠지만 보통은 아주 잠깐 보였다가 사라집니다.
firstPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
newPageProgressIndicatorBuilder: (_) =>
MyLoading(color: widgetMainColor),
4. 테스트
공지사항 테이블에 데이터를 추가하고 테스트해보겠습니다. 한 페이지에 불러올 데이터는 15개입니다.
최초 공지사항 조회시 마지막 번호(NOTICE_SEQ)가 21로 시작하는 것이 보이며 15개가 조회됩니다.
아래로 스크롤하게 되면 다시 조회가 실행되고 반환된 번호는 6으로 확인되며, 페이징이 정상적으로 동작하는 것을 알 수 있습니다.
참고문서
'Framework > Flutter' 카테고리의 다른 글
[Flutter]Naver Map 사용하기 (1) | 2024.12.09 |
---|---|
[Error]error: Sandbox: rsync.samba(22953) deny(1) file-write-create ... (0) | 2024.08.26 |
[Flutter]iOS 앱 기본 언어 정보 한글로 변경하기 (0) | 2024.05.20 |
[Error]"Uncaught TypeError: Cannot read property 'cancel' of undefined" (0) | 2023.12.28 |
[Error]DT_TOOLCHAIN_DIR cannot be used to evaluate LIBRARY_SEARCH_PATHS, use TOOLCHAIN_DIR instead (0) | 2023.12.04 |
소중한 공감 감사합니다.