Flutter로 Cursor Pagination 구현하기 - 2. Pagination View 구현

이전 글

Pagination Common View (ListView)

이전 글에서 Pagination Provider를 구현하였습니다. 사실 이것으로 페이지네이션의 본질적인 부분은 끝이 난 것이 맞지만, 실제로는 View를 구현해야 하는 일이 남아 있습니다. 페이지네이션의 뷰가 모두 비슷하다고 가정을 한다면 이 역시 추상화할 수 있습니다.

 

여기서는 ListView 형태로 페이지네이션 뷰를 직접 구현하겠습니다.

 

참고 사항

본 글에서 코드를 보여줄 때, 일부러 앞 코드의 일부(함수 시그니처라든지, 중요 포인트 등)를 포함시켜서 보일 예정입니다. 코드의 전문을 보여주지 않아 가독성을 높이면서도, 일부를 앞뒤로 포함시켜서 코드의 위치가 어디에 존재하는지, 어느 맥락에서 작업이 이루어지고 있는 지를 간접적으로 유추할 수 있습니다.

 

또한 이번 글은 한 dart 파일에 있는 하나의 StatefulWidget을 완성하고 있습니다.

 

본 글은 riverpod에 대해서 이미 알고 있음을 가정합니다.

클래스 선언

StatefulWidget을 ConsumerStatefulWidget으로 바꾸기

일단, 페이지네이션 뷰는, ListView에서 ScrollController를 사용해 줄 것이니깐 StatelessWidget이면 안 되구요, StatefulWidget으로 선언해주겠습니다. 먼저 Flutter에서 기본으로 제공하는 자동 완성으로 StatefulWidget을 선언해줍니다.

class PaginationListView extends StatefulWidget {
  const PaginationListView({super.key});

  @override
  State<PaginationListView> createState() => _PaginationListViewState();
}

class _PaginationListViewState extends State<PaginationListView> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

그리고 이를 Riverpod에서 제공하는 ConsumerStatefulWidget으로 바꿉니다. 이를 위해서는 State 클래스 역시 ConsumerState로 변경해주어야 합니다.

class PaginationListView extends ConsumerStatefulWidget {
  const PaginationListView({super.key});

  @override
  ConsumerState<PaginationListView> createState() => _PaginationListViewState();
}

class _PaginationListViewState extends ConsumerState<PaginationListView> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

이렇게 세 부분만 변경해 주면 됩니다.

 

이렇게만 하면 StatefulWidget과 거의 동일한데, Riverpod Provider들을 사용할 수 있게 됩니다.

제네릭 받기

여기에서 이 클래스는 제네릭을 받을 겁니다. 이 제네릭은, 이 페이지네이션 뷰가 어떤 데이터 모델을 페이지네이션할 것인지 정의합니다.

class PaginationListView<T extends IModelWithId>
    extends ConsumerStatefulWidget {
  const PaginationListView({super.key});

  @override
  ConsumerState<PaginationListView> createState() =>
      _PaginationListViewState<T>();
}

class _PaginationListViewState<T extends IModelWithId>
    extends ConsumerState<PaginationListView<T>> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

조금 복잡해 보일 수 있는데, 그냥 T만 추가한 것입니다.

 

한 가지 주의해야 할 것은, Paganation에 T를 제네릭으로 받게 한다면, 밑에 _PaginationListViewState<T>가 상속하는 ConsumerState의 PaginationListView<T>에도 T가 들어가야, 나중에 발생하는 Dynamic 문제를 해결할 수 있습니다.

 

즉 ConsumerState<PaginationListView>에서 ConsumerState<PaginationListView<T>>로 바꿔야 함을 꼭 잊지 마세요.

Provider, itemBuilder injection

먼저, 데이터 모델을 관리하고 pagination을 진행할 provider를 외부에서 주입받겠습니다. 이 provider는 데이터 상태를 들고 있으므로, 당연히 외부에서 주입받아야 합니다.

class PaginationListView<T extends IModelWithId>
    extends ConsumerStatefulWidget {
  final StateNotifierProvider<PaginationProvider, CursorPaginationBase>
      provider; // provider 추가
  const PaginationListView({
    required this.provider, // 외부에서 주입받기
    super.key,
  });

  @override
  ConsumerState<PaginationListView> createState() =>
      _PaginationListViewState<T>();
}

그리고 원래는 여기서 itemBuilder까지 injection 할 수 있도록 정의하는 게 개발 과정에서의 자연스러운 흐름일 수도 있는데요, 다만 설명을 하기에는 자연스러운 흐름은 아닙니다. 따라서 View를 직접 구현하면서 itemBuilder가 필요할 때 다시, 여기로 돌아오겠습니다.

ScrollController - 이벤트 리스너 추가하기

이제는 PaginationListView가 아닌, 그 밑에 있는 클래스인 _PaginationListViewState를 다룰 차례입니다.

 

먼저 이 뷰는 ListView이므로, ScrollController가 필요합니다. 왜 스크롤 컨트롤러가 필요할까요? 그것은 바로, 유저가 일정 부분까지 스크롤을 하면, 자동으로 다음 데이터를 요청하도록 조작할 것이기 때문입니다.

 

이렇게 하면 유저 입장에서는 자신이 직접 데이터를 요청하지 않아도, 알아서 백엔드 단에서 요청이 처리되므로, UX가 더욱더 개선됩니다. 끊기는 경험 없이 앱을 이용할 수 있습니다.

예를 들어 이러한 형태로 스크롤 뷰가 있다고 가정합니다. 이때 사용자 모바일 앱 화면에 보이는 스크린은 주황색 사각형으로 표현되어 있습니다.

 

이제 사용자가 스크롤을 내리면, 아이템들이 위로 올라갈 텐데

화면을 내리다가, 데이터 끝에 어느 즈음에서 자동으로 다음 데이터를 fetch 할 예정입니다.

class _PaginationListViewState<T extends IModelWithId>
    extends ConsumerState<PaginationListView> {
  final controller = ScrollController(); // controller 선언

  @override
  void initState() {
    super.initState();

    controller.addListener(listener);
  }

    // ...

controller를 선언하고, initState를 통해서 listener를 추가할 예정입니다.

ScrollController는 리스너를 받을 수 있는데요, 리스너는 이벤트 리스너로써, 어떤 특정한 이벤트가 발생했을 때 수행할 작업을 정의할 수 있습니다.

 

controller.addListener는 파라미터로 콜백 함수를 받습니다. controller.addListener(() {}) 이렇게 받아도 괜찮은데, 편의상 새롭게 listener라는 메소드를 따로 빼겠습니다.

@override
void initState() {
  super.initState();

  controller.addListener(listener);
}

// ...

void listener() {
  final provider = ref.read(widget.provider.notifier);

  // 현재 위치가 최대 길이보다 약간 덜 되는 위치까지 왔다면
  // 새로운 데이터를 추가 요청한다
  if (controller.offset > controller.position.maxScrollExtent - 450) {
    provider.paginate(
      fetchMore: true,
    );
  }
}

현재 위치가, 만약에 최대 길이보다 450픽셀 정도 위의 위치까지 왔다면, provider에서 paginate 함수를 실행합니다. 이때 추가적인 데이터를 요청하는 것이므로, fetchMore를 true로 적용합니다.

 

참고로 이때 provider.paginate 는 이전 글에서 아래 함수의 형태를 하고 있었습니다.

Future<void> paginate({
    int fetchCount = 20,
    bool fetchMore = false,
    bool forceRefetch = false,
  }) async {
    // ...
}

450픽셀은 제가 그냥 임의로 정한 값입니다. 이 정도 값이 되었을 때 유저가 빠르게 스크롤을 내려도, 너무 늦지 않게 데이터가 도착하더라고요. 컴포넌트의 크기, 데이터의 종류 등을 고려해서 픽셀 값을 정해도 되며, 이 픽셀을 상위에서 int? 값으로 받아서 widget.pixelOffset ?? 450 이런 식으로 사용할 수도 있고, 그건 클래스를 설계하는 사람의 마음입니다.

final provider = ref.read(widget.provider.notifier);

그리고, 여기에서 주의해야 할 것은 이벤트 리스너 안에서 Provider를 watch 하면 안 됩니다. 이벤트 리스너는 매번 이벤트가 발생할 때마다(여기서는 스크롤이 발생할 때마다) 실행되는데, 상태를 지속적으로 지켜본다는 의미의 watch를 하면 안 됩니다. 단 1회만 상태를 읽는다는 의미의 read를 해야 합니다.

 

특히 initState 안에서는 watch를 쓰면 안 됩니다. initState는 초기 상태를 초기화할 때 한 번 사용하는데 여기에서 ref.watch 를 실행하게 되면 문제가 발생합니다.

 

그리고 잊지 말아야 하는 것이 ScrollController 는 위젯이 dispose 되고 나면 반드시 controller 역시 dispose 시켜야 합니다. 안 그러면 다시 똑같은 위젯에 방문했을 때, 사라져야 할 과거 controller가 남아있어 초기화가 안 되는 등 여러 문제가 발생할 수 있습니다.

@override
void initState() {
  super.initState();

  controller.addListener(listener);
}

void listener() {
  // ...
}

/// controller와 listener를 반드시 삭제해야 한다
@override
void dispose() {
  controller.removeListener(listener);
  controller.dispose();

  super.dispose();
}

View 구현하기 - build 함수 정의

이제 대망의 build 함수를 만들어 봅시다.

state watch하기

@override
void dispose() {
    // ...
}

@override
Widget build(BuildContext context) {
  final state = ref.watch(widget.provider);

  return const Placeholder();
}

먼저 ConsumerState 위젯이니깐, 여기에서 사용할 state를 watch 해줍시다. 이때 state는 당연히, CursorPaginationBase 타입입니다.

 

ConsumerStatefulWidget은 build 메소드에 WidgetRef 파라미터를 추가로 받을 필요는 없습니다. 상속을 받는 ConsumerState 부모 클래스에서 이미 ref를 들고 있습니다.

/// A [State] that has access to a [WidgetRef] through [ref], allowing
/// it to read providers.
abstract class ConsumerState<T extends ConsumerStatefulWidget>
    extends State<T> {
  /// An object that allows widgets to interact with providers.
  late final WidgetRef ref = context as WidgetRef;
}

원래 이렇게 되어 있습니다.

Early return 상황 먼저 만들기

이전 provider.paginate 함수를 만들 때와 비슷한 방식으로, 먼저 비교적 구현이 간단한 Early Return 상황부터 먼저 해결해 주겠습니다.

 

먼저 처음 로딩할 때의 상황이 있습니다. 이때는 어떻게 할 것이냐면, 첫 로딩 시니깐 아무런 데이터를 들고 있지 않잖아요? 그러니깐 그냥 CircularProgressIndicator를 리턴할 것입니다.

@override
Widget build(BuildContext context) {
  final state = ref.watch(widget.provider);

  // 첫 로딩
  if (state is CursorPaginationLoading) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

두 번째 얼리리턴 상황은, 에러가 발생했을 때의 상황이 있습니다. 이때는 어떻게 할 것이냐면, 에러 메시지를 화면에 보여주고 나서, 재시도 버튼을 띄울 것입니다. 재시도를 할 때는, paginate 함수에서 파라미터에 forceRefetchtrue로 바꾸어서 다시 요청하면 됩니다.

// 첫 로딩
if (state is CursorPaginationLoading) {
    // ...
}

// Error 발생
if (state is CursorPaginationError) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Text(
        state.message,
        textAlign: TextAlign.center,
      ),
      const SizedBox(height: 16.0),
            /// [paginate] 재시도 요청 버튼, 강제로 새로고침하기
      ElevatedButton(
        onPressed: () {
          ref.read(widget.provider.notifier).paginate(
                forceRefetch: true,
              );
        },
        child: const Text(
          '재시도',
        ),
      ),
    ],
  );
}

state 캐스팅하기

이제 Early Return 상황은 모두 끝났습니다. 여기에서 보장되는 것은, state는 무조건 데이터를 들고 있는 상태입니다. 즉 state는 무조건 CursorPagination, CursorPaginationRefetching, CurosrPaginationFetchMore 중에 하나입니다. 이들 모두가 CursorPagination 의 자식 클래스 혹은 그 자체이기에, 따라서 아래와 같이 캐스팅이 가능합니다. 다형성의 마법이죠.

// 첫 로딩
if (state is CursorPaginationLoading) {
    // ...
}

// Error 발생
if (state is CursorPaginationError) {
    // ...
}

// CursorPagination
// CursorPaginationRefetching
// CurosrPaginationFetchMore
final pState = state as CursorPagination<T>;

참고로 pState는 parsed state의 약자입니다.

이때 <T> 가 어디서 왔는지 헷갈린다면, 맨 처음에 위젯을 선언할 때 제네릭을 받도록 선언하였음을 잊지 마십시오. 우리가 직접 선언한 T입니다.

class PaginationListView<T extends IModelWithId>
    extends ConsumerStatefulWidget {
    // ...
}

class _PaginationListViewState<T extends IModelWithId>
    extends ConsumerState<PaginationListView> {
    // ...
}

View 구현하기

ListView.separated

이제 마지막으로 View를 구현합니다. 이때 ListView를 이용해서 구현하겠습니다. 사실 여기까지는 다른 프로젝트에서도 공통으로 적용될 수 있겠으나, 이 부분부터는 각 상황에 맞추어서 직접 구현해야 합니다.

// CursorPagination
// CursorPaginationRefetching
// CurosrPaginationFetchMore
final pState = state as CursorPagination<T>;

return ListView.separated(
  itemBuilder: itemBuilder,
  separatorBuilder: separatorBuilder,
  itemCount: itemCount,
);

ListView.separated를 이용하겠습니다. seperateditemBuilder 뿐만 아니라, separatorBuilder도 따로 받아서, item과 item 사이를 separator로 채워줍니다.

 

먼저 itemCount의 경우는 쉽습니다. pState.data.length + 1을 해주면 됩니다. pState는 CursorPagination이라는 게 확인이 되었고, 앞에서 조건들을 이미 처리를 해주어서 data는 null이 들어오지 않는다는 것을 확인했습니다. 따라서 그냥 데이터 길이를 사용하면 됩니다.

 

여기에서 +1이 붙습니다. 이 정체는 무엇일까요? 이것은 사용자가 화면을 맨 아래로 빠르게 내렸을 때, 만약에 데이터를 불러오는 중이라면, CircularProgressIndicator를, 더 데이터가 존재하지 않는다면 그에 대한 텍스트를 추가로 붙일 것입니다. 이 하나의 위젯이 추가로 필요해서 +1을 해줍니다. 이게 없다면 사용자 입장에서는 화면 아래로 끝까지 스크롤을 했는데, 밑에 더 데이터가 들어오는지, 혹은 이게 끝인 건지 구분하기가 어려워집니다.

 

itemBuilder

itemBuilder가 문제인데, 이 itemBuilder는 외부에서 따로 받을 겁니다.

 

파일의 맨 위로 올라가서, PaginationWidgetBuilder라는 타입을 typedef로 선언해 주겠습니다.

typedef PaginationWidgetBuilder<T extends IModelWithId> = Widget Function(
  BuildContext context,
  int index,
  T model,
);

PaginationWidgetBuilder<T> 는 제네릭으로 데이터 모델을 받고, Widget을 반환하는 일급 객체 함수로써 파라미터로 context, index, model을 받는다고 정의합니다.

typedef PaginationWidgetBuilder<T extends IModelWithId> = Widget Function(
  BuildContext context,
  int index,
  T model,
);

class PaginationListView<T extends IModelWithId>
    extends ConsumerStatefulWidget {
  final StateNotifierProvider<PaginationProvider, CursorPaginationBase>
      provider;
  final PaginationWidgetBuilder<T> itemBuilder; // 외부에서 injection

  const PaginationListView({
    required this.provider,
    required this.itemBuilder,
    super.key,
  });
    // ...
}

그리고서 itemBuilder를 외부에서 정의해서 주입할 수 있도록 합니다. 이렇게 되면 외부에서 PaginationWidgetBuilder를 사용할 때, 어떻게 컴포넌트를 그릴 지 직접 정의할 수 있습니다.

final pState = state as CursorPagination<T>;

return ListView.separated(
  itemBuilder: (context, index) {
    if (index < pState.data.length) {
      final item = pState.data[index];
      return widget.itemBuilder(
        context,
        index,
        item,
      );
    }

    return const Center(
      child: CircularProgressIndicator(),
    );
  },
  separatorBuilder: separatorBuilder,
  itemCount: pState.data.length + 1,
);

그리고 다시 아래로 돌아와서, itemBuilder에 widget.itemBuilder를 넣어주면 되겠습니다.

 

이때 separated의 itemBuilder는 Function(BuildContext, int)의 일급객체로써, 뒤에 index를 사용할 수 있습니다. 만약에 이 index가 data의 길이보다 작다면, 유효한 데이터이므로 widget.itemBuilder 로 값들을 넣어서 반환을 해주면 됩니다.

 

그게 아니라면 유효한 값이 아니니 CircularProgressIndicator를 리턴을 해주면 되는데, 기왕 하는 김에 조금 더 세분화하여서 예외 상황을 처리해 봅시다.

if (index < pState.data.length) {
    // ...
}

return Center(
  child: pState is CursorPaginationFetchingMore
      ? const CircularProgressIndicator()
      : const Text('더 데이터가 없습니다'),
);

만약에, pState가 CursorPaginationFetchingMore이라면, 아직 데이터를 불러오는 중이란 뜻이므로 CircularProgressIndicator 를 리턴합니다.

 

그러나 그게 아니라면, 더 이상 fetchMore할 데이터 자체가 없다는 의미가 됩니다. 즉 이미 마지막 데이터까지 모두 보았다는 의미입니다. 따라서 더 데이터가 없다는 안내 문구를 리턴합니다.

 

separatorBuilder

사실 separatorBuilder 역시 이런 식으로 typedef를 만들어서, 외부에서 주입해서 사용하도록 설계할 수 있는데요, 다만 여기서는 separator 같은 경우 굳이 이런 식으로 만들지는 않고, 그냥 적당한 SizedBox 로 만들겠습니다.

itemBuilder: // ...
separatorBuilder: (context, index) => const SizedBox(height: 16.0),
itemCount: pState.data.length + 1,

RefreshIndicator로 감싸기

이전에 Provider에서 새로 고침의 상황을 대비해서 코드를 설계했습니다. 새로고침 기능을 사용할 차례입니다.

ListView.seperated를 RefreshIndicator로 감싸줍니다. RefreshIndicator는 플러터에서 제공하는 기본 위젯입니다.

@override
Widget build(BuildContext context) {
	/// ...

	return RefreshIndicator(
	  onRefresh: () async {
	    ref.read(widget.provider.notifier).paginate(forceRefetch: true);
	  },
	  child: ListView.separated(
	      controller: controller,
	      itemCount: cp.data.length + 1,
				/// ...

onRefresh를 따로 받고 있는데, 여기서 새로고침 시 수행할 행동을 넣어줄 수 있습니다.

 

ref.read(widget.provider.notifier).paginate(forceRefetch: true);로 새로고침하여 Pagination을 다시 해줍니다. 이때 주의할 것은 watch가 아니라 read 입니다(새로고침은 한 번만 하니깐요).

 

forceRefetch를 true로 해주면, 기존의 데이터를 전부 날려버리고 완전히 새롭게 새로고침이 됩니다. 사용자 입장에서는 좀 더 극단적인 UI가 되는데, 기존의 데이터가 전부 날라가므로 잠시동안 빈 화면이 뜨게 됩니다.

 

이게 싫다면 forceRefetch를 false로 해주면 됩니다.

Physics 추가하기

child: ListView.separated(
  physics: const AlwaysScrollableScrollPhysics(),
  controller: controller,
  itemCount: cp.data.length + 1,

사소한 디테일로, AlwaysScrollableScrollPhysics를 리스트뷰에 추가해줍시다. 이렇게 되면, 아이템 개수가 적어서 화면 아래로 내려가지 않아도, 스크롤은 작동합니다.

 

만약에 이 Physics가 설정되지 않으면, 아이템 개수가 작을 때 scrollable 위젯으로 작동하지 않아서, 새로고침 등을 못하는 불상사가 벌어질 수 있습니다. 추가합시다.

 

Padding 넣고 완성하기

마지막으로 적당하게 Padding을 넣고 마무리하면 완성입니다.

@override
Widget build(BuildContext context) {
    final state = ref.watch(widget.provider);

    // ...

    final pState = state as CursorPagination<T>;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16.0),
      child: ListView.separated(
        itemBuilder: (context, index) {
          if (index < pState.data.length) {
            final item = pState.data[index];
            return widget.itemBuilder(
              context,
              index,
              item,
            );
          }

          return Padding(
            padding: const EdgeInsets.symmetric(
              horizontal: 16.0,
              vertical: 8.0,
            ),
            child: Center(
              child: pState is CursorPaginationFetchingMore
                  ? const CircularProgressIndicator()
                  : const Text('더 데이터가 없습니다'),
            ),
          );
        },
        separatorBuilder: (context, index) => const SizedBox(height: 16.0),
        itemCount: pState.data.length + 1,
      ),
    );
}

이렇게 Common view를 완성을 하면, 이는 공용 컴포넌트로써 큰 장점을 갖습니다. 이제부터 페이지네이션 과정 시 다른 UI 단에서는 이 컴포넌트를 가져다 쓰기만 하면 됩니다. 심지어, 이를 사용하는 위젯은 StatefulWidget일 필요도 없습니다. StatelessWidget이여도 가능합니다.

 

다음 글은 이를 실제로 적용해봐서, 왜 지금까지 수행한 페이지네이션의 추상화가 어떤 도움을 주는 지 알아보겠습니다.

레퍼런스