创建照片滤镜轮播
众所周知,照片加上滤镜会更好看。在本食谱中,您将构建一个可滚动的滤镜选择轮播。
以下动画展示了应用程序的行为
本食谱从已经就位的照片和滤镜开始。滤镜使用 Image
小部件的 color
和 colorBlendMode
属性应用。
添加选择环和深色渐变
#选定的滤镜圆圈显示在选择环内。此外,在可用滤镜后面有一个深色渐变,这有助于滤镜与您选择的任何照片之间的对比度。
创建一个名为 FilterSelector
的新有状态小部件,您将使用它来实现选择器。
@immutable
class FilterSelector extends StatefulWidget {
const FilterSelector({
super.key,
});
@override
State<FilterSelector> createState() => _FilterSelectorState();
}
class _FilterSelectorState extends State<FilterSelector> {
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
将 FilterSelector
小部件添加到现有的小部件树中。将 FilterSelector
小部件放置在照片的顶部,底部居中。
Stack(
children: [
Positioned.fill(
child: _buildPhotoWithFilter(),
),
const Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
child: FilterSelector(),
),
],
),
在 FilterSelector
小部件中,使用 Stack
小部件在深色渐变之上显示一个选择器环。
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 + 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 _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),
),
),
),
),
),
);
}
}
选择器圆圈的大小和背景渐变的大小取决于轮播中单个滤镜的大小,称为 itemSize
。itemSize
取决于可用宽度。因此,使用 LayoutBuilder
小部件来确定可用空间,然后计算单个滤镜的 itemSize
大小。
选择器环包含一个 IgnorePointer
小部件,因为当添加轮播交互性时,选择器环不应干扰点击和拖动事件。
创建滤镜轮播项
#轮播中的每个滤镜项目都显示一个圆形图像,图像上应用了与关联滤镜颜色相对应的颜色。
定义一个名为 FilterItem
的新无状态小部件,它显示单个列表项。
@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.fluttercn.cn/cookbook/img-files'
'/effects/instagram-buttons/millennial-texture.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
),
),
);
}
}
实现滤镜轮播
#当用户拖动时,滤镜项目会向左和向右滚动。滚动需要某种 Scrollable
小部件。
您可能考虑使用水平 ListView
小部件,但 ListView
小部件将第一个元素放置在可用空间的开头,而不是选择器环所在的中心。
PageView
小部件更适合轮播。PageView
小部件从可用空间的中心布局其子项,并提供捕捉物理效果。捕捉物理效果是导致项目捕捉到中心的原因,无论用户在何处释放拖动。
配置您的窗口小部件树以腾出空间用于 PageView
。
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildCarousel(itemSize),
_buildSelectionRing(itemSize),
],
);
});
}
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return const SizedBox();
},
),
);
}
根据给定的index
,在PageView
小部件内构建每个FilterItem
小部件。
Color itemColor(int index) => widget.filters[index % widget.filters.length];
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: FilterItem(
color: itemColor(index),
onFilterSelected: () {},
),
);
},
),
);
}
PageView
小部件显示所有FilterItem
小部件,您可以左右拖动。但是,现在每个FilterItem
小部件占据了整个屏幕宽度,并且每个FilterItem
小部件都以相同的大小和不透明度显示。屏幕上应该有五个FilterItem
小部件,并且FilterItem
小部件需要随着它们远离屏幕中心而缩小和淡出。
这两个问题的解决方案是引入PageViewController
。PageViewController
的viewportFraction
属性用于在屏幕上同时显示多个FilterItem
小部件。在PageViewController
更改时重建每个FilterItem
小部件,允许您在用户滚动时更改每个FilterItem
小部件的大小和不透明度。
创建一个PageViewController
并将其连接到PageView
小部件。
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
late final PageController _controller;
Color itemColor(int index) => widget.filters[index % widget.filters.length];
@override
void initState() {
super.initState();
_controller = PageController(
viewportFraction: _viewportFractionPerItem,
);
_controller.addListener(_onPageChanged);
}
void _onPageChanged() {
final page = (_controller.page ?? 0).round();
widget.onFilterChanged(widget.filters[page]);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: FilterItem(
color: itemColor(index),
onFilterSelected: () {},
),
);
},
),
);
}
}
添加PageViewController
后,屏幕上同时可见五个FilterItem
小部件,并且照片过滤器在您滚动时会发生变化,但FilterItem
小部件的大小仍然相同。
将每个FilterItem
小部件包装在AnimatedBuilder
中,以在滚动位置更改时更改每个FilterItem
小部件的视觉属性。
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FilterItem(
color: itemColor(index),
onFilterSelected: () => {},
);
},
),
);
},
),
);
}
AnimatedBuilder
小部件在_controller
更改其滚动位置时每次都会重建。这些重建允许您在用户拖动时更改FilterItem
的大小和不透明度。
在AnimatedBuilder
中为每个FilterItem
小部件计算适当的比例和不透明度,并应用这些值。
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
if (!_controller.hasClients ||
!_controller.position.hasContentDimensions) {
// The PageViewController isn't connected to the
// PageView widget yet. Return an empty box.
return const SizedBox();
}
// The integer index of the current page,
// 0, 1, 2, 3, and so on
final selectedIndex = _controller.page!.roundToDouble();
// The fractional amount that the current filter
// is dragged to the left or right, for example, 0.25 when
// the current filter is dragged 25% to the left.
final pageScrollAmount = _controller.page! - selectedIndex;
// The page-distance of a filter just before it
// moves off-screen.
const maxScrollDistance = _filtersPerScreen / 2;
// The page-distance of this filter item from the
// currently selected filter item.
final pageDistanceFromSelected =
(selectedIndex - index + pageScrollAmount).abs();
// The distance of this filter item from the
// center of the carousel as a percentage, that is, where the selector
// ring sits.
final percentFromCenter =
1.0 - pageDistanceFromSelected / maxScrollDistance;
final itemScale = 0.5 + (percentFromCenter * 0.5);
final opacity = 0.25 + (percentFromCenter * 0.75);
return Transform.scale(
scale: itemScale,
child: Opacity(
opacity: opacity,
child: FilterItem(
color: itemColor(index),
onFilterSelected: () => () {},
),
),
);
},
),
);
},
),
);
}
每个FilterItem
小部件现在随着它远离屏幕中心而缩小和淡出。
添加一个方法,在点击FilterItem
小部件时更改所选过滤器。
void _onFilterTapped(int index) {
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
配置每个FilterItem
小部件,使其在点击时调用_onFilterTapped
。
FilterItem(
color: itemColor(index),
onFilterSelected: () => _onFilterTapped,
),
恭喜!您现在拥有一个可拖动、可点击的照片过滤器轮播。
交互式示例
#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.fluttercn.cn/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.fluttercn.cn/cookbook/img-files'
'/effects/instagram-buttons/millennial-texture.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
),
),
);
}
}