Некоторые из вас могут оказаться в ситуации, когда вам необходимо предоставить пользователям возможность выбора определенных линий, путей или фигур в вашем приложении Flutter. Когда вы думаете о том, как их выделить, вы можете просто увеличить толщину обводки или изменить цвет выделения. Но в некоторых ситуациях этого может быть недостаточно, чтобы сделать выбор заметным для пользователя. Особенно в ситуациях, когда пересекаются несколько линий и фигур. А как насчет анимации выделения?

В настоящее время графические инструменты используют своего рода эффект анимации движущихся штриховых линий, также называемый марширующие муравьи, чтобы помочь пользователю отслеживать свой выбор. В этой статье я собираюсь показать вам способ реализации этого эффекта во Flutter.

1. CustomPaint и CustomPainter

Давайте начнем с основ рисования пользовательской графики во Flutter. Для этого мы можем использовать виджет CustomPaint в сочетании с CustomPainter. Хотя CustomPainter — это компонент, который помогает вам рисовать графику непосредственно на холсте, CustomPaint — это просто оболочка вокруг него, которая делегирует рисование виджета вашему CustomPainter и может быть смонтирована в дереве виджетов.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CustomPaint(
        painter: MyCustomPaint(),
        child: Center(
          child: Container(
            width: 20,
            height: 20,
            color: Colors.blue,
          ),
        ),
      ),
    );
  }
}

class MyCustomPaint extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height),
        Paint()..color = Colors.red);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

В приведенном выше коде вы увидите простой CustomPainter, который рисует прямоугольник красного цвета по всему холсту. Метод painter нашего CustomPainter будет вызываться для каждого setState, если shouldRepaint возвращает true >. Метод shouldRepaint указывает, нужно ли перерисовывать CustomPainter при создании нового экземпляра. Это помогает фреймворку оптимизировать покраску.

Параметр типа canvas предоставляет методы для рисования геометрии, а size указывает размер области рисования. Эта область начинается в левом верхнем углу экрана, где находится координата XY (0, 0). Параметр рисования этих методов рисования позволяет вам определить, как отрисовывается различная графика с точки зрения цвета и стиля. В методе shouldRepaint вы должны указать, нужно ли перерисовывать CustomPainter при создании нового экземпляра. Изменение значений элементов CustomPainter может привести, например, к необходимости перерисовки. Этот механизм помогает оптимизировать производительность платформы.

Чтобы использовать наш собственный CustomPainter в дереве виджетов, нам нужно создать его экземпляр и передать экземпляр CustomPainter для аргумента painter. CustomPaint также принимает параметр child. Этот ребенок всегда рисуется над рисователем, как вы можете видеть по синему прямоугольнику в центре на изображении ниже.

2. Рисуем пути

Поскольку мы не хотим рисовать простые прямоугольники или линии, нам нужно разобраться с концепцией рисования путей. Контуры состоят из нескольких сегментов и позволяют нам рисовать на холсте сложные фигуры, например многоугольники. Каждый путь начинается с создания экземпляра объекта пути. Начальную точку сегмента пути можно установить, вызвав метод void moveTo(double x, double y). Если мы хотим соединить эту точку с другой с помощью отрезка линии, нам нужно вызвать метод void lineTo(double x, double y) пути. Мы можем повторять этот процесс сколько угодно, чтобы создать более сложный путь. После того, как мы закончили определять наш путь, нам нужно вызвать его закрыть метод. Наконец, мы можем нарисовать его, используя метод drawPath экземпляра canvas.

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CustomPaint(
        painter: MyCustomPaint([
          const Offset(100, 100),
          const Offset(200, 100),
          const Offset(200, 200),
          const Offset(300, 200),
        ], Colors.red, 40.0),
      ),
    );
  }
}

class MyCustomPaint extends CustomPainter {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;

  MyCustomPaint(this.points, this.color, this.strokeWidth)
      : assert(points.length > 1, 'At least two points are needed.');

  @override
  void paint(Canvas canvas, Size size) {
    final pPaint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    final p = Path();

    var aX = points.first.dx;
    var aY = points.first.dy;
    p.moveTo(aX, aY);

    for (var i = 1; i < points.length; i++) {
      var bX = points[i].dx;
      var bY = points[i].dy;

      final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
      final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
      final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);

      // extend line by (strokewidth / 2) to overlap ends
      p.lineTo(bxExt, byExt);
      p.moveTo(bX, bY);

      aX = bX;
      aY = bY;
    }

    p.close();
    canvas.drawPath(p, pPaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

С помощью приведенного выше кода мы сможем рисовать собственные пути, предоставляя точки типа Offset нашему CustomPainter.

Часть, в которой мы расширяем конечную точку (B) сегмента на половину strokeWidth при вызове void lineTo(double x, double y), является необязательной. Это предотвращает неприглядные окончания сегментов пути, как вы можете видеть ниже.

3. Обведенные пути

Поскольку Flutter на данный момент изначально не поддерживает обведенные пути, нам нужно написать собственный метод для преобразования обычных путей в обведенные.

  Path _dashPath(
    Path source,
    List<double> dash, {
    double offset = 0.5,
  }) {
    final dest = Path();
    for (final metric in source.computeMetrics()) {
      var distance = offset;
      var draw = true;
      for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
        final len = dash[i];
        if (draw) {
          dest.addPath(
              metric.extractPath(distance, distance + len), Offset.zero);
        }
        distance += len;
        draw = !draw;
      }
    }

    return dest;
  }

Приведенный выше метод _dashPath позволяет нам это сделать. Он разбивает исходный путь на сегменты, где для каждого сегмента создается пунктирный эквивалент в соответствии с указанным шаблоном штриха и смещением. Наконец, будет возвращен полученный обведенный путь. Запустите приведенный ниже код, чтобы увидеть метод в действии.

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CustomPaint(
        painter: MyCustomPaint([
          const Offset(100, 100),
          const Offset(200, 100),
          const Offset(200, 200),
          const Offset(300, 200),
        ], Colors.red, 2.0),
      ),
    );
  }
}

class MyCustomPaint extends CustomPainter {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;

  MyCustomPaint(this.points, this.color, this.strokeWidth)
      : assert(points.length > 1, 'At least two points are needed.');

  @override
  void paint(Canvas canvas, Size size) {
    final pPaint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    final p = Path();

    var aX = points.first.dx;
    var aY = points.first.dy;
    p.moveTo(aX, aY);

    for (var i = 1; i < points.length; i++) {
      var bX = points[i].dx;
      var bY = points[i].dy;

      final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
      final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
      final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);

      // extend line by (strokewidth / 2) to overlap ends
      p.lineTo(bxExt, byExt);
      p.moveTo(bX, bY);

      aX = bX;
      aY = bY;
    }

    p.close();
    canvas.drawPath(_dashPath(p, [10, 5]), pPaint);
  }

  Path _dashPath(
    Path source,
    List<double> dash, {
    double offset = 0.5,
  }) {
    final dest = Path();
    for (final metric in source.computeMetrics()) {
      var distance = offset;
      var draw = true;
      for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
        final len = dash[i];
        if (draw) {
          dest.addPath(
              metric.extractPath(distance, distance + len), Offset.zero);
        }
        distance += len;
        draw = !draw;
      }
    }

    return dest;
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Теперь у нас должна быть возможность создать обведенный путь, как показано ниже.

4. Анимируйте путь

И последнее, но не менее важное: нам нужно перемещать наши штрихи, соответственно, заставлять наших муравьев маршировать. Ради возможности повторного использования и читабельности мы заворачиваем всё в виджет.

import 'package:flutter/material.dart';
import 'dashed_path_painter.dart';

class MarchingAntsPathWidget extends StatefulWidget {
  final List<Offset> points;
  final Duration duration;
  final double dashWidth;
  final double dashSpace;
  final double strokeWidth;
  final Color strokeColor;

  const MarchingAntsPathWidget(
      {required this.points,
      this.dashWidth = 10.0,
      this.dashSpace = 5.0,
      this.strokeWidth = 2.0,
      this.strokeColor = Colors.black,
      this.duration = const Duration(milliseconds: 500),
      super.key});

  @override
  State<MarchingAntsPathWidget> createState() => _MarchingAntsPathWidgetState();
}

class _MarchingAntsPathWidgetState extends State<MarchingAntsPathWidget>
    with SingleTickerProviderStateMixin {
  late final Animation<double> _animation;
  late final AnimationController _controller;
  late final Tween<double> _tween;

  @override
  void initState() {
    super.initState();

    _tween =
        Tween<double>(begin: 0.0, end: widget.dashWidth + widget.dashSpace);

    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    _animation = _tween.animate(_controller)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _controller.reset();
        } else if (status == AnimationStatus.dismissed) {
          _controller.forward();
        }
      });

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: DashedPathPainter(
        widget.points,
        _animation.value,
        widget.dashWidth,
        widget.dashSpace,
        widget.strokeColor,
        widget.strokeWidth,
      ),
    );
  }
}

Наша анимация инициализируется внутри метода initState вновь созданного виджета. AnimationController используется вместе с Tween‹double, значение которого начинается с 0,0 и заканчивается суммой ширины одного штриха и пространства между каждым штрихом. .

Значение анимации только что созданного двойного анимации движения мы собираемся использовать в качестве параметра функции _dashPath нашего CustomPainter, которую мы реализовали ранее. Возможно, вы помните, что у него был параметр offset для смещения штрихов вдоль сегментов пути.

Если мы проиграем это в бесконечном цикле, это будет выглядеть так, будто удары, известные как муравьи, маршируют бесконечно по дорожке. Чтобы обновлять пользовательский интерфейс при каждом изменении анимации, нам нужно добавить прослушиватель к нашей анимации анимации и вызывать setState при вызове. Добавляя прослушиватель статуса к анимации, мы сбрасываем и воспроизводим цикл анимации снова и снова, пока AnimationController не будет удален. Теперь наш класс CustomPainter должен выглядеть так, как показано ниже.

import 'package:flutter/material.dart';
import 'dart:math' as math;

class DashedPathPainter extends CustomPainter {
  final List<Offset> points;
  final double offset;
  final double dashWidth;
  final double dashSpace;
  final Color color;
  final double strokeWidth;

  DashedPathPainter(this.points, this.offset, this.dashWidth, this.dashSpace,
      this.color, this.strokeWidth)
      : assert(points.length > 1, 'At least two points are needed.');

  @override
  void paint(Canvas canvas, Size size) {
    final pPaint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    final p = Path();

    var aX = points.first.dx;
    var aY = points.first.dy;
    p.moveTo(aX, aY);

    for (var i = 1; i < points.length; i++) {
      var bX = points[i].dx;
      var bY = points[i].dy;

      final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
      final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
      final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);

      // extend line by (strokewidth / 2) to overlap ends
      p.lineTo(bxExt, byExt);
      p.moveTo(bX, bY);

      aX = bX;
      aY = bY;
    }

    p.close();
    canvas.drawPath(
        _dashPath(p, [dashWidth, dashSpace], offset: offset), pPaint);
  }

  Path _dashPath(
    Path source,
    List<double> dash, {
    double offset = 0.5,
  }) {
    final dest = Path();
    for (final metric in source.computeMetrics()) {
      var distance = offset;
      var draw = true;
      for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
        final len = dash[i];
        if (draw) {
          dest.addPath(
              metric.extractPath(distance, distance + len), Offset.zero);
        }
        distance += len;
        draw = !draw;
      }
    }

    return dest;
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

Если вы все правильно собрали, результат должен выглядеть примерно так.

Краткое содержание

Спасибо за прочтение статьи. Если у вас есть идеи по улучшению или вопросы, дайте мне знать в комментариях. Полный исходный код с некоторыми небольшими расширениями вы найдете на моем GitHub.