Я пытаюсь познакомиться с CustomPainters во Flutter и пытался использовать собственный рисовальщик, чтобы нарисовать барьер с вырезом вокруг определенного виджета на экране, чтобы вести себя как учебник.
У меня есть стек с пользовательским рисовальщиком, и я использую наложения, чтобы наложить учебный барьер на главную страницу. Моя мысль заключалась в том, чтобы иметь список виджетов для выделения, с «учебником», который вел бы пользователя виджет за виджетом по мере его щелчка, а художник перерисовывался, чтобы выделить следующий целевой виджет, или закрывал последний.
К сожалению, специальный рисовальщик рисует только первый вариант, а затем исчезает*. Я могу подтвердить с помощью утверждений debugPrint, что художник получает обновленные границы, но он просто не перерисовывается должным образом (это верно с/без переопределения определений == и hashCode, а также независимо от того, всегда ли shouldRepaint установлено в true или имеет дополнительную логику внутри ).
]
*На dartpad.dev маляр вообще не рендерится, даже первый.
Я также пробовал давать ключи виджетам Stack, Positioned и CustomPaint — без изменений.
В режиме отладки, если я устанавливаю точку останова в функции рисования моего HolePainter, после нажатия кнопки воспроизведения при каждой перерисовке и возврата в приложение она ведет себя так, как ожидалось.

Чего мне не хватает в документации? Есть ли известный трюк, вызывающий ожидаемое поведение?
Заранее спасибо.
Мой фрагмент кода:
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/rendering/CustomPainter-class.html но я попал туда по вашей ссылке и перешел на связанные страницы, так что спасибо всем одинаковый.
конечно, я имел в виду CustomPainter, извините, что ввела в заблуждение...
Сейчас я попробовал обе рекомендации, указанные там, и ни одна из них не имеет никакого значения. Не совсем уверен, в чем здесь проблема. Это все тот же код, но я добавил параметр перерисовки в свой класс CustomPainter (и, конечно же, перешел в super) и обновил ValueNotifier при изменении целей. Нет эффекта.
тогда выкладывай минимальный, (не)рабочий код - я использую repaint: ... очень часто и он работает без проблем
Я добавил основную функцию, которой не хватало. Теперь у него есть все необходимое для запуска в dartpad или в новом проекте Flutter (извините, я думал, что опубликовал это с этим, но, видимо, пропустил это). -- Я не могу вырезать слишком много, не изменив при этом желаемую функциональность. Если это будет полезно, я могу попытаться выпотрошить сегменты стиля класса TutorialOverlayTooltip. Тем не менее, как есть, он работает на MacOS для первой отрисовки, но вообще не работает в Интернете/дартпаде и никогда не работает на какой-либо платформе для последующих отрисовок (по какой-то причине, если только в режиме отладки с точками останова).
кстати, честно говоря, вы могли бы добиться гораздо лучшего пользовательского опыта, если бы создали класс, который расширяется ImplicitlyAnimatedWidget - с его помощью вы могли бы иметь плавные, анимированные переходы между каждым шагом как с цветом фона, так и с формой отверстия - теперь у вас есть резкие, резкие "прыжки" "
Истинный; для этого я просто пытался понять основы того, как реализовать что-то подобное - есть масса возможностей для доработки.
И спасибо огромное! Я вижу, что ты изменил. Я немного озадачен, почему мой способ вообще не сработал, но ваш работает как шарм даже в Интернете, тогда как мой вообще не работал в Интернете. Очевидно, мне придется углубиться в это и по-настоящему изучить ваш подход, но теперь у меня есть четкий пример, в который можно погрузиться. -- Если вы хотите изложить свою суть в ответном комментарии, я приму это, чтобы вы получили репутацию. Я ценю, что вы нашли время взглянуть и объяснить мне это.
кстати, если вы хотите увидеть анимацию в замедленном режиме, найдите строку // timeDilation = 5 и раскомментируйте ее.
Я сделал! Спасибо! Я понимаю, что вы говорите, и следую вашей логике в анимации. Мне нужно провести дальнейшее исследование некоторых других функций, которые вы использовали в методе рисования, поскольку я заметил, что вы использовали некоторые дополнительные функции, которые я изначально не использовал, и которые, похоже, напрямую способствовали вашей работе там, где моя застряла. -- Могу ли я спросить; Как вы научились/получили опыт работы с пользовательским художником? Я знаю, что в документах есть много информации; это просто метод проб и ошибок, или есть другие ресурсы, которые помогли вам, прежде чем это помогло? В любом случае, спасибо огромное!
когда дело доходит до ImplicitlyAnimatedWidget - суть всего решения - я следовал исходникам (лучший пример - AnimatedContainer) - чтение только документации ImplicitlyAnimatedWidget - это, по моему мнению, пустая трата времени ;-) кстати, анимированная версия сейчас выглядит лучше ? вы можете поиграть с двумя другими виджетами, которые я использовал: AnimatedSwitcher и AnimatedSize, и изменить/удалить их, чтобы получить другой вид всплывающих подсказок.
обновление, я добавил дополнительное выравнивание для всплывающей подсказки (по умолчанию — вверху по центру), также использование маршрутов вместо наложений дает лучший UX, имхо (проверьте мою суть)





Ожидаемое поведение пользовательского рисовальщика можно лучше реализовать, изменив метод рисования (не требуется прослушивание перерисовки) и установив состояние, которое вызовет перерисовку пользовательского рисовальщика. Более того, используя 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 по ссылке ниже:
«[...] но он просто не перерисовывается должным образом» — перейдите по адресу api.flutter.dev/flutter/widgets/CustomPaint-class.html и прочитайте абзац, начинающийся со слов: «Самый эффективный способ запуска перекраска должна либо:"