IT 프로그래밍-Flutter

[Flutter] endless listview

godsangin 2022. 10. 17. 21:58
반응형

안녕하세요.

지난시간에는 ListView의 작성방법과 클릭 시 상세페이지로 이동하는 방법에 대해 알아보았습니다.

https://in-idea.tistory.com/64

 

[Flutter] 플루터 화면 이동하기

안녕하세요. 오늘은 지난시간에 이어 플루터에서 화면이동을 하는 방법에 대해 알아보겠습니다. (지난 실습은 아래 URL에서 확인하실 수 있습니다.) https://in-idea.tistory.com/63 [Flutter] 플루터 리스트

in-idea.tistory.com

 

오늘은 지난번에 구현한 아이템에 로딩간격을 두어 endless한 ListView를 만드는 방법에 대해 알아보도록 하겠습니다.

 

저는 10개 아이템을 간격으로 로딩 시차를 두어 로딩을 하는 방법을 사용하고자 합니다. 여기서 잠깐 !! 아이템을 이렇게 페이지네이션하는 이유는 무엇일까요 ??

보편적인 서비스는 매우 많은 양의 데이터를 가지고 있습니다. 예를들어 인스타그램이나 페이스북에서는 다음과 같이 무한정한 컨텐츠를 사용자에게 보여주는 것을 볼 수 있습니다.(화면은 한정적으로 보이지만 스크롤을 내리면 컨텐츠가 무한하게 나오는걸 다들 알고계실거라 생각합니다.) 

 

하지만 모든 사용자가 모든 컨텐츠를 끝까지 미리 다운로드한 뒤에 스크롤을 하게되면 어떻게 될까요 ? 서비스의 서버에도 과부하가 생길테고, 사용자도 엄청난 시간을 기다려야 응답을 받을 수 있을 것입니다.(실질적으로 불가능하겠죠 ?) 때문에 상용서비스를 제공하기 위해 페이지네이션은 필수작업이 될 것입니다.

 

자, 그러면 이제 페이지네이션을 한 아이템을 Endless한 ListView로 출력하는 방법을 소개해보겠습니다.

 

지난번 Book 아이템을 선언한 ListView를 다음의 형태로 변경할 것입니다.

1. InfiniteScrollView

class InfiniteScrollView extends GetView<InfiniteScrollController> {
  void setDataByTagList(List<Book> bookList) async {
    if (bookList.isEmpty) {
      controller.resizeHumorList();
    }
    controller.clear();
    controller.books = bookList;
    controller._getData();
  }

  @override
  Widget build(BuildContext context) {
    return Obx(
      () => ListView.builder(
        padding: const EdgeInsets.all(8),
        itemCount: controller.data.length + 1,
        controller: controller.scrollController.value,
        itemBuilder: (ctx, index) {
          if (index < controller.data.length) {
            //각 아이템들
            return BookTile(controller.data[index]);
          }
          if (controller.hasMore.value || controller.isLoading.value) {
            //controller에서 더 가져올 아이템이 남아있을 경우 프로그래스바 출력
            return Center(child: RefreshProgressIndicator());
          }
          //더 이상 가져올 아이템이 없으면 view에 영향이 없는 sizebox 출력
          return SizedBox();
        },
      ),
    );
  }
}

2. InfiniteScrollContoller(컨텐츠 로딩을 위해 아이템을 30개로 추가하였습니다.)

class InfiniteScrollController extends GetxController {
  var scrollController = ScrollController().obs;
  var data = <Book>[].obs;
  var isLoading = false.obs;
  var hasMore = false.obs;
  int startIndex = 0;
  int limit = 10;
  // List<Book> originBooks = [];

  List<Book> books = [
    Book(
        id: 1,
        title: "하얼빈",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3368498/33684983664.20220808092115.jpg?type=w300"),
    Book(
        id: 2,
        title: "역행자",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3255028/32550285396.20220914160250.jpg?type=w300"),
    Book(
        id: 3,
        title: "잘될 수밖에 없는 너에게",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3410601/34106011618.20220818093442.jpg?type=w300"),
    Book(
        id: 4,
        title: "데뷔 못 하면 죽는 병 걸림 1부 초판 한정 굿즈박스 세트",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3449237/34492373619.20220905183722.jpg?type=w300"),
    Book(
        id: 5,
        title: "불편한 편의점 2",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3368499/33684998621.20220811101806.jpg?type=w300"),
    Book(
        id: 6,
        title: "파친코 2",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3393982/33939827618.20220808182811.jpg?type=w300"),
    Book(
        id: 7,
        title: "불편한 편의점",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3244499/32444990070.20220527030724.jpg?type=w300"),
    Book(
        id: 8,
        title: "파친코 1",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3343571/33435716826.20220728093341.jpg?type=w300"),
    Book(
        id: 9,
        title: "원씽",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3247335/32473353629.20220527033429.jpg?type=w300"),
    Book(
        id: 10,
        title: "그대만 모르는 비밀",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3434574/34345747674.20220830140856.jpg?type=w300"),
    Book(
        id: 11,
        title: "나는 나를 바꾸기로 했다.",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3448491/34484911671.20220925092341.jpg?type=w300"),
    Book(
        id: 12,
        title: "심리학이 분노에 답하다",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3435019/34350197624.20220830140740.jpg?type=w300"),
    Book(
        id: 13,
        title: "사람을 얻는 지혜",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3246466/32464661309.20220527031041.jpg?type=w300"),
    Book(
        id: 14,
        title: "데뷔 못 하면 죽는 병 걸림 1부 초판 한정 굿즈박스 세트",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3449237/34492373619.20220905183722.jpg?type=w300"),
    Book(
        id: 15,
        title: "데일 카네기 인간관계론",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3243819/32438198729.20220527051238.jpg?type=w300"),
    Book(
        id: 16,
        title: "그릿",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3248607/32486076353.20220527025441.jpg?type=w300"),
    Book(
        id: 17,
        title: "역설계",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3444108/34441080624.20220907094259.jpg?type=w300"),
    Book(
        id: 18,
        title: "언어를 디자인하라",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3396417/33964178619.20220818093732.jpg?type=w300"),
    Book(
        id: 19,
        title: "빠르게 실패하기",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3437281/34372815618.20220902093947.jpg?type=w300"),
    Book(
        id: 20,
        title: "10배의 법칙",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3245958/32459581279.20220524183550.jpg?type=w300"),
    Book(
        id: 21,
        title: "10배의 법칙",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3245958/32459581279.20220524183550.jpg?type=w300"),
    Book(
        id: 22,
        title: "당신의 뇌는 최적화를 원한다",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3248560/32485605645.20220527091246.jpg?type=w300"),
    Book(
        id: 23,
        title: "반짝이는 하루, 그게 오늘이야",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3492669/34926693619.20221004122233.jpg?type=w300"),
    Book(
        id: 24,
        title: "타이탄의 도구들",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3246335/32463355414.20220527053004.jpg?type=w300"),
    Book(
        id: 25,
        title: "잘될 수밖에 없는 너에게",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3410601/34106011618.20220818093442.jpg?type=w300"),
    Book(
        id: 26,
        title: "최소한의 이웃",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3422118/34221187620.20220824092932.jpg?type=w300"),
    Book(
        id: 27,
        title: "나는 당신이 행복했으면 좋겠습니다",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3246336/32463363384.20220527091322.jpg?type=w300"),
    Book(
        id: 28,
        title: "최소한의 이웃",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3422118/34221187620.20220824092932.jpg?type=w300"),
    Book(
        id: 29,
        title: "당신은 결국 무엇이든 해내는 사람",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3244164/32441646786.20220527032531.jpg?type=w300"),
    Book(
        id: 30,
        title: "나는 오래된 거리처럼 너를 사랑하고",
        imgSrc:
            "https://shopping-phinf.pstatic.net/main_3425824/34258246625.20220830140607.jpg?type=w300"),
  ];
  @override
  void onInit() async {
    await resizeHumorList();
    _getData();
    this.scrollController.value.addListener(() {
      if (this.scrollController.value.position.pixels ==
              this.scrollController.value.position.maxScrollExtent &&
          this.hasMore.value) {
        _getData();
      }
    });
    super.onInit();
  }

  resizeHumorList() async {
    startIndex = 0;
    limit = 10;
  }

  clear() {
    startIndex = 0;
    data.clear();
  }

  _getData() async {
    isLoading.value = true;
    // print("data size == " + humorSize.toString());
    data.addAll(await fetchBookList());
    startIndex += limit;
    isLoading.value = false;
    hasMore.value = data.length < books.length;
  }

  reload() async {
    isLoading.value = true;
    data.clear();
    await Future.delayed(Duration(seconds: 2));
    _getData();
  }

  Future<List<Book>> fetchBookList() async {
    // limit += 10;
    print(startIndex.toString() + "  " + limit.toString());
    List<Book> list = books.sublist(startIndex, startIndex + limit);
    return list;
  }
}

 

3. MyHomePageState

class _MyHomePageState extends State<MyHomePage> {
  InfiniteScrollView infsv = InfiniteScrollView();
  RxBool isLoading = false.obs;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    // isLoading = infsv.controller.hasMore;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: Container(
            padding: EdgeInsets.all(10.0),
            alignment: Alignment.topCenter,
            child: infsv));
  }
}

 

4. AppBinding

class AppBinding extends Bindings {
  @override
  void dependencies() {
    Get.put(InfiniteScrollController());
  }
}

 

5. MyApp

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialBinding: AppBinding(), // 추가
      home: const MyHomePage(title: 'My Flutter Example'),
    );
  }
}

여기서 추가적으로 플루터의 get라이브러리가 사용되었는데요, get라이브러리는 각 객체(변수)의 생명주기에 따른 동작을 직접 정의할 수 있도록 해줍니다. 예를들어 int a라는 변수가 있다면 이 a의 값이 변할때 이를 감지하여 특정 동작을 정의하는 것입니다. 반응형(리엑티브) 프로그래밍이라고도 하지요. 예제에서는 observing할 값으로 data(book item의 리스트), isLoading, hasMore 변수를 사용합니다.(InfiniteScrollontroller)

 

기본적인 컨셉은 앱과 서버를 동일시 여기고 전체 BookList를 담아둔 변수(books)를 에서 값을 10개씩 빼오는 것입니다.(사실상 서버에 있는 데이터라고 할 수 있겠습니다.) 그리고 클라이언트의 리스트변수를 담당하는 data변수를 observing하게 함으로써 데이터가 늘어나면 아이템이 실시간으로 변할 수 있도록 하는 것입니다. 또한 데이터가 추가되는 동안에 UX를 최적화하기 위해 isLoading 변수를 두고, 모든 데이터를 다 불러왔을 경우를 예외처리하기 위해 hasMore라는 변수를 둡니다.

 

기존 MyHomePageState에 infiniteScrollView를 선언하고, 이 infiniteScrollView는 infiniteScrollController에 의해 변경되게 됩니다. infiniteScrollController는 정적인 아이템을 담고 있고, infiniteScrollView는 좀전에 말씀드린 observing에 의한 동작을 정의하게 됩니다.(build 메소드 참고) 아! 스크롤의 끝에 다다랐을 때 추가로 아이템을 불러오는 동작은 Controller에 선언되어 있습니다.(onInit 메소드 참고) 

 

반응형 프로그래밍을 사용하지 않아도 모두 가능한 것 아니냐구요 ??

네 ! 가능합니다. 하지만 observing 가능한 객체를 사용하지 않는다면, 모든 경우의 동기적인 동작에 의한 반응동작(?)을 별도로 나누어 관리해야하기 때문에(말이 조금 어렵네요…쉽게 말해서 어떤변수가 달라질 경우 이 변수에 영향을 받는 모든 변수들에도 이와 같은 상황(인과관계)을 알리는 과정에서 callback이 엄청나게 많이 이루어지게 됩니다. 변수가 많은 경우에 한정될 수 있지만, 이 또한 사용서비스라면 얼마든지 일어나는 일입니다.)

 

크흠..!! 아무튼 이와 같은 반응형 프로그래밍은 웹, 네이티브 앱에서도 활발하게 사용되고 있습니다. 

 

Endless scrollview에 대해 설명하다가 반응형프로그래밍으로 너무 많이 새어나왔네요..여러분 코드는 정상적으로 동작하나요 ??

 

실제로 서버 - 클라이언트 간 통신과정에서 이루어지는 로직과는 조금 다르지만(현재는 앱이 서버, 클라이언트 역할을 모두 다 하고 있습니다.) 이를통해 어느정도 페이지네이션의 동작과정을 익히셨을 것이라고 생각합니다. 

 

제가 플루터의 리스트뷰로 준비한 컨텐츠는 여기까지이구요. 기회가 된다면 서버-클라이언트 까지 모두 구현한 예제를 만들어서 찾아오도록 하겠습니다.

 

감사합니다. 오늘도 좋은하루 되세요~~!