Flutter + Dart/Flutter + Dart 공부
스크롤과 페이지 네비게이션 가이드
GREEN나무
2025. 5. 14. 17:21
728x90
✅ 스크롤 관련 속성 및 메서드
분류 | 항목 | 설명 |
위젯 | ListView, SingleChildScrollView, CustomScrollView |
스크롤 가능한 레이아웃 |
속성 | physics | 스크롤 동작을 제어하는 물리 법칙 설정 |
controller | 스크롤 위치/이벤트 제어용 ScrollController | |
scrollDirection | 스크롤 방향 설정 (Axis.vertical, Axis.horizontal) | |
primary | 기본 스크롤 컨트롤러 사용 여부 (보통 NestedView에서 false) | |
shrinkWrap | 내부 내용에 맞춰 크기 줄일지 여부 | |
physics 종류 | AlwaysScrollableScrollPhysics | 항상 스크롤 가능 |
NeverScrollableScrollPhysics | 스크롤 불가 | |
BouncingScrollPhysics | iOS 스타일 바운스 효과 | |
ClampingScrollPhysics | Android 스타일 끊김 스크롤 | |
ScrollController 메서드 | jumpTo(offset) | 지정 위치로 즉시 이동 |
animateTo(offset, duration, curve) | 애니메이션으로 지정 위치로 이동 | |
position.pixels | 현재 스크롤 위치 | |
position.maxScrollExtent | 최대로 스크롤할 수 있는 위치 | |
position.atEdge | 스크롤이 양끝에 도달했는지 여부 | |
ScrollController 이벤트 | addListener() | 스크롤 시마다 실행할 콜백 등록 |
✅ 페이징 관련 속성 및 메서드
분류 | 항목 | 설명 |
위젯 | PageView, PageView.builder | 페이지 단위로 넘기는 뷰 |
PageController | 현재 페이지 위치를 관리하는 컨트롤러 | |
PageController 속성/메서드 | initialPage | 시작 페이지 지정 |
jumpToPage(index) | 지정 페이지로 즉시 이동 | |
animateToPage(index, duration, curve) | 지정 페이지로 애니메이션 이동 | |
page | 현재 페이지(double형) | |
PageView 속성 | scrollDirection | 페이지 방향 설정 |
onPageChanged(index) | 페이지 변경 시 콜백 실행 | |
페이징 UI 도우미 | SmoothPageIndicator | 외부 라이브러리: 점 기반 인디케이터 |
DotsIndicator, PageIndicatorContainer | 커스텀 인디케이터 구현에 활용 |
✅ 공통 인터랙션 관련 속성
분류 | 항목 | 설명 |
위젯 | GestureDetector | 터치 입력 감지 (탭, 드래그 등) |
NotificationListener<ScrollNotification> | 스크롤 이벤트 캐치 | |
AnimatedSwitcher, AnimatedOpacity | 페이지 전환 시 애니메이션 효과 추가 | |
스크롤/페이지 동기화 | ScrollController + PageController | 특정 항목 도달 시 페이지 이동, 반대로 페이지 전환 시 스크롤 이동 처리 가능 |
✅ 스크롤/페이지 UI 디자인 요소
분류 | 항목 | 설명 |
레이아웃 | Expanded, Flexible, SizedBox | 스크롤 영역 크기 제어 |
Stack + Positioned | 페이지 넘김 버튼/인디케이터 오버레이 | |
디자인 보완 | Scrollbar | 스크롤바 표시 |
ScrollIndicator, ProgressBar | 하단 진행도 표시용 | |
PageTransitionSwitcher, FadeTransition, SlideTransition | 페이지 전환 애니메이션 |
✅ 실제 상황별 추천 조합
상황 | 위젯 | 조합 특징 |
문서 넘김 | PageView + GestureDetector | 드래그/터치 모두 지원 |
무한 목록 | ListView.builder + ScrollController + onScrollEnd | 무한 스크롤 로딩 |
섹션별 목록 | ListView + ScrollController.jumpTo() | 섹션 버튼으로 특정 위치 이동 |
단순 페이지 뷰 | PageView.builder + PageController | 고정된 페이징 |
반응형 목록 | ListView + MediaQuery | 목록 사이즈 조절 및 방향 변화 대응 |
✅ 영역 터치로 페이지 이동
분류 | 항목 | 설명 |
기본 위젯 | GestureDetector | 터치 이벤트 감지 (onTap, onTapDown, onTapUp 등) |
InkWell, InkResponse | 터치 피드백 효과 포함한 터치 영역 | |
위치 계산 | TapDownDetails.localPosition | 터치된 상대 좌표 확인 가능 |
LayoutBuilder + BoxConstraints | 터치 위치를 기준으로 왼쪽/오른쪽 판별 | |
페이지 이동 로직 | PageController.animateToPage() | 페이지 애니메이션 전환 |
PageController.jumpToPage() | 페이지 즉시 전환 | |
예시 로직 | 왼쪽 영역 터치 시 이전 페이지, 오른쪽 터치 시 다음 페이지 |
✅ 드래그로 페이지 이동
분류 | 항목 | 설명 |
기본 위젯 | PageView, PageView.builder | 수평/수직 드래그 페이지 전환 |
컨트롤러 | PageController | 현재 페이지 상태 및 이동 제어 |
속성 | scrollDirection | Axis.horizontal 또는 Axis.vertical 설정 |
onPageChanged(int index) | 페이지 변경 시 이벤트 콜백 | |
물리 설정 | physics | 예: BouncingScrollPhysics, ClampingScrollPhysics |
제스처 세부 제어 | GestureDetector + onHorizontalDragUpdate | 커스텀 드래그 처리 가능 (복잡한 제어 시) |
기타 UI 보완 | SmoothPageIndicator, DotsIndicator | 페이지 위치 시각화 |
✅ 버튼 터치로 페이지 이동
분류 | 항목 | 설명 |
기본 위젯 | ElevatedButton, IconButton, TextButton, GestureDetector | 터치 가능한 버튼 구성 |
페이지 이동 메서드 | PageController.animateToPage() | 지정 페이지로 애니메이션 이동 |
jumpToPage(index) | 즉시 이동 | |
예시 사용 | 하단 prev, next 버튼 → 각 버튼에 controller.animateToPage() 연결 | |
조건 처리 | 페이지 범위 제한 (0 이상, 최대 페이지 이하) 체크 필요 |
✅ 영역 터치로 스크롤 이동
분류 | 항목 | 설명 |
기본 위젯 | GestureDetector, InkWell | 특정 영역 감지 |
스크롤 제어 | ScrollController | 스크롤 위치 계산 및 이동 담당 |
스크롤 메서드 | jumpTo(double offset) | 즉시 해당 위치로 이동 |
animateTo(offset, duration, curve) | 애니메이션으로 해당 위치 이동 | |
영역 판단 | TapDownDetails.localPosition.dx | 터치 위치를 기준으로 위/아래 영역 판단 |
실제 활용 예 | 예: 하단 화면 터치 → 아래로 스크롤, 상단 화면 터치 → 위로 스크롤 |
✅ 보조 정보 요약
기능 | 제어 위젯 | 컨트롤러 | 핵심 메서드 |
영역 터치로 페이지 이동 | GestureDetector, InkWell | PageController | animateToPage(), jumpToPage() |
드래그로 페이지 이동 | PageView | PageController | 내부 자동 드래그 처리 |
버튼으로 페이지 이동 | ElevatedButton, IconButton | PageController | animateToPage() |
영역 터치로 스크롤 이동 | GestureDetector, InkWell | ScrollController | jumpTo(), animateTo() |
A. 스크롤바 예시 (항상 스크롤바 표시)
body: Scrollbar(
thumbVisibility: true, // 항상 스크롤바 표시
child: ListView.builder(
itemCount: items.length,
itemBuilder: (_, idx) => Card(
child: ListTile(
leading: Image.network(it['img']!),
title: Text(it['title']!),
subtitle: Text(it['content']!.substring(0, 20)),
),
),
),
)
핵심 포인트
항목 | 설명 |
Scrollbar | 스크롤 위치 시각화 |
thumbVisibility: true | 항상 보이도록 설정 |
ListView.builder | 항목이 많아도 효율적 렌더링 |
B. 숫자 페이징 예시 (항목 수 > 20)
Wrap(
spacing: 4,
children: List.generate(totalPages, (i) => TextButton(
onPressed: () => setState(() => currentPage = i + 1),
child: Text('${i + 1}'),
style: TextButton.styleFrom(
backgroundColor: currentPage == i + 1 ? Colors.blueAccent : null,
primary: currentPage == i + 1 ? Colors.white : Colors.black,
),
)),
)
핵심 포인트
항목 | 설명 |
Wrap + TextButton | 숫자 페이지 네비게이션 구현 |
currentPage | 현재 페이지 상태 관리 |
sublist() | 현재 페이지에 해당하는 항목만 표시 |
C. 조건부 스크롤 + 숫자 페이지 (항목 수 > 20인 경우만 페이징)
final needsPaging = items.length > perPage;
final displayItems = needsPaging ? items.sublist(start, end) : items;
...
if (needsPaging) Row( ... )
핵심 포인트
항목 | 설명 |
needsPaging | 조건 분기 플래그 |
스크롤바 + 페이지 | 스크롤은 항상 유지, 페이징은 조건부 적용 |
Scrollbar + ListView.builder | 스크롤 성능 및 UX 유지 |
요약 표
구현 방식 | 추천 상황 | 주요 위젯 | 주석 |
스크롤바만 | 항목이 적을 때 | Scrollbar, ListView.builder | 가장 간단한 구현 |
숫자 페이징 | 항목이 많고 UX가 중요할 때 | Wrap, TextButton, sublist() | 전체 페이지 수 표시 가능 |
조건부 혼합 | 항목 수 유동적일 때 | 위 두 방식 혼합 | 확장성 우수 |
상황별 예시
1. 항상 스크롤 있음
ListView.builder(
physics: AlwaysScrollableScrollPhysics(),
itemCount: 10,
itemBuilder: (context, index) => ListTile(title: Text('항목 $index')),
)
- 설명: 항목이 적더라도 스크롤이 항상 가능하게 설정됩니다.
- 포인트: AlwaysScrollableScrollPhysics() 사용.
2. 목록이 10개 이상인 경우만 스크롤 작동
ListView.builder(
physics: items.length > 10 ? null : NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text('항목 $index')),
)
- 설명: 10개 이하는 스크롤 불가, 11개부터 스크롤 허용.
- 포인트: physics 조건 분기.
3. 10개 이상인 경우 페이징만 작동 (하단에 버튼)
class PaginationButtons extends StatefulWidget {
@override
_PaginationButtonsState createState() => _PaginationButtonsState();
}
class _PaginationButtonsState extends State<PaginationButtons> {
int currentPage = 0;
final itemsPerPage = 10;
final List<String> items = List.generate(40, (i) => "항목 $i");
@override
Widget build(BuildContext context) {
int totalPages = (items.length / itemsPerPage).ceil();
List<String> pagedItems = items.skip(currentPage * itemsPerPage).take(itemsPerPage).toList();
return Column(
children: [
Expanded(
child: ListView.builder(
physics: NeverScrollableScrollPhysics(),
itemCount: pagedItems.length,
itemBuilder: (context, index) => ListTile(title: Text(pagedItems[index])),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(totalPages, (index) {
return TextButton(
onPressed: () => setState(() => currentPage = index),
child: Text('${index + 1}'),
);
}),
),
],
);
}
}
- 설명: 스크롤 비활성화, 버튼으로 페이지 이동.
- 포인트: skip과 take로 데이터 분할.

전문
더보기
import 'package:flutter/material.dart';
void main() {
runApp(DiaryApp());
}
class DiaryApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pagination Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: Scaffold(
appBar: AppBar(title: Text('숫자 페이징 예제')),
body: PaginationButtons(),
),
);
}
}
class PaginationButtons extends StatefulWidget {
@override
_PaginationButtonsState createState() => _PaginationButtonsState();
}
class _PaginationButtonsState extends State<PaginationButtons> {
int currentPage = 0;
final int itemsPerPage = 10;
final List<String> items = List.generate(40, (i) => "항목 ${i + 1}");
@override
Widget build(BuildContext context) {
int totalPages = (items.length / itemsPerPage).ceil();
List<String> pagedItems =
items.skip(currentPage * itemsPerPage).take(itemsPerPage).toList();
return Column(
children: [
Expanded(
child: ListView.builder(
physics: NeverScrollableScrollPhysics(),
itemCount: pagedItems.length,
itemBuilder:
(context, index) => ListTile(title: Text(pagedItems[index])),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(totalPages, (index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton(
onPressed: () => setState(() => currentPage = index),
style: TextButton.styleFrom(
backgroundColor:
currentPage == index
? Colors.blueAccent
: Colors.transparent,
foregroundColor:
currentPage == index ? Colors.white : Colors.black,
),
child: Text('${index + 1}'),
),
);
}),
),
),
],
);
}
}
4. 드래그로 페이지 이동 (PageView)
PageView.builder(
itemCount: (items.length / 10).ceil(),
itemBuilder: (context, pageIndex) {
final pageItems = items.skip(pageIndex * 10).take(10).toList();
return ListView(
children: pageItems.map((e) => ListTile(title: Text(e))).toList(),
);
},
)
- 설명: 옆으로 드래그하여 페이지 전환.
- 포인트: PageView.builder 사용.
5. 영역 터치로 페이지 이동
GestureDetector(
onTapUp: (details) {
final screenWidth = MediaQuery.of(context).size.width;
if (details.localPosition.dx < screenWidth / 2) {
// 왼쪽 터치
setState(() => currentPage = (currentPage - 1).clamp(0, totalPages - 1));
} else {
// 오른쪽 터치
setState(() => currentPage = (currentPage + 1).clamp(0, totalPages - 1));
}
},
child: ListView(
children: pagedItems.map((e) => ListTile(title: Text(e))).toList(),
),
)
- 설명: 좌우 터치 영역에 따라 페이지 변경.
- 포인트: onTapUp + localPosition.
6. 문서 페이지 넘기기 (드래그, 터치, 진행도)
class DocumentReader extends StatelessWidget {
final List<String> pages = List.generate(5, (i) => "문서 내용 $i");
@override
Widget build(BuildContext context) {
return PageView.builder(
itemCount: pages.length,
itemBuilder: (context, index) {
return Stack(
children: [
GestureDetector(
onTapUp: (details) {
final width = MediaQuery.of(context).size.width;
if (details.localPosition.dx < width / 2) {
PageController().previousPage(duration: Duration(milliseconds: 300), curve: Curves.ease);
} else {
PageController().nextPage(duration: Duration(milliseconds: 300), curve: Curves.ease);
}
},
child: Container(
padding: EdgeInsets.all(24),
child: Center(child: Text(pages[index], style: TextStyle(fontSize: 24))),
),
),
Positioned(
bottom: 10,
left: 0,
right: 0,
child: Center(child: Text('${index + 1} / ${pages.length}')),
),
],
);
},
);
}
}
- 설명: 드래그 및 터치 모두 허용, 페이지 표시.
- 포인트: 하단 페이지 진행도 텍스트.
7. 20개 이상: 스크롤 + 페이징
ListView.builder(
controller: ScrollController(),
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text('항목 $index')),
)
// + 아래에 페이지 버튼 추가 가능
- 설명: 스크롤은 계속 가능, 페이지 버튼으로 특정 구간 바로 이동도 가능.
- 확장: JumpToPage(index) 로 특정 위치로 이동.
8. 30개 이상: 스크롤만 작동 + 영역 터치로 아래로 이동
class TouchScroll extends StatelessWidget {
final ScrollController _scrollController = ScrollController();
final List<String> items = List.generate(50, (i) => "항목 $i");
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapUp: (details) {
final height = MediaQuery.of(context).size.height;
if (details.localPosition.dy > height * 0.5) {
_scrollController.animateTo(
_scrollController.offset + 500,
duration: Duration(milliseconds: 300),
curve: Curves.ease,
);
}
},
child: ListView.builder(
controller: _scrollController,
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
),
);
}
}
- 설명: 하단 터치 시 아래로 스크롤 10개 분량 이동.
- 포인트: GestureDetector + animateTo.
+ NestedScrollView
NestedScrollView는 Flutter에서 상단의 스크롤 가능한 영역(예: SliverAppBar)과 본문 콘텐츠 영역(예: ListView, GridView 등)을 함께 스크롤하는 UI를 구성할 때 사용하는 위젯입니다. 특히 상단 바가 스크롤에 따라 축소되거나 고정되는 "collapsing toolbar" UI에 적합합니다.
사용 위치 예시
상단 고정바
body NestedScrollView
플로트 버튼
✅ 1. 핵심 개념
항목 | 설명 |
목적 | 상단과 하단 스크롤 콘텐츠를 함께 연결하여 동기화된 스크롤 구현 |
특징 | SliverAppBar 같은 Sliver 기반 위젯과 함께 사용 내부 콘텐츠를 body에 포함 |
주요 사용처 | 앱바가 스크롤에 따라 사라지거나 고정되어야 할 때 (ex. 뉴스 앱, 스토어 앱 등) |
✅ 2. 관련 주요 위젯/속성/메서드/이벤트 정리
항목 | 종류 | 설명 |
NestedScrollView | 위젯 | 상하 스크롤을 결합해주는 메인 컨테이너 |
headerSliverBuilder | 속성 | 상단에 위치할 Sliver 위젯들을 빌드 (보통 SliverAppBar) |
body | 속성 | 하단 콘텐츠 영역. ListView, GridView, TabBarView 등 가능 |
SliverAppBar | 위젯 | 스크롤에 반응하는 앱바. 축소/확장/고정 등 가능 |
floating | 속성 (SliverAppBar) | 위로 스크롤 시 앱바가 다시 나타남 |
pinned | 속성 (SliverAppBar) | 앱바가 스크롤 시 상단에 고정됨 |
expandedHeight | 속성 (SliverAppBar) | 확장 가능한 앱바의 높이 |
FlexibleSpaceBar | 위젯 | SliverAppBar 내에서 배경과 타이틀을 부드럽게 확장 |
ScrollController | 클래스 | 스크롤 위치 제어 및 이벤트 감지 |
TabBar, TabBarView | 위젯 | 탭 전환 시 내부 콘텐츠를 스크롤 연동 |
ScrollPhysics | 속성 | 스크롤 동작 방식 제어 |
primary, controller, physics | 속성 | 스크롤 최적화용 설정들 |
✅ 3. 기본 문법
NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverAppBar(
title: Text("NestedScrollView 예시"),
expandedHeight: 200.0,
pinned: true,
floating: true,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
),
bottom: TabBar(
tabs: [
Tab(text: "Tab 1"),
Tab(text: "Tab 2"),
],
),
),
];
},
body: TabBarView(
children: [
ListView.builder(
itemCount: 30,
itemBuilder: (context, index) => ListTile(title: Text("Item $index")),
),
Center(child: Text("Tab 2 내용")),
],
),
)
✅ 4. 주요 동작 설명
- SliverAppBar는 스크롤 시 축소/확장/고정/자동 숨김 등 다양한 동작 가능.
- TabBar와 TabBarView를 함께 사용하면 각 탭 별로 스크롤 콘텐츠 제공 가능.
- 상단의 AppBar와 하단 콘텐츠가 동기화된 스크롤로 자연스럽게 작동함.
- 내부 body에는 반드시 스크롤 가능한 위젯이 들어가야 함 (ListView, CustomScrollView, 등).
✅ 5. 주의사항 및 팁
팁 | 설명 |
ListView 대신 CustomScrollView 사용 시 | 모든 콘텐츠를 SliverList, SliverToBoxAdapter로 구성해야 함 |
TabBarView 안에 있는 ListView 각각의 ScrollController가 필요할 수 있음 |
|
스크롤 충돌 방지 | primary: false 또는 NeverScrollableScrollPhysics 적용 |
SliverOverlapAbsorber / SliverOverlapInjector | 중첩된 Sliver의 겹침을 해결할 때 사용 |
✅ 6. 대표 예제 UI 구조도
NestedScrollView
├── headerSliverBuilder
│ └── SliverAppBar
│ └── FlexibleSpaceBar
├── body
└── TabBarView
├── ListView
└── ListView