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 같은 에러가 나기 쉽습니다. 공식 문서도 이 에러는 ListView가 Column 안에 들어갈 때 자주 생긴다고 설명합니다. 이유는 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 문서도 스크롤 위젯 안에 또 다른 스크롤 위젯이 들어갈 때, 그리고 Row나 Column 같은 flex 위젯과 함께 섞일 때 무제한 크기 문제가 자주 생긴다고 설명합니다.
이럴 때는 다음 중 하나를 먼저 생각해 보시면 됩니다.
- 목록을 하나로 합칠 수 있는지
- 가로 목록으로 바꾸는 것이 맞는지
CustomScrollView와 sliver 계열이 더 맞는지
ListView 자체도 내부적으로 스크롤 뷰와 슬리버 목록을 묶어 둔 편한 형태이고, 더 세밀한 제어가 필요하면 CustomScrollView와 SliverList로 갈 수 있습니다.
스크롤 위치가 자꾸 초기화되는 실수
탭을 옮겼다가 돌아왔을 때 목록이 맨 위로 올라가 버리면 당황하게 됩니다. 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 대신 Card와 Container를 써도 됩니다.
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원'),
),
);
},
)