Flutter 튜토리얼 9편: 스크롤링과 Sliver
요약
핵심 요지
- 문제 정의: 기본 스크롤 위젯으로는 앱 바 축소, 패럴랙스 효과 등 고급 스크롤 경험을 구현하기 어렵다.
- 핵심 주장: Flutter의
Sliver1 시스템은 스크롤 영역을 세밀하게 제어해서 복잡한 스크롤 효과를 구현할 수 있다. - 주요 근거:
CustomScrollView2로 여러 Sliver를 조합하고,SliverAppBar3로 축소되는 앱 바를,SliverList/SliverGrid로 효율적인 리스트를 만든다. - 실무 기준: 단순 스크롤은
ListView를 사용하고, 여러 스크롤 요소를 조합하거나 커스텀 효과가 필요하면 Sliver를 사용한다. - 한계: Sliver는 학습 곡선이 있고, 단순한 경우에는 오히려 복잡도만 높인다.
문서가 설명하는 범위
- 기본 스크롤 위젯과 고급 스크롤의 차이
- Sliver의 개념과 CustomScrollView 사용법
- SliverAppBar로 플로팅 앱 바 만들기
- SliverList와 SliverGrid 활용
- 패럴랙스 스크롤 효과 구현
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Scrolling - 스크롤 위젯 개요
- Using slivers to achieve fancy scrolling - Sliver 시스템 설명
- Place a floating app bar above a list - 플로팅 앱 바 구현
- Create a scrolling parallax effect - 패럴랙스 효과 구현
문제 상황
앱을 만들다 보면 단순히 콘텐츠를 스크롤하는 것 이상의 경험이 필요해집니다.
앱 바가 스크롤에 따라 축소되거나, 배경 이미지가 다른 속도로 움직이는 패럴랙스 효과가 대표적입니다.
기본 스크롤 위젯의 한계
// 기본 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
NOTEFlutter는 다양한 스크롤 상황에 맞는 위젯을 제공합니다.
상황에 맞는 위젯을 선택해야 성능과 사용자 경험 모두를 잡을 수 있습니다.단순 스크롤 → SingleChildScrollView, ListView복잡한 스크롤 → CustomScrollView + Sliver
What
NOTEFlutter의 스크롤 위젯은 용도에 따라 구분됩니다.
위젯 용도 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
NOTESliver는 “스크롤 가능한 영역의 조각”입니다.
여러 Sliver를 조합해서 앱 바, 리스트, 그리드가 하나의 스크롤 뷰에서 자연스럽게 연동되는 경험을 만들 수 있습니다.CustomScrollView = SliverAppBar + SliverList + SliverGrid + ...
What
NOTE주요 Sliver 위젯입니다.
Sliver 역할 SliverAppBar스크롤에 연동되는 앱 바 SliverListSliver 버전의 리스트 SliverGridSliver 버전의 그리드 SliverToBoxAdapter일반 위젯을 Sliver로 변환 SliverPersistentHeader고정/축소되는 헤더 SliverFillRemaining남은 공간을 채우는 Sliver
How
TIPCustomScrollView 기본 구조
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
CustomScrollView의slivers속성에는 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 버전을 사용해야 합니다.
SliverList와SliverGrid는 일반ListView,GridView와 동일한 성능 최적화를 제공합니다.
What
NOTESliver 리스트와 그리드의 생성자입니다.
생성자 용도 SliverList()자식 목록을 직접 전달 SliverList.builder()항목을 동적으로 생성 (권장) SliverList.separated()항목 사이에 구분자 추가 SliverGrid()그리드 레이아웃 SliverGrid.builder()그리드 항목을 동적으로 생성 SliverGrid.count()열 개수 지정 그리드 SliverGrid.extent()최대 너비 지정 그리드
How
TIPSliverList 예제
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,)
결론: SliverList와 SliverGrid로 CustomScrollView 안에서 효율적인 리스트와 그리드를 구현합니다.
챕터 5: 패럴랙스 스크롤 효과 구현하기
Why
NOTE패럴랙스(parallax)5 효과는 배경 이미지가 전경보다 느리게 움직여서 깊이감을 주는 기법입니다.
카드 리스트에서 이미지가 카드와 다른 속도로 움직이면 시각적으로 매력적인 경험을 제공합니다.
What
NOTEFlutter에서 패럴랙스 효과는
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();@overrideWidget 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;@overrideBoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {return BoxConstraints.tightFor(width: constraints.maxWidth);}@overridevoid 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,);}@overridebool 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매개변수로 스크롤 위치를 전달받아야 실시간으로 업데이트됩니다.
이를 빠뜨리면 이미지가 움직이지 않습니다.
결론: Flow와 FlowDelegate를 활용하면 시각적으로 매력적인 패럴랙스 스크롤 효과를 구현할 수 있습니다.
챕터 6: iOS 스타일 스크롤 구현하기
Why
NOTEiOS 앱에서는 큰 제목이 스크롤하면 작아지는 네비게이션 바 스타일이 일반적입니다.
Flutter의CupertinoSliverNavigationBar8로 이 패턴을 구현할 수 있습니다.
What
NOTECupertino 디자인 시스템에서 제공하는 Sliver 위젯입니다.
위젯 용도 CupertinoSliverNavigationBariOS 스타일 네비게이션 바 CupertinoSliverRefreshControl당겨서 새로고침
How
TIPiOS 스타일 플로팅 네비게이션 바
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 비교
기능 Material Cupertino 앱 바 SliverAppBarCupertinoSliverNavigationBar리스트 항목 ListTileCupertinoListTile새로고침 RefreshIndicatorCupertinoSliverRefreshControl스크롤 물리 ClampingScrollPhysicsBouncingScrollPhysics
Watch out
WARNINGiOS 스타일의 탄성 스크롤(bounce)은 기본적으로 iOS에서만 적용됩니다.
Android에서도 사용하려면 명시적으로BouncingScrollPhysics를 설정해야 합니다.CustomScrollView(physics: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics(),),slivers: [...],)
결론: Cupertino Sliver 위젯으로 iOS 네이티브 느낌의 스크롤 경험을 구현할 수 있습니다.
한계
Sliver 시스템은 강력하지만 모든 상황에 적합하지는 않습니다.
- 학습 곡선: 일반 위젯과 다른 개념이라 처음에는 이해하기 어렵습니다.
- 복잡성 증가: 단순한 리스트에 Sliver를 사용하면 오히려 코드가 복잡해집니다.
- 디버깅 어려움: Sliver 레이아웃 오류는 일반 위젯보다 디버깅이 까다롭습니다.
- 커스텀 Sliver 구현: 기본 제공 Sliver로 부족한 경우
RenderSliver를 직접 구현해야 하며 난이도가 높습니다.
Footnotes
-
Sliver(슬리버): 스크롤 가능한 영역의 일부분을 나타내는 위젯으로, CustomScrollView 안에서 사용된다. ↩
-
CustomScrollView(커스텀스크롤뷰): 여러 Sliver 위젯을 조합해서 커스텀 스크롤 효과를 만드는 위젯이다. ↩
-
SliverAppBar(슬리버앱바): 스크롤에 연동되어 확장/축소되는 앱 바 위젯이다. ↩
-
shrinkWrap(쉬링크랩): 스크롤 위젯이 자식의 크기에 맞춰 축소되도록 하는 속성으로, 성능 저하를 일으킬 수 있다. ↩
-
parallax(패럴랙스): 배경과 전경이 다른 속도로 움직여서 깊이감을 주는 시각 효과다. ↩
-
Flow(플로우): 자식 위젯의 위치와 변환을 효율적으로 제어하는 위젯이다. ↩
-
FlowDelegate(플로우델리게이트): Flow 위젯의 자식 위치와 변환을 계산하는 클래스다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!