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';
6
7import 'package:flutter/cupertino.dart';
8import 'package:flutter/foundation.dart';
9
10/// A [Magnifier] positioned by rules dictated by the native Android magnifier.
11///
12/// The positioning rules are based on [magnifierInfo], as follows:
13///
14/// - The loupe tracks the gesture's _x_ coordinate, clamping to the beginning
15/// and end of the currently editing line.
16///
17/// - The focal point never contains anything out of the bounds of the text
18/// field or other widget being magnified (the [MagnifierInfo.fieldBounds]).
19///
20/// - The focal point always remains aligned with the _y_ coordinate of the touch.
21///
22/// - The loupe always remains on the screen.
23///
24/// - When the line targeted by the touch's _y_ coordinate changes, the position
25/// is animated over [jumpBetweenLinesAnimationDuration].
26///
27/// This behavior was based on the Android 12 source code, where possible, and
28/// on eyeballing a Pixel 6 running Android 12 otherwise.
29class TextMagnifier extends StatefulWidget {
30 /// Creates a [TextMagnifier].
31 ///
32 /// The [magnifierInfo] must be provided, and must be updated with new values
33 /// as the user's touch changes.
34 const TextMagnifier({
35 super.key,
36 required this.magnifierInfo,
37 });
38
39 /// A [TextMagnifierConfiguration] that returns a [CupertinoTextMagnifier] on
40 /// iOS, [TextMagnifier] on Android, and null on all other platforms, and
41 /// shows the editing handles only on iOS.
42 static TextMagnifierConfiguration adaptiveMagnifierConfiguration = TextMagnifierConfiguration(
43 shouldDisplayHandlesInMagnifier: defaultTargetPlatform == TargetPlatform.iOS,
44 magnifierBuilder: (
45 BuildContext context,
46 MagnifierController controller,
47 ValueNotifier<MagnifierInfo> magnifierInfo,
48 ) {
49 switch (defaultTargetPlatform) {
50 case TargetPlatform.iOS:
51 return CupertinoTextMagnifier(
52 controller: controller,
53 magnifierInfo: magnifierInfo,
54 );
55 case TargetPlatform.android:
56 return TextMagnifier(
57 magnifierInfo: magnifierInfo,
58 );
59 case TargetPlatform.fuchsia:
60 case TargetPlatform.linux:
61 case TargetPlatform.macOS:
62 case TargetPlatform.windows:
63 return null;
64 }
65 }
66 );
67
68 /// The duration that the position is animated if [TextMagnifier] just switched
69 /// between lines.
70 static const Duration jumpBetweenLinesAnimationDuration = Duration(milliseconds: 70);
71
72 /// The current status of the user's touch.
73 ///
74 /// As the value of the [magnifierInfo] changes, the position of the loupe is
75 /// adjusted automatically, according to the rules described in the
76 /// [TextMagnifier] class description.
77 final ValueNotifier<MagnifierInfo> magnifierInfo;
78
79 @override
80 State<TextMagnifier> createState() => _TextMagnifierState();
81}
82
83class _TextMagnifierState extends State<TextMagnifier> {
84 // Should _only_ be null on construction. This is because of the animation logic.
85 //
86 // Animations are added when `last_build_y != current_build_y`. This condition
87 // is true on the initial render, which would mean that the initial
88 // build would be animated - this is undesired. Thus, this is null for the
89 // first frame and the condition becomes `magnifierPosition != null && last_build_y != this_build_y`.
90 Offset? _magnifierPosition;
91
92 // A timer that unsets itself after an animation duration.
93 // If the timer exists, then the magnifier animates its position -
94 // if this timer does not exist, the magnifier tracks the gesture (with respect
95 // to the positioning rules) directly.
96 Timer? _positionShouldBeAnimatedTimer;
97 bool get _positionShouldBeAnimated => _positionShouldBeAnimatedTimer != null;
98
99 Offset _extraFocalPointOffset = Offset.zero;
100
101 @override
102 void initState() {
103 super.initState();
104 widget.magnifierInfo
105 .addListener(_determineMagnifierPositionAndFocalPoint);
106 }
107
108 @override
109 void dispose() {
110 widget.magnifierInfo
111 .removeListener(_determineMagnifierPositionAndFocalPoint);
112 _positionShouldBeAnimatedTimer?.cancel();
113 super.dispose();
114 }
115
116 @override
117 void didChangeDependencies() {
118 _determineMagnifierPositionAndFocalPoint();
119 super.didChangeDependencies();
120 }
121
122 @override
123 void didUpdateWidget(TextMagnifier oldWidget) {
124 if (oldWidget.magnifierInfo != widget.magnifierInfo) {
125 oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint);
126 widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint);
127 }
128 super.didUpdateWidget(oldWidget);
129 }
130
131 void _determineMagnifierPositionAndFocalPoint() {
132 final MagnifierInfo selectionInfo =
133 widget.magnifierInfo.value;
134 final Rect screenRect = Offset.zero & MediaQuery.sizeOf(context);
135
136 // Since by default we draw at the top left corner, this offset
137 // shifts the magnifier so we draw at the center, and then also includes
138 // the "above touch point" shift.
139 final Offset basicMagnifierOffset = Offset(
140 Magnifier.kDefaultMagnifierSize.width / 2,
141 Magnifier.kDefaultMagnifierSize.height +
142 Magnifier.kStandardVerticalFocalPointShift);
143
144 // Since the magnifier should not go past the edges of the line,
145 // but must track the gesture otherwise, constrain the X of the magnifier
146 // to always stay between line start and end.
147 final double magnifierX = clampDouble(
148 selectionInfo.globalGesturePosition.dx,
149 selectionInfo.currentLineBoundaries.left,
150 selectionInfo.currentLineBoundaries.right);
151
152 // Place the magnifier at the previously calculated X, and the Y should be
153 // exactly at the center of the handle.
154 final Rect unadjustedMagnifierRect =
155 Offset(magnifierX, selectionInfo.caretRect.center.dy) - basicMagnifierOffset &
156 Magnifier.kDefaultMagnifierSize;
157
158 // Shift the magnifier so that, if we are ever out of the screen, we become in bounds.
159 // This probably won't have much of an effect on the X, since it is already bound
160 // to the currentLineBoundaries, but will shift vertically if the magnifier is out of bounds.
161 final Rect screenBoundsAdjustedMagnifierRect =
162 MagnifierController.shiftWithinBounds(
163 bounds: screenRect, rect: unadjustedMagnifierRect);
164
165 // Done with the magnifier position!
166 final Offset finalMagnifierPosition = screenBoundsAdjustedMagnifierRect.topLeft;
167
168 // The insets, from either edge, that the focal point should not point
169 // past lest the magnifier displays something out of bounds.
170 final double horizontalMaxFocalPointEdgeInsets =
171 (Magnifier.kDefaultMagnifierSize.width / 2) / Magnifier._magnification;
172
173 // Adjust the focal point horizontally such that none of the magnifier
174 // ever points to anything out of bounds.
175 final double newGlobalFocalPointX;
176
177 // If the text field is so narrow that we must show out of bounds,
178 // then settle for pointing to the center all the time.
179 if (selectionInfo.fieldBounds.width <
180 horizontalMaxFocalPointEdgeInsets * 2) {
181 newGlobalFocalPointX = selectionInfo.fieldBounds.center.dx;
182 } else {
183 // Otherwise, we can clamp the focal point to always point in bounds.
184 newGlobalFocalPointX = clampDouble(
185 screenBoundsAdjustedMagnifierRect.center.dx,
186 selectionInfo.fieldBounds.left + horizontalMaxFocalPointEdgeInsets,
187 selectionInfo.fieldBounds.right - horizontalMaxFocalPointEdgeInsets);
188 }
189
190 // Since the previous value is now a global offset (i.e. `newGlobalFocalPoint`
191 // is now a global offset), we must subtract the magnifier's global offset
192 // to obtain the relative shift in the focal point.
193 final double newRelativeFocalPointX =
194 newGlobalFocalPointX - screenBoundsAdjustedMagnifierRect.center.dx;
195
196 // The Y component means that if we are pressed up against the top of the screen,
197 // then we should adjust the focal point such that it now points to how far we moved
198 // the magnifier. screenBoundsAdjustedMagnifierRect.top == unadjustedMagnifierRect.top for most cases,
199 // but when pressed up against the top of the screen, we adjust the focal point by
200 // the amount that we shifted from our "natural" position.
201 final Offset focalPointAdjustmentForScreenBoundsAdjustment = Offset(
202 newRelativeFocalPointX,
203 unadjustedMagnifierRect.top - screenBoundsAdjustedMagnifierRect.top,
204 );
205
206 Timer? positionShouldBeAnimated = _positionShouldBeAnimatedTimer;
207
208 if (_magnifierPosition != null && finalMagnifierPosition.dy != _magnifierPosition!.dy) {
209 if (_positionShouldBeAnimatedTimer != null &&
210 _positionShouldBeAnimatedTimer!.isActive) {
211 _positionShouldBeAnimatedTimer!.cancel();
212 }
213
214 // Create a timer that deletes itself when the timer is complete.
215 // This is `mounted` safe, since the timer is canceled in `dispose`.
216 positionShouldBeAnimated = Timer(
217 TextMagnifier.jumpBetweenLinesAnimationDuration,
218 () => setState(() {
219 _positionShouldBeAnimatedTimer = null;
220 }));
221 }
222
223 setState(() {
224 _magnifierPosition = finalMagnifierPosition;
225 _positionShouldBeAnimatedTimer = positionShouldBeAnimated;
226 _extraFocalPointOffset = focalPointAdjustmentForScreenBoundsAdjustment;
227 });
228 }
229
230 @override
231 Widget build(BuildContext context) {
232 assert(_magnifierPosition != null,
233 'Magnifier position should only be null before the first build.');
234
235 return AnimatedPositioned(
236 top: _magnifierPosition!.dy,
237 left: _magnifierPosition!.dx,
238 // Material magnifier typically does not animate, unless we jump between lines,
239 // in which case we animate between lines.
240 duration: _positionShouldBeAnimated
241 ? TextMagnifier.jumpBetweenLinesAnimationDuration
242 : Duration.zero,
243 child: Magnifier(
244 additionalFocalPointOffset: _extraFocalPointOffset,
245 ),
246 );
247 }
248}
249
250/// A Material-styled magnifying glass.
251///
252/// {@macro flutter.widgets.magnifier.intro}
253///
254/// This widget focuses on mimicking the _style_ of the magnifier on material.
255/// For a widget that is focused on mimicking the _behavior_ of a material
256/// magnifier, see [TextMagnifier], which uses [Magnifier].
257///
258/// The styles implemented in this widget were based on the Android 12 source
259/// code, where possible, and on eyeballing a Pixel 6 running Android 12
260/// otherwise.
261class Magnifier extends StatelessWidget {
262 /// Creates a [RawMagnifier] in the Material style.
263 const Magnifier({
264 super.key,
265 this.additionalFocalPointOffset = Offset.zero,
266 this.borderRadius = const BorderRadius.all(Radius.circular(_borderRadius)),
267 this.filmColor = const Color.fromARGB(8, 158, 158, 158),
268 this.shadows = const <BoxShadow>[
269 BoxShadow(
270 blurRadius: 1.5,
271 offset: Offset(0.0, 2.0),
272 spreadRadius: 0.75,
273 color: Color.fromARGB(25, 0, 0, 0),
274 )
275 ],
276 this.clipBehavior = Clip.hardEdge,
277 this.size = Magnifier.kDefaultMagnifierSize,
278 });
279
280 /// The default size of this [Magnifier].
281 ///
282 /// The size of the magnifier may be modified through the constructor;
283 /// [kDefaultMagnifierSize] is extracted from the default parameter of
284 /// [Magnifier]'s constructor so that positioners may depend on it.
285 static const Size kDefaultMagnifierSize = Size(77.37, 37.9);
286
287 /// The vertical distance that the magnifier should be above the focal point.
288 ///
289 /// The [kStandardVerticalFocalPointShift] value is a constant so that
290 /// positioning of this [Magnifier] can be done with a guaranteed size, as
291 /// opposed to an estimate.
292 static const double kStandardVerticalFocalPointShift = 22.0;
293
294 static const double _borderRadius = 40;
295 static const double _magnification = 1.25;
296
297 /// Any additional offset the focal point requires to "point"
298 /// to the correct place.
299 ///
300 /// This value is added to [kStandardVerticalFocalPointShift] to obtain the
301 /// actual offset.
302 ///
303 /// This is useful for instances where the magnifier is not pointing to
304 /// something directly below it.
305 final Offset additionalFocalPointOffset;
306
307 /// The border radius for this magnifier.
308 ///
309 /// The magnifier's shape is a [RoundedRectangleBorder] with this radius.
310 final BorderRadius borderRadius;
311
312 /// The color to tint the image in this [Magnifier].
313 ///
314 /// On native Android, there is a almost transparent gray tint to the
315 /// magnifier, in order to better distinguish the contents of the lens from
316 /// the background.
317 final Color filmColor;
318
319 /// A list of shadows cast by the [Magnifier].
320 ///
321 /// If the shadows use a [BlurStyle] that paints inside the shape, or if they
322 /// are offset, then a [clipBehavior] that enables clipping (such as the
323 /// default [Clip.hardEdge]) is recommended, otherwise the shadow will occlude
324 /// the magnifier (the shadow is drawn above the magnifier so as to not be
325 /// included in the magnified image).
326 ///
327 /// By default, the shadows are offset vertically by two logical pixels, so
328 /// clipping is recommended.
329 ///
330 /// A shadow that uses [BlurStyle.outer] and is not offset does not need
331 /// clipping; in that case, consider setting [clipBehavior] to [Clip.none].
332 final List<BoxShadow> shadows;
333
334 /// Whether and how to clip the [shadows] that render inside the loupe.
335 ///
336 /// Defaults to [Clip.hardEdge].
337 ///
338 /// A value of [Clip.none] can be used if the shadow will not paint where the
339 /// magnified image appears, or if doing so is intentional (e.g. to blur the
340 /// edges of the magnified image).
341 ///
342 /// See the discussion at [shadows].
343 final Clip clipBehavior;
344
345 /// The [Size] of this [Magnifier].
346 ///
347 /// The [shadows] are drawn outside of the [size].
348 final Size size;
349
350 @override
351 Widget build(BuildContext context) {
352 return RawMagnifier(
353 decoration: MagnifierDecoration(
354 shape: RoundedRectangleBorder(borderRadius: borderRadius),
355 shadows: shadows,
356 ),
357 clipBehavior: clipBehavior,
358 magnificationScale: _magnification,
359 focalPointOffset: additionalFocalPointOffset +
360 Offset(0, kStandardVerticalFocalPointShift + kDefaultMagnifierSize.height / 2),
361 size: size,
362 child: ColoredBox(
363 // This couldn't be part of the decoration (even if the
364 // MagnifierDecoration supported specifying a color) because the
365 // decoration's shadows are offset and therefore we set a clipBehavior
366 // that clips the inner part of the decoration to avoid occluding the
367 // magnified image with the shadow.
368 color: filmColor,
369 ),
370 );
371 }
372}
373

Provided by KDAB

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