Slivers 심화 — CustomScrollView, SliverAppBar, SliverList

Sliver는 스크롤 가능한 영역의 일부를 의미합니다. CustomScrollView 안에서 여러 Sliver 위젯을 조합하면, ListView와 GridView를 한 화면에 함께 넣거나 접히는 AppBar 같은 고급 스크롤 UI를 구현할 수 있습니다.


왜 Sliver가 필요한가?

DART
// 이렇게 하면 에러! ListView 안에 ListView를 넣을 수 없음
ListView(
  children: [
    ListView.builder(...),  // 에러: 무한 높이 충돌
    GridView.builder(...),  // 에러: 같은 이유
  ],
)

// Sliver로 해결
CustomScrollView(
  slivers: [
    SliverList(...),
    SliverGrid(...),
  ],
)

CustomScrollView 기본 구조

DART
CustomScrollView(
  slivers: [
    // 접히는 AppBar
    SliverAppBar(
      expandedHeight: 200,
      floating: false,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('Sliver 예제'),
        background: Image.network(
          'https://example.com/header.jpg',
          fit: BoxFit.cover,
        ),
      ),
    ),

    // 리스트
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(
          title: Text('아이템 $index'),
        ),
        childCount: 20,
      ),
    ),
  ],
)

SliverAppBar 옵션

속성동작
floating: true스크롤 올리면 바로 나타남
pinned: true접혀도 상단에 고정
snap: truefloating과 함께 사용, 스냅 효과
expandedHeight펼쳐진 상태의 높이
stretch: true과도하게 스크롤하면 이미지 늘어남
DART
// 흔한 조합들
// 1. 고정 AppBar (Instagram 프로필)
SliverAppBar(pinned: true, expandedHeight: 200)

// 2. 플로팅 AppBar (검색 바)
SliverAppBar(floating: true, snap: true)

// 3. 완전히 사라지는 AppBar
SliverAppBar(floating: false, pinned: false, expandedHeight: 200)

Sliver 위젯 종류

SliverList

DART
// 빌더 방식 (권장)
SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) => ListTile(title: Text('$index')),
    childCount: 100,
  ),
)

// SliverList.builder (더 간결)
SliverList.builder(
  itemCount: 100,
  itemBuilder: (context, index) => ListTile(title: Text('$index')),
)

// 구분선 포함
SliverList.separated(
  itemCount: 50,
  itemBuilder: (context, index) => ListTile(title: Text('$index')),
  separatorBuilder: (context, index) => const Divider(),
)

SliverGrid

DART
SliverGrid(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 8,
    mainAxisSpacing: 8,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) => Card(
      child: Center(child: Text('$index')),
    ),
    childCount: 20,
  ),
)

SliverToBoxAdapter — 일반 위젯을 Sliver로

DART
CustomScrollView(
  slivers: [
    SliverAppBar(...),

    // 일반 위젯을 Sliver 안에 넣기
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          '섹션 제목',
          style: Theme.of(context).textTheme.titleLarge,
        ),
      ),
    ),

    SliverList.builder(...),

    // 하단 여백
    const SliverToBoxAdapter(
      child: SizedBox(height: 80),
    ),
  ],
)

SliverPadding

DART
SliverPadding(
  padding: const EdgeInsets.symmetric(horizontal: 16),
  sliver: SliverGrid(...),
)

SliverFillRemaining

DART
// 남은 공간을 모두 채우기
SliverFillRemaining(
  hasScrollBody: false,
  child: Center(
    child: Text('콘텐츠가 없습니다'),
  ),
)

실전: 프로필 화면

DART
class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // 접히는 프로필 헤더
          SliverAppBar(
            expandedHeight: 250,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('심정훈'),
              background: Stack(
                fit: StackFit.expand,
                children: [
                  Image.network(
                    'https://example.com/cover.jpg',
                    fit: BoxFit.cover,
                  ),
                  const DecoratedBox(
                    decoration: BoxDecoration(
                      gradient: LinearGradient(
                        begin: Alignment.topCenter,
                        end: Alignment.bottomCenter,
                        colors: [Colors.transparent, Colors.black54],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),

          // 프로필 정보
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Flutter 개발자'),
                  const SizedBox(height: 8),
                  Row(
                    children: [
                      _statItem('게시물', '42'),
                      _statItem('팔로워', '1.2K'),
                      _statItem('팔로잉', '156'),
                    ],
                  ),
                ],
              ),
            ),
          ),

          // 탭 (고정)
          SliverPersistentHeader(
            pinned: true,
            delegate: _TabHeaderDelegate(),
          ),

          // 게시물 그리드
          SliverPadding(
            padding: const EdgeInsets.all(2),
            sliver: SliverGrid.builder(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 3,
                crossAxisSpacing: 2,
                mainAxisSpacing: 2,
              ),
              itemCount: 30,
              itemBuilder: (context, index) => Image.network(
                'https://picsum.photos/200?random=$index',
                fit: BoxFit.cover,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _statItem(String label, String count) {
    return Expanded(
      child: Column(
        children: [
          Text(count, style: const TextStyle(fontWeight: FontWeight.bold)),
          Text(label, style: const TextStyle(color: Colors.grey)),
        ],
      ),
    );
  }
}

SliverPersistentHeader — 고정 헤더

DART
class _TabHeaderDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return Container(
      color: Theme.of(context).scaffoldBackgroundColor,
      child: const TabBar(
        tabs: [
          Tab(icon: Icon(Icons.grid_on)),
          Tab(icon: Icon(Icons.list)),
        ],
      ),
    );
  }

  @override
  double get maxExtent => 48;

  @override
  double get minExtent => 48;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    return false;
  }
}

정리

  • CustomScrollView + Sliver 위젯으로 복잡한 스크롤 UI를 구현합니다
  • SliverAppBarfloating, pinned, snap 조합으로 다양한 동작을 만들 수 있습니다
  • SliverToBoxAdapter로 일반 위젯을 Sliver 안에 넣을 수 있습니다
  • SliverPersistentHeader로 스크롤 시 고정되는 헤더를 만듭니다
  • ListView와 GridView를 한 화면에 함께 넣을 때 Sliver가 필수입니다
댓글 로딩 중...