Flutter ListView 기본 사용법과 자주 하는 실수

ListView 기본 개념

Flutter에서 ListView는 여러 항목을 한 줄씩 보여주면서 스크롤까지 처리할 때 가장 자주 쓰는 위젯입니다. 이름 그대로 “목록”을 보여주는 데 특화되어 있고, 기본 생성자는 위젯 목록을 직접 넣는 방식이며, ListView.builder는 필요한 시점에 항목을 만들어 주는 방식입니다. ListView.separated는 항목 사이에 구분선을 넣을 때 편합니다.

쉽게 말하면, 화면에 메뉴 목록, 게시글 목록, 채팅 목록, 설정 목록처럼 비슷한 모양이 반복될 때 거의 항상 ListView를 먼저 떠올리면 됩니다. 항목 하나하나는 Container, Card, ListTile 같은 위젯으로 만들 수 있고, 특히 ListTile은 제목과 보조 문구, 아이콘을 한 줄에 넣기 편해서 자주 같이 쓰입니다.

ListView 필요한 순간

반복 항목 화면

같은 모양의 항목이 여러 개 반복된다면 ListView가 잘 맞습니다. 예를 들어 공지 목록, 쇼핑 상품 목록, 설정 메뉴, 앱 알림 목록 같은 화면이 여기에 들어갑니다. ListView는 세로 스크롤 목록을 만드는 대표 위젯이고, 필요하면 가로 방향으로도 바꿀 수 있습니다.

항목 수가 많을 때

항목이 몇 개 안 되면 일반 ListView(children: [])로도 충분하지만, 항목 수가 많거나 계속 늘어날 수 있다면 ListView.builder()가 더 잘 맞습니다. 공식 예제도 긴 목록을 보여줄 때 ListView.builder()를 사용하고, 필요한 항목만 그때그때 만드는 방식을 안내합니다.

ListView 기본 사용법

가장 단순한 ListView

항목 수가 적고 화면에 보여줄 위젯이 이미 정해져 있다면 가장 단순한 형태로 시작하면 됩니다.

ListView(
  children: const [
    ListTile(
      leading: Icon(Icons.home),
      title: Text('홈'),
    ),
    ListTile(
      leading: Icon(Icons.person),
      title: Text('프로필'),
    ),
    ListTile(
      leading: Icon(Icons.settings),
      title: Text('설정'),
    ),
  ],
)

이 방식은 이해하기 쉽고 바로 화면을 만들기 좋습니다. 다만 항목이 아주 많아지면 이 방식보다 builder가 더 낫습니다. 기본 생성자는 위젯 목록을 직접 받는 방식이고, 많은 항목이나 무한 목록은 ListView.builder를 쓰는 쪽이 권장됩니다.

ListView.builder 사용법

실제로는 ListView.builder를 더 자주 쓰게 됩니다. 서버 데이터, 게시글 배열, 상품 목록처럼 항목 수가 달라지는 화면에 잘 맞습니다.

final items = ['사과', '바나나', '포도', '딸기', '수박'];

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(items[index]),
    );
  },
)

itemBuilder는 화면에 필요한 시점에 항목을 만들어 주고, itemCount는 전체 개수를 알려줍니다. 공식 예제도 긴 문자열 목록을 보여줄 때 이 방식을 사용합니다.

ListView.separated 사용법

항목 사이에 선이나 빈 공간을 넣고 싶다면 ListView.separated가 편합니다.

final items = ['공지사항', '이벤트', '업데이트', '고객센터'];

ListView.separated(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(items[index]),
    );
  },
  separatorBuilder: (context, index) {
    return const Divider(height: 1);
  },
)

이 방식은 항목과 구분선을 따로 나눠서 작성할 수 있어서 코드가 깔끔해집니다. ListView.separated는 “항목 사이에 구분 요소가 있는 고정 길이 목록”을 만들 때 쓰는 생성자입니다.

자주 쓰는 옵션 정리

scrollDirection

ListView는 기본값이 세로 스크롤입니다. 가로 목록으로 바꾸고 싶다면 scrollDirection: Axis.horizontal을 넣으면 됩니다.

ListView(
  scrollDirection: Axis.horizontal,
  children: const [
    Card(child: SizedBox(width: 120, child: Center(child: Text('1')))),
    Card(child: SizedBox(width: 120, child: Center(child: Text('2')))),
    Card(child: SizedBox(width: 120, child: Center(child: Text('3')))),
  ],
)

공식 예제도 가로 목록은 scrollDirection을 바꿔서 만들도록 안내합니다.

padding

목록 가장자리 여백은 padding으로 처리하면 됩니다.

ListView.builder(
  padding: const EdgeInsets.all(16),
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(items[index]),
    );
  },
)

항목 안쪽 여백을 전부 따로 잡는 것보다, 목록 바깥 여백을 먼저 주는 편이 훨씬 편합니다. padding은 ListView 기본 생성자와 builder, separated 모두에서 사용할 수 있습니다.

controller

스크롤 위치를 직접 다루고 싶다면 ScrollController를 붙입니다. 예를 들어 버튼을 눌렀을 때 맨 위로 올리거나, 현재 스크롤 값을 확인할 때 씁니다. ListView는 controller를 받을 수 있고, 스크롤 위치 저장과도 연결됩니다.

shrinkWrap

shrinkWrap은 ListView 높이를 내용물 크기에 맞추고 싶을 때 쓰는 옵션입니다. 다만 이 옵션은 꼭 필요할 때만 써야 합니다. 공식 문서도 shrinkWrap은 기본값이 false이고, 이 값을 켜면 스크롤 중 크기를 다시 계산해야 해서 비용이 꽤 커진다고 설명합니다.

ListView와 ListTile 같이 쓰기

초보자에게는 ListView + ListTile 조합이 가장 편합니다. ListTile은 한 줄짜리 항목을 만들기 좋고, leading, title, subtitle, trailing만 알아도 앱 메뉴나 설정 화면을 빠르게 만들 수 있습니다. ListTile은 보통 ListView 안에서 많이 사용됩니다.

ListView(
  children: const [
    ListTile(
      leading: Icon(Icons.notifications),
      title: Text('알림'),
      subtitle: Text('푸시 알림과 메시지 설정'),
      trailing: Icon(Icons.chevron_right),
    ),
    ListTile(
      leading: Icon(Icons.lock),
      title: Text('보안'),
      subtitle: Text('비밀번호와 인증 설정'),
      trailing: Icon(Icons.chevron_right),
    ),
  ],
)

처음에는 이렇게 만들어 두고, 나중에 Card, Container, InkWell을 섞어서 모양을 바꿔 가면 됩니다.

SingleChildScrollView와 차이

ListView는 여러 항목을 스크롤하는 데 맞춰진 위젯입니다. 반면 SingleChildScrollView는 말 그대로 “자식 하나”를 스크롤하는 데 맞춰져 있습니다. 공식 문서는 자식이 많은 스크롤 목록이라면 SingleChildScrollView 안에 Column을 넣는 방식보다 ListView가 훨씬 효율적이라고 설명합니다.

즉, 긴 목록이라면 ListView를 먼저 떠올리는 게 맞습니다. 화면 전체가 하나의 긴 폼이고 자식 수가 많지 않을 때만 SingleChildScrollView + Column을 고민하는 편이 안전합니다.

자주 하는 실수

Column 안에 ListView를 바로 넣는 실수

초보자가 가장 많이 만나는 에러가 이것입니다. Column 안에 ListView를 아무 제한 없이 넣으면 Vertical viewport was given unbounded height 같은 에러가 나기 쉽습니다. 공식 문서도 이 에러는 ListViewColumn 안에 들어갈 때 자주 생긴다고 설명합니다. 이유는 ListView는 세로 공간을 최대한 쓰려고 하고, Column은 자식 높이를 기본값으로 제한하지 않기 때문입니다.

잘못된 예시는 보통 이런 모양입니다.

Column(
  children: [
    const Text('제목'),
    ListView.builder(
      itemCount: 20,
      itemBuilder: (context, index) {
        return ListTile(title: Text('항목 $index'));
      },
    ),
  ],
)

이럴 때는 Expanded로 감싸서 남는 공간 안에서 ListView가 보이게 만들면 됩니다.

Column(
  children: [
    const Text('제목'),
    Expanded(
      child: ListView.builder(
        itemCount: 20,
        itemBuilder: (context, index) {
          return ListTile(title: Text('항목 $index'));
        },
      ),
    ),
  ],
)

화면 일부만 목록으로 쓰고 싶다면 SizedBox(height: 300)처럼 높이를 직접 주는 방법도 있습니다. 핵심은 ListView에 “세로 크기 기준”을 주는 것입니다.

긴 목록인데 children만 계속 넣는 실수

항목이 몇 개 안 될 때는 children으로 바로 넣어도 괜찮습니다. 하지만 항목이 많아지거나 서버 데이터처럼 계속 달라지는 목록이라면 ListView.builder()가 더 낫습니다. 공식 문서도 기본 생성자는 작은 수의 위젯에 알맞고, 큰 목록이나 무한 목록은 ListView.builder를 쓰라고 안내합니다.

실무에서는 게시글 100개, 댓글 200개, 상품 수백 개처럼 항목이 금방 많아집니다. 이런 경우 처음부터 builder로 시작하는 편이 안전합니다.

shrinkWrap을 습관처럼 켜는 실수

에러를 피하려고 shrinkWrap: true를 아무 생각 없이 넣는 경우가 많습니다. 물론 꼭 필요한 상황은 있습니다. 예를 들어 부모 쪽이 스크롤을 맡고 있고, 내부 ListView는 내용물 높이만큼만 보이게 하고 싶을 때입니다. 하지만 공식 문서는 shrink wrapping이 꽤 비용이 크다고 분명히 설명합니다. 스크롤할 때마다 크기 계산이 다시 필요하기 때문입니다.

그래서 shrinkWrap: true는 “무조건 넣는 기본값”처럼 쓰면 안 됩니다. 먼저 Expanded, SizedBox, 화면 분리로 해결할 수 있는지 보는 편이 좋습니다. 내부 스크롤을 막고 부모 스크롤만 쓰고 싶다면 아래처럼 쓰는 경우가 많습니다.

SingleChildScrollView(
  child: Column(
    children: [
      const Text('상단 설명'),
      ListView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        itemCount: 10,
        itemBuilder: (context, index) {
          return ListTile(title: Text('항목 $index'));
        },
      ),
    ],
  ),
)

다만 긴 목록이라면 이런 방식보다 화면 구성을 다시 나누는 쪽이 보통 더 낫습니다. 공식 문서도 중첩 스크롤은 성능을 해치지 않는 방법을 고민해야 한다고 안내합니다.

itemCount를 빼먹는 실수

ListView.builder에서 itemCount는 필수가 아니지만, 데이터 개수가 정해져 있다면 넣는 편이 좋습니다. itemCount를 넣어 두면 builder가 어디까지 항목을 만들지 분명해지고, 코드를 읽는 사람도 목록 끝을 바로 이해할 수 있습니다. 공식 예제 역시 긴 목록에서 itemCount를 같이 사용합니다.

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(title: Text(items[index]));
  },
)

작은 차이 같아 보여도, 나중에 서버 데이터와 붙일 때 오류를 줄이는 데 도움이 됩니다.

ListView 안에 또 ListView를 넣는 실수

세로 스크롤 목록 안에 또 세로 스크롤 목록을 넣으면 크기 계산과 스크롤 동작이 꼬이기 쉽습니다. Flutter 문서도 스크롤 위젯 안에 또 다른 스크롤 위젯이 들어갈 때, 그리고 RowColumn 같은 flex 위젯과 함께 섞일 때 무제한 크기 문제가 자주 생긴다고 설명합니다.

이럴 때는 다음 중 하나를 먼저 생각해 보시면 됩니다.

  • 목록을 하나로 합칠 수 있는지
  • 가로 목록으로 바꾸는 것이 맞는지
  • CustomScrollView와 sliver 계열이 더 맞는지

ListView 자체도 내부적으로 스크롤 뷰와 슬리버 목록을 묶어 둔 편한 형태이고, 더 세밀한 제어가 필요하면 CustomScrollViewSliverList로 갈 수 있습니다.

스크롤 위치가 자꾸 초기화되는 실수

탭을 옮겼다가 돌아왔을 때 목록이 맨 위로 올라가 버리면 당황하게 됩니다. ListView는 세션 동안 스크롤 위치를 유지하려고 시도하고, 이때 서로 다른 스크롤 목록을 구분하려면 PageStorageKey 사용이 권장됩니다.

예를 들어 탭 화면마다 다른 ListView가 있다면 이렇게 둘 수 있습니다.

ListView.builder(
  key: const PageStorageKey('notice_list'),
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(title: Text(items[index]));
  },
)

목록이 여러 개일수록 이런 작은 설정이 꽤 중요해집니다.

바로 써보기 좋은 예제

설정 화면 예제

아래 예제는 실제로 자주 만드는 설정 메뉴 화면입니다.

final settings = [
  {'icon': Icons.person, 'title': '계정'},
  {'icon': Icons.lock, 'title': '보안'},
  {'icon': Icons.notifications, 'title': '알림'},
  {'icon': Icons.info, 'title': '앱 정보'},
];

ListView.separated(
  itemCount: settings.length,
  itemBuilder: (context, index) {
    final item = settings[index];
    return ListTile(
      leading: Icon(item['icon'] as IconData),
      title: Text(item['title'] as String),
      trailing: const Icon(Icons.chevron_right),
      onTap: () {},
    );
  },
  separatorBuilder: (context, index) => const Divider(height: 1),
)

이 예제 하나만 손에 익혀도 메뉴 목록, 공지 목록, 프로필 목록처럼 비슷한 화면을 금방 바꿔 만들 수 있습니다. ListTile은 이런 한 줄짜리 메뉴 항목에 특히 잘 맞습니다.

상품 카드 목록 예제

카드 모양을 반복하고 싶다면 ListTile 대신 CardContainer를 써도 됩니다.

ListView.builder(
  padding: const EdgeInsets.all(16),
  itemCount: 10,
  itemBuilder: (context, index) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: ListTile(
        leading: const Icon(Icons.shopping_bag),
        title: Text('상품 ${index + 1}'),
        subtitle: const Text('설명 문구가 들어가는 자리입니다.'),
        trailing: const Text('9,900원'),
      ),
    );
  },
)

결론

Flutter에서 ListView는 목록 화면을 만들 때 가장 먼저 익혀야 하는 위젯입니다. 항목이 적을 때는 기본 ListView로도 충분하지만, 항목 수가 많아지거나 데이터가 바뀌는 화면이라면 ListView.builder를 쓰는 편이 훨씬 편합니다. 항목 사이에 선이나 간격이 필요할 때는 ListView.separated까지 함께 익혀두면 활용 범위가 넓어집니다.

처음에는 단순히 목록을 보여주는 위젯처럼 보이지만, 실제로는 메뉴 화면, 공지 목록, 상품 리스트, 채팅 화면처럼 여러 곳에서 반복해서 쓰이게 됩니다. 그래서 초반에 기본 사용법과 함께 자주 생기는 실수까지 같이 익혀두면 이후 작업이 한결 수월해집니다.

특히 Column 안에 ListView를 바로 넣어서 높이 관련 오류가 나는 경우, 긴 목록인데 children만 계속 넣는 경우, shrinkWrap을 습관처럼 켜는 경우는 초보자가 자주 겪는 부분입니다. 이 부분만 미리 알고 있어도 에러 때문에 시간을 허비하는 일이 많이 줄어듭니다.

결국 ListView는 단순히 스크롤 목록을 만드는 위젯이 아니라, Flutter 화면 제작에서 아주 자주 쓰이는 기본 도구입니다. 기본형, builder, separated의 차이와 함께 높이 제한, 스크롤 방식, 성능 차이까지 함께 이해해두면 이후 다른 목록 화면도 훨씬 쉽게 만들 수 있습니다.

FAQ

Flutter ListView는 언제 사용하면 되나요?

비슷한 형태의 항목이 여러 개 반복되고, 사용자가 위아래나 좌우로 스크롤해야 하는 화면이라면 ListView를 쓰면 됩니다. 설정 메뉴, 게시글 목록, 상품 목록, 알림 화면처럼 한 줄씩 항목이 이어지는 화면에서 특히 자주 사용합니다.

ListView와 ListView.builder는 어떤 차이가 있나요?

기본 ListView는 children에 위젯을 직접 넣는 방식이고, ListView.builder는 필요한 항목을 그때그때 만들어 주는 방식입니다. 항목 수가 적고 고정되어 있다면 기본 ListView도 괜찮지만, 항목이 많거나 서버 데이터처럼 개수가 달라진다면 builder가 더 잘 맞습니다.

ListView.separated는 언제 쓰면 좋나요?

각 항목 사이에 구분선이나 간격을 넣고 싶을 때 쓰면 좋습니다. 메뉴 목록이나 공지 목록처럼 항목 간 구분이 있어야 보기 편한 화면에서 많이 사용합니다. divider를 직접 하나씩 넣는 것보다 더 깔끔하게 작성할 수 있습니다.

Column 안에 ListView를 넣으면 왜 오류가 나나요?

ListView는 스크롤 가능한 목록이라 세로 공간을 넓게 사용하려고 하는데, Column 안에서는 높이 기준이 애매해지는 경우가 많습니다. 그래서 높이를 정하지 않은 채 바로 넣으면 오류가 날 수 있습니다. 이런 경우에는 Expanded로 감싸거나 SizedBox로 높이를 지정해주는 식으로 해결합니다.

shrinkWrap은 꼭 필요한 옵션인가요?

항상 필요한 옵션은 아닙니다. 내용물 크기만큼만 ListView 높이를 맞추고 싶을 때 쓸 수 있지만, 무조건 켜두는 방식은 좋지 않습니다. 항목 수가 많아질수록 부담이 커질 수 있어서, 먼저 Expanded나 높이 지정으로 해결할 수 있는지 보는 편이 낫습니다.

ListView 안에 ListTile을 많이 쓰는 이유는 무엇인가요?

ListTile은 아이콘, 제목, 설명, 오른쪽 화살표 같은 요소를 한 줄에 넣기 편해서 목록 항목을 만들기에 아주 적합합니다. 설정 화면이나 메뉴 화면처럼 익숙한 앱 화면을 빠르게 만들 수 있어서 ListView와 함께 자주 쓰입니다.

ListView 대신 SingleChildScrollView를 쓰면 안 되나요?

간단한 화면에서는 사용할 수 있지만, 항목이 반복되는 긴 목록이라면 보통 ListView가 더 알맞습니다. SingleChildScrollView는 자식 하나를 스크롤하는 데 쓰는 경우가 많고, 반복 목록까지 한 번에 처리하기에는 비효율적인 경우가 있습니다. 목록 중심 화면이라면 ListView를 먼저 떠올리는 편이 좋습니다.

긴 목록인데 children으로만 작성해도 괜찮나요?

항목이 몇 개 안 된다면 큰 문제는 없지만, 길어질 가능성이 있다면 처음부터 builder를 쓰는 편이 좋습니다. children 방식은 항목 수가 늘어날수록 관리가 불편해지고, 실제 데이터와 연결할 때도 번거로워질 수 있습니다.

가로로 스크롤되는 목록도 ListView로 만들 수 있나요?

네, 가능합니다. 기본값은 세로 스크롤이지만 scrollDirection을 Axis.horizontal로 바꾸면 가로 목록도 만들 수 있습니다. 추천 상품, 이미지 카드, 태그 목록처럼 옆으로 넘겨보는 화면에 자주 사용합니다.

ListView를 처음 배울 때 가장 먼저 익혀야 할 부분은 무엇인가요?

기본 ListView, ListView.builder, ListView.separated의 차이를 먼저 익히는 것이 좋습니다. 그다음으로는 Column 안에서 높이 오류가 나는 이유, Expanded로 감싸는 방법, shrinkWrap을 아무 때나 쓰지 않는 이유까지 함께 익혀두면 실제 화면을 만들 때 훨씬 덜 막히게 됩니다.

댓글 남기기