ListView와 GridView — 스크롤 가능한 목록 만들기
ListView와 GridView — 스크롤 가능한 목록 만들기
앱에서 목록은 가장 흔한 UI 패턴입니다. Flutter의 ListView와 GridView를 사용해서 효율적인 스크롤 목록을 만드는 방법을 정리해보겠습니다.
ListView — 기본 목록
ListView (정적 목록)
자식 위젯이 적을 때 사용합니다. 모든 자식을 한 번에 빌드합니다.
ListView(
padding: const EdgeInsets.all(16),
children: const [
ListTile(
leading: Icon(Icons.map),
title: Text('지도'),
),
ListTile(
leading: Icon(Icons.photo),
title: Text('사진'),
),
ListTile(
leading: Icon(Icons.phone),
title: Text('전화'),
),
],
)
ListView.builder (동적 목록)
아이템이 많을 때는 반드시 builder를 사용하세요. 화면에 보이는 것만 빌드합니다.
ListView.builder(
itemCount: 100, // 아이템 수
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text('아이템 $index'),
subtitle: Text('설명 텍스트'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
print('$index번 아이템 클릭');
},
);
},
)
면접 포인트: ListView()와 ListView.builder()의 차이가 면접에서 자주 나옵니다. ListView()는 모든 아이템을 한 번에 생성하고, ListView.builder()는 보이는 것만 lazily 생성 합니다. 아이템이 많으면 반드시 builder를 사용해야 합니다.
ListView.separated (구분선 포함)
ListView.separated(
itemCount: items.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
);
},
)
ListTile — 목록 아이템
ListTile(
leading: const CircleAvatar( // 좌측 위젯
backgroundImage: NetworkImage('...'),
),
title: const Text('홍길동'), // 제목
subtitle: const Text('Flutter 개발자'), // 부제목
trailing: const Icon(Icons.star), // 우측 위젯
isThreeLine: false, // 3줄 모드
dense: false, // 압축 모드
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
onTap: () {},
onLongPress: () {},
)
GridView — 그리드 레이아웃
GridView.count (고정 열 수)
GridView.count(
crossAxisCount: 2, // 열 수
crossAxisSpacing: 8, // 가로 간격
mainAxisSpacing: 8, // 세로 간격
childAspectRatio: 1.0, // 가로:세로 비율
padding: const EdgeInsets.all(8),
children: List.generate(20, (index) {
return Card(
child: Center(
child: Text('아이템 $index'),
),
);
}),
)
GridView.builder (동적 그리드)
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.75,
),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이미지 영역
Expanded(
child: Image.network(
product.imageUrl,
fit: BoxFit.cover,
width: double.infinity,
),
),
// 텍스트 영역
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'₩${product.price}',
style: const TextStyle(color: Colors.red),
),
],
),
),
],
),
);
},
)
SliverGridDelegateWithMaxCrossAxisExtent
열 수 대신 최대 너비 를 지정하면 화면 크기에 따라 열 수가 자동 조절됩니다.
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, // 각 아이템 최대 너비
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
itemCount: 20,
itemBuilder: (context, index) => Card(
child: Center(child: Text('$index')),
),
)
무한 스크롤
class InfiniteListScreen extends StatefulWidget {
const InfiniteListScreen({super.key});
@override
State<InfiniteListScreen> createState() => _InfiniteListScreenState();
}
class _InfiniteListScreenState extends State<InfiniteListScreen> {
final List<String> _items = [];
bool _isLoading = false;
int _page = 1;
@override
void initState() {
super.initState();
_loadMore();
}
Future<void> _loadMore() async {
if (_isLoading) return;
setState(() => _isLoading = true);
// API 호출 시뮬레이션
await Future.delayed(const Duration(seconds: 1));
final newItems = List.generate(
20,
(i) => '아이템 ${(_page - 1) * 20 + i}',
);
setState(() {
_items.addAll(newItems);
_page++;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: _items.length + 1, // +1은 로딩 인디케이터용
itemBuilder: (context, index) {
// 마지막 아이템에 도달하면 추가 로딩
if (index == _items.length) {
_loadMore();
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
return ListTile(title: Text(_items[index]));
},
);
}
}
Pull-to-Refresh
RefreshIndicator(
onRefresh: () async {
// 데이터 새로고침
await _fetchData();
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(items[index]));
},
),
)
Dismissible — 스와이프 삭제
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return Dismissible(
key: ValueKey(items[index].id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) {
setState(() {
items.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('삭제되었습니다')),
);
},
child: ListTile(title: Text(items[index].name)),
);
},
)
정리
- 아이템이 적으면
ListView(), 많으면ListView.builder()를 사용하세요 ListView.separated()로 구분선을 간편하게 추가할 수 있습니다GridView.builder()로 효율적인 그리드를 구현합니다- 무한 스크롤은 마지막 아이템에서 추가 데이터를 로드하는 패턴입니다
RefreshIndicator로 당겨서 새로고침,Dismissible로 스와이프 삭제를 구현합니다
댓글 로딩 중...