ListView와 GridView — 스크롤 가능한 목록 만들기

앱에서 목록은 가장 흔한 UI 패턴입니다. Flutter의 ListView와 GridView를 사용해서 효율적인 스크롤 목록을 만드는 방법을 정리해보겠습니다.


ListView — 기본 목록

ListView (정적 목록)

자식 위젯이 적을 때 사용합니다. 모든 자식을 한 번에 빌드합니다.

DART
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를 사용하세요. 화면에 보이는 것만 빌드합니다.

DART
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 (구분선 포함)

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

ListTile — 목록 아이템

DART
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 (고정 열 수)

DART
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 (동적 그리드)

DART
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

열 수 대신 최대 너비 를 지정하면 화면 크기에 따라 열 수가 자동 조절됩니다.

DART
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')),
  ),
)

무한 스크롤

DART
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

DART
RefreshIndicator(
  onRefresh: () async {
    // 데이터 새로고침
    await _fetchData();
  },
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
      return ListTile(title: Text(items[index]));
    },
  ),
)

Dismissible — 스와이프 삭제

DART
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로 스와이프 삭제를 구현합니다
댓글 로딩 중...