Flutter Custom Painter не перерисовывается

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

У меня есть стек с пользовательским рисовальщиком, и я использую наложения, чтобы наложить учебный барьер на главную страницу. Моя мысль заключалась в том, чтобы иметь список виджетов для выделения, с «учебником», который вел бы пользователя виджет за виджетом по мере его щелчка, а художник перерисовывался, чтобы выделить следующий целевой виджет, или закрывал последний.

К сожалению, специальный рисовальщик рисует только первый вариант, а затем исчезает*. Я могу подтвердить с помощью утверждений debugPrint, что художник получает обновленные границы, но он просто не перерисовывается должным образом (это верно с/без переопределения определений == и hashCode, а также независимо от того, всегда ли shouldRepaint установлено в true или имеет дополнительную логику внутри ).

Flutter Custom Painter не перерисовывается]

*На dartpad.dev маляр вообще не рендерится, даже первый.

Я также пробовал давать ключи виджетам Stack, Positioned и CustomPaint — без изменений.

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

Flutter Custom Painter не перерисовывается

Мне нужна помощь, чтобы понять, почему художник не выполняет повторный рендеринг...

Чего мне не хватает в документации? Есть ли известный трюк, вызывающий ожидаемое поведение?

Заранее спасибо.

Мой фрагмент кода:

import 'dart:async';

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DebugPageTutorial(),
    );
  }
}

class DebugPageTutorial extends StatefulWidget {
  const DebugPageTutorial({super.key});

  @override
  State<DebugPageTutorial> createState() => _DebugPageTutorialState();
}

class _DebugPageTutorialState extends State<DebugPageTutorial> {
  final GlobalKey _overlayKey1 = GlobalKey();
  final GlobalKey _overlayKey2 = GlobalKey();
  final GlobalKey _overlayKey3 = GlobalKey();

  OverlayEntry? currentOverlayEntry;

  @override
  void initState() {
    super.initState();
    currentOverlayEntry = null;
  }

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

  void _onTutorialFinished() {
    if (currentOverlayEntry?.mounted ?? false) currentOverlayEntry?.remove();
    currentOverlayEntry = null;
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                key: _overlayKey1,
                'This is a tutorial page',
                style: const TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                key: _overlayKey2,
                'This is a tutorial page',
              ),
              Padding(
                padding: const EdgeInsets.all(32.0),
                child: Container(
                  key: _overlayKey3,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.cyan,
                  ),
                  height: 50.0,
                  width: 50.0,
                ),
              ),
              MaterialButton(
                onPressed: () async {
                  currentOverlayEntry = TutorialOverlay.createOverlayEntry(
                    context,
                    overlayKeys: [
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey1,
                        overlayTooltip: const Text('Hello'),
                      ),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey2,
                        overlayTooltip: const Text('World'),
                        padding: const EdgeInsets.all(16.0),
                        shapeBorder: const RoundedRectangleBorder(
                          borderRadius: BorderRadius.all(
                            Radius.circular(
                              8.0,
                            ),
                          ),
                        ),
                        color: Colors.green,
                      ),
                      TutorialOverlayTooltip(
                          overlayKey: _overlayKey3,
                          overlayTooltip: const Text('Beep'),
                          shapeBorder: CircleBorder(),
                          padding: const EdgeInsets.all(16.0),
                          color: Colors.red),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey1,
                        overlayTooltip: const Text('Bop'),
                      ),
                    ],
                    onTutorialFinished: _onTutorialFinished,
                  );
                  Overlay.of(context).insert(currentOverlayEntry!);
                },
                child: const Text('Show Tutorial'),
              ),
            ],
          ),
        ),
      );
}

class TutorialOverlay extends StatefulWidget {
  /// A list of keys and overlay tooltips to display when the overlay is
  /// displayed. The overlay will be displayed in the order of the list.
  final List<TutorialOverlayTooltip> overlayKeys;

  /// A global key to use as the ancestor for the overlay entry, ensuring that
  /// the overlay entry is not shifted improperly when the overlay is only being
  /// painted on a portion of the screen. If null, the overlay will be painted
  /// based on the heuristics of the entire screen.
  final GlobalKey? ancestorKey;

  final FutureOr<void> Function()? onTutorialFinished;

  const TutorialOverlay({
    super.key,
    required this.overlayKeys,
    this.ancestorKey,
    this.onTutorialFinished,
  });

  static OverlayEntry createOverlayEntry(
    BuildContext context, {
    required List<TutorialOverlayTooltip> overlayKeys,
    GlobalKey? ancestorKey,
    FutureOr<void> Function()? onTutorialFinished,
  }) =>
      OverlayEntry(
        builder: (BuildContext context) => TutorialOverlay(
          overlayKeys: overlayKeys,
          ancestorKey: ancestorKey,
          onTutorialFinished: onTutorialFinished,
        ),
      );

  @override
  State<TutorialOverlay> createState() => _TutorialOverlayState();
}

class _TutorialOverlayState extends State<TutorialOverlay> {
  late List<TutorialOverlayTooltip> _overlayKeys;
  int _currentIndex = 0;
  TutorialOverlayTooltip? get _currentTooltip =>
      _currentIndex < _overlayKeys.length ? _overlayKeys[_currentIndex] : null;
  final ValueNotifier<int> _repaintKey = ValueNotifier<int>(0);

  @override
  void initState() {
    super.initState();
    _overlayKeys = widget.overlayKeys;
  }

  Rect? _getNextRenderBox(GlobalKey? key) {
    final RenderBox? renderBox =
        key?.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox != null && renderBox.hasSize) {
      final Offset offset = renderBox.localToGlobal(
        Offset.zero,
        ancestor: widget.ancestorKey?.currentContext?.findRenderObject(),
      );
      return Rect.fromLTWH(
        offset.dx,
        offset.dy,
        renderBox.size.width,
        renderBox.size.height,
      );
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    final Rect? nextRenderBox = _getNextRenderBox(_currentTooltip?.overlayKey);
    if (nextRenderBox == null) return const SizedBox();

    // Determine position for the tutorial text
    double textTop = nextRenderBox.top > 100
        ? nextRenderBox.top - 40
        : nextRenderBox.bottom + 20;

    debugPrint('build: ${nextRenderBox.toString()}');

    return Stack(
      children: [
        // Paint the background
        Positioned.fill(
          key: ValueKey('tutorial_paint:$_currentIndex'),
          left: 0.0,
          right: 0.0,
          top: 0.0,
          bottom: 0.0,
          child: CustomPaint(
            painter: HolePainter(
              repaint: _repaintKey,
              targetRect: nextRenderBox,
              shapeBorder: _currentTooltip?.shapeBorder ??
                  const RoundedRectangleBorder(),
              color: _currentTooltip?.color ?? const Color(0x90000000),
              direction: _currentTooltip?.direction ?? TextDirection.ltr,
              padding: _currentTooltip?.padding ?? EdgeInsets.zero,
            ),
          ),
        ),
        // Tutorial text box
        Positioned(
          top: textTop,
          left: nextRenderBox.left,
          child: Material(
            color: Colors.transparent,
            child: Container(
              padding: const EdgeInsets.all(8),
              color: Colors.white,
              child: GestureDetector(
                behavior: HitTestBehavior.translucent,
                onTap: () {
                  if (_currentIndex + 1 < _overlayKeys.length) {
                    return setState(() {
                      _currentIndex = _currentIndex + 1;
                      _repaintKey.value = _currentIndex;
                    });
                  }
                  widget.onTutorialFinished?.call();
                },
                child: _currentTooltip?.overlayTooltip ?? const SizedBox(),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

/// Contains information for drawing a tutorial overlay over a given widget
/// based on the provided global key.
class TutorialOverlayTooltip {
  /// The key of the widget to highlight in the cutout of the tutorial overlay
  final GlobalKey overlayKey;

  /// The widget to render by the cutout of the totorial overlay
  final Widget overlayTooltip;

  /// The padding around the widget to render by the cutout of the totorial
  /// overlay. Default is EdgeInsets.zero
  final EdgeInsets padding;

  /// The shape of the cutout of the totorial overlay. Default is a rounded
  /// rectangle with no border radius
  final ShapeBorder shapeBorder;

  /// The color of the barrier of the totorial overlay. Default is
  /// Black with 50% opacity
  final Color color;

  /// The direction of the cutout of the totorial overlay. Default is
  /// [TextDirection.ltr]
  final TextDirection direction;

  const TutorialOverlayTooltip({
    required this.overlayKey,
    required this.overlayTooltip,
    this.padding = EdgeInsets.zero,
    this.shapeBorder = const RoundedRectangleBorder(),
    this.color = const Color(0x90000000), // Black with 50% opacity
    this.direction = TextDirection.ltr,
  });
}

/// A painter that covers the area with a shaped hole around a target box
class HolePainter extends CustomPainter {
  /// The key of the widget to highlight in the cutout of the tutorial overlay
  final ValueNotifier? repaint;

  /// The target rect to paint a hole around
  final Rect targetRect;

  /// The padding around the target rect in the hole
  final EdgeInsets padding;

  /// The shape of the hole to paint around the target rect
  final ShapeBorder shapeBorder;

  /// The color of the barrier that the hole is cut from.
  final Color color;

  /// The direction of the hole. Default is [TextDirection.ltr]
  final TextDirection direction;

  const HolePainter({
    this.repaint,
    required this.targetRect,
    this.shapeBorder =
        const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
    this.color = const Color(0x90000000), // Black with 50% opacity
    this.padding = EdgeInsets.zero,
    this.direction = TextDirection.ltr,
  }): super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
      ..color = color
      ..blendMode = BlendMode.dstOver;

    // Create a padded rectangle from the targetRect using padding
    final Rect paddedRect = Rect.fromLTRB(
      targetRect.left - padding.left,
      targetRect.top - padding.top,
      targetRect.right + padding.right,
      targetRect.bottom + padding.bottom,
    );

    // Create the background path covering the entire canvas
    Path backgroundPath = Path()
      ..addRect(
        Rect.fromLTWH(
          0,
          0,
          size.width,
          size.height,
        ),
      );

    // Create the hole path depending on the shapeBorder
    Path holePath = Path();
    if (shapeBorder is RoundedRectangleBorder) {
      BorderRadiusGeometry borderRadiusGeometry =
          (shapeBorder as RoundedRectangleBorder).borderRadius;
      BorderRadius borderRadius = borderRadiusGeometry.resolve(direction);
      holePath.addRRect(
        RRect.fromRectAndCorners(
          paddedRect,
          topLeft: borderRadius.topLeft,
          topRight: borderRadius.topRight,
          bottomLeft: borderRadius.bottomLeft,
          bottomRight: borderRadius.bottomRight,
        ),
      );
    } else if (shapeBorder is CircleBorder) {
      // Use the smaller side to ensure it fits within the padded rect
      double radius = paddedRect.width < paddedRect.height
          ? paddedRect.width / 2
          : paddedRect.height / 2;
      holePath.addOval(
        Rect.fromCircle(
          center: paddedRect.center,
          radius: radius,
        ),
      );
    } else {
      // Only support RoundedRectangleBorder and CircleBorder for now
      throw Exception('Unsupported shape border type');
    }

    // Combine the paths to create a cut-out effect
    Path combinedPath = Path.combine(
      PathOperation.difference,
      backgroundPath,
      holePath,
    );
    // Draw to the canvas
    canvas.drawPath(
      combinedPath,
      paint,
    );
    debugPrint('paint: ${targetRect.toString()}');
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    if (oldDelegate is HolePainter)
      debugPrint(
        'repaint: ${oldDelegate.targetRect.toString()} => ${targetRect.toString()}',
      );
    return true;
  }
}

«[...] но он просто не перерисовывается должным образом» — перейдите по адресу api.flutter.dev/flutter/widgets/CustomPaint-class.html и прочитайте абзац, начинающийся со слов: «Самый эффективный способ запуска перекраска должна либо:"

pskink 04.05.2024 19:30

Спасибо! Я попробую еще раз, когда смогу. Кроме того, ссылка на упомянутый вами абзац на самом деле такая: api.flutter.dev/flutter/rendering/CustomPainter-class.html но я попал туда по вашей ссылке и перешел на связанные страницы, так что спасибо всем одинаковый.

Joeseph Schmoe 06.05.2024 16:19

конечно, я имел в виду CustomPainter, извините, что ввела в заблуждение...

pskink 06.05.2024 16:24

Сейчас я попробовал обе рекомендации, указанные там, и ни одна из них не имеет никакого значения. Не совсем уверен, в чем здесь проблема. Это все тот же код, но я добавил параметр перерисовки в свой класс CustomPainter (и, конечно же, перешел в super) и обновил ValueNotifier при изменении целей. Нет эффекта.

Joeseph Schmoe 07.05.2024 16:47

тогда выкладывай минимальный, (не)рабочий код - я использую repaint: ... очень часто и он работает без проблем

pskink 07.05.2024 16:57

Я добавил основную функцию, которой не хватало. Теперь у него есть все необходимое для запуска в dartpad или в новом проекте Flutter (извините, я думал, что опубликовал это с этим, но, видимо, пропустил это). -- Я не могу вырезать слишком много, не изменив при этом желаемую функциональность. Если это будет полезно, я могу попытаться выпотрошить сегменты стиля класса TutorialOverlayTooltip. Тем не менее, как есть, он работает на MacOS для первой отрисовки, но вообще не работает в Интернете/дартпаде и никогда не работает на какой-либо платформе для последующих отрисовок (по какой-то причине, если только в режиме отладки с точками останова).

Joeseph Schmoe 07.05.2024 20:36

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

pskink 08.05.2024 14:15

Истинный; для этого я просто пытался понять основы того, как реализовать что-то подобное - есть масса возможностей для доработки.

Joeseph Schmoe 08.05.2024 20:13

И спасибо огромное! Я вижу, что ты изменил. Я немного озадачен, почему мой способ вообще не сработал, но ваш работает как шарм даже в Интернете, тогда как мой вообще не работал в Интернете. Очевидно, мне придется углубиться в это и по-настоящему изучить ваш подход, но теперь у меня есть четкий пример, в который можно погрузиться. -- Если вы хотите изложить свою суть в ответном комментарии, я приму это, чтобы вы получили репутацию. Я ценю, что вы нашли время взглянуть и объяснить мне это.

Joeseph Schmoe 08.05.2024 20:16

кстати, если вы хотите увидеть анимацию в замедленном режиме, найдите строку // timeDilation = 5 и раскомментируйте ее.

pskink 09.05.2024 16:52

Я сделал! Спасибо! Я понимаю, что вы говорите, и следую вашей логике в анимации. Мне нужно провести дальнейшее исследование некоторых других функций, которые вы использовали в методе рисования, поскольку я заметил, что вы использовали некоторые дополнительные функции, которые я изначально не использовал, и которые, похоже, напрямую способствовали вашей работе там, где моя застряла. -- Могу ли я спросить; Как вы научились/получили опыт работы с пользовательским художником? Я знаю, что в документах есть много информации; это просто метод проб и ошибок, или есть другие ресурсы, которые помогли вам, прежде чем это помогло? В любом случае, спасибо огромное!

Joeseph Schmoe 09.05.2024 21:29

когда дело доходит до ImplicitlyAnimatedWidget - суть всего решения - я следовал исходникам (лучший пример - AnimatedContainer) - чтение только документации ImplicitlyAnimatedWidget - это, по моему мнению, пустая трата времени ;-) кстати, анимированная версия сейчас выглядит лучше ? вы можете поиграть с двумя другими виджетами, которые я использовал: AnimatedSwitcher и AnimatedSize, и изменить/удалить их, чтобы получить другой вид всплывающих подсказок.

pskink 09.05.2024 21:47

обновление, я добавил дополнительное выравнивание для всплывающей подсказки (по умолчанию — вверху по центру), также использование маршрутов вместо наложений дает лучший UX, имхо (проверьте мою суть)

pskink 13.05.2024 06:29
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
13
180
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

На основе комментариев @pskink в теме под вопросом:

Ожидаемое поведение пользовательского рисовальщика можно лучше реализовать, изменив метод рисования (не требуется прослушивание перерисовки) и установив состояние, которое вызовет перерисовку пользовательского рисовальщика. Более того, используя ImplicitlyAnimatedWidget, мы смогли сгладить рывки между различными отрисовками.

Код ниже:

import 'dart:async';

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: DebugPageTutorial(),
    );
  }
}

class DebugPageTutorial extends StatefulWidget {
  const DebugPageTutorial({super.key});

  @override
  State<DebugPageTutorial> createState() => _DebugPageTutorialState();
}

class _DebugPageTutorialState extends State<DebugPageTutorial> {
  final GlobalKey _overlayKey1 = GlobalKey();
  final GlobalKey _overlayKey2 = GlobalKey();
  final GlobalKey _overlayKey3 = GlobalKey();

  OverlayEntry? currentOverlayEntry;

  @override
  void initState() {
    super.initState();
    currentOverlayEntry = null;
  }

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

  void _onTutorialFinished() {
    if (currentOverlayEntry?.mounted ?? false) currentOverlayEntry?.remove();
    currentOverlayEntry = null;
  }

  @override
  Widget build(BuildContext context) => Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                key: _overlayKey1,
                'This is a tutorial page',
                style: const TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                key: _overlayKey2,
                'This is a tutorial page',
              ),
              Padding(
                padding: const EdgeInsets.all(32.0),
                child: Container(
                  key: _overlayKey3,
                  decoration: const BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.cyan,
                  ),
                  height: 50.0,
                  width: 50.0,
                ),
              ),
              MaterialButton(
                onPressed: () async {
                  currentOverlayEntry = TutorialOverlay.createOverlayEntry(
                    context,
                    overlayKeys: [
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey1,
                        overlayTooltip: ConstrainedBox(
                          constraints: const BoxConstraints(maxWidth: 150),
                          child: const Text('Excepteur irure exercitation consequat esse aute occaecat voluptate nulla minim.'),
                        ),
                        color: Colors.indigo.shade900.withOpacity(0.9),
                      ),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey2,
                        overlayTooltip: ConstrainedBox(
                          constraints: const BoxConstraints(maxWidth: 125),
                          child: const Text('Proident qui proident dolore dolor minim voluptate mollit dolore eiusmod nostrud nulla.'),
                        ),
                        padding: const EdgeInsets.all(16.0),
                        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
                        color: Colors.orange,
                      ),
                      TutorialOverlayTooltip(
                        overlayKey: _overlayKey3,
                        overlayTooltip: ConstrainedBox(
                          constraints: const BoxConstraints(maxWidth: 150),
                          child: const Text('Sint elit officia non Lorem magna id.'),
                        ),
                        shape: const CircleBorder(),
                        padding: const EdgeInsets.all(24.0),
                        color: Colors.green.shade900.withOpacity(0.9),
                      ),
                    ],
                    onTutorialFinished: _onTutorialFinished,
                  );
                  Overlay.of(context).insert(currentOverlayEntry!);
                },
                child: const Text('Show Tutorial'),
              ),
            ],
          ),
        ),
      );
}

class TutorialOverlay extends StatefulWidget {
  /// A list of keys and overlay tooltips to display when the overlay is
  /// displayed. The overlay will be displayed in the order of the list.
  final List<TutorialOverlayTooltip> overlayKeys;

  /// A global key to use as the ancestor for the overlay entry, ensuring that
  /// the overlay entry is not shifted improperly when the overlay is only being
  /// painted on a portion of the screen. If null, the overlay will be painted
  /// based on the heuristics of the entire screen.
  final GlobalKey? ancestorKey;

  final FutureOr<void> Function()? onTutorialFinished;

  const TutorialOverlay({
    super.key,
    required this.overlayKeys,
    this.ancestorKey,
    this.onTutorialFinished,
  });

  static OverlayEntry createOverlayEntry(
    BuildContext context, {
    required List<TutorialOverlayTooltip> overlayKeys,
    GlobalKey? ancestorKey,
    FutureOr<void> Function()? onTutorialFinished,
  }) =>
      OverlayEntry(
        builder: (BuildContext context) => TutorialOverlay(
          overlayKeys: overlayKeys,
          ancestorKey: ancestorKey,
          onTutorialFinished: onTutorialFinished,
        ),
      );

  @override
  State<TutorialOverlay> createState() => _TutorialOverlayState();
}

class _TutorialOverlayState extends State<TutorialOverlay> {
  late List<TutorialOverlayTooltip> _overlayKeys;
  int _currentIndex = 0;
  TutorialOverlayTooltip? get _currentTooltip =>
      _currentIndex < _overlayKeys.length ? _overlayKeys[_currentIndex] : null;

  @override
  void initState() {
    super.initState();
    _overlayKeys = widget.overlayKeys;
  }

  Rect? _getNextRenderBox(GlobalKey? key) {
    final renderBox = key?.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox != null && renderBox.hasSize) {
      final Offset offset = renderBox.localToGlobal(
        Offset.zero,
        ancestor: widget.ancestorKey?.currentContext?.findRenderObject(),
      );
      return offset & renderBox.size;
    }
    return null;
  }
  @override
  Widget build(BuildContext context) {
    final Rect? nextRenderBox = _getNextRenderBox(_currentTooltip?.overlayKey);
    if (nextRenderBox == null) return const SizedBox();

    // debugPrint('build: ${nextRenderBox.toString()}');

    final tooltipColor = HSLColor.fromColor(_currentTooltip?.color ?? const Color(0x90000000));
    return AnimatedTutorial(
      duration: Durations.long2,
      targetRect: nextRenderBox,
      shape: _currentTooltip?.shape ?? const RoundedRectangleBorder(),
      color: _currentTooltip?.color ?? const Color(0x90000000),
      padding: _currentTooltip?.padding ?? EdgeInsets.zero,
      curve: Curves.ease,
      child: Material(
        color: tooltipColor.withAlpha(1).withLightness(0.75).toColor(),
        borderRadius: BorderRadius.circular(6),
        elevation: 3,
        clipBehavior: Clip.antiAlias,
        child: InkWell(
          splashColor: Colors.white24,
          highlightColor: Colors.transparent,
          onTap: () {
            int newIndex = _currentIndex + 1;
            if (newIndex >= _overlayKeys.length) {
              widget.onTutorialFinished?.call();
              return;
            }
            setState(() => _currentIndex = newIndex);
          },
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: AnimatedSize(
              duration: Durations.medium2,
              curve: Curves.ease,
              child: AnimatedSwitcher(
                duration: Durations.medium2,
                child: KeyedSubtree(
                  key: UniqueKey(),
                  child: _currentTooltip?.overlayTooltip ?? const SizedBox(),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// Contains information for drawing a tutorial overlay over a given widget
/// based on the provided global key.
class TutorialOverlayTooltip {
  /// The key of the widget to highlight in the cutout of the tutorial overlay
  final GlobalKey overlayKey;

  /// The widget to render by the cutout of the totorial overlay
  final Widget overlayTooltip;

  /// The padding around the widget to render by the cutout of the totorial
  /// overlay. Default is EdgeInsets.zero
  final EdgeInsets padding;

  /// The shape of the cutout of the totorial overlay. Default is a rounded
  /// rectangle with no border radius
  final ShapeBorder shape;

  /// The color of the barrier of the totorial overlay. Default is
  /// Black with 50% opacity
  final Color color;

  const TutorialOverlayTooltip({
    required this.overlayKey,
    required this.overlayTooltip,
    this.padding = EdgeInsets.zero,
    this.shape = const RoundedRectangleBorder(),
    this.color = const Color(0x90000000), // Black with 50% opacity
  });
}

class AnimatedTutorial extends ImplicitlyAnimatedWidget {
  AnimatedTutorial({
    super.key,
    required super.duration,
    required this.targetRect,
    required this.padding,
    required ShapeBorder shape,
    required Color color,
    required this.child,
    super.curve,
  }) : decoration = ShapeDecoration(shape: shape, color: color);

  final Rect targetRect;
  final EdgeInsets padding;
  final Decoration decoration;
  final Widget child;

  @override
  ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() {
    return _AnimatedTutorialState();
  }
}

class _AnimatedTutorialState extends AnimatedWidgetBaseState<AnimatedTutorial> {
  RectTween? _targetRect;
  EdgeInsetsGeometryTween? _padding;
  DecorationTween? _decoration;

  @override
  Widget build(BuildContext context) {
    // timeDilation = 5; // sloooow motion for testing
    return CustomPaint(
      painter: HolePainter(
        targetRect: _targetRect?.evaluate(animation) as Rect,
        decoration: _decoration?.evaluate(animation) as ShapeDecoration,
        direction: Directionality.of(context),
        padding: _padding?.evaluate(animation) as EdgeInsetsGeometry,
      ),
      child: CustomSingleChildLayout(
        delegate: TooltipDelegate(_targetRect?.evaluate(animation) as Rect),
        child: widget.child,
      ),
    );
  }

  @override
  void forEachTween(TweenVisitor<dynamic> visitor) {
    _targetRect = visitor(_targetRect, widget.targetRect, (dynamic value) => RectTween(begin: value as Rect)) as RectTween?;
    _padding = visitor(_padding, widget.padding, (dynamic value) => EdgeInsetsGeometryTween(begin: value as EdgeInsetsGeometry)) as EdgeInsetsGeometryTween?;
    _decoration = visitor(_decoration, widget.decoration, (dynamic value) => DecorationTween(begin: value as Decoration)) as DecorationTween?;
  }
}

class TooltipDelegate extends SingleChildLayoutDelegate {
  TooltipDelegate(this.rect);

  final Rect rect;
  final padding = const Offset(0, 6);

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    assert(size.width - childSize.width >= 0);
    assert(size.height - childSize.height >= 0);
    final position = rect.topLeft - childSize.bottomLeft(padding);
    return _clamp(position.dy >= 0? position : rect.bottomLeft, size, childSize);
  }

  Offset _clamp(Offset position, Size size, Size childSize) {
    return Offset(
      position.dx.clamp(0, size.width - childSize.width),
      position.dy.clamp(0, size.height - childSize.height),
    );
  }

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

  @override
  bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => true;
}

/// A painter that covers the area with a shaped hole around a target box
class HolePainter extends CustomPainter {
  const HolePainter({
    required this.targetRect,
    required this.decoration,
    required this.padding,
    this.direction = TextDirection.ltr,
  });

  /// The target rect to paint a hole around
  final Rect targetRect;

  /// The padding around the target rect in the hole
  final EdgeInsetsGeometry padding;

  /// The shape decoration of the hole to paint around the target rect
  final ShapeDecoration decoration;

  /// The direction of the hole. Default is [TextDirection.ltr]
  final TextDirection direction;

  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
      ..color = decoration.color ?? Colors.transparent;

    final Rect paddedRect = padding.resolve(direction).inflateRect(targetRect);
    Path path = Path()
      ..fillType = PathFillType.evenOdd
      ..addRect(Offset.zero & size)
      ..addPath(decoration.getClipPath(paddedRect, direction), Offset.zero);
    canvas.drawPath(path, paint);
    // debugPrint('paint: ${targetRect.toString()}');
  }

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

Приведенный выше код работает на всех платформах и не обнаруживает ни одной из проблем, упомянутых в вопросе. Он также доступен в Gist по ссылке ниже:

Github Gist от pskink

Другие вопросы по теме