flutter / website

Flutter documentation web site
https://docs.flutter.dev
Other
2.81k stars 3.21k forks source link

After deprecation period, remove this recipe from website #8204

Open Yang-Xijie opened 1 year ago

Yang-Xijie commented 1 year ago

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:

Expected fix

No response

Additional context

No response

Yang-Xijie commented 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),
          ),
        ),
      ),
    );
  }
}
Yang-Xijie commented 1 year ago

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.

Yang-Xijie commented 1 year ago

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),
          ),
        ),
      ),
    );
  }
}
Yang-Xijie commented 1 year ago

A detailed text explanation in Chinese:

整体结构

应用整体为单页,使用 MaterialApp 包裹 PhotoWithFilterPage

PhotoWithFilterPage 使用 Stack,呈现出背景有颜色滤镜的图片 PhotoWithFilterView(目前使用项目中的资源图片),下方的选色器 ColorSelectorView 为一个整体。可以看到,PhotoWithFilterPage 是一个 StatefulWidget,其中状态为 selectedColorColorSelectorView 通过回调函数 onColorSelected 对状态进行修改,PhotoWithFilterView 对状态进行使用。

ColorSelectorView

分析下方的选色器 ColorSelectorView,主要由三层构成:中间一层是一个可以左右滑动的 ColorsView,主要使用 Scrollable,使用与 PageView 类似的逻辑进行控制,从而实现每次滑动时的吸附动画效果。前面一层 RingView 画一个圈,表示当前选中的颜色,始终处于最下方正中间。最后一层是一个从上到下的从透明到黑色的颜色梯度 ShadowView,使得选取的背景图片和 ColorsView 在视觉上不冲突。

开启 debugPaintSizeEnabled = true; 截图如下:

debug

手势冲突

ColorSelectorViewStack 中,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

ColorsViewRingView 都添加了上下高度为 verticlePaddingSizePaddingShadowView 则将 ColorsView 的背景填满。

ColorView 因为要添加 RingView,所以添加了和 RingView 的圆环宽度大小一样的 Padding

ColorsView

接下来我们讲解比较核心的 ColorsView,主要是由 Scrollable 构成 UI,Scrollable 保证了全平台统一的滑动体验。

Scrollable 的参数

我们先来查看 Scrollable 的参数:

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) 中讲的比较直观,配合矩阵可以做出很不错的动画效果。

ColorsViewFlowDelegatepaintChildren 的最后,在 for 循环中调用 context.paintChild() 实现对各个子 Widget 的绘制。具体是一些数学运算,代码中也有英文注释,感兴趣的同学可以自行查看。

交互逻辑

ColorsView 中,有两套交互逻辑:

atsansone commented 1 year ago

@domesticmouse : Could you review this issue and make sure we can make the changes outlined?

domesticmouse commented 1 year ago

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.

sfshaza2 commented 3 months ago

As per https://github.com/flutter/website/issues/10774, I have added a deprecation notice to this recipe and will delete it eventually.