写点什么

flutter 系列之: 做一个图像滤镜

作者:程序那些事
  • 2023-06-16
    广东
  • 本文字数:4378 字

    阅读完需:约 14 分钟

简介

很多时候,我们需要一些特效功能,比如给图片做个滤镜什么的,如果是 h5 页面,那么我们可以很容易的通过 css 滤镜来实现这个功能。


那么如果在 flutter 中,如果要实现这样的滤镜功能应该怎么处理呢?一起来看看吧。

我们的目标

在继续进行之前,我们先来讨论下本章到底要做什么。最终的目标是希望能够实现一个图片的滤镜功能。


那么我们的 app 界面实际上可以分为两个部分。第一个部分就是带滤镜效果的图片,第二个部分就是可以切换的滤镜按钮。


接下来我们一步步来看如何实现这些功能。

带滤镜的图片

要实现这个功能其实比较简单,我们构建一个 widget,因为这个 widget 中的图片需要根据自身选择的滤镜颜色来改变图片的状态,所以这里我们需要的是一个 StatefulWidget,在 state 里面,存储的就是当前的_filterColor。


构建一个图片的 widget 的代码可以如下所示:


class ImageFilterApp extends StatefulWidget {  const ImageFilterApp({super.key});
@override State<ImageFilterApp> createState() => _ImageFilterAppState();}
class _ImageFilterAppState extends State<ImageFilterApp> { final _filters = [ Colors.white, ...Colors.primaries ];
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(), ), ], ), ); }
Widget _buildPhotoWithFilter() { return ValueListenableBuilder( valueListenable: _filterColor, builder: (context, value, child) { final color = value; return Image.asset( 'images/head.jpg', color: color.withOpacity(0.5), colorBlendMode: BlendMode.color, fit: BoxFit.cover, ); }, ); }}
复制代码


在 build 方法中,我们返回了一个 Positioned.fill 填充的 widget,这个 widget 可以把 app 的视图填满。


在_buildPhotoWithFilter 方法中,我们返回了 Image.asset,里面可以设置 image 的 color 和 colorBlendMode。这两个值就是图片滤镜的关键。


就这么简单?一个图片滤镜就完成了?对的就是这么简单。图片滤镜就是 Image.asset 中自带的功能。


但是在实际的应用中,这个 color 不会是固定的,是需要根据我们的不同选择而进行变化的。为了能够接受到这个变化的值,我们使用了 ValueListenableBuilder,通过传入一个可变的 ValueNotifier,来实现监听 color 变化的结果。


  final _filterColor = ValueNotifier<Color>(Colors.white);
void _onFilterChanged(Color value) { _filterColor.value = value; }
复制代码


另外,我们提供了一个触发_filterColor 的值进行变化的方法_onFilterChanged。


上面的代码运行的结果如下:



很好,现在我们已经有了一个带有颜色 filter 功能的界面了。 接下来我们还需要一个 filter 的按钮,来触发 filter 颜色的变化。

打造 filter 按钮

这里我们的 filter 包含了 Colors.primaries 中所有的颜色再加上一个自定义的白色。


每一个 filter 按钮其实都可以用一个 widget 来表示。我们希望是一个圆形的 filter 按钮,里面有一个图片的小的缩略图来展示 filter 的效果。


另外通过 tap 对应的 filter 按钮,还可以实现 color 切换的功能。


所以对于 Filter 按钮 widget 来说,可以接收两个参数,一个是当前的 color,另外一个是 tap 之后的 VoidCallback onFilterSelected, 所以最终我们的 FilterItem 是下面的样子的:


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.0), child: ClipOval( child: Image.asset( 'images/head.jpg', color: color.withOpacity(0.5), colorBlendMode: BlendMode.hardLight, ), ), ), ), ); }
复制代码

打造可滑动按钮

上一节我们创建好了 filter 按钮,接下来就是把 filter 按钮组装起来,形成一个可滑动的 filter 按钮组件。


要想滑动 widget,我们可以使用 Scrollable 组件,通过传入一个 PageController 来控制 PageView 的展示。


Scrollable 出了 controller 之外,还有一个非常重要的属性就是 viewportBuilder。在 viewportBuilder 中可以传入 viewportOffset。


当 Scrollable 滑动的时候,viewportOffset 中的 pixels 是会动态变化的。我们可以根据 viewportOffset 中的 pixels 的变化来重绘 filter 按钮。


如果要根据 viewportOffset 的变化来重新定位 child 组件的位置的话,最好的方式就是将其包裹在 Flow 组件中。


因为 Flow 提供了一个 FlowDelegate,我们可以在 FlowDelegate 中根据 viewportOffset 的不同来重绘 filter widget。这个 FlowDelegate 的实现如下:


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) { print(viewportOffset.pixels);
final count = context.childCount;
//绘制宽度 final size = context.size.width;
// 一个单独item的宽度 final itemExtent = size / filtersPerScreen;
// active item的index final active = viewportOffset.pixels / itemExtent; print('active$active');
// 要绘制的最小的index,在active item的左边最多绘制3个items final min = math.max(0, active.floor() - 3).toInt();
//要绘制的最大index,在active item的右边最多绘制3个items final max = math.min(count - 1, active.ceil() + 3).toInt();
// 重新绘制要展示的item 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) { //viewportOffset被替换的情况下触发 return oldDelegate.viewportOffset != viewportOffset; }}
复制代码


在 paintChildren 的最后,我们通过调用 context.paintChild 来重绘 child。


可以看到这里传入了三个参数,第一个参数是 child 的 index,这个 index 指的是创建 Flow 时候传入的 children 数组中的 index:


      Flow(        delegate: CarouselFlowDelegate(          viewportOffset: viewportOffset,          filtersPerScreen: _filtersPerScreen,        ),        children: [          for (int i = 0; i < filterCount; i++)            FilterItem(              onFilterSelected: () => _onFilterTapped(i),              color: itemColor(i),            ),        ],      )
复制代码


最后,我们把创建 Flow 的方法_buildCarousel 放到 Scrollable 中去,并将 viewportOffset 作为 Flow 的构造函数参数传入,从而实现 Flow 根据 Scrollable 的滑动而发送相应的变化:


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: [ _buildCarousel( viewportOffset: viewportOffset, itemSize: itemSize, ), ], ); }, ); }, );
复制代码

最后要解决的问题

到目前为止,一切看起来都很好。但是如果你仔细研究的话可能会产生一个疑问。那就是 Scrollable 的 controller 是 PageController,我们是通过 PageController 中的 page 来切换对应的 filter 颜色的:


  void _onPageChanged() {    print('page${_controller.page}');    final page = (_controller.page ?? 0).round();    if (page != _page) {      _page = page;      widget.onFilterChanged(widget.filters[page]);    }  }
复制代码


那么这个 page 是如何变化的呢?什么时候从 0 变成 1 呢?


我们先来看下 PageController 的构造函数:


    _controller = PageController(      initialPage: _page,      viewportFraction: _viewportFractionPerItem,    );
复制代码


除了初始化的 initialPage 之外,还有一个 viewportFraction。这个值就是指一个 view 可以被分成多少个 page。


以我的 iphone14 为例,它的 constraints.maxWidth=390.0, 如果被分成 5 份的话,一份的值是 78.0。 也就是说当 Scrollable 滑动 78,的时候,page 就从 0 变成 1 了。这和我们在 Flow 中重绘 child 时候,取的 index 是一致的。


最后,效果图如下:



本文的例子:https://github.com/ddean2009/learn-flutter.git

发布于: 刚刚阅读数: 5
用户头像

关注公众号:程序那些事,更多精彩等着你! 2020-06-07 加入

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧,尽在公众号:程序那些事!

评论

发布
暂无评论
flutter系列之:做一个图像滤镜_flutter_程序那些事_InfoQ写作社区