// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; /// {@template widgets.material.magnifier.magnifier} /// A [Magnifier] positioned by rules dictated by the native Android magnifier. /// {@endtemplate} /// /// {@template widgets.material.magnifier.positionRules} /// Positions itself based on [magnifierInfo]. Specifically, follows the /// following rules: /// - Tracks the gesture's x coordinate, but clamped to the beginning and end of the /// currently editing line. /// - Focal point may never contain anything out of bounds. /// - Never goes out of bounds vertically; offset until the entire magnifier is in the screen. The /// focal point, regardless of this transformation, always points to the touch y coordinate. /// - If just jumped between lines (prevY != currentY) then animate for duration /// [jumpBetweenLinesAnimationDuration]. /// {@endtemplate} class TextMagnifier extends StatefulWidget { /// {@macro widgets.material.magnifier.magnifier} /// /// {@template widgets.material.magnifier.androidDisclaimer} /// These constants and default parameters were taken from the /// Android 12 source code where directly transferable, and eyeballed on /// a Pixel 6 running Android 12 otherwise. /// {@endtemplate} /// /// {@macro widgets.material.magnifier.positionRules} const TextMagnifier({ super.key, required this.magnifierInfo, }); /// A [TextMagnifierConfiguration] that returns a [CupertinoTextMagnifier] on iOS, /// [TextMagnifier] on Android, and null on all other platforms, and shows the editing handles /// only on iOS. static TextMagnifierConfiguration adaptiveMagnifierConfiguration = TextMagnifierConfiguration( shouldDisplayHandlesInMagnifier: defaultTargetPlatform == TargetPlatform.iOS, magnifierBuilder: ( BuildContext context, MagnifierController controller, ValueNotifier<MagnifierInfo> magnifierInfo, ) { switch (defaultTargetPlatform) { case TargetPlatform.iOS: return CupertinoTextMagnifier( controller: controller, magnifierInfo: magnifierInfo, ); case TargetPlatform.android: return TextMagnifier( magnifierInfo: magnifierInfo, ); case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: return null; } } ); /// The duration that the position is animated if [TextMagnifier] just switched /// between lines. @visibleForTesting static const Duration jumpBetweenLinesAnimationDuration = Duration(milliseconds: 70); /// [TextMagnifier] positions itself based on [magnifierInfo]. /// /// {@macro widgets.material.magnifier.positionRules} final ValueNotifier<MagnifierInfo> magnifierInfo; @override State<TextMagnifier> createState() => _TextMagnifierState(); } class _TextMagnifierState extends State<TextMagnifier> { // Should _only_ be null on construction. This is because of the animation logic. // // Animations are added when `last_build_y != current_build_y`. This condition // is true on the initial render, which would mean that the initial // build would be animated - this is undesired. Thus, this is null for the // first frame and the condition becomes `magnifierPosition != null && last_build_y != this_build_y`. Offset? _magnifierPosition; // A timer that unsets itself after an animation duration. // If the timer exists, then the magnifier animates its position - // if this timer does not exist, the magnifier tracks the gesture (with respect // to the positioning rules) directly. Timer? _positionShouldBeAnimatedTimer; bool get _positionShouldBeAnimated => _positionShouldBeAnimatedTimer != null; Offset _extraFocalPointOffset = Offset.zero; @override void initState() { super.initState(); widget.magnifierInfo .addListener(_determineMagnifierPositionAndFocalPoint); } @override void dispose() { widget.magnifierInfo .removeListener(_determineMagnifierPositionAndFocalPoint); _positionShouldBeAnimatedTimer?.cancel(); super.dispose(); } @override void didChangeDependencies() { _determineMagnifierPositionAndFocalPoint(); super.didChangeDependencies(); } @override void didUpdateWidget(TextMagnifier oldWidget) { if (oldWidget.magnifierInfo != widget.magnifierInfo) { oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); } super.didUpdateWidget(oldWidget); } /// {@macro widgets.material.magnifier.positionRules} void _determineMagnifierPositionAndFocalPoint() { final MagnifierInfo selectionInfo = widget.magnifierInfo.value; final Rect screenRect = Offset.zero & MediaQuery.sizeOf(context); // Since by default we draw at the top left corner, this offset // shifts the magnifier so we draw at the center, and then also includes // the "above touch point" shift. final Offset basicMagnifierOffset = Offset( Magnifier.kDefaultMagnifierSize.width / 2, Magnifier.kDefaultMagnifierSize.height + Magnifier.kStandardVerticalFocalPointShift); // Since the magnifier should not go past the edges of the line, // but must track the gesture otherwise, constrain the X of the magnifier // to always stay between line start and end. final double magnifierX = clampDouble( selectionInfo.globalGesturePosition.dx, selectionInfo.currentLineBoundaries.left, selectionInfo.currentLineBoundaries.right); // Place the magnifier at the previously calculated X, and the Y should be // exactly at the center of the handle. final Rect unadjustedMagnifierRect = Offset(magnifierX, selectionInfo.caretRect.center.dy) - basicMagnifierOffset & Magnifier.kDefaultMagnifierSize; // Shift the magnifier so that, if we are ever out of the screen, we become in bounds. // This probably won't have much of an effect on the X, since it is already bound // to the currentLineBoundaries, but will shift vertically if the magnifier is out of bounds. final Rect screenBoundsAdjustedMagnifierRect = MagnifierController.shiftWithinBounds( bounds: screenRect, rect: unadjustedMagnifierRect); // Done with the magnifier position! final Offset finalMagnifierPosition = screenBoundsAdjustedMagnifierRect.topLeft; // The insets, from either edge, that the focal point should not point // past lest the magnifier displays something out of bounds. final double horizontalMaxFocalPointEdgeInsets = (Magnifier.kDefaultMagnifierSize.width / 2) / Magnifier._magnification; // Adjust the focal point horizontally such that none of the magnifier // ever points to anything out of bounds. final double newGlobalFocalPointX; // If the text field is so narrow that we must show out of bounds, // then settle for pointing to the center all the time. if (selectionInfo.fieldBounds.width < horizontalMaxFocalPointEdgeInsets * 2) { newGlobalFocalPointX = selectionInfo.fieldBounds.center.dx; } else { // Otherwise, we can clamp the focal point to always point in bounds. newGlobalFocalPointX = clampDouble( screenBoundsAdjustedMagnifierRect.center.dx, selectionInfo.fieldBounds.left + horizontalMaxFocalPointEdgeInsets, selectionInfo.fieldBounds.right - horizontalMaxFocalPointEdgeInsets); } // Since the previous value is now a global offset (i.e. `newGlobalFocalPoint` // is now a global offset), we must subtract the magnifier's global offset // to obtain the relative shift in the focal point. final double newRelativeFocalPointX = newGlobalFocalPointX - screenBoundsAdjustedMagnifierRect.center.dx; // The Y component means that if we are pressed up against the top of the screen, // then we should adjust the focal point such that it now points to how far we moved // the magnifier. screenBoundsAdjustedMagnifierRect.top == unadjustedMagnifierRect.top for most cases, // but when pressed up against the top of the screen, we adjust the focal point by // the amount that we shifted from our "natural" position. final Offset focalPointAdjustmentForScreenBoundsAdjustment = Offset( newRelativeFocalPointX, unadjustedMagnifierRect.top - screenBoundsAdjustedMagnifierRect.top, ); Timer? positionShouldBeAnimated = _positionShouldBeAnimatedTimer; if (_magnifierPosition != null && finalMagnifierPosition.dy != _magnifierPosition!.dy) { if (_positionShouldBeAnimatedTimer != null && _positionShouldBeAnimatedTimer!.isActive) { _positionShouldBeAnimatedTimer!.cancel(); } // Create a timer that deletes itself when the timer is complete. // This is `mounted` safe, since the timer is canceled in `dispose`. positionShouldBeAnimated = Timer( TextMagnifier.jumpBetweenLinesAnimationDuration, () => setState(() { _positionShouldBeAnimatedTimer = null; })); } setState(() { _magnifierPosition = finalMagnifierPosition; _positionShouldBeAnimatedTimer = positionShouldBeAnimated; _extraFocalPointOffset = focalPointAdjustmentForScreenBoundsAdjustment; }); } @override Widget build(BuildContext context) { assert(_magnifierPosition != null, 'Magnifier position should only be null before the first build.'); return AnimatedPositioned( top: _magnifierPosition!.dy, left: _magnifierPosition!.dx, // Material magnifier typically does not animate, unless we jump between lines, // in which case we animate between lines. duration: _positionShouldBeAnimated ? TextMagnifier.jumpBetweenLinesAnimationDuration : Duration.zero, child: Magnifier( additionalFocalPointOffset: _extraFocalPointOffset, ), ); } } /// A Material styled magnifying glass. /// /// {@macro flutter.widgets.magnifier.intro} /// /// This widget focuses on mimicking the _style_ of the magnifier on material. For a /// widget that is focused on mimicking the behavior of a material magnifier, see [TextMagnifier]. class Magnifier extends StatelessWidget { /// Creates a [RawMagnifier] in the Material style. /// /// {@macro widgets.material.magnifier.androidDisclaimer} const Magnifier({ super.key, this.additionalFocalPointOffset = Offset.zero, this.borderRadius = const BorderRadius.all(Radius.circular(_borderRadius)), this.filmColor = const Color.fromARGB(8, 158, 158, 158), this.shadows = const <BoxShadow>[ BoxShadow( blurRadius: 1.5, offset: Offset(0, 2), spreadRadius: 0.75, color: Color.fromARGB(25, 0, 0, 0)) ], this.size = Magnifier.kDefaultMagnifierSize, }); /// The default size of this [Magnifier]. /// /// The size of the magnifier may be modified through the constructor; /// [kDefaultMagnifierSize] is extracted from the default parameter of /// [Magnifier]'s constructor so that positioners may depend on it. @visibleForTesting static const Size kDefaultMagnifierSize = Size(77.37, 37.9); /// The vertical distance that the magnifier should be above the focal point. /// /// [kStandardVerticalFocalPointShift] is an unmodifiable constant so that positioning of this /// [Magnifier] can be done with a guaranteed size, as opposed to an estimate. @visibleForTesting static const double kStandardVerticalFocalPointShift = 22; static const double _borderRadius = 40; static const double _magnification = 1.25; /// Any additional offset the focal point requires to "point" /// to the correct place. /// /// This is useful for instances where the magnifier is not pointing to something /// directly below it. final Offset additionalFocalPointOffset; /// The border radius for this magnifier. final BorderRadius borderRadius; /// The color to tint the image in this [Magnifier]. /// /// On native Android, there is a almost transparent gray tint to the /// magnifier, in order to better distinguish the contents of the lens from /// the background. final Color filmColor; /// The shadows for this [Magnifier]. final List<BoxShadow> shadows; /// The [Size] of this [Magnifier]. /// /// This size does not include the border. final Size size; @override Widget build(BuildContext context) { return RawMagnifier( decoration: MagnifierDecoration( shape: RoundedRectangleBorder(borderRadius: borderRadius), shadows: shadows, ), magnificationScale: _magnification, focalPointOffset: additionalFocalPointOffset + Offset(0, kStandardVerticalFocalPointShift + kDefaultMagnifierSize.height / 2), size: size, child: ColoredBox( color: filmColor, ), ); } }