Open Yang-Xijie opened 1 year ago
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
,主要由三层构成:中间一层是一个可以左右滑动的 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
的高度和宽度。
ColorsView
和 RingView
都添加了上下高度为 verticlePaddingSize
的 Padding
。ShadowView
则将 ColorsView
的背景填满。
ColorView
因为要添加 RingView
,所以添加了和 RingView
的圆环宽度大小一样的 Padding
。
接下来我们讲解比较核心的 ColorsView
,主要是由 Scrollable
构成 UI,Scrollable
保证了全平台统一的滑动体验。
我们先来查看 Scrollable
的参数:
controller
ScrollController
,ScrollController
可以用来设置一个 Scrollable
的初始滚动位置 initialScrollOffset
、读取当前的滚动位置 offset
、或者用 animateTo()
来改变当前的滚动位置。ScrollController
的子类 PageController
,来方便的添加 viewportFraction
。viewport
可以理解为“视野”,我们希望 Scrollable
在屏幕中的部分呈现出 colorCountOnScreen
个 ColorView
,将 viewportFraction
设置为 1.0 / colorCountOnScreen
。axisDirection
表示滑动的主轴为向右的轴。physics
使用 PageScrollPhysics
使得在滑动的时候有着类似一页一页滑动的吸附效果。viewportBuilder
viewportOffset.applyViewportDimension()
设置 Scrollable
在屏幕上显示的长度。viewportOffset.applyContentDimensions()
设置可滑动的范围(可以通过这个去隐藏一些边缘的内容),差为内容的总长度。viewportOffset
来确定 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.
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:
Material
widget is unnessary._buildShadowGradient
,height: itemSize + ...
will be better thanheight: itemSize * 2 + ...
because theScrollable
will take less space on the screen. Ifheight: itemSize + ...
used, it will be better to modifyLinearGradient
’sColors.black
toColors.black87
to make the UI more comfortable.Ring
has the with 6.0 while theFilterItem
has padding set to 8.0. It will be better if they are the same.FilterSelctor
, current widget hierarchy isScrollable > LayoutBuilder > Stack
. It will be better to change it toLayoutBuilder > Stack > Scrollable
becauseScrollable
is not related with theRing
andShadow
.IgnorePointer
is useless becauseScrollable
has a higher position (upper in the widget hierarchy) thanSelctionRing
.Expected fix
No response
Additional context
No response