inblog logo
|
vosw1
    Flutter

    Sliver : 스크롤 및 리스트 렌더링

    송민경's avatar
    송민경
    Apr 19, 2024
    Sliver : 스크롤 및 리스트 렌더링
    Contents
    첫번째 방법 Scaffold에 appbar와 tabbar 두기두번째 방법 Column + Expanded

    Using slivers to achieve fancy scrolling
    Where to find information on using slivers to implement fancy scrolling effects, like elastic scrolling, in Flutter.
    Using slivers to achieve fancy scrolling
    https://docs.flutter.dev/ui/layout/scrolling/slivers
    Using slivers to achieve fancy scrolling

    1. Sliver

    • 스크롤 가능한 위젯의 일종
    • 효율적인 스크롤 및 리스트 렌더링을 위해 사용되는 독특한 디자인 패턴
    • 리스트의 각 아이템을 개별적으로 렌더링하고 스크롤 이벤트를 처리하는 데 최적화
    • CustomScrollView 위젯과 함께 사용되는 경우 매우 효율적인 리스트 렌더링 제공
    • 내부에도 모두 Sliver 를 사용해야 함
    아니면 화면의 크기를 계산해서 그림을 그려야함
    height: MediaQuery.of(context).size.height // 전체 화면 높이 - MediaQuery.of(context).padding.top // 상태바 높이 - _appBar.preferredSize.height // 앱바 높이 - 16 // 텍스트 높이 - 48 // 버튼 높이

    2. SliverList / SliverGrid

    • gridDelegate를 이용해서 그리드 레이아웃 정의
    • 매 index마다 itemBuilder가 생성된 그리드 반환
      • SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 1.0, ), delegate: SliverChildBuilderDelegate( (context, index) { return Card( child: Container( color: Colors.blue[index % 9 * 100], child: Center( child: Text('Grid Item $index'), ), ), ); }, childCount: 20, ), ),
    notion image

    3. SliverChildListDelegate : 일반적인 위젯 리스트→ SliverList

    SliverList( delegate: SliverChildListDelegate( [ Container( child: Image(image: NetworkImage('https://picsum.photos/id/1/200/300'),fit: BoxFit.cover), ), SizedBox(height: 10,), Container( child: Image(image: NetworkImage('https://picsum.photos/id/2/200/300'),fit: BoxFit.cover), ), SizedBox(height: 10,), Container( child: Image(image: NetworkImage('https://picsum.photos/id/3/200/300'),fit: BoxFit.cover), ), ] ), ),
     

    4. SliverChildBuilderDelegate

    • ListView.builder나 GridView.builder와 유사한 방식으로 작동
    • 매 index마다 itemBuilder 콜백을 사용하여 슬리버에 대한 자식을 반환
    • 동적인 리스트나 그리드를 쉽게 생성
    SliverList( delegate: SliverChildBuilderDelegate( childCount: 10, (context, index) => Padding( padding: const EdgeInsets.symmetric(vertical: 5.0), child: Image.network("https://picsum.photos/id/${index}/200/300", fit: BoxFit.contain,), ), ) ),
     

    5. ListView.builder / List.generate

    • List.generate : 미리 전체 아이템 리스트를 생성 / 메모리 많이 사용
    • ListView.builder : lazy loading기법을 사용하여 리스트 뷰 아이템이 필요할 때만 생성하고 화면에 표시
    • itemBuilder : 콜백함수를 이용해서 아이템이 필요할 때만 생성 / 대규모 데이터 처리에 효율적
     

    6. CustomScrollView : 조금 더 세부적인 ScrollView

    • snap과 floating옵션 : 위아래로 살짝만 스크롤해도 SliverAppBar가 화면에 나타나고 사라짐
    • pinned 옵션 : 스크롤을 올리더라도 앱바가 화면에서 사라지지 않음
    • expandedHeight : 앱바의 크기를 지정 / 기본값은 60
    • flexibleSpace : 유연한 공간을 제공하여 다양한 콘텐츠를 넣을수 있음
    class HomePage extends StatelessWidget { const HomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( snap: true, floating: true, title: Text("SliverAppBar"), pinned: false, expandedHeight: 250, flexibleSpace: FlexibleSpaceBar( title: Text("제목"), centerTitle: true, background: Image.network( 'https://picsum.photos/200/300', fit: BoxFit.cover, ), ), const SliverAppBar( title: Text("pinned"), pinned: true, centerTitle: true, ),
     

    7. SliverToBoxAdapter

    일반적인 위젯을 슬리버에서도 사용할 수 있게 어댑터 역할 해준다.
    SliverToBoxAdapter( child: Container( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, // 가로 스크롤 itemCount: 7, itemBuilder: (BuildContext context, int index) { return Image( image: AssetImage("assets/다운로드 (${index + 1}).jpg")); }, ), )),
    위 코드는 스크롤뷰 안에 어뎁터로 가로 리스트뷰를 넣었는데 다른 방법으로는 NestedScrollView 안에 ListView를 넣거나 CustomScrollView에서 SliverList를 생성하면 된다.
     

    8. SliverFixedExtentList

    내부 아이템들의 사이즈를 무시하고 지정한 크기 100으로 고정된다.
    SliverFixedExtentList( itemExtent: 100, // 내부 요소의 크기 무시 delegate: SliverChildBuilderDelegate( // 계산이 필요하면 빌더를 사용 (context, index) { if (index % 4 == 0 && index != 0) return Ad(((index / 4) - 1).toInt()); return Diary(index); }, )), ], ), ); }
     

    9. SliverFillViewport

    현재 뷰포트를 채우기 위한 뷰를 생성한다.
    SliverFillViewport( delegate: SliverChildBuilderDelegate( childCount: 10, (context, index) { return Card( child: Container( color: Colors.blue[index % 9 * 100], child: Center( child: Text('Fill Viewport Item $index'), ), ), ); }, ), ),
     

    10. SliverFillRemaining : 남은 공간을 채우기 위한 View 생성

    // 위에 있는 Sliver, SliverFillRemaining( child: Center( child: Text('This is the remaining content.'), ), ),
     

    11. SliverPersistentHeader : 앱에서 항상 표시되는 헤더

    SliverPersistentHeader( pinned: false, floating : false, delegate: MySliverPersistentHeaderDelegate( minHeight: 50.0, maxHeight: 120.0, child: Container( color: Colors.blue[300], child: const Center( child: Text( 'SliverPersistentHeader', style: TextStyle(color: Colors.white, fontSize: 20), ), ), ), ), ),
    • minHeight : 작은 값으로 축소된 헤더의 크기를 지정할 때 사용 / pinned: true일때 유효

    pinned: true일때

    • floating : false : 가장 상단으로 올라가야 내려옴
    • floating : true : 스크롤을 하자마자 헤더가 내려옴
    • SliverPersistentHeaderDelegate를 상속 -> 필요한 메소드와 속성 사용 헤더의 크기와 위치를 동적 조정 가능
    class MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { MySliverPersistentHeaderDelegate({ required this.minHeight, required this.maxHeight, required this.child, }); final double minHeight; final double maxHeight; final Widget child; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return SizedBox.expand(child: child); } @override double get maxExtent => maxHeight; @override double get minExtent => minHeight; @override bool shouldRebuild(covariant MySliverPersistentHeaderDelegate oldDelegate) { return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child; } }
     

    pinned false일때

    ## 상단 PersistenceHeader pinned false 적용 ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/a21638a1-cc1f-4c5f-be54-d65c3bb6a357/df6d4704-c9be-4873-be3b-b30f71602a71/Untitled.png) ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/a21638a1-cc1f-4c5f-be54-d65c3bb6a357/76a418db-1f32-4785-8c99-3332b62d193c/Untitled.png) ```dart import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.purple), home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( length: 3, child: Scaffold( appBar: AppBar( title: Text("Kurly"), ), body: HomeBody(), ), ); } } class HomeBody extends StatelessWidget { const HomeBody({super.key}); @override Widget build(BuildContext context) { return NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) { return [ SliverPersistentHeader(delegate: TabBarDelegate(), pinned: false) ]; }, body: TabBarView( children: [ ScreenA(), Container(color: Colors.blue), Container(color: Colors.yellow), ], ), ); } } class ScreenA extends StatelessWidget { const ScreenA({ super.key, }); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverToBoxAdapter( child: Container( height: 300, color: Colors.greenAccent, ), ), SliverPersistentHeader(delegate: CategoryBreadcrumbs(), pinned: true), SliverList( delegate: SliverChildBuilderDelegate( childCount: 40, (context, index) { return Container( height: 40, // 보는 재미를 위해 인덱스에 아무 숫자나 곱한 뒤 255로 // 나눠 다른 색이 보이도록 함. color: Color.fromRGBO( (index * 45) % 255, (index * 70) % 255, (index * 25), 1.0), ); }, ), ), ], ); } } class CategoryBreadcrumbs extends SliverPersistentHeaderDelegate { const CategoryBreadcrumbs(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( color: Colors.white, height: 48, padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ const Text("총 239개", style: TextStyle(color: Colors.black)), const Spacer(), TextButton( onPressed: () {}, child: const Center(child: Text("필터")), ) ], ), ); } @override double get maxExtent => 48; @override double get minExtent => 48; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } class TabBarDelegate extends SliverPersistentHeaderDelegate { const TabBarDelegate(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return TabBar( tabs: [ Tab(child: Text("컬리추천")), Tab(child: const Text("신상품")), Tab(child: const Text("베스트")), ], ); } @override double get maxExtent => 48; @override double get minExtent => 48; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } ```
    notion image
    notion image
     

    첫번째 방법 Scaffold에 appbar와 tabbar 두기

    import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.purple), home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( length: 3, child: Scaffold( appBar: AppBar( title: Text("Kurly"), bottom: TabBar( tabs: [ Tab(child: Text("컬리추천")), Tab(child: const Text("신상품")), Tab(child: const Text("베스트")), ], ), ), body: HomeBody(), ), ); } } class HomeBody extends StatelessWidget { const HomeBody({super.key}); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverFillRemaining( child: TabBarView( children: [ ScreenA(), Container(color: Colors.blue), Container(color: Colors.yellow), ], ), ), ], ); } } class ScreenA extends StatelessWidget { const ScreenA({ super.key, }); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverToBoxAdapter( child: Container( height: 300, color: Colors.greenAccent, ), ), SliverPersistentHeader(delegate: CategoryBreadcrumbs(), pinned: true), SliverList( delegate: SliverChildBuilderDelegate( childCount: 40, (context, index) { return Container( height: 40, // 보는 재미를 위해 인덱스에 아무 숫자나 곱한 뒤 255로 // 나눠 다른 색이 보이도록 함. color: Color.fromRGBO( (index * 45) % 255, (index * 70) % 255, (index * 25), 1.0), ); }, ), ), ], ); } } class CategoryBreadcrumbs extends SliverPersistentHeaderDelegate { const CategoryBreadcrumbs(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( color: Colors.white, height: 48, padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ const Text("총 239개", style: TextStyle(color: Colors.black)), const Spacer(), TextButton( onPressed: () {}, child: const Center(child: Text("필터")), ) ], ), ); } @override double get maxExtent => 48; @override double get minExtent => 48; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } }

    두번째 방법 Column + Expanded

    import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.purple), home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( length: 3, child: Scaffold( appBar: AppBar( title: Text("Kurly"), ), body: HomeBody(), ), ); } } class HomeBody extends StatelessWidget { const HomeBody({super.key}); @override Widget build(BuildContext context) { return Column( children: [ TabBar( tabs: [ Tab(child: Text("컬리추천")), Tab(child: const Text("신상품")), Tab(child: const Text("베스트")), ], ), Expanded( child: TabBarView( children: [ ScreenA(), Container(color: Colors.blue), Container(color: Colors.yellow), ], ), ), ], ); } } class ScreenA extends StatelessWidget { const ScreenA({ super.key, }); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverToBoxAdapter( child: Container( height: 300, color: Colors.greenAccent, ), ), SliverPersistentHeader(delegate: CategoryBreadcrumbs(), pinned: true), SliverList( delegate: SliverChildBuilderDelegate( childCount: 40, (context, index) { return Container( height: 40, // 보는 재미를 위해 인덱스에 아무 숫자나 곱한 뒤 255로 // 나눠 다른 색이 보이도록 함. color: Color.fromRGBO( (index * 45) % 255, (index * 70) % 255, (index * 25), 1.0), ); }, ), ), ], ); } } class CategoryBreadcrumbs extends SliverPersistentHeaderDelegate { const CategoryBreadcrumbs(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( color: Colors.white, height: 48, padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ const Text("총 239개", style: TextStyle(color: Colors.black)), const Spacer(), TextButton( onPressed: () {}, child: const Center(child: Text("필터")), ) ], ), ); } @override double get maxExtent => 48; @override double get minExtent => 48; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } } class TabBarDelegate extends SliverPersistentHeaderDelegate { const TabBarDelegate(); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return TabBar( tabs: [ Tab(child: Text("컬리추천")), Tab(child: const Text("신상품")), Tab(child: const Text("베스트")), ], ); } @override double get maxExtent => 48; @override double get minExtent => 48; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return false; } }
    notion image
    notion image
    notion image
    💡
    핵심은 NestedScrollView는 중첩된 스크롤을 공유해준다는 것이다. 보통 TabBar와 많이 활용된다
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    첫 글 보러가기
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    https://medium.com/@honor-driven/flutter로-shopping-앱-만들기-3-nestedscrollview-cec18854f859
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    첫 글 보러가기
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    https://medium.com/@honor-driven/flutter로-shopping-앱-만들기-3-nestedscrollview-cec18854f859
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    첫 글 보러가기
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    https://medium.com/@honor-driven/flutter로-shopping-앱-만들기-3-nestedscrollview-cec18854f859
    Flutter로 Shopping 앱 만들기 — 3 NestedScrollView
    import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( title: Text("앱바"), pinned: false, //snap: true, //floating: true, expandedHeight: 250, flexibleSpace: Container( color: Colors.green, ), ), SliverToBoxAdapter( child: Container( color: Colors.red, height: 300, ), ), SliverToBoxAdapter( child: SizedBox( height: 100, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 30, itemBuilder: (context, index) { return Container( width: 50, color: Colors.yellow[((index % 9) + 1) * 100], // 0 ~ 8 ); }, ), ), ), SliverList( delegate: SliverChildBuilderDelegate( childCount: 20, (context, index) { return Container( height: 50, color: Colors.blue[((index % 9) + 1) * 100], // 0 ~ 8 ); }, ), ), ], ), ); } }
    import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( //반응하는 앱바 title: Text("앱바"), pinned: true, // 앱바 고정 // pinned: false, // 앱바 사라짐 // snap: true, // //손가락으로 튕구다 // floating: true, expandedHeight: 250, flexibleSpace: Container( color: Colors.green, ), ), SliverToBoxAdapter( child: Container( color: Colors.red, height: 300, ), ), SliverPersistentHeader(delegate: ) SliverList( // 특정 타이밍에 자식의 스크롤을 무력화 시킨다. 포문 돌리면서 넣는거 delegate: SliverChildBuilderDelegate( childCount: 20, (context, index) { return Container( color: Colors.yellow[((index % 9) + 1) * 100], //0~8 height: 50, ); }, ), ), SliverList( // 특정 타이밍에 자식의 스크롤을 무력화 시킨다. 포문 돌리면서 넣는거 delegate: SliverChildBuilderDelegate( childCount: 20, (context, index) { return Container( color: Colors.blue[((index % 9) + 1) * 100], //0~8 height: 50, ); }, ), ) ], ), ); } } class MyPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { final double minHeight; final double maxHeight; MyPersistentHeaderDelegate({ required this.minHeight, required this.maxHeight, }); @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( color: Colors.blueGrey, child: Center( child: Text( 'SliverPersistentHeader', style: TextStyle(fontSize: 20.0, color: Colors.white), ), ), ); } @override double get maxExtent => maxHeight; @override double get minExtent => minHeight; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return true; } }
    notion image
    import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: HomePage(), ); } } class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( title: Text("Flutter Shopping App"), centerTitle: true, pinned: false, ), SliverAppBar( title: Text("앱바"), pinned: true, //snap: true, //floating: true, expandedHeight: 250, flexibleSpace: Container( color: Colors.green, ), ), SliverToBoxAdapter( child: Container( color: Colors.red, height: 300, ), ), SliverPersistentHeader( pinned: true, delegate: MyPersistentHeaderDelegate( child: Container( color: Colors.blueGrey, child: Center( child: Text( 'SliverPersistentHeader', style: TextStyle(fontSize: 20.0, color: Colors.white), ), ), ), minHeight: 50, maxHeight: 500, ), ), SliverPersistentHeader( pinned: true, delegate: MyPersistentHeaderDelegate( child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 30, itemBuilder: (context, index) { return Container( width: 100, color: Colors.yellow[((index % 9) + 1) * 100], // 0 ~ 8 ); }, ), minHeight: 100, maxHeight: 100, ), ), SliverList( delegate: SliverChildBuilderDelegate( childCount: 20, (context, index) { return Container( height: 50, color: Colors.blue[((index % 9) + 1) * 100], // 0 ~ 8 ); }, ), ), SliverFillRemaining( // 마지막 남은 높이 계산 child: Center( child: Text('This is the remaining content.'), ), ), ], ), ); } } class MyPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { final double minHeight; final double maxHeight; final Widget child; MyPersistentHeaderDelegate({ required this.child, required this.minHeight, required this.maxHeight, }); @override double get maxExtent => maxHeight; @override double get minExtent => minHeight; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return true; } @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return child; } }
    Share article
    Contents
    첫번째 방법 Scaffold에 appbar와 tabbar 두기두번째 방법 Column + Expanded

    vosw1

    RSS·Powered by Inblog