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 | import 'dart:async'; |
6 | |
7 | import 'package:flutter/cupertino.dart'; |
8 | import '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. |
29 | class 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 | |
83 | class _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. |
261 | class 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 | |