티스토리 뷰
Flutter에서 사진 필터 캐러셀을 구현하는 것은 앱에서 사진 편집 기능을 추가할 때 유용한 기능 중 하나입니다. 이번 블로그에서는 Flutter를 사용하여 사진 필터를 선택할 수 있는 스크롤 가능한 캐러셀을 만드는 방법을 다룹니다. 이를 통해 사용자들은 다양한 필터를 간편하게 선택할 수 있으며, 필터가 적용된 사진을 실시간으로 확인할 수 있습니다.
사진 필터 캐러셀의 주요 개념
사진 필터 캐러셀은 다음과 같은 구조와 기능을 가지고 있습니다:
- 필터 선택 영역: 필터를 선택할 수 있는 원형 선택기가 있으며, 선택된 필터는 하이라이트됩니다.
- 필터 적용 미리보기: 사용자가 필터를 선택할 때마다 사진에 즉시 적용되어 미리보기가 가능합니다.
- 스크롤 가능한 캐러셀: 사용자는 좌우로 드래그하여 다양한 필터를 탐색할 수 있습니다.
1. 선택기와 그라데이션 추가
필터 선택기는 선택기 링으로 표시되며, 어두운 그라데이션 배경이 필터와 사진 간의 대비를 높여줍니다. 선택기가 중앙에 위치하여 사용자가 현재 선택된 필터를 명확히 확인할 수 있습니다.
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildSelectionRing(itemSize),
],
);
},
);
}
Widget _buildShadowGradient(double itemSize) {
return SizedBox(
height: itemSize * 2,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
),
);
}
Widget _buildSelectionRing(double itemSize) {
return IgnorePointer(
child: SizedBox(
width: itemSize,
height: itemSize,
child: const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: 6, color: Colors.white),
),
),
),
),
);
}
}
이 코드를 통해 선택기 링과 그라데이션 효과가 적용된 배경을 만들 수 있습니다.
2. 필터 아이템 생성
각 필터는 색상이 적용된 원형 이미지로 표현됩니다. 이를 위해 FilterItem이라는 위젯을 생성하여 사용자가 클릭할 수 있는 인터페이스를 제공합니다.
class FilterItem extends StatelessWidget {
const FilterItem({
Key? key,
required this.color,
this.onFilterSelected,
}) : super(key: key);
final Color color;
final VoidCallback? onFilterSelected;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onFilterSelected,
child: ClipOval(
child: Image.network(
'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millennial-texture.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
);
}
}
3. 필터 캐러셀 구현
필터 캐러셀은 PageView 위젯을 사용하여 구현됩니다. PageView는 스크롤할 때 선택된 필터가 중앙에 고정되고, 나머지 필터는 양쪽으로 스크롤됩니다. 필터 간 크기 및 투명도 변화는 PageController를 통해 제어됩니다.
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(viewportFraction: 1.0 / _filtersPerScreen);
}
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return FilterItem(
color: widget.filters[index],
onFilterSelected: () => _onFilterSelected(index),
);
},
),
);
}
void _onFilterSelected(int index) {
_controller.animateToPage(index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut);
}
}
4. 필터 아이템 애니메이션 적용
필터가 선택될 때 각 아이템의 크기와 투명도가 부드럽게 변화하도록 AnimatedBuilder를 사용하여 애니메이션을 적용할 수 있습니다. 이를 통해 사용자는 필터를 스크롤할 때 자연스러운 전환 효과를 경험할 수 있습니다.
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
child: PageView.builder(
controller: _controller,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FilterItem(
color: widget.filters[index],
onFilterSelected: () => _onFilterSelected(index),
);
},
);
},
),
);
}
결론: Flutter로 캐러셀 UI 쉽게 구현하기
Flutter의 강력한 PageView와 AnimatedBuilder 위젯을 활용하여 사용자는 드래그 가능한 필터 캐러셀을 손쉽게 구현할 수 있습니다. 이 방법을 통해 사용자 경험을 극대화하고, 직관적이고 시각적으로 매력적인 인터페이스를 만들 수 있습니다. Flutter의 풍부한 위젯을 사용하면 더 나은 UI를 쉽고 빠르게 구현할 수 있습니다.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ViewportOffset;
void main() {
runApp(
const MaterialApp(
home: ExampleInstagramFilterSelection(),
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class ExampleInstagramFilterSelection extends StatefulWidget {
const ExampleInstagramFilterSelection({super.key});
@override
State<ExampleInstagramFilterSelection> createState() =>
_ExampleInstagramFilterSelectionState();
}
class _ExampleInstagramFilterSelectionState
extends State<ExampleInstagramFilterSelection> {
final _filters = [
Colors.white,
...List.generate(
Colors.primaries.length,
(index) => Colors.primaries[(index * 4) % Colors.primaries.length],
)
];
final _filterColor = ValueNotifier<Color>(Colors.white);
void _onFilterChanged(Color value) {
_filterColor.value = value;
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: _buildPhotoWithFilter(),
),
Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
child: _buildFilterSelector(),
),
],
),
);
}
Widget _buildPhotoWithFilter() {
return ValueListenableBuilder(
valueListenable: _filterColor,
builder: (context, color, child) {
return Image.network(
'https://docs.flutter.dev/cookbook/img-files'
'/effects/instagram-buttons/millennial-dude.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.color,
fit: BoxFit.cover,
);
},
);
}
Widget _buildFilterSelector() {
return FilterSelector(
onFilterChanged: _onFilterChanged,
filters: _filters,
);
}
}
@immutable
class FilterSelector extends StatefulWidget {
const FilterSelector({
super.key,
required this.filters,
required this.onFilterChanged,
this.padding = const EdgeInsets.symmetric(vertical: 24),
});
final List<Color> filters;
final void Function(Color selectedColor) onFilterChanged;
final EdgeInsets padding;
@override
State<FilterSelector> createState() => _FilterSelectorState();
}
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
late final PageController _controller;
late int _page;
int get filterCount => widget.filters.length;
Color itemColor(int index) => widget.filters[index % filterCount];
@override
void initState() {
super.initState();
_page = 0;
_controller = PageController(
initialPage: _page,
viewportFraction: _viewportFractionPerItem,
);
_controller.addListener(_onPageChanged);
}
void _onPageChanged() {
final page = (_controller.page ?? 0).round();
if (page != _page) {
_page = page;
widget.onFilterChanged(widget.filters[page]);
}
}
void _onFilterTapped(int index) {
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scrollable(
controller: _controller,
axisDirection: AxisDirection.right,
physics: const PageScrollPhysics(),
viewportBuilder: (context, viewportOffset) {
return LayoutBuilder(
builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
viewportOffset
..applyViewportDimension(constraints.maxWidth)
..applyContentDimensions(0.0, itemSize * (filterCount - 1));
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildCarousel(
viewportOffset: viewportOffset,
itemSize: itemSize,
),
_buildSelectionRing(itemSize),
],
);
},
);
},
);
}
Widget _buildShadowGradient(double itemSize) {
return SizedBox(
height: itemSize * 2 + widget.padding.vertical,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: SizedBox.expand(),
),
);
}
Widget _buildCarousel({
required ViewportOffset viewportOffset,
required double itemSize,
}) {
return Container(
height: itemSize,
margin: widget.padding,
child: Flow(
delegate: CarouselFlowDelegate(
viewportOffset: viewportOffset,
filtersPerScreen: _filtersPerScreen,
),
children: [
for (int i = 0; i < filterCount; i++)
FilterItem(
onFilterSelected: () => _onFilterTapped(i),
color: itemColor(i),
),
],
),
);
}
Widget _buildSelectionRing(double itemSize) {
return IgnorePointer(
child: Padding(
padding: widget.padding,
child: SizedBox(
width: itemSize,
height: itemSize,
child: const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: 6, color: Colors.white),
),
),
),
),
),
);
}
}
class CarouselFlowDelegate extends FlowDelegate {
CarouselFlowDelegate({
required this.viewportOffset,
required this.filtersPerScreen,
}) : super(repaint: viewportOffset);
final ViewportOffset viewportOffset;
final int filtersPerScreen;
@override
void paintChildren(FlowPaintingContext context) {
final count = context.childCount;
// All available painting width
final size = context.size.width;
// The distance that a single item "page" takes up from the perspective
// of the scroll paging system. We also use this size for the width and
// height of a single item.
final itemExtent = size / filtersPerScreen;
// The current scroll position expressed as an item fraction, e.g., 0.0,
// or 1.0, or 1.3, or 2.9, etc. A value of 1.3 indicates that item at
// index 1 is active, and the user has scrolled 30% towards the item at
// index 2.
final active = viewportOffset.pixels / itemExtent;
// Index of the first item we need to paint at this moment.
// At most, we paint 3 items to the left of the active item.
final min = math.max(0, active.floor() - 3).toInt();
// Index of the last item we need to paint at this moment.
// At most, we paint 3 items to the right of the active item.
final max = math.min(count - 1, active.ceil() + 3).toInt();
// Generate transforms for the visible items and sort by distance.
for (var index = min; index <= max; index++) {
final itemXFromCenter = itemExtent * index - viewportOffset.pixels;
final percentFromCenter = 1.0 - (itemXFromCenter / (size / 2)).abs();
final itemScale = 0.5 + (percentFromCenter * 0.5);
final opacity = 0.25 + (percentFromCenter * 0.75);
final itemTransform = Matrix4.identity()
..translate((size - itemExtent) / 2)
..translate(itemXFromCenter)
..translate(itemExtent / 2, itemExtent / 2)
..multiply(Matrix4.diagonal3Values(itemScale, itemScale, 1.0))
..translate(-itemExtent / 2, -itemExtent / 2);
context.paintChild(
index,
transform: itemTransform,
opacity: opacity,
);
}
}
@override
bool shouldRepaint(covariant CarouselFlowDelegate oldDelegate) {
return oldDelegate.viewportOffset != viewportOffset;
}
}
@immutable
class FilterItem extends StatelessWidget {
const FilterItem({
super.key,
required this.color,
this.onFilterSelected,
});
final Color color;
final VoidCallback? onFilterSelected;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onFilterSelected,
child: AspectRatio(
aspectRatio: 1.0,
child: Padding(
padding: const EdgeInsets.all(8),
child: ClipOval(
child: Image.network(
'https://docs.flutter.dev/cookbook/img-files'
'/effects/instagram-buttons/millennial-texture.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
),
),
);
}
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter로 쉬머 로딩 효과 구현하기: 상세 가이드 (0) | 2024.10.22 |
---|---|
Flutter로 패럴랙스 스크롤 효과 구현하기: 단계별 가이드 (1) | 2024.10.21 |
Flutter 중첩 내비게이션(Nested Navigation) 구현: 완벽한 가이드 (3) | 2024.10.19 |
Flutter에서 다운로드 버튼 구현하기: 단계별 가이드 (1) | 2024.10.18 |
탭(Tab) 만들기: 쉽고 효율적인 사용자 인터페이스 구현 (0) | 2024.10.17 |