Framework/Flutter

[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으로 확인되며, 페이징이 정상적으로 동작하는 것을 알 수 있습니다.

 

참고문서
 

infinite_scroll_pagination | Flutter package

Lazily load and display pages of items as the user scrolls down your screen.

pub.dev

반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.