Foggy day

[Flutter] ListView - 사용법 본문

Flutter/Flutter widget

[Flutter] ListView - 사용법

jinhan38 2023. 3. 23. 00:06

 

 

이번 포스팅에서는 ListView 위젯에 대해 알아보겠습니다. 

 

 

1. 기본 사용법

2. shrinkWrap
3. cacheExtent
4. itemExtent, prototypeItem

5. ListView.builder, ListView.seperated

 

 

 

1. 기본 사용법

 

ListView의 기본 사용법은 간단합니다. children에 위젯들을 전달해 주면 됩니다.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("ListViewScreen"),),
      body: _body(),
    );
  }

  Widget _body() {
    return ListView(
      children: _listItem(),
    );
  }

  List<Widget> _listItem() {
    List<Widget> widgets = [];
    for (int i = 0; i < 10; i++) {
      widgets.add(
        Container(
          margin: const EdgeInsets.all(20),
          height: 250,
          alignment: Alignment.center,
          color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
          child: Text(
            "$i",
            style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
          ),
        ),
      );
    }
    return widgets;
  }

 

 

 

2. shrinkWrap

ListView안에 또 다른 ListView가 들어간다면 아래 에러를 볼 수 있을 것입니다. 이때 shrinkWrap의 도움을 받을 수 있습니다.

Vertical viewport was given unbounded height. 

 

그런데 shrinkWrap으로 간단하게 문제를 해결하기 전에 viewport에 대해 알아보고 넘어가야겠습니다.

viewport는 화면에서 내용이 표시되는 영역입니다. 예를 들어 현재 화면의 height가 700이고, 화면 전부를 ListView 위젯이 사용한다면 ListView의 viewport는 700입니다. ListView의 실제 길이가 2000이라고 해도 화면에 보이는 사이즈는 700이기 때문입니다.

ListView 위에 height 100의 파란색 Container가 있다면, ListView의 viewport는 600입니다. 화면에 보이는 height는 600이기 때문입니다. 

 

왼쪽 뷰포트 700, 오른쪽 뷰포트 600

 

 

ListView는 부모 위젯의 사이즈만큼, 최대한 늘어납니다. 그리고 자식 위젯의 사이즈만큼 scroll도 늘어납니다. 그런데 ListView 안에 ListView가 있다면? 자식 ListView가 부모 ListView 만큼 늘어날수록 부모 위젯도 자식 위젯만큼 사이즈가 늘어납니다. 이렇게 서로 무한정 사이즈가 증가하게 되고, Vertical viewport was given unbounded height 에러가 발생하게 되는 것입니다. viewport에 어떤 것 을 보여줘야 할지 알 수 없기 때문입니다. 

 

해결 방법은 두 가지입니다.

  • 자식 ListView의 Height를 강제합니다.
  • 자식 ListView에 shrinkWrap을 true로 설정합니다.

 

물론 두 경우의 결과물은 달라집니다. height를 300으로 강제하면 자식 ListView의 사이즈가 1000이라고 하더라도 viewport는 300이 됩니다. shrinkWrap을 사용하면 자식 ListView는 자식 위젯들을 부모 ListView의 viewport만큼 보여줄 수 있습니다. 두 경우의 코드와 동영상을 첨부했습니다. 

 

 

자식 ListView에 height 300으로 설정

  Widget _body() {
    return SizedBox(
      height: 600,
      child: ListView(
        controller: _controller,

        padding: EdgeInsets.zero,
        children: [
          ..._listItem(),
          SizedBox(
            height: 300,
            child: ListView(
              children: [
                ...List.generate(
                  5,
                  (index) => Container(
                    height: 100,
                    alignment: Alignment.center,
                    child: const Text("abcdefg",style: TextStyle(fontSize: 25),),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

 

 

 

자식 ListView에 shrinkWrap 사용 

 

  Widget _body() {
    return SizedBox(
      height: 600,
      child: ListView(
        padding: EdgeInsets.zero,
        controller: _controller,
        children: [
          ..._listItem(),
          ListView(
            shrinkWrap: true,
            children: [
              ...List.generate(
                5,
                (index) => Container(
                  height: 100,
                  alignment: Alignment.center,
                  child: const Text("abcdefg",style: TextStyle(fontSize: 25),),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

 

 

 

3. cacheExtent

ListView가 SingleChildScrollView와 다른 점은 뷰포트에 보이지 않는 영역을(화면에 보이지 않는 영역) 미리 그려놓지 않는다는 것입니다. SingleChildScrollView는 뷰포트 외의 모든 항목들을 한 번에 렌더링 시킵니다. 이는 성능저하에 중요한 요인이 됩니다. 

해상도가 높은 이미지들을 ListView에 넣고, 스크롤을 빠르게 하면 이미지가 좀 늦게 렌더링 되는 것을 볼 수 있습니다. ListView는 viewport에 도달했을 때 자식 위젯들을 그리기 때문입니다. 

화면이 늦게 나오는 문제를 해결하고 싶을 때 사용하는 특성이 cacheExtent입니다. cacheExtent는 캐시를 확장시킨다는 단어의 의미처럼 ListView의 viewport 영역 위아래를 미리 렌더링 해놓습니다. 그렇다면 스크롤링을 할 때 좀 더 빠르게 이미지들을 로드할 수 있습니다. 안드로이드의 recycleView와 같은 개념입니다. 

cacheExtent의 기본 값은 250입니다. viewport가 700이라면 위 250, 아래 250을 더해서 총 1200 사이즈만큼의 자식 위젯을 그립니다. 값을 증가시킨다면 훨씬 더 많은 영역을 미리 그려놓습니다. 사용자가 보기에는 정보들이 빨리 로드돼서 편리함을 느낄 수 있지만 과도할 경우 성능저하를 초래할 수 있습니다. 

 

비교를 위해 두 개의 영상을 준비했습니다. 첫 번째는 cacheExtent를 지정하지 않았고(기본값 250), 두 번째 영상은 cacheExtent로 4000을 입력했습니다. 두 영상 모두 ListView의 viewport는 631, maxScrollExtent(스크롤 길이)는 4369.0입니다.

 

첫번째 영상은 보면 처음 이미지가 로드되는 것은 빠르지만 스크롤을 빠르게 했을 때 계속해서 이미지를 새로 로드시키는 모습을 볼 수 있습니다. 

 

두번째 영상은 처음 이미지 로드에는 시간이 좀 더 걸리지만 빠르게 스크롤했을 때 이미지를 로드하는 모습을 볼 수 없습니다. cacheExtent을 4000으로 입력해서 8631의 영역을 유지하기 때문입니다.

631(viewport) + 4000(topCacheExtent) + 4000(bottomCacheExtent) = 8631

 

  final String _networkImage =
      "https://media.cntraveler.com/photos/639c6b27fe765cefd6b219b7/4:3/w_7696,h_5772,c_limit/Switzerland_GettyImages-1293043653.jpg";

  Widget _body() {
    return ListView(
      cacheExtent: 4000,
      padding: EdgeInsets.zero,
      children: [
        ..._listItem(),
      ],
    );
  }
  
  List<Widget> _listItem() {
    List<Widget> widgets = [];
    for (int i = 0; i < 20; i++) {
      widgets.add(
        Container(
          height: 250,
          alignment: Alignment.center,
          color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
          child: Image.network(_networkImage),
        ),
      );
    }
    return widgets;
  }

 

 


4. itemExtent, prototypeItem

itemExtent(double)와 prototypeItem(Widget) 특성은 자식 위젯들을 사이즈를 결정합니다. 

 

itemExtent이 250이고, ListView의 scrollDirection이 vertical인 경우 아이템의 세로 사이즈가 250이 됩니다. scrollDirection이 horizontal인 경우에는 아이템의 가로 사이즈가 250이 됩니다. 

 

prototypeItem는 Widget을 받습니다. 만약에 SizedBox(height: 200, width: 200)을 입력한다면 scrollDirection.vertical은 아이템의 세로 사이즈가 200, scrollDirection.horizontal은 아이템의 가로 사이즈가 200으로 설정됩니다. 

 

*두 특성을 동시에 사용하는 것은 불가능합니다. 그리고 자식 위젯의 사이즈보다 우선합니다.

  Widget _body() {
    return ListView(
      itemExtent: 250,
      padding: EdgeInsets.zero,
      children: [
        ..._listItem(),
      ],
    );
  }
  Widget _body() {
    return ListView(
      prototypeItem: const SizedBox(height: 200, width: 200),
      padding: EdgeInsets.zero,
      children: [
        ..._listItem(),
      ],
    );
  }

 

 

 

5. ListView.builder, ListView.seperated

 

ListView builder와 seperated는 위젯이 렌더링 될 때 마다 자식 위젯들을 따로 설정해줄 수 있습니다. ListView은 한번에 모든 위젯들을 넣어줬다면, builder와 seperated는 해당 인덱스가 viewport나 cacheExtent영역에 들어왔을 때 위젯을 그려주는 것입니다. 

간단한 코드 첨부했습니다. seperated가 약간 다른 점은 separatorBuilder를 통해 자식 위젯들의 사이에 다른 위젯을 넣어줄 수 있습니다. 일반적으로 구분선이나 여백을 추가하는 편입니다. 

  Widget _build() {
    return ListView.builder(
      padding: EdgeInsets.zero,
      itemCount: _listItem().length,
      itemBuilder: (context, index) {
        return _listItem()[index];
      },
    );
  }

  Widget separated() {
    return ListView.separated(
      padding: EdgeInsets.zero,
      itemBuilder: (context, index) {
        return _listItem()[index];
      },
      separatorBuilder: (context, index) {
        return const SizedBox(height: 30);
      },
      itemCount: _listItem().length,
    );
  }

 

 

 

 

 

Full Code

import 'dart:math';

import 'package:flutter/material.dart';

class ListViewScreen extends StatefulWidget {
  const ListViewScreen({Key? key}) : super(key: key);

  @override
  State<ListViewScreen> createState() => _ListViewScreenState();
}

class _ListViewScreenState extends State<ListViewScreen> {
  final ScrollController _controller = ScrollController();
  final String _networkImage =
      "https://media.cntraveler.com/photos/639c6b27fe765cefd6b219b7/4:3/w_7696,h_5772,c_limit/Switzerland_GettyImages-1293043653.jpg";

  @override
  void initState() {
    _controller.addListener(() {
      print('_controller.position : ${_controller.position}');
    });
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("ListViewScreen"),
      ),
      body: separated(),
    );
  }

  Widget _body() {
    return ListView(
      itemExtent: 250,
      // prototypeItem: const SizedBox(height: 200, width: 200),
      padding: EdgeInsets.zero,
      children: [
        ..._listItem(),
      ],
    );
  }

  Widget _build() {
    return ListView.builder(
      padding: EdgeInsets.zero,
      itemCount: _listItem().length,
      itemBuilder: (context, index) {
        return _listItem()[index];
      },
    );
  }

  Widget separated() {
    return ListView.separated(
      padding: EdgeInsets.zero,
      itemBuilder: (context, index) {
        return _listItem()[index];
      },
      separatorBuilder: (context, index) {
        return const SizedBox(height: 30);
      },
      itemCount: _listItem().length,
    );
  }

  Widget _shrinkWrap() {
    return SizedBox(
      height: 600,
      child: ListView(
        controller: _controller,
        padding: EdgeInsets.zero,
        children: [
          ..._listItem(),
          SizedBox(
            height: 300,
            child: ListView(
              children: [
                ...List.generate(
                  5,
                  (index) => Container(
                    height: 100,
                    alignment: Alignment.center,
                    child: const Text(
                      "abcdefg",
                      style: TextStyle(fontSize: 25),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  List<Widget> _listItem() {
    List<Widget> widgets = [];
    for (int i = 0; i < 20; i++) {
      widgets.add(
        Container(
          height: 250,
          alignment: Alignment.center,
          color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
          // child: Text(
          //   "$i",
          //   style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
          // ),
          child: Stack(
            children: [
              Image.network(_networkImage),
              Text(
                "$i",
                style:
                    const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
              ),
            ],
          ),
        ),
      );
    }
    return widgets;
  }
}