Files

495 lines
14 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'animated_overlay_popup.dart';
import 'custom_notification.dart';
const Duration _defaultHintDuration = Duration(milliseconds: 2200);
class HintBubbleStyle {
static const double maxWidth = 320;
static const double anchorGap = 8;
static const double screenMargin = 8;
static const double radius = 10;
static const Size tail = Size(14, 6);
static const EdgeInsets padding = EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
);
static const BoxShadow shadow = BoxShadow(
color: Color(0x47000000),
blurRadius: 14,
offset: Offset(0, 4),
);
static Color fill(ColorScheme cs) => cs.surfaceContainerHighest;
static Color outline(ColorScheme cs) =>
cs.outlineVariant.withValues(alpha: 0.5);
static BoxDecoration decoration(ColorScheme cs) => BoxDecoration(
color: fill(cs),
borderRadius: const BorderRadius.all(Radius.circular(radius)),
border: Border.all(color: outline(cs)),
boxShadow: const [shadow],
);
static TextStyle textStyle(ColorScheme cs) => TextStyle(
color: cs.onSurface,
fontSize: 13,
height: 1.3,
fontWeight: FontWeight.w500,
);
static TooltipThemeData tooltipTheme(ColorScheme cs) => TooltipThemeData(
decoration: decoration(cs),
textStyle: textStyle(cs),
padding: padding,
margin: const EdgeInsets.all(screenMargin),
);
}
OverlayEntry? _activeHint;
Offset? _lastPress;
bool _trackingPresses = false;
void trackHintBubblePresses() {
if (_trackingPresses) return;
_trackingPresses = true;
GestureBinding.instance.pointerRouter.addGlobalRoute(_recordPress);
}
void _recordPress(PointerEvent event) {
if (event is PointerDownEvent) _lastPress = event.position;
}
void showHintBubble(
BuildContext anchor,
String message, {
Duration duration = _defaultHintDuration,
}) {
final overlay = Overlay.of(anchor);
final anchorRect = _anchorRectIn(overlay, anchor);
if (anchorRect == null) {
showCustomNotificationOnOverlay(overlay, message);
return;
}
_dismissHint(_activeHint);
final themes = InheritedTheme.capture(from: anchor, to: overlay.context);
final pressDx = _pressDxWithin(overlay, anchorRect);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => themes.wrap(
_HintLayer(
overlay: overlay,
anchor: anchor,
initialAnchorRect: anchorRect,
pressDx: pressDx,
message: message,
duration: duration,
onDismiss: () => _dismissHint(entry),
),
),
);
_activeHint = entry;
overlay.insert(entry);
}
void _dismissHint(OverlayEntry? entry) {
if (entry == null) return;
if (identical(_activeHint, entry)) _activeHint = null;
if (!entry.mounted) return;
entry
..remove()
..dispose();
}
Rect? _anchorRectIn(OverlayState overlay, BuildContext anchor) {
if (!overlay.mounted || !anchor.mounted) return null;
final anchorBox = anchor.findRenderObject();
final overlayBox = overlay.context.findRenderObject();
if (anchorBox is! RenderBox || !anchorBox.attached || !anchorBox.hasSize) {
return null;
}
if (overlayBox is! RenderBox || !overlayBox.attached) return null;
return MatrixUtils.transformRect(
anchorBox.getTransformTo(overlayBox),
Offset.zero & anchorBox.size,
);
}
double? _pressDxWithin(OverlayState overlay, Rect anchorRect) {
final press = _lastPress;
final overlayBox = overlay.context.findRenderObject();
if (press == null || overlayBox is! RenderBox) return null;
final local = overlayBox.globalToLocal(press);
return anchorRect.contains(local) ? local.dx - anchorRect.left : null;
}
class _HintLayer extends StatefulWidget {
final OverlayState overlay;
final BuildContext anchor;
final Rect initialAnchorRect;
final double? pressDx;
final String message;
final Duration duration;
final VoidCallback onDismiss;
const _HintLayer({
required this.overlay,
required this.anchor,
required this.initialAnchorRect,
required this.pressDx,
required this.message,
required this.duration,
required this.onDismiss,
});
@override
State<_HintLayer> createState() => _HintLayerState();
}
class _HintLayerState extends State<_HintLayer>
with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_HintLayer> {
late Rect _anchorRect = widget.initialAnchorRect;
late final Timer _lifetime;
@override
Duration get overlayForwardDuration => const Duration(milliseconds: 140);
@override
Duration get overlayReverseDuration => const Duration(milliseconds: 120);
@override
VoidCallback get onOverlayDismiss => widget.onDismiss;
@override
void initState() {
super.initState();
_lifetime = Timer(widget.duration, closeOverlay);
GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointer);
_followAnchor();
}
@override
void dispose() {
_lifetime.cancel();
GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointer);
super.dispose();
}
void _onPointer(PointerEvent event) {
if (event is PointerDownEvent || event is PointerSignalEvent) {
closeOverlay();
}
}
void _followAnchor() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final rect = _anchorRectIn(widget.overlay, widget.anchor);
if (rect == null) {
widget.onDismiss();
return;
}
if (rect != _anchorRect) setState(() => _anchorRect = rect);
_followAnchor();
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final media = MediaQuery.of(context);
final pressDx = widget.pressDx;
return Positioned.fill(
child: IgnorePointer(
child: FadeTransition(
opacity: overlayAnimation,
child: Material(
type: MaterialType.transparency,
child: _HintBubbleBox(
anchor: _anchorRect,
pressX: pressDx == null ? null : _anchorRect.left + pressDx,
insets: media.padding + media.viewInsets,
fill: HintBubbleStyle.fill(cs),
outline: HintBubbleStyle.outline(cs),
growth: overlayAnimation,
child: Semantics(
liveRegion: true,
child: Padding(
padding: HintBubbleStyle.padding,
child: Text(
widget.message,
style: HintBubbleStyle.textStyle(cs),
),
),
),
),
),
),
),
);
}
}
class _HintBubbleBox extends SingleChildRenderObjectWidget {
final Rect anchor;
final double? pressX;
final EdgeInsets insets;
final Color fill;
final Color outline;
final Animation<double> growth;
const _HintBubbleBox({
required this.anchor,
required this.pressX,
required this.insets,
required this.fill,
required this.outline,
required this.growth,
required super.child,
});
@override
RenderObject createRenderObject(BuildContext context) => _RenderHintBubble(
anchor: anchor,
pressX: pressX,
insets: insets,
fill: fill,
outline: outline,
growth: growth,
);
@override
void updateRenderObject(
BuildContext context,
_RenderHintBubble renderObject,
) {
renderObject
..anchor = anchor
..pressX = pressX
..insets = insets
..fill = fill
..outline = outline
..growth = growth;
}
}
class _RenderHintBubble extends RenderShiftedBox {
_RenderHintBubble({
required Rect anchor,
required double? pressX,
required EdgeInsets insets,
required Color fill,
required Color outline,
required Animation<double> growth,
}) : _anchor = anchor,
_pressX = pressX,
_insets = insets,
_fill = fill,
_outline = outline,
_growth = growth,
super(null);
static const double _restingScale = 0.92;
static const double _tipRounding = 1.5;
Rect _anchor;
set anchor(Rect value) {
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
double? _pressX;
set pressX(double? value) {
if (value == _pressX) return;
_pressX = value;
markNeedsLayout();
}
EdgeInsets _insets;
set insets(EdgeInsets value) {
if (value == _insets) return;
_insets = value;
markNeedsLayout();
}
Color _fill;
set fill(Color value) {
if (value == _fill) return;
_fill = value;
markNeedsPaint();
}
Color _outline;
set outline(Color value) {
if (value == _outline) return;
_outline = value;
markNeedsPaint();
}
Animation<double> _growth;
set growth(Animation<double> value) {
if (identical(value, _growth)) return;
if (attached) _growth.removeListener(markNeedsPaint);
_growth = value;
if (attached) _growth.addListener(markNeedsPaint);
markNeedsPaint();
}
VerticalDirection? _tailTowards;
double _tipX = 0;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_growth.addListener(markNeedsPaint);
}
@override
void detach() {
_growth.removeListener(markNeedsPaint);
super.detach();
}
@override
bool get sizedByParent => true;
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
void performLayout() {
final bubble = child;
if (bubble == null) return;
const margin = HintBubbleStyle.screenMargin;
bubble.layout(
BoxConstraints(
maxWidth: math.max(
0,
math.min(
HintBubbleStyle.maxWidth,
size.width - _insets.horizontal - margin * 2,
),
),
maxHeight: math.max(0, size.height - _insets.vertical - margin * 2),
),
parentUsesSize: true,
);
final bubbleSize = bubble.size;
final target = _pressX ?? _anchor.center.dx;
final minLeft = _insets.left + margin;
final maxLeft = math.max(
minLeft,
size.width - _insets.right - margin - bubbleSize.width,
);
final left = (target - bubbleSize.width / 2).clamp(minLeft, maxLeft);
final topLimit = _insets.top + margin;
final bottomLimit = size.height - _insets.bottom - margin;
final above = _anchor.top - HintBubbleStyle.anchorGap - bubbleSize.height;
final below = _anchor.bottom + HintBubbleStyle.anchorGap;
final double top;
if (above >= topLimit) {
top = above;
_tailTowards = VerticalDirection.down;
} else if (below + bubbleSize.height <= bottomLimit) {
top = below;
_tailTowards = VerticalDirection.up;
} else {
top = math.max(topLimit, bottomLimit - bubbleSize.height);
_tailTowards = null;
}
(bubble.parentData! as BoxParentData).offset = Offset(left, top);
final tipInset = HintBubbleStyle.radius + HintBubbleStyle.tail.width / 2;
_tipX = bubbleSize.width < tipInset * 2
? left + bubbleSize.width / 2
: target.clamp(left + tipInset, left + bubbleSize.width - tipInset);
}
@override
void paint(PaintingContext context, Offset offset) {
final bubble = child;
if (bubble == null) return;
final bubbleOffset = (bubble.parentData! as BoxParentData).offset;
final body = bubbleOffset & bubble.size;
final pivot = switch (_tailTowards) {
VerticalDirection.down => Offset(
_tipX,
body.bottom + HintBubbleStyle.tail.height,
),
VerticalDirection.up => Offset(
_tipX,
body.top - HintBubbleStyle.tail.height,
),
null => body.center,
};
final scale = _restingScale + (1 - _restingScale) * _growth.value;
final transform = Matrix4.translationValues(pivot.dx, pivot.dy, 0)
..multiply(Matrix4.diagonal3Values(scale, scale, 1))
..multiply(Matrix4.translationValues(-pivot.dx, -pivot.dy, 0));
layer = context.pushTransform(needsCompositing, offset, transform, (
context,
offset,
) {
final shape = _shape(body.shift(offset), _tipX + offset.dx);
context.canvas
..drawPath(
shape.shift(HintBubbleStyle.shadow.offset),
HintBubbleStyle.shadow.toPaint(),
)
..drawPath(shape, Paint()..color = _fill)
..drawPath(
shape,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = _outline,
);
context.paintChild(bubble, bubbleOffset + offset);
}, oldLayer: layer as TransformLayer?);
}
Path _shape(Rect body, double tipX) {
final rounded = Path()
..addRRect(
RRect.fromRectAndRadius(
body,
const Radius.circular(HintBubbleStyle.radius),
),
);
final towards = _tailTowards;
if (towards == null) return rounded;
final sign = towards == VerticalDirection.down ? 1.0 : -1.0;
final edge = towards == VerticalDirection.down ? body.bottom : body.top;
final half = HintBubbleStyle.tail.width / 2;
final tipY = edge + sign * HintBubbleStyle.tail.height;
final shoulderY =
tipY - sign * HintBubbleStyle.tail.height * _tipRounding / half;
final tail = Path()
..moveTo(tipX - half, edge - sign)
..lineTo(tipX - half, edge)
..quadraticBezierTo(
tipX - half * 0.45,
edge,
tipX - _tipRounding,
shoulderY,
)
..quadraticBezierTo(tipX, tipY, tipX + _tipRounding, shoulderY)
..quadraticBezierTo(tipX + half * 0.45, edge, tipX + half, edge)
..lineTo(tipX + half, edge - sign)
..close();
return Path.combine(PathOperation.union, rounded, tail);
}
}