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

송민경's avatar
Apr 19, 2024
Sliver : 스크롤 및 리스트 렌더링

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와 많이 활용된다
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
RSSPowered by inblog