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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com