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

47次阅读

共计 5968 个字符,预计需要花费 15 分钟才能阅读完成。

简介

很多时候,咱们须要一些特效性能,比方给图片做个滤镜什么的,如果是 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

正文完
 0