1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:math' as math;
7import 'dart:ui';
8
9import 'package:flutter/rendering.dart';
10
11import 'basic.dart';
12import 'container.dart';
13import 'framework.dart';
14import 'inherited_theme.dart';
15import 'navigator.dart';
16import 'overlay.dart';
17
18/// {@template flutter.widgets.magnifier.MagnifierBuilder}
19/// Signature for a builder that builds a [Widget] with a [MagnifierController].
20///
21/// Consuming [MagnifierController] or [ValueNotifier]<[MagnifierInfo]> is not
22/// required, although if a Widget intends to have entry or exit animations, it should take
23/// [MagnifierController] and provide it an [AnimationController], so that [MagnifierController]
24/// can wait before removing it from the overlay.
25/// {@endtemplate}
26///
27/// See also:
28///
29/// - [MagnifierInfo], the data class that updates the
30/// magnifier.
31typedef MagnifierBuilder = Widget? Function(
32 BuildContext context,
33 MagnifierController controller,
34 ValueNotifier<MagnifierInfo> magnifierInfo,
35);
36
37/// A data class that contains the geometry information of text layouts
38/// and selection gestures, used to position magnifiers.
39@immutable
40class MagnifierInfo {
41 /// Constructs a [MagnifierInfo] from provided geometry values.
42 const MagnifierInfo({
43 required this.globalGesturePosition,
44 required this.caretRect,
45 required this.fieldBounds,
46 required this.currentLineBoundaries,
47 });
48
49 /// Const [MagnifierInfo] with all values set to 0.
50 static const MagnifierInfo empty = MagnifierInfo(
51 globalGesturePosition: Offset.zero,
52 caretRect: Rect.zero,
53 currentLineBoundaries: Rect.zero,
54 fieldBounds: Rect.zero,
55 );
56
57 /// The offset of the gesture position that the magnifier should be shown at.
58 final Offset globalGesturePosition;
59
60 /// The rect of the current line the magnifier should be shown at,
61 /// without taking into account any padding of the field; only the position
62 /// of the first and last character.
63 final Rect currentLineBoundaries;
64
65 /// The rect of the handle that the magnifier should follow.
66 final Rect caretRect;
67
68 /// The bounds of the entire text field that the magnifier is bound to.
69 final Rect fieldBounds;
70
71 @override
72 bool operator ==(Object other) {
73 if (identical(this, other)) {
74 return true;
75 }
76 return other is MagnifierInfo
77 && other.globalGesturePosition == globalGesturePosition
78 && other.caretRect == caretRect
79 && other.currentLineBoundaries == currentLineBoundaries
80 && other.fieldBounds == fieldBounds;
81 }
82
83 @override
84 int get hashCode => Object.hash(
85 globalGesturePosition,
86 caretRect,
87 fieldBounds,
88 currentLineBoundaries,
89 );
90}
91
92/// {@template flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
93/// A configuration object for a magnifier.
94/// {@endtemplate}
95///
96/// {@macro flutter.widgets.magnifier.intro}
97///
98/// {@template flutter.widgets.magnifier.TextMagnifierConfiguration.details}
99/// In general, most features of the magnifier can be configured through
100/// [MagnifierBuilder]. [TextMagnifierConfiguration] is used to configure
101/// the magnifier's behavior through the [SelectionOverlay].
102/// {@endtemplate}
103class TextMagnifierConfiguration {
104 /// Constructs a [TextMagnifierConfiguration] from parts.
105 ///
106 /// If [magnifierBuilder] is null, a default [MagnifierBuilder] will be used
107 /// that never builds a magnifier.
108 const TextMagnifierConfiguration({
109 MagnifierBuilder? magnifierBuilder,
110 this.shouldDisplayHandlesInMagnifier = true
111 }) : _magnifierBuilder = magnifierBuilder;
112
113 /// The passed in [MagnifierBuilder].
114 ///
115 /// This is nullable because [disabled] needs to be static const,
116 /// so that it can be used as a default parameter. If left null,
117 /// the [magnifierBuilder] getter will be a function that always returns
118 /// null.
119 final MagnifierBuilder? _magnifierBuilder;
120
121 /// {@macro flutter.widgets.magnifier.MagnifierBuilder}
122 MagnifierBuilder get magnifierBuilder => _magnifierBuilder ?? (_, __, ___) => null;
123
124 /// Determines whether a magnifier should show the text editing handles or not.
125 final bool shouldDisplayHandlesInMagnifier;
126
127 /// A constant for a [TextMagnifierConfiguration] that is disabled.
128 ///
129 /// In particular, this [TextMagnifierConfiguration] is considered disabled
130 /// because it never builds anything, regardless of platform.
131 static const TextMagnifierConfiguration disabled = TextMagnifierConfiguration();
132}
133
134/// [MagnifierController]'s main benefit over holding a raw [OverlayEntry] is that
135/// [MagnifierController] will handle logic around waiting for a magnifier to animate in or out.
136///
137/// If a magnifier chooses to have an entry / exit animation, it should provide the animation
138/// controller to [MagnifierController.animationController]. [MagnifierController] will then drive
139/// the [AnimationController] and wait for it to be complete before removing it from the
140/// [Overlay].
141///
142/// To check the status of the magnifier, see [MagnifierController.shown].
143// TODO(antholeole): This whole paradigm can be removed once portals
144// lands - then the magnifier can be controlled though a widget in the tree.
145// https://github.com/flutter/flutter/pull/105335
146class MagnifierController {
147 /// If there is no in / out animation for the magnifier, [animationController] should be left
148 /// null.
149 MagnifierController({this.animationController}) {
150 animationController?.value = 0;
151 }
152
153 /// The controller that will be driven in / out when show / hide is triggered,
154 /// respectively.
155 AnimationController? animationController;
156
157 /// The magnifier's [OverlayEntry], if currently in the overlay.
158 ///
159 /// This is public in case other overlay entries need to be positioned
160 /// above or below this [overlayEntry]. Anything in the paint order after
161 /// the [RawMagnifier] will not be displayed in the magnifier; this means that if it
162 /// is desired for an overlay entry to be displayed in the magnifier,
163 /// it _must_ be positioned below the magnifier.
164 ///
165 /// {@tool snippet}
166 /// ```dart
167 /// void magnifierShowExample(BuildContext context) {
168 /// final MagnifierController myMagnifierController = MagnifierController();
169 ///
170 /// // Placed below the magnifier, so it will show.
171 /// Overlay.of(context).insert(OverlayEntry(
172 /// builder: (BuildContext context) => const Text('I WILL display in the magnifier')));
173 ///
174 /// // Will display in the magnifier, since this entry was passed to show.
175 /// final OverlayEntry displayInMagnifier = OverlayEntry(
176 /// builder: (BuildContext context) =>
177 /// const Text('I WILL display in the magnifier'));
178 ///
179 /// Overlay.of(context)
180 /// .insert(displayInMagnifier);
181 /// myMagnifierController.show(
182 /// context: context,
183 /// below: displayInMagnifier,
184 /// builder: (BuildContext context) => const RawMagnifier(
185 /// size: Size(100, 100),
186 /// ));
187 ///
188 /// // By default, new entries will be placed over the top entry.
189 /// Overlay.of(context).insert(OverlayEntry(
190 /// builder: (BuildContext context) => const Text('I WILL NOT display in the magnifier')));
191 ///
192 /// Overlay.of(context).insert(
193 /// below:
194 /// myMagnifierController.overlayEntry, // Explicitly placed below the magnifier.
195 /// OverlayEntry(
196 /// builder: (BuildContext context) => const Text('I WILL display in the magnifier')));
197 /// }
198 /// ```
199 /// {@end-tool}
200 ///
201 /// A null check on [overlayEntry] will not suffice to check if a magnifier is in the
202 /// overlay or not; instead, you should check [shown]. This is because it is possible,
203 /// such as in cases where [hide] was called with `removeFromOverlay` false, that the magnifier
204 /// is not shown, but the entry is not null.
205 OverlayEntry? get overlayEntry => _overlayEntry;
206 OverlayEntry? _overlayEntry;
207
208 /// If the magnifier is shown or not.
209 ///
210 /// [shown] is:
211 /// - false when nothing is in the overlay.
212 /// - false when [animationController] is [AnimationStatus.dismissed].
213 /// - false when [animationController] is animating out.
214 /// and true in all other circumstances.
215 bool get shown {
216 if (overlayEntry == null) {
217 return false;
218 }
219
220 if (animationController != null) {
221 return animationController!.status == AnimationStatus.completed ||
222 animationController!.status == AnimationStatus.forward;
223 }
224
225 return true;
226 }
227
228 /// Shows the [RawMagnifier] that this controller controls.
229 ///
230 /// Returns a future that completes when the magnifier is fully shown, i.e. done
231 /// with its entry animation.
232 ///
233 /// To control what overlays are shown in the magnifier, utilize [below]. See
234 /// [overlayEntry] for more details on how to utilize [below].
235 ///
236 /// If the magnifier already exists (i.e. [overlayEntry] != null), then [show] will
237 /// override the old overlay and not play an exit animation. Consider awaiting [hide]
238 /// first, to guarantee
239 Future<void> show({
240 required BuildContext context,
241 required WidgetBuilder builder,
242 Widget? debugRequiredFor,
243 OverlayEntry? below,
244 }) async {
245 _overlayEntry?.remove();
246 _overlayEntry?.dispose();
247
248 final OverlayState overlayState = Overlay.of(
249 context,
250 rootOverlay: true,
251 debugRequiredFor: debugRequiredFor,
252 );
253
254 final CapturedThemes capturedThemes = InheritedTheme.capture(
255 from: context,
256 to: Navigator.maybeOf(context)?.context,
257 );
258
259 _overlayEntry = OverlayEntry(
260 builder: (BuildContext context) => capturedThemes.wrap(builder(context)),
261 );
262 overlayState.insert(overlayEntry!, below: below);
263
264 if (animationController != null) {
265 await animationController?.forward();
266 }
267 }
268
269 /// Schedules a hide of the magnifier.
270 ///
271 /// If this [MagnifierController] has an [AnimationController],
272 /// then [hide] reverses the animation controller and waits
273 /// for the animation to complete. Then, if [removeFromOverlay]
274 /// is true, remove the magnifier from the overlay.
275 ///
276 /// In general, `removeFromOverlay` should be true, unless
277 /// the magnifier needs to preserve states between shows / hides.
278 ///
279 /// See also:
280 ///
281 /// * [removeFromOverlay] which removes the [OverlayEntry] from the [Overlay]
282 /// synchronously.
283 Future<void> hide({bool removeFromOverlay = true}) async {
284 if (overlayEntry == null) {
285 return;
286 }
287
288 if (animationController != null) {
289 await animationController?.reverse();
290 }
291
292 if (removeFromOverlay) {
293 this.removeFromOverlay();
294 }
295 }
296
297 /// Remove the [OverlayEntry] from the [Overlay].
298 ///
299 /// This method removes the [OverlayEntry] synchronously,
300 /// regardless of exit animation: this leads to abrupt removals
301 /// of [OverlayEntry]s with animations.
302 ///
303 /// To allow the [OverlayEntry] to play its exit animation, consider calling
304 /// [hide] instead, with `removeFromOverlay` set to true, and optionally await
305 /// the returned Future.
306 @visibleForTesting
307 void removeFromOverlay() {
308 _overlayEntry?.remove();
309 _overlayEntry?.dispose();
310 _overlayEntry = null;
311 }
312
313 /// A utility for calculating a new [Rect] from [rect] such that
314 /// [rect] is fully constrained within [bounds].
315 ///
316 /// Any point in the output rect is guaranteed to also be a point contained in [bounds].
317 ///
318 /// It is a runtime error for [rect].width to be greater than [bounds].width,
319 /// and it is also an error for [rect].height to be greater than [bounds].height.
320 ///
321 /// This algorithm translates [rect] the shortest distance such that it is entirely within
322 /// [bounds].
323 ///
324 /// If [rect] is already within [bounds], no shift will be applied to [rect] and
325 /// [rect] will be returned as-is.
326 ///
327 /// It is perfectly valid for the output rect to have a point along the edge of the
328 /// [bounds]. If the desired output rect requires that no edges are parallel to edges
329 /// of [bounds], see [Rect.deflate] by 1 on [bounds] to achieve this effect.
330 static Rect shiftWithinBounds({
331 required Rect rect,
332 required Rect bounds,
333 }) {
334 assert(rect.width <= bounds.width,
335 'attempted to shift $rect within $bounds, but the rect has a greater width.');
336 assert(rect.height <= bounds.height,
337 'attempted to shift $rect within $bounds, but the rect has a greater height.');
338
339 Offset rectShift = Offset.zero;
340 if (rect.left < bounds.left) {
341 rectShift += Offset(bounds.left - rect.left, 0);
342 } else if (rect.right > bounds.right) {
343 rectShift += Offset(bounds.right - rect.right, 0);
344 }
345
346 if (rect.top < bounds.top) {
347 rectShift += Offset(0, bounds.top - rect.top);
348 } else if (rect.bottom > bounds.bottom) {
349 rectShift += Offset(0, bounds.bottom - rect.bottom);
350 }
351
352 return rect.shift(rectShift);
353 }
354}
355
356/// A decoration for a [RawMagnifier].
357///
358/// [MagnifierDecoration] does not expose [ShapeDecoration.color], [ShapeDecoration.image],
359/// or [ShapeDecoration.gradient], since they will be covered by the [RawMagnifier]'s lens.
360///
361/// Also takes an [opacity] (see https://github.com/flutter/engine/pull/34435).
362class MagnifierDecoration extends ShapeDecoration {
363 /// Constructs a [MagnifierDecoration].
364 ///
365 /// By default, [MagnifierDecoration] is a rectangular magnifier with no shadows, and
366 /// fully opaque.
367 const MagnifierDecoration({
368 this.opacity = 1,
369 super.shadows,
370 super.shape = const RoundedRectangleBorder(),
371 });
372
373 /// The magnifier's opacity.
374 final double opacity;
375
376 @override
377 bool operator ==(Object other) {
378 if (identical(this, other)) {
379 return true;
380 }
381
382 return super == other && other is MagnifierDecoration && other.opacity == opacity;
383 }
384
385 @override
386 int get hashCode => Object.hash(super.hashCode, opacity);
387}
388
389/// A common base class for magnifiers.
390///
391/// {@tool dartpad}
392/// This sample demonstrates what a magnifier is, and how it can be used.
393///
394/// ** See code in examples/api/lib/widgets/magnifier/magnifier.0.dart **
395/// {@end-tool}
396///
397/// {@template flutter.widgets.magnifier.intro}
398/// This magnifying glass is useful for scenarios on mobile devices where
399/// the user's finger may be covering part of the screen where a granular
400/// action is being performed, such as navigating a small cursor with a drag
401/// gesture, on an image or text.
402/// {@endtemplate}
403///
404/// A magnifier can be conveniently managed by [MagnifierController], which handles
405/// showing and hiding the magnifier, with an optional entry / exit animation.
406///
407/// See:
408/// * [MagnifierController], a controller to handle magnifiers in an overlay.
409class RawMagnifier extends StatelessWidget {
410 /// Constructs a [RawMagnifier].
411 ///
412 /// {@template flutter.widgets.magnifier.RawMagnifier.invisibility_warning}
413 /// By default, this magnifier uses the default [MagnifierDecoration],
414 /// the focal point is directly under the magnifier, and there is no magnification:
415 /// This means that a default magnifier will be entirely invisible to the naked eye,
416 /// since it is painting exactly what is under it, exactly where it was painted
417 /// originally.
418 /// {@endtemplate}
419 const RawMagnifier({
420 super.key,
421 this.child,
422 this.decoration = const MagnifierDecoration(),
423 this.focalPointOffset = Offset.zero,
424 this.magnificationScale = 1,
425 required this.size,
426 }) : assert(magnificationScale != 0,
427 'Magnification scale of 0 results in undefined behavior.');
428
429 /// An optional widget to position inside the len of the [RawMagnifier].
430 ///
431 /// This is positioned over the [RawMagnifier] - it may be useful for tinting the
432 /// [RawMagnifier], or drawing a crosshair like UI.
433 final Widget? child;
434
435 /// This magnifier's decoration.
436 ///
437 /// {@macro flutter.widgets.magnifier.RawMagnifier.invisibility_warning}
438 final MagnifierDecoration decoration;
439
440
441 /// The offset of the magnifier from [RawMagnifier]'s center.
442 ///
443 /// {@template flutter.widgets.magnifier.offset}
444 /// For example, if [RawMagnifier] is globally positioned at Offset(100, 100),
445 /// and [focalPointOffset] is Offset(-20, -20), then [RawMagnifier] will see
446 /// the content at global offset (80, 80).
447 ///
448 /// If left as [Offset.zero], the [RawMagnifier] will show the content that
449 /// is directly below it.
450 /// {@endtemplate}
451 final Offset focalPointOffset;
452
453 /// How "zoomed in" the magnification subject is in the lens.
454 final double magnificationScale;
455
456 /// The size of the magnifier.
457 ///
458 /// This does not include added border; it only includes
459 /// the size of the magnifier.
460 final Size size;
461
462 @override
463 Widget build(BuildContext context) {
464 return Stack(
465 clipBehavior: Clip.none,
466 alignment: Alignment.center,
467 children: <Widget>[
468 ClipPath.shape(
469 shape: decoration.shape,
470 child: Opacity(
471 opacity: decoration.opacity,
472 child: _Magnifier(
473 shape: decoration.shape,
474 focalPointOffset: focalPointOffset,
475 magnificationScale: magnificationScale,
476 child: SizedBox.fromSize(
477 size: size,
478 child: child,
479 ),
480 ),
481 ),
482 ),
483 // Because `BackdropFilter` will filter any widgets before it, we should
484 // apply the style after (i.e. in a younger sibling) to avoid the magnifier
485 // from seeing its own styling.
486 Opacity(
487 opacity: decoration.opacity,
488 child: _MagnifierStyle(
489 decoration,
490 size: size,
491 ),
492 )
493 ],
494 );
495 }
496}
497
498class _MagnifierStyle extends StatelessWidget {
499 const _MagnifierStyle(this.decoration, {required this.size});
500
501 final MagnifierDecoration decoration;
502 final Size size;
503
504 @override
505 Widget build(BuildContext context) {
506 double largestShadow = 0;
507 for (final BoxShadow shadow in decoration.shadows ?? <BoxShadow>[]) {
508 largestShadow = math.max(
509 largestShadow,
510 (shadow.blurRadius + shadow.spreadRadius) +
511 math.max(shadow.offset.dy.abs(), shadow.offset.dx.abs()));
512 }
513
514 return ClipPath(
515 clipBehavior: Clip.hardEdge,
516 clipper: _DonutClip(
517 shape: decoration.shape,
518 spreadRadius: largestShadow,
519 ),
520 child: DecoratedBox(
521 decoration: decoration,
522 child: SizedBox.fromSize(
523 size: size,
524 ),
525 ),
526 );
527 }
528}
529
530/// A `clipPath` that looks like a donut if you were to fill its area.
531///
532/// This is necessary because the shadow must be added after the magnifier is drawn,
533/// so that the shadow does not end up in the magnifier. Without this clip, the magnifier would be
534/// entirely covered by the shadow.
535///
536/// The negative space of the donut is clipped out (the donut hole, outside the donut).
537/// The donut hole is cut out exactly like the shape of the magnifier.
538class _DonutClip extends CustomClipper<Path> {
539 _DonutClip({required this.shape, required this.spreadRadius});
540
541 final double spreadRadius;
542 final ShapeBorder shape;
543
544 @override
545 Path getClip(Size size) {
546 final Path path = Path();
547 final Rect rect = Offset.zero & size;
548
549 path.fillType = PathFillType.evenOdd;
550 path.addPath(shape.getOuterPath(rect.inflate(spreadRadius)), Offset.zero);
551 path.addPath(shape.getInnerPath(rect), Offset.zero);
552 return path;
553 }
554
555 @override
556 bool shouldReclip(_DonutClip oldClipper) => oldClipper.shape != shape;
557}
558
559class _Magnifier extends SingleChildRenderObjectWidget {
560 const _Magnifier({
561 super.child,
562 required this.shape,
563 this.magnificationScale = 1,
564 this.focalPointOffset = Offset.zero,
565 });
566
567 // The Offset that the center of the _Magnifier points to, relative
568 // to the center of the magnifier.
569 final Offset focalPointOffset;
570
571 // The enlarge multiplier of the magnification.
572 //
573 // If equal to 1.0, the content in the magnifier is true to its real size.
574 // If greater than 1.0, the content appears bigger in the magnifier.
575 final double magnificationScale;
576
577 // Shape of the magnifier.
578 final ShapeBorder shape;
579
580 @override
581 RenderObject createRenderObject(BuildContext context) {
582 return _RenderMagnification(focalPointOffset, magnificationScale, shape);
583 }
584
585 @override
586 void updateRenderObject(
587 BuildContext context, _RenderMagnification renderObject) {
588 renderObject
589 ..focalPointOffset = focalPointOffset
590 ..shape = shape
591 ..magnificationScale = magnificationScale;
592 }
593}
594
595class _RenderMagnification extends RenderProxyBox {
596 _RenderMagnification(
597 this._focalPointOffset,
598 this._magnificationScale,
599 this._shape, {
600 RenderBox? child,
601 }) : super(child);
602
603 Offset get focalPointOffset => _focalPointOffset;
604 Offset _focalPointOffset;
605 set focalPointOffset(Offset value) {
606 if (_focalPointOffset == value) {
607 return;
608 }
609 _focalPointOffset = value;
610 markNeedsPaint();
611 }
612
613 double get magnificationScale => _magnificationScale;
614 double _magnificationScale;
615 set magnificationScale(double value) {
616 if (_magnificationScale == value) {
617 return;
618 }
619 _magnificationScale = value;
620 markNeedsPaint();
621 }
622
623 ShapeBorder get shape => _shape;
624 ShapeBorder _shape;
625 set shape(ShapeBorder value) {
626 if (_shape == value) {
627 return;
628 }
629 _shape = value;
630 markNeedsPaint();
631 }
632
633 @override
634 bool get alwaysNeedsCompositing => true;
635
636 @override
637 BackdropFilterLayer? get layer => super.layer as BackdropFilterLayer?;
638
639 @override
640 void paint(PaintingContext context, Offset offset) {
641 final Offset thisCenter = Alignment.center.alongSize(size) + offset;
642 final Matrix4 matrix = Matrix4.identity()
643 ..translate(
644 magnificationScale * ((focalPointOffset.dx * -1) - thisCenter.dx) + thisCenter.dx,
645 magnificationScale * ((focalPointOffset.dy * -1) - thisCenter.dy) + thisCenter.dy)
646 ..scale(magnificationScale);
647 final ImageFilter filter = ImageFilter.matrix(matrix.storage, filterQuality: FilterQuality.high);
648
649 if (layer == null) {
650 layer = BackdropFilterLayer(
651 filter: filter,
652 );
653 } else {
654 layer!.filter = filter;
655 }
656
657 context.pushLayer(layer!, super.paint, offset);
658 }
659}
660