After deprecation period, remove this recipe from website
Page URL
https://docs.flutter.dev/cookbook/effects/photo-filter-carousel/
Page source
https://github.com/flutter/website/tree/main/src/cookbook/effects/photo-filter-carousel.md
Describe the problem
This cookbook has several places to improve:
- codes and explanation are different from each other, as mentioned in #8202
Materialwidget is unnessary.- In
_buildShadowGradient,height: itemSize + ...will be better thanheight: itemSize * 2 + ...because theScrollablewill take less space on the screen. Ifheight: itemSize + ...used, it will be better to modifyLinearGradient’sColors.blacktoColors.black87to make the UI more comfortable. - The
Ringhas the with 6.0 while theFilterItemhas padding set to 8.0. It will be better if they are the same. - In
FilterSelctor, current widget hierarchy isScrollable > LayoutBuilder > Stack. It will be better to change it toLayoutBuilder > Stack > ScrollablebecauseScrollableis not related with theRingandShadow. IgnorePointeris useless becauseScrollablehas a higher position (upper in the widget hierarchy) thanSelctionRing.
Expected fix
No response
Additional context
No response
I tried writing a new version of codes that I am satisfied with (copy it to a newly created Flutter project and replace lib/main.dart), with app functions not changed. From my point of view, the new codes will be better for readers to study.
If I have some spare time, I will add the text explanation for the codes and create a PR. I will check CONTRIBUTION.md before I do that.
import 'dart:math' show min, max;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show debugPaintSizeEnabled, ViewportOffset;
void main() {
// debugPaintSizeEnabled = true;
runApp(
const MaterialApp(
home: PhotoWithFilterPage(),
debugShowCheckedModeBanner: false,
),
);
}
class PhotoWithFilterPage extends StatefulWidget {
const PhotoWithFilterPage({super.key});
@override
State<PhotoWithFilterPage> createState() => _PhotoWithFilterPageState();
}
class _PhotoWithFilterPageState extends State<PhotoWithFilterPage> {
Color selectedColor = Colors.white;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomCenter,
children: [
Positioned.fill(
child: PhotoWithFilterView(filterColor: selectedColor),
),
ColorSelectorView(
onColorSelected: (Color color) {
setState(() {
selectedColor = color;
});
},
)
],
);
}
}
class ColorSelectorView extends StatefulWidget {
const ColorSelectorView({
super.key,
required this.onColorSelected,
this.colorCountOnScreen = 5,
this.ringWidth = 8.0,
this.verticlePaddingSize = 24.0,
});
final void Function(Color selectedColor) onColorSelected;
final int colorCountOnScreen;
final double ringWidth;
final double verticlePaddingSize;
@override
State<ColorSelectorView> createState() => _ColorSelectorViewState();
}
class _ColorSelectorViewState extends State<ColorSelectorView> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final double itemSize =
constraints.maxWidth * 1.0 / widget.colorCountOnScreen;
return Stack(
alignment: Alignment.bottomCenter,
children: [
ShadowView(
height: itemSize + widget.verticlePaddingSize * 2,
),
ColorsView(
colors: [
Colors.white,
...List.generate(
Colors.primaries.length,
(index) =>
Colors.primaries[(index * 4) % Colors.primaries.length],
)
],
onColorSelected: widget.onColorSelected,
fullWidth: constraints.maxWidth,
colorCountOnScreen: widget.colorCountOnScreen,
itemSize: itemSize,
verticlePaddingSize: widget.verticlePaddingSize,
ringWidth: widget.ringWidth,
),
IgnorePointer(
// `RingView` with `Padding` is on `ColorsView` (in `Stack`).
// Without `IgnorePointer`, user cannot slide the `ColorSelectorView`
// when mouse on or finger tapped at the most center `ColorView`.
child: Padding(
padding: EdgeInsets.only(bottom: widget.verticlePaddingSize),
child: RingView(
size: itemSize,
borderWidth: widget.ringWidth,
),
),
)
],
);
});
}
}
class ColorsView extends StatefulWidget {
const ColorsView({
super.key,
required this.colors,
required this.onColorSelected,
required this.itemSize,
required this.fullWidth,
required this.verticlePaddingSize,
required this.ringWidth,
required this.colorCountOnScreen,
});
final List<Color> colors;
final void Function(Color selectedColor) onColorSelected;
final double itemSize;
final double fullWidth;
final double verticlePaddingSize;
final double ringWidth;
final int colorCountOnScreen;
@override
State<ColorsView> createState() => _ColorsViewState();
}
class _ColorsViewState extends State<ColorsView> {
late final PageController _pageController;
late int _currentPage;
int get colorCount => widget.colors.length;
Color itemColor(int index) => widget.colors[index % colorCount];
@override
void initState() {
super.initState();
_currentPage = 0;
_pageController = PageController(
initialPage: _currentPage,
viewportFraction: 1.0 / widget.colorCountOnScreen,
);
_pageController.addListener(_onPageChanged);
}
void _onPageChanged() {
final newPage = (_pageController.page ?? 0.0).round();
if (newPage != _currentPage) {
_currentPage = newPage;
widget.onColorSelected(widget.colors[_currentPage]);
}
}
void _onColorSelected(int index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scrollable(
controller: _pageController,
axisDirection: AxisDirection.right,
physics: const PageScrollPhysics(),
viewportBuilder: (context, viewportOffset) {
viewportOffset.applyViewportDimension(widget.fullWidth);
viewportOffset.applyContentDimensions(
0.0, widget.itemSize * (colorCount - 1));
return Padding(
padding: EdgeInsets.symmetric(vertical: widget.verticlePaddingSize),
child: SizedBox(
height: widget.itemSize,
child: Flow(
delegate: ColorsViewFlowDelegate(
viewportOffset: viewportOffset,
colorCountOnScreen: widget.colorCountOnScreen,
),
children: [
for (int i = 0; i < colorCount; i++)
Padding(
padding: EdgeInsets.all(widget.ringWidth),
child: ColorView(
onTap: () => _onColorSelected(i),
color: itemColor(i),
),
),
],
),
),
);
},
);
}
}
class ColorsViewFlowDelegate extends FlowDelegate {
ColorsViewFlowDelegate({
required this.viewportOffset,
required this.colorCountOnScreen,
}) : super(repaint: viewportOffset);
final ViewportOffset viewportOffset;
final int colorCountOnScreen;
@override
void paintChildren(FlowPaintingContext context) {
final count = context.childCount;
// All available painting width
final size = context.size.width;
// The distance that a single item "newPage" 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 / colorCountOnScreen;
// 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 minimum = 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 maximum = min(count - 1, active.ceil() + 3).toInt();
// Generate transforms for the visible items and sort by distance.
for (var index = minimum; index <= maximum; 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 ColorsViewFlowDelegate oldDelegate) {
return oldDelegate.viewportOffset != viewportOffset;
}
}
class ColorView extends StatelessWidget {
const ColorView({
super.key,
required this.color,
required this.onTap,
});
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: 1.0,
child: ClipOval(
child: Image(
image: const AssetImage("assets/texture.jpg"),
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight)),
),
);
}
}
class PhotoWithFilterView extends StatelessWidget {
const PhotoWithFilterView({super.key, required this.filterColor});
final Color filterColor;
@override
Widget build(BuildContext context) {
return Image(
image: const AssetImage("assets/photo.jpg"),
color: filterColor.withOpacity(0.5),
colorBlendMode: BlendMode.color,
fit: BoxFit.cover,
);
}
}
class ShadowView extends StatelessWidget {
final double height;
const ShadowView({super.key, required this.height});
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black87,
],
),
),
child: SizedBox.expand(),
),
);
}
}
class RingView extends StatelessWidget {
const RingView({super.key, required this.size, required this.borderWidth});
final double size;
final double borderWidth;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: borderWidth, color: Colors.white),
),
),
),
);
}
}
Oops, I use local assets... Just replace
Image(
image: const AssetImage("assets/texture.jpg"),
...
);
Image(
image: const AssetImage("assets/photo.jpg"),
...
);
with
Image.network(
'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-texture.jpg',
...
)
Image.network(
'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-dude.jpg',
...
);
to make the codes run.
New codes with ColorSelectorView converted to a StatelessWidget:
import 'dart:math' show min, max;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show debugPaintSizeEnabled, ViewportOffset;
void main() {
// debugPaintSizeEnabled = true;
runApp(
const MaterialApp(
home: PhotoWithFilterPage(),
debugShowCheckedModeBanner: false,
),
);
}
class PhotoWithFilterPage extends StatefulWidget {
const PhotoWithFilterPage({super.key});
@override
State<PhotoWithFilterPage> createState() => _PhotoWithFilterPageState();
}
class _PhotoWithFilterPageState extends State<PhotoWithFilterPage> {
Color selectedColor = Colors.white;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomCenter,
children: [
Positioned.fill(
child: PhotoWithFilterView(filterColor: selectedColor),
),
ColorSelectorView(
onColorSelected: (Color color) {
setState(() {
selectedColor = color;
});
},
)
],
);
}
}
class ColorSelectorView extends StatelessWidget {
const ColorSelectorView({
super.key,
required this.onColorSelected,
this.colorCountOnScreen = 5,
this.ringWidth = 8.0,
this.verticlePaddingSize = 24.0,
});
final void Function(Color selectedColor) onColorSelected;
final int colorCountOnScreen;
final double ringWidth;
final double verticlePaddingSize;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final double itemSize = constraints.maxWidth * 1.0 / colorCountOnScreen;
return Stack(
alignment: Alignment.bottomCenter,
children: [
ShadowView(
height: itemSize + verticlePaddingSize * 2,
),
ColorsView(
colors: [
Colors.white,
...List.generate(
Colors.primaries.length,
(index) =>
Colors.primaries[(index * 4) % Colors.primaries.length],
)
],
onColorSelected: onColorSelected,
fullWidth: constraints.maxWidth,
colorCountOnScreen: colorCountOnScreen,
itemSize: itemSize,
verticlePaddingSize: verticlePaddingSize,
ringWidth: ringWidth,
),
IgnorePointer(
// `RingView` with `Padding` is on `ColorsView` (in `Stack`).
// Without `IgnorePointer`, user cannot slide the `ColorSelectorView`
// when mouse on or finger tapped at the most center `ColorView`.
child: Padding(
padding: EdgeInsets.symmetric(vertical: verticlePaddingSize),
child: RingView(
size: itemSize,
borderWidth: ringWidth,
),
),
)
],
);
});
}
}
class ColorsView extends StatefulWidget {
const ColorsView({
super.key,
required this.colors,
required this.onColorSelected,
required this.itemSize,
required this.fullWidth,
required this.verticlePaddingSize,
required this.ringWidth,
required this.colorCountOnScreen,
});
final List<Color> colors;
final void Function(Color selectedColor) onColorSelected;
final double itemSize;
final double fullWidth;
final double verticlePaddingSize;
final double ringWidth;
final int colorCountOnScreen;
@override
State<ColorsView> createState() => _ColorsViewState();
}
class _ColorsViewState extends State<ColorsView> {
late final PageController _pageController;
late int _currentPage;
int get colorCount => widget.colors.length;
Color itemColor(int index) => widget.colors[index % colorCount];
@override
void initState() {
super.initState();
_currentPage = 0;
_pageController = PageController(
initialPage: _currentPage,
viewportFraction: 1.0 / widget.colorCountOnScreen,
);
_pageController.addListener(_onPageChanged);
}
void _onPageChanged() {
final newPage = (_pageController.page ?? 0.0).round();
if (newPage != _currentPage) {
_currentPage = newPage;
widget.onColorSelected(widget.colors[_currentPage]);
}
}
void _onColorSelected(int index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scrollable(
controller: _pageController,
axisDirection: AxisDirection.right,
physics: const PageScrollPhysics(),
viewportBuilder: (context, viewportOffset) {
viewportOffset.applyViewportDimension(widget.fullWidth);
viewportOffset.applyContentDimensions(
0.0, widget.itemSize * (colorCount - 1));
return Padding(
padding: EdgeInsets.symmetric(vertical: widget.verticlePaddingSize),
child: SizedBox(
height: widget.itemSize,
child: Flow(
delegate: ColorsViewFlowDelegate(
viewportOffset: viewportOffset,
colorCountOnScreen: widget.colorCountOnScreen,
),
children: [
for (int i = 0; i < colorCount; i++)
Padding(
padding: EdgeInsets.all(widget.ringWidth),
child: ColorView(
onTap: () => _onColorSelected(i),
color: itemColor(i),
),
),
],
),
),
);
},
);
}
}
class ColorsViewFlowDelegate extends FlowDelegate {
ColorsViewFlowDelegate({
required this.viewportOffset,
required this.colorCountOnScreen,
}) : super(repaint: viewportOffset);
final ViewportOffset viewportOffset;
final int colorCountOnScreen;
@override
void paintChildren(FlowPaintingContext context) {
final count = context.childCount;
// All available painting width
final size = context.size.width;
// The distance that a single item "newPage" 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 / colorCountOnScreen;
// 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 minimum = 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 maximum = min(count - 1, active.ceil() + 3).toInt();
// Generate transforms for the visible items and sort by distance.
for (var index = minimum; index <= maximum; 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 ColorsViewFlowDelegate oldDelegate) {
return oldDelegate.viewportOffset != viewportOffset;
}
}
class ColorView extends StatelessWidget {
const ColorView({
super.key,
required this.color,
required this.onTap,
});
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: 1.0,
child: ClipOval(
child: Image(
image: const AssetImage("assets/texture.jpg"),
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight)),
),
);
}
}
class PhotoWithFilterView extends StatelessWidget {
const PhotoWithFilterView({super.key, required this.filterColor});
final Color filterColor;
@override
Widget build(BuildContext context) {
return Image(
image: const AssetImage("assets/photo.jpg"),
color: filterColor.withOpacity(0.5),
colorBlendMode: BlendMode.color,
fit: BoxFit.cover,
);
}
}
class ShadowView extends StatelessWidget {
final double height;
const ShadowView({super.key, required this.height});
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black87,
],
),
),
child: SizedBox.expand(),
),
);
}
}
class RingView extends StatelessWidget {
const RingView({super.key, required this.size, required this.borderWidth});
final double size;
final double borderWidth;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: borderWidth, color: Colors.white),
),
),
),
);
}
}
A detailed text explanation in Chinese:
整体结构
应用整体为单页,使用 MaterialApp 包裹 PhotoWithFilterPage。
PhotoWithFilterPage 使用 Stack,呈现出背景有颜色滤镜的图片 PhotoWithFilterView(目前使用项目中的资源图片),下方的选色器 ColorSelectorView 为一个整体。可以看到,PhotoWithFilterPage 是一个 StatefulWidget,其中状态为 selectedColor,ColorSelectorView 通过回调函数 onColorSelected 对状态进行修改,PhotoWithFilterView 对状态进行使用。
ColorSelectorView
分析下方的选色器 ColorSelectorView,主要由三层构成:中间一层是一个可以左右滑动的 ColorsView,主要使用 Scrollable,使用与 PageView 类似的逻辑进行控制,从而实现每次滑动时的吸附动画效果。前面一层 RingView 画一个圈,表示当前选中的颜色,始终处于最下方正中间。最后一层是一个从上到下的从透明到黑色的颜色梯度 ShadowView,使得选取的背景图片和 ColorsView 在视觉上不冲突。
开启 debugPaintSizeEnabled = true; 截图如下:
手势冲突
在 ColorSelectorView 的 Stack 中,RingView 添加了 IgnorePointer。这是因为从层级关系上来说,RingView 遮挡(拦截)了 ColorsView 的滑动手势。使用 IgnorePointer 可以使得包裹的 Widget 不接受手势。
使用约束确定大小
这里需要注意一点,我们需要确定下方 ColorSelectorView 的高度。代码的逻辑是,通过 ColorSelectorView.colorCountOnScreen 来决定 ColorsView 在屏幕上呈现多少个 ColorView,这样的话一个 ColorView(含 Padding)的宽度应该是屏幕宽度除以 ColorSelectorView。我们使得 ColorView(含 Padding)的高度和宽度一致即可。为了使得整个 ColorsView 有通用性,我们使用 LayoutBuilder 拿到 ColorSelectorView 的约束 constraints,使用 constraints.maxWidth(上层 Widget 传给 ColorSelectorView 的最大宽度) 计算得到一个 ColorView 的高度和宽度。
关于 Padding
ColorsView 和 RingView 都添加了上下高度为 verticlePaddingSize 的 Padding。ShadowView 则将 ColorsView 的背景填满。
ColorView 因为要添加 RingView,所以添加了和 RingView 的圆环宽度大小一样的 Padding。
ColorsView
接下来我们讲解比较核心的 ColorsView,主要是由 Scrollable 构成 UI,Scrollable 保证了全平台统一的滑动体验。
Scrollable 的参数
我们先来查看 Scrollable 的参数:
controllerScrollController,ScrollController可以用来设置一个Scrollable的初始滚动位置initialScrollOffset、读取当前的滚动位置offset、或者用animateTo()来改变当前的滚动位置。- 这里我们使用
ScrollController的子类PageController,来方便的添加viewportFraction。 viewport可以理解为“视野”,我们希望Scrollable在屏幕中的部分呈现出colorCountOnScreen个ColorView,将viewportFraction设置为1.0 / colorCountOnScreen。
axisDirection表示滑动的主轴为向右的轴。physics使用PageScrollPhysics使得在滑动的时候有着类似一页一页滑动的吸附效果。viewportBuilderviewportOffset.applyViewportDimension()设置Scrollable在屏幕上显示的长度。viewportOffset.applyContentDimensions()设置可滑动的范围(可以通过这个去隐藏一些边缘的内容),差为内容的总长度。- 根据滑动的位置(偏移量)
viewportOffset来确定Flow中的布局。
Flow
Flow sizes and positions children efficiently, according to the logic in a FlowDelegate.
简单来说,Flow 可以对 children 实现自定义程度很高的布局,使用者需要对 FlowDelegate 中的 paintChildren() 进行重载。在 YouTube | Flow (Flutter Widget of the Week) 中讲的比较直观,配合矩阵可以做出很不错的动画效果。
在 ColorsViewFlowDelegate 中 paintChildren 的最后,在 for 循环中调用 context.paintChild() 实现对各个子 Widget 的绘制。具体是一些数学运算,代码中也有英文注释,感兴趣的同学可以自行查看。
交互逻辑
在 ColorsView 中,有两套交互逻辑:
PageController检测到用户翻页(左右滑动),需要对当前位置做四舍五入然后更新int _currentPage和selectedColor的值。- 实现在
_ColorsViewState._onPageChanged()中。 - 在
_ColorsViewState.initState()中_pageController.addListener(_onPageChanged);表示每次_pageController的double page值发生改变都会调用_onPageChanged()。
- 实现在
- 用户点击
ColorView进一步调用onTap,从而改变int _currentPage和selectedColor的值。- 实现在
_ColorsViewState._onColorSelected()中。 - 在
_pageController.animateToPage()中使用动画呈现滑动效果。
- 实现在
@domesticmouse : Could you review this issue and make sure we can make the changes outlined?
Hey @khanhnwin it looks like the linked cookbook page contains code that isn't in a Flutter project? Any ideas on when you plan to move it into a project?
@atsansone this issue is blocked until the linked page is converted to a snippet based page. Even if we update this code now, there is nothing to stop it falling out of sync again the next time we update stable.
As per https://github.com/flutter/website/issues/10774, I have added a deprecation notice to this recipe and will delete it eventually.
@Yang-Xijie this recipe is being turned down this week, but your notes are so helpful! I wonder if you could repost in the new Flutter forum? https://forum.itsallwidgets.com/