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