Flutter 튜토리얼 9편: 스크롤링과 Sliver

요약#

핵심 요지#

  • 문제 정의: 기본 스크롤 위젯으로는 앱 바 축소, 패럴랙스 효과 등 고급 스크롤 경험을 구현하기 어렵다.
  • 핵심 주장: Flutter의 Sliver1 시스템은 스크롤 영역을 세밀하게 제어해서 복잡한 스크롤 효과를 구현할 수 있다.
  • 주요 근거: CustomScrollView2로 여러 Sliver를 조합하고, SliverAppBar3로 축소되는 앱 바를, SliverList/SliverGrid로 효율적인 리스트를 만든다.
  • 실무 기준: 단순 스크롤은 ListView를 사용하고, 여러 스크롤 요소를 조합하거나 커스텀 효과가 필요하면 Sliver를 사용한다.
  • 한계: Sliver는 학습 곡선이 있고, 단순한 경우에는 오히려 복잡도만 높인다.

문서가 설명하는 범위#

  • 기본 스크롤 위젯과 고급 스크롤의 차이
  • Sliver의 개념과 CustomScrollView 사용법
  • SliverAppBar로 플로팅 앱 바 만들기
  • SliverList와 SliverGrid 활용
  • 패럴랙스 스크롤 효과 구현

읽는 시간: 18분 | 난이도: 중급


참고 자료#


문제 상황#

앱을 만들다 보면 단순히 콘텐츠를 스크롤하는 것 이상의 경험이 필요해집니다.
앱 바가 스크롤에 따라 축소되거나, 배경 이미지가 다른 속도로 움직이는 패럴랙스 효과가 대표적입니다.

기본 스크롤 위젯의 한계#

// 기본 ListView로는 앱 바 축소 효과를 구현할 수 없다
Scaffold(
appBar: AppBar(title: Text('고정된 앱 바')),
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
)

문제는 다음과 같습니다.

  • 앱 바와 리스트가 독립적이라서 연동된 스크롤 효과를 만들 수 없다.
  • 리스트와 그리드를 하나의 스크롤 뷰에서 섞어 쓰기 어렵다.
  • shrinkWrap: true4를 사용한 중첩 스크롤은 성능이 크게 저하된다.
  • 탄성 스크롤, 패럴랙스 같은 고급 효과는 기본 위젯으로 불가능하다.

해결 방법#

Flutter의 Sliver 시스템은 스크롤 가능한 영역을 조각(sliver)으로 나눠서 각각 다른 동작을 정의할 수 있게 합니다.

챕터 1: 스크롤 위젯 이해하기#

Why#

NOTE

Flutter는 다양한 스크롤 상황에 맞는 위젯을 제공합니다.
상황에 맞는 위젯을 선택해야 성능과 사용자 경험 모두를 잡을 수 있습니다.

단순 스크롤 → SingleChildScrollView, ListView
복잡한 스크롤 → CustomScrollView + Sliver

What#

NOTE

Flutter의 스크롤 위젯은 용도에 따라 구분됩니다.

위젯용도
SingleChildScrollView단일 자식을 스크롤
ListView세로 리스트
GridView그리드 레이아웃
CustomScrollView여러 Sliver 조합
DraggableScrollableSheet드래그 가능한 시트
ListWheelScrollView휠 스타일 스크롤

How#

TIP

기본 스크롤 위젯 선택 기준

graph TD A[스크롤이 필요한가?] -->|예| B{콘텐츠 유형} B -->|단일 위젯| C[SingleChildScrollView] B -->|동일 항목 반복| D{레이아웃} B -->|혼합 요소| E[CustomScrollView] D -->|세로 리스트| F[ListView] D -->|그리드| G[GridView] E --> H[Sliver 조합]

무한 스크롤에는 builder 사용

// 좋음: 필요한 항목만 생성
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(items[index]),
)
// 나쁨: 모든 항목을 한 번에 생성
ListView(
children: items.map((item) => ItemWidget(item)).toList(),
)

Watch out#

WARNING

중첩 스크롤에서 shrinkWrap: true를 사용하면 모든 자식을 한 번에 빌드해서 성능이 크게 저하됩니다.
대신 Sliver를 사용해야 합니다.

// 나쁨: shrinkWrap은 성능 문제를 일으킨다
ListView(
shrinkWrap: true,
children: [...],
)
// 좋음: CustomScrollView와 SliverList 사용
CustomScrollView(
slivers: [
SliverList(...),
],
)

결론: 단순 스크롤은 기본 위젯을, 복잡한 스크롤은 Sliver를 사용합니다.


챕터 2: Sliver 시스템 이해하기#

Why#

NOTE

Sliver는 “스크롤 가능한 영역의 조각”입니다.
여러 Sliver를 조합해서 앱 바, 리스트, 그리드가 하나의 스크롤 뷰에서 자연스럽게 연동되는 경험을 만들 수 있습니다.

CustomScrollView = SliverAppBar + SliverList + SliverGrid + ...

What#

NOTE

주요 Sliver 위젯입니다.

Sliver역할
SliverAppBar스크롤에 연동되는 앱 바
SliverListSliver 버전의 리스트
SliverGridSliver 버전의 그리드
SliverToBoxAdapter일반 위젯을 Sliver로 변환
SliverPersistentHeader고정/축소되는 헤더
SliverFillRemaining남은 공간을 채우는 Sliver

How#

TIP

CustomScrollView 기본 구조

CustomScrollView(
slivers: <Widget>[
// 1. 앱 바 영역
SliverAppBar(
title: Text('제목'),
expandedHeight: 200,
),
// 2. 일반 위젯 (Sliver로 변환)
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('섹션 헤더'),
),
),
// 3. 리스트 영역
SliverList.builder(
itemCount: 20,
itemBuilder: (context, index) => ListTile(
title: Text('항목 $index'),
),
),
],
)

일반 위젯 vs Sliver

graph LR subgraph "일반 스크롤" A[Scaffold] --> B[AppBar] A --> C[ListView] end subgraph "Sliver 스크롤" D[CustomScrollView] --> E[SliverAppBar] D --> F[SliverList] D --> G[SliverGrid] end

일반 방식에서는 AppBar와 ListView가 분리되어 있지만, Sliver 방식에서는 모든 요소가 하나의 스크롤 컨텍스트에서 동작합니다.

Watch out#

WARNING

CustomScrollViewslivers 속성에는 Sliver 위젯만 넣을 수 있습니다.
일반 위젯은 SliverToBoxAdapter로 감싸야 합니다.

// 오류: 일반 위젯은 slivers에 직접 넣을 수 없다
CustomScrollView(
slivers: [
Container(child: Text('헤더')), // 오류!
],
)
// 올바른 사용
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(child: Text('헤더')),
),
],
)

결론: Sliver는 여러 스크롤 요소를 하나의 스크롤 뷰에서 조합할 수 있게 합니다.


챕터 3: SliverAppBar로 플로팅 앱 바 만들기#

Why#

NOTE

사용자가 콘텐츠를 스크롤할 때 앱 바가 축소되거나 사라지면 화면 공간을 더 효율적으로 사용할 수 있습니다.
이런 동작은 SliverAppBar로 구현합니다.

스크롤 시작 → 앱 바 축소 → 제목만 남음
위로 스크롤 → 앱 바 다시 나타남

What#

NOTE

SliverAppBar는 스크롤에 연동되는 앱 바입니다.
expandedHeight, pinned, floating 속성으로 동작을 제어합니다.

How#

TIP

기본 플로팅 앱 바

Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: Text('플로팅 앱 바'),
expandedHeight: 200, // 확장 시 높이
pinned: true, // 스크롤해도 최소 크기로 유지
flexibleSpace: FlexibleSpaceBar(
background: Image.network(
'https://picsum.photos/800/400',
fit: BoxFit.cover,
),
),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (context, index) => ListTile(
title: Text('항목 $index'),
),
),
],
),
)

SliverAppBar 속성

속성설명기본값
expandedHeight확장 시 높이null
pinned축소 후에도 앱 바 표시false
floating위로 스크롤 시 즉시 나타남false
snapfloating과 함께 사용, 스냅 효과false
flexibleSpace확장 영역에 표시할 위젯null

동작 조합

// 1. 고정형: 항상 앱 바 표시
SliverAppBar(pinned: true)
// 2. 플로팅형: 위로 스크롤하면 나타남
SliverAppBar(floating: true)
// 3. 스냅형: 플로팅 + 자동 완성
SliverAppBar(floating: true, snap: true)
// 4. 고정 + 플로팅: 축소 후 고정, 위로 스크롤하면 확장
SliverAppBar(pinned: true, floating: true)

Watch out#

WARNING

snap: true는 반드시 floating: true와 함께 사용해야 합니다.
단독으로 사용하면 오류가 발생합니다.

// 오류: snap은 floating 없이 사용 불가
SliverAppBar(snap: true) // 오류!
// 올바른 사용
SliverAppBar(floating: true, snap: true)

결론: SliverAppBar로 스크롤에 반응하는 앱 바를 쉽게 구현할 수 있습니다.


챕터 4: SliverList와 SliverGrid 활용하기#

Why#

NOTE

CustomScrollView 안에서 리스트나 그리드를 사용하려면 Sliver 버전을 사용해야 합니다.
SliverListSliverGrid는 일반 ListView, GridView와 동일한 성능 최적화를 제공합니다.

What#

NOTE

Sliver 리스트와 그리드의 생성자입니다.

생성자용도
SliverList()자식 목록을 직접 전달
SliverList.builder()항목을 동적으로 생성 (권장)
SliverList.separated()항목 사이에 구분자 추가
SliverGrid()그리드 레이아웃
SliverGrid.builder()그리드 항목을 동적으로 생성
SliverGrid.count()열 개수 지정 그리드
SliverGrid.extent()최대 너비 지정 그리드

How#

TIP

SliverList 예제

CustomScrollView(
slivers: [
// 구분자가 있는 리스트
SliverList.separated(
itemCount: 20,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('항목 $index'),
subtitle: Text('설명 텍스트'),
),
separatorBuilder: (context, index) => Divider(),
),
],
)

SliverGrid 예제

CustomScrollView(
slivers: [
SliverGrid.count(
crossAxisCount: 2, // 2열
mainAxisSpacing: 8, // 세로 간격
crossAxisSpacing: 8, // 가로 간격
childAspectRatio: 1.5, // 가로:세로 비율
children: List.generate(20, (index) => Card(
child: Center(child: Text('Grid $index')),
)),
),
],
)

혼합 레이아웃

CustomScrollView(
slivers: [
// 앱 바
SliverAppBar(title: Text('혼합 레이아웃'), pinned: true),
// 섹션 헤더
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('추천 항목', style: TextStyle(fontSize: 20)),
),
),
// 가로 스크롤 (높이 고정)
SliverToBoxAdapter(
child: SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) => Card(
child: SizedBox(width: 100, child: Center(child: Text('$index'))),
),
),
),
),
// 섹션 헤더
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('전체 항목', style: TextStyle(fontSize: 20)),
),
),
// 그리드
SliverGrid.count(
crossAxisCount: 3,
children: List.generate(30, (index) => Card(
child: Center(child: Text('$index')),
)),
),
],
)

Watch out#

WARNING

SliverGrid에서 childAspectRatio를 잘못 설정하면 콘텐츠가 잘리거나 오버플로우가 발생합니다.
내용물의 실제 비율을 고려해서 설정해야 합니다.

// 문제: 텍스트가 많은데 정사각형으로 설정
SliverGrid.count(
crossAxisCount: 2,
childAspectRatio: 1.0, // 정사각형 → 텍스트 오버플로우 가능
children: cards,
)
// 해결: 내용에 맞는 비율 설정
SliverGrid.count(
crossAxisCount: 2,
childAspectRatio: 0.8, // 세로로 더 긴 비율
children: cards,
)

결론: SliverListSliverGridCustomScrollView 안에서 효율적인 리스트와 그리드를 구현합니다.


챕터 5: 패럴랙스 스크롤 효과 구현하기#

Why#

NOTE

패럴랙스(parallax)5 효과는 배경 이미지가 전경보다 느리게 움직여서 깊이감을 주는 기법입니다.
카드 리스트에서 이미지가 카드와 다른 속도로 움직이면 시각적으로 매력적인 경험을 제공합니다.

What#

NOTE

Flutter에서 패럴랙스 효과는 Flow6 위젯과 커스텀 FlowDelegate7로 구현합니다.
스크롤 위치에 따라 이미지의 오프셋을 계산해서 다른 속도로 움직이게 합니다.

How#

TIP

패럴랙스 효과의 원리

sequenceDiagram participant S as Scroll Position participant D as FlowDelegate participant I as Image S->>D: 스크롤 위치 변경 D->>D: 뷰포트 내 위치 계산 D->>D: 스크롤 비율 (0.0~1.0) D->>I: 이미지 오프셋 적용 Note over I: 카드보다 느리게 이동

기본 구조

class LocationCard extends StatelessWidget {
final String imageUrl;
final String title;
final GlobalKey _backgroundKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// 패럴랙스 배경
_buildParallaxBackground(context),
// 그라데이션 오버레이
_buildGradient(),
// 제목
_buildTitle(),
],
),
),
),
);
}
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _backgroundKey,
),
children: [
Image.network(
imageUrl,
key: _backgroundKey,
fit: BoxFit.cover,
),
],
);
}
Widget _buildGradient() {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black54],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.6, 0.95],
),
),
),
);
}
Widget _buildTitle() {
return Positioned(
left: 20,
bottom: 20,
child: Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
);
}
}

FlowDelegate 구현

class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(width: constraints.maxWidth);
}
@override
void paintChildren(FlowPaintingContext context) {
// 스크롤 영역과 리스트 항목의 RenderBox 가져오기
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
// 리스트 항목의 뷰포트 내 위치 계산
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox,
);
// 스크롤 비율 계산 (0.0 = 상단, 1.0 = 하단)
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
// 수직 정렬 계산 (-1.0 = 상단, 1.0 = 하단)
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// 이미지 크기와 오프셋 계산
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox).size;
final listItemSize = context.size;
final childRect = verticalAlignment.inscribe(
backgroundSize,
Offset.zero & listItemSize,
);
// 변환 적용하여 이미지 그리기
context.paintChild(
0,
transform: Transform.translate(offset: Offset(0.0, childRect.top)).transform,
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
}

Watch out#

WARNING

패럴랙스 효과는 매 프레임마다 계산이 필요해서 성능에 영향을 줄 수 있습니다.
이미지 크기를 최적화하고, 화면에 보이는 항목 수를 제한하는 것이 좋습니다.

// 성능 최적화: 캐시된 이미지 사용
Image.network(
imageUrl,
key: _backgroundKey,
fit: BoxFit.cover,
cacheWidth: 800, // 캐시 크기 제한
)

또한 Flow 위젯은 repaint 매개변수로 스크롤 위치를 전달받아야 실시간으로 업데이트됩니다.
이를 빠뜨리면 이미지가 움직이지 않습니다.

결론: FlowFlowDelegate를 활용하면 시각적으로 매력적인 패럴랙스 스크롤 효과를 구현할 수 있습니다.


챕터 6: iOS 스타일 스크롤 구현하기#

Why#

NOTE

iOS 앱에서는 큰 제목이 스크롤하면 작아지는 네비게이션 바 스타일이 일반적입니다.
Flutter의 CupertinoSliverNavigationBar8로 이 패턴을 구현할 수 있습니다.

What#

NOTE

Cupertino 디자인 시스템에서 제공하는 Sliver 위젯입니다.

위젯용도
CupertinoSliverNavigationBariOS 스타일 네비게이션 바
CupertinoSliverRefreshControl당겨서 새로고침

How#

TIP

iOS 스타일 플로팅 네비게이션 바

CupertinoApp(
home: CupertinoPageScaffold(
child: CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
largeTitle: Text('설정'),
// iOS 17+ 스타일의 검색 바
// searchBar: CupertinoSearchTextField(),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (context, index) => CupertinoListTile(
title: Text('항목 $index'),
trailing: CupertinoListTileChevron(),
),
),
],
),
),
)

Material vs Cupertino 비교

기능MaterialCupertino
앱 바SliverAppBarCupertinoSliverNavigationBar
리스트 항목ListTileCupertinoListTile
새로고침RefreshIndicatorCupertinoSliverRefreshControl
스크롤 물리ClampingScrollPhysicsBouncingScrollPhysics

Watch out#

WARNING

iOS 스타일의 탄성 스크롤(bounce)은 기본적으로 iOS에서만 적용됩니다.
Android에서도 사용하려면 명시적으로 BouncingScrollPhysics를 설정해야 합니다.

CustomScrollView(
physics: BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [...],
)

결론: Cupertino Sliver 위젯으로 iOS 네이티브 느낌의 스크롤 경험을 구현할 수 있습니다.


한계#

Sliver 시스템은 강력하지만 모든 상황에 적합하지는 않습니다.

  • 학습 곡선: 일반 위젯과 다른 개념이라 처음에는 이해하기 어렵습니다.
  • 복잡성 증가: 단순한 리스트에 Sliver를 사용하면 오히려 코드가 복잡해집니다.
  • 디버깅 어려움: Sliver 레이아웃 오류는 일반 위젯보다 디버깅이 까다롭습니다.
  • 커스텀 Sliver 구현: 기본 제공 Sliver로 부족한 경우 RenderSliver를 직접 구현해야 하며 난이도가 높습니다.

Footnotes#

  1. Sliver(슬리버): 스크롤 가능한 영역의 일부분을 나타내는 위젯으로, CustomScrollView 안에서 사용된다.

  2. CustomScrollView(커스텀스크롤뷰): 여러 Sliver 위젯을 조합해서 커스텀 스크롤 효과를 만드는 위젯이다.

  3. SliverAppBar(슬리버앱바): 스크롤에 연동되어 확장/축소되는 앱 바 위젯이다.

  4. shrinkWrap(쉬링크랩): 스크롤 위젯이 자식의 크기에 맞춰 축소되도록 하는 속성으로, 성능 저하를 일으킬 수 있다.

  5. parallax(패럴랙스): 배경과 전경이 다른 속도로 움직여서 깊이감을 주는 시각 효과다.

  6. Flow(플로우): 자식 위젯의 위치와 변환을 효율적으로 제어하는 위젯이다.

  7. FlowDelegate(플로우델리게이트): Flow 위젯의 자식 위치와 변환을 계산하는 클래스다.

  8. CupertinoSliverNavigationBar(쿠퍼티노슬리버네비게이션바): iOS 스타일의 큰 제목이 있는 네비게이션 바 Sliver 위젯이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 9편: 스크롤링과 Sliver
https://moodturnpost.net/posts/flutter/flutter-scrolling-slivers/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차