티스토리 뷰
패럴랙스(Parallax) 스크롤링 효과는 사용자가 리스트를 스크롤할 때 배경 이미지가 전경보다 느리게 움직이는 시각적 효과로, 앱에 깊이감을 더할 수 있습니다. Flutter에서 이러한 패럴랙스 효과를 쉽게 구현할 수 있으며, 이번 블로그에서는 Flutter의 위젯과 FlowDelegate
를 사용하여 패럴랙스 스크롤 효과를 어떻게 구현할 수 있는지 설명하겠습니다.
참고. Create a scrolling parallax effect
패럴랙스 효과란?
패럴랙스 효과는 리스트의 카드가 스크롤되는 동안, 배경 이미지가 더 천천히 움직이는 듯한 시각적 트릭을 제공하는 방식입니다. 이 효과를 통해 사용자는 화면의 깊이와 움직임을 동시에 느낄 수 있으며, 특히 이미지가 많은 리스트에서 효과적으로 사용됩니다. 이러한 효과는 사용자가 스크롤할 때 카드의 이미지가 화면에서 위 또는 아래로 움직이게 만들어 자연스럽고 몰입감 있는 사용자 경험을 제공합니다.
1. 패럴랙스 아이템 리스트 생성하기
패럴랙스 스크롤링을 구현하기 위해서는 우선 리스트를 구성해야 합니다. SingleChildScrollView
와 Column
을 사용하여 여러 개의 패럴랙스 아이템을 보여주는 위젯을 생성할 수 있습니다.
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
// 패럴랙스 아이템 추가
for (final location in locations)
LocationListItem(
imageUrl: location.imageUrl,
name: location.name,
country: location.country,
),
],
),
);
}
}
이 코드에서는 각 패럴랙스 아이템을 담을 리스트를 만들고, 리스트의 각 아이템에 대해 배경 이미지와 텍스트를 표시합니다.
2. 패럴랙스 리스트 아이템 디자인
각 리스트 아이템은 이미지와 텍스트로 구성되며, 이 텍스트는 이미지 위에 덧씌워집니다. 텍스트의 가독성을 높이기 위해 어두운 그라데이션 효과를 추가할 수 있습니다.
@immutable
class LocationListItem extends StatelessWidget {
const LocationListItem({
super.key,
required this.imageUrl,
required this.name,
required this.country,
});
final String imageUrl;
final String name;
final String country;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
_buildParallaxBackground(context),
_buildGradient(),
_buildTitleAndSubtitle(),
],
),
),
),
);
}
Widget _buildParallaxBackground(BuildContext context) {
return Positioned.fill(
child: Image.network(
imageUrl,
fit: BoxFit.cover,
),
);
}
Widget _buildGradient() {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.6, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle() {
return Positioned(
left: 20,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
country,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
);
}
}
3. 패럴랙스 효과 구현
패럴랙스 스크롤 효과는 이미지의 위치를 스크롤 상태에 따라 조금씩 이동시키는 방식으로 구현됩니다. 이를 위해 FlowDelegate
를 사용하여 리스트 아이템의 배경 이미지를 조금씩 다른 위치로 이동시킵니다.
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
),
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
],
);
}
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({required this.scrollable, required this.listItemContext});
final ScrollableState scrollable;
final BuildContext listItemContext;
@override
void paintChildren(FlowPaintingContext context) {
// 리스트 아이템의 현재 위치를 계산하고, 배경 이미지의 스크롤 속도를 조정하는 코드
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(Offset.zero, ancestor: scrollableBox);
final scrollFraction = (listItemOffset.dy / scrollable.position.viewportDimension).clamp(0.0, 1.0);
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
context.paintChild(
0,
transform: Transform.translate(offset: Offset(0.0, verticalAlignment.y * context.size.height)).transform,
);
}
@override
bool shouldRepaint(FlowDelegate oldDelegate) => true;
}
이 코드를 통해 이미지가 스크롤 위치에 따라 천천히 위아래로 이동하는 패럴랙스 효과를 구현할 수 있습니다. 배경 이미지의 위치를 계산하여 스크롤 시에 리스트 아이템의 스크롤 속도보다 더 느리게 움직이도록 합니다.
결론: Flutter로 패럴랙스 스크롤 효과 쉽게 구현하기
Flutter에서는 FlowDelegate와 같은 강력한 도구를 통해 복잡한 스크롤링 효과를 쉽게 구현할 수 있습니다. 패럴랙스 스크롤 효과는 단순한 리스트에 깊이감을 더하고, 사용자 경험을 한층 더 풍부하게 만들어 줍니다. 앱에 생동감을 부여하는 패럴랙스 효과는 특히 이미지가 많이 사용되는 앱에서 유용하며, Flutter를 통해 이러한 효과를 쉽게 구현할 수 있습니다.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
debugShowCheckedModeBanner: false,
home: const Scaffold(
body: Center(
child: ExampleParallax(),
),
),
);
}
}
class ExampleParallax extends StatelessWidget {
const ExampleParallax({
super.key,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
for (final location in locations)
LocationListItem(
imageUrl: location.imageUrl,
name: location.name,
country: location.place,
),
],
),
);
}
}
class LocationListItem extends StatelessWidget {
LocationListItem({
super.key,
required this.imageUrl,
required this.name,
required this.country,
});
final String imageUrl;
final String name;
final String country;
final GlobalKey _backgroundImageKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
_buildParallaxBackground(context),
_buildGradient(),
_buildTitleAndSubtitle(),
],
),
),
),
);
}
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _backgroundImageKey,
),
children: [
Image.network(
imageUrl,
key: _backgroundImageKey,
fit: BoxFit.cover,
),
],
);
}
Widget _buildGradient() {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.6, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle() {
return Positioned(
left: 20,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
country,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
);
}
}
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;
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
width: constraints.maxWidth,
);
}
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
// Paint the background.
context.paintChild(
0,
transform:
Transform.translate(offset: Offset(0.0, childRect.top)).transform,
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
}
class Parallax extends SingleChildRenderObjectWidget {
const Parallax({
super.key,
required Widget background,
}) : super(child: background);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderParallax(scrollable: Scrollable.of(context));
}
@override
void updateRenderObject(
BuildContext context, covariant RenderParallax renderObject) {
renderObject.scrollable = Scrollable.of(context);
}
}
class ParallaxParentData extends ContainerBoxParentData<RenderBox> {}
class RenderParallax extends RenderBox
with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin {
RenderParallax({
required ScrollableState scrollable,
}) : _scrollable = scrollable;
ScrollableState _scrollable;
ScrollableState get scrollable => _scrollable;
set scrollable(ScrollableState value) {
if (value != _scrollable) {
if (attached) {
_scrollable.position.removeListener(markNeedsLayout);
}
_scrollable = value;
if (attached) {
_scrollable.position.addListener(markNeedsLayout);
}
}
}
@override
void attach(covariant PipelineOwner owner) {
super.attach(owner);
_scrollable.position.addListener(markNeedsLayout);
}
@override
void detach() {
_scrollable.position.removeListener(markNeedsLayout);
super.detach();
}
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! ParallaxParentData) {
child.parentData = ParallaxParentData();
}
}
@override
void performLayout() {
size = constraints.biggest;
// Force the background to take up all available width
// and then scale its height based on the image's aspect ratio.
final background = child!;
final backgroundImageConstraints =
BoxConstraints.tightFor(width: size.width);
background.layout(backgroundImageConstraints, parentUsesSize: true);
// Set the background's local offset, which is zero.
(background.parentData as ParallaxParentData).offset = Offset.zero;
}
@override
void paint(PaintingContext context, Offset offset) {
// Get the size of the scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
// Calculate the global position of this list item.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final backgroundOffset =
localToGlobal(size.centerLeft(Offset.zero), ancestor: scrollableBox);
// Determine the percent position of this list item within the
// scrollable area.
final scrollFraction =
(backgroundOffset.dy / viewportDimension).clamp(0.0, 1.0);
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// Convert the background alignment into a pixel offset for
// painting purposes.
final background = child!;
final backgroundSize = background.size;
final listItemSize = size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
// Paint the background.
context.paintChild(
background,
(background.parentData as ParallaxParentData).offset +
offset +
Offset(0.0, childRect.top));
}
}
class Location {
const Location({
required this.name,
required this.place,
required this.imageUrl,
});
final String name;
final String place;
final String imageUrl;
}
const urlPrefix =
'https://docs.flutter.dev/cookbook/img-files/effects/parallax';
const locations = [
Location(
name: 'Mount Rushmore',
place: 'U.S.A',
imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
),
Location(
name: 'Gardens By The Bay',
place: 'Singapore',
imageUrl: '$urlPrefix/02-singapore.jpg',
),
Location(
name: 'Machu Picchu',
place: 'Peru',
imageUrl: '$urlPrefix/03-machu-picchu.jpg',
),
Location(
name: 'Vitznau',
place: 'Switzerland',
imageUrl: '$urlPrefix/04-vitznau.jpg',
),
Location(
name: 'Bali',
place: 'Indonesia',
imageUrl: '$urlPrefix/05-bali.jpg',
),
Location(
name: 'Mexico City',
place: 'Mexico',
imageUrl: '$urlPrefix/06-mexico-city.jpg',
),
Location(
name: 'Cairo',
place: 'Egypt',
imageUrl: '$urlPrefix/07-cairo.jpg',
),
];
'Flutter Cookbook' 카테고리의 다른 글
Flutter 스태거드 메뉴 애니메이션 구현 가이드: 한 단계씩 따라하는 Flutter 애니메이션 마스터하기 (0) | 2024.10.28 |
---|---|
Flutter로 쉬머 로딩 효과 구현하기: 상세 가이드 (0) | 2024.10.22 |
Flutter로 사진 필터 캐러셀 구현하기: 단계별 가이드 (1) | 2024.10.20 |
Flutter 중첩 내비게이션(Nested Navigation) 구현: 완벽한 가이드 (3) | 2024.10.19 |
Flutter에서 다운로드 버튼 구현하기: 단계별 가이드 (1) | 2024.10.18 |