| // 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:math' as math; |
| import 'package:flutter/widgets.dart'; |
| |
| /// A [CupertinoMagnifier] used for magnifying text in cases where a user's |
| /// finger may be blocking the point of interest, like a selection handle. |
| /// |
| /// Delegates styling to [CupertinoMagnifier] with its position depending on |
| /// [magnifierInfo]. |
| /// |
| /// Specifically, the [CupertinoTextMagnifier] follows the following rules. |
| /// [CupertinoTextMagnifier]: |
| /// - is positioned horizontally inside the screen width, with [horizontalScreenEdgePadding] padding. |
| /// - is hidden if a gesture is detected [hideBelowThreshold] units below the line |
| /// that the magnifier is on, shown otherwise. |
| /// - follows the x coordinate of the gesture directly (with respect to rule 1). |
| /// - has some vertical drag resistance; i.e. if a gesture is detected k units below the field, |
| /// then has vertical offset [dragResistance] * k. |
| class CupertinoTextMagnifier extends StatefulWidget { |
| /// Constructs a [RawMagnifier] in the Cupertino style, positioning with respect to |
| /// [magnifierInfo]. |
| /// |
| /// The default constructor parameters and constants were eyeballed on |
| /// an iPhone XR iOS v15.5. |
| const CupertinoTextMagnifier({ |
| super.key, |
| this.animationCurve = Curves.easeOut, |
| required this.controller, |
| this.dragResistance = 10.0, |
| this.hideBelowThreshold = 48.0, |
| this.horizontalScreenEdgePadding = 10.0, |
| required this.magnifierInfo, |
| }); |
| |
| /// The curve used for the in / out animations. |
| final Curve animationCurve; |
| |
| /// This magnifier's controller. |
| /// |
| /// The [CupertinoTextMagnifier] requires a [MagnifierController] |
| /// in order to show / hide itself without removing itself from the |
| /// overlay. |
| final MagnifierController controller; |
| |
| /// A drag resistance on the downward Y position of the lens. |
| final double dragResistance; |
| |
| /// The difference in Y between the gesture position and the caret center |
| /// so that the magnifier hides itself. |
| final double hideBelowThreshold; |
| |
| /// The padding on either edge of the screen that any part of the magnifier |
| /// cannot exist past. |
| /// |
| /// This includes any part of the magnifier, not just the center; for example, |
| /// the left edge of the magnifier cannot be outside the [horizontalScreenEdgePadding].v |
| /// |
| /// If the screen has width w, then the magnifier is bound to |
| /// `_kHorizontalScreenEdgePadding, w - _kHorizontalScreenEdgePadding`. |
| final double horizontalScreenEdgePadding; |
| |
| /// [CupertinoTextMagnifier] will determine its own positioning |
| /// based on the [MagnifierInfo] of this notifier. |
| final ValueNotifier<MagnifierInfo> |
| magnifierInfo; |
| |
| /// The duration that the magnifier drags behind its final position. |
| static const Duration _kDragAnimationDuration = Duration(milliseconds: 45); |
| |
| @override |
| State<CupertinoTextMagnifier> createState() => |
| _CupertinoTextMagnifierState(); |
| } |
| |
| class _CupertinoTextMagnifierState extends State<CupertinoTextMagnifier> |
| with SingleTickerProviderStateMixin { |
| // Initialize to dummy values for the event that the initial call to |
| // _determineMagnifierPositionAndFocalPoint calls hide, and thus does not |
| // set these values. |
| Offset _currentAdjustedMagnifierPosition = Offset.zero; |
| double _verticalFocalPointAdjustment = 0; |
| late AnimationController _ioAnimationController; |
| late Animation<double> _ioAnimation; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _ioAnimationController = AnimationController( |
| value: 0, |
| vsync: this, |
| duration: CupertinoMagnifier._kInOutAnimationDuration, |
| )..addListener(() => setState(() {})); |
| |
| widget.controller.animationController = _ioAnimationController; |
| widget.magnifierInfo |
| .addListener(_determineMagnifierPositionAndFocalPoint); |
| |
| _ioAnimation = Tween<double>( |
| begin: 0.0, |
| end: 1.0, |
| ).animate(CurvedAnimation( |
| parent: _ioAnimationController, |
| curve: widget.animationCurve, |
| )); |
| } |
| |
| @override |
| void dispose() { |
| widget.controller.animationController = null; |
| _ioAnimationController.dispose(); |
| widget.magnifierInfo |
| .removeListener(_determineMagnifierPositionAndFocalPoint); |
| super.dispose(); |
| } |
| |
| @override |
| void didUpdateWidget(CupertinoTextMagnifier oldWidget) { |
| if (oldWidget.magnifierInfo != widget.magnifierInfo) { |
| oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); |
| widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); |
| } |
| super.didUpdateWidget(oldWidget); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| _determineMagnifierPositionAndFocalPoint(); |
| super.didChangeDependencies(); |
| } |
| |
| void _determineMagnifierPositionAndFocalPoint() { |
| final MagnifierInfo textEditingContext = |
| widget.magnifierInfo.value; |
| |
| // The exact Y of the center of the current line. |
| final double verticalCenterOfCurrentLine = |
| textEditingContext.caretRect.center.dy; |
| |
| // If the magnifier is currently showing, but we have dragged out of threshold, |
| // we should hide it. |
| if (verticalCenterOfCurrentLine - |
| textEditingContext.globalGesturePosition.dy < |
| -widget.hideBelowThreshold) { |
| // Only signal a hide if we are currently showing. |
| if (widget.controller.shown) { |
| widget.controller.hide(removeFromOverlay: false); |
| } |
| return; |
| } |
| |
| // If we are gone, but got to this point, we shouldn't be: show. |
| if (!widget.controller.shown) { |
| _ioAnimationController.forward(); |
| } |
| |
| // Never go above the center of the line, but have some resistance |
| // going downward if the drag goes too far. |
| final double verticalPositionOfLens = math.max( |
| verticalCenterOfCurrentLine, |
| verticalCenterOfCurrentLine - |
| (verticalCenterOfCurrentLine - |
| textEditingContext.globalGesturePosition.dy) / |
| widget.dragResistance); |
| |
| // The raw position, tracking the gesture directly. |
| final Offset rawMagnifierPosition = Offset( |
| textEditingContext.globalGesturePosition.dx - |
| CupertinoMagnifier.kDefaultSize.width / 2, |
| verticalPositionOfLens - |
| (CupertinoMagnifier.kDefaultSize.height - |
| CupertinoMagnifier.kMagnifierAboveFocalPoint), |
| ); |
| |
| final Rect screenRect = Offset.zero & MediaQuery.sizeOf(context); |
| |
| // Adjust the magnifier position so that it never exists outside the horizontal |
| // padding. |
| final Offset adjustedMagnifierPosition = MagnifierController.shiftWithinBounds( |
| bounds: Rect.fromLTRB( |
| screenRect.left + widget.horizontalScreenEdgePadding, |
| // iOS doesn't reposition for Y, so we should expand the threshold |
| // so we can send the whole magnifier out of bounds if need be. |
| screenRect.top - |
| (CupertinoMagnifier.kDefaultSize.height + |
| CupertinoMagnifier.kMagnifierAboveFocalPoint), |
| screenRect.right - widget.horizontalScreenEdgePadding, |
| screenRect.bottom + |
| (CupertinoMagnifier.kDefaultSize.height + |
| CupertinoMagnifier.kMagnifierAboveFocalPoint)), |
| rect: rawMagnifierPosition & CupertinoMagnifier.kDefaultSize, |
| ).topLeft; |
| |
| setState(() { |
| _currentAdjustedMagnifierPosition = adjustedMagnifierPosition; |
| // The lens should always point to the center of the line. |
| _verticalFocalPointAdjustment = |
| verticalCenterOfCurrentLine - verticalPositionOfLens; |
| }); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return AnimatedPositioned( |
| duration: CupertinoTextMagnifier._kDragAnimationDuration, |
| curve: widget.animationCurve, |
| left: _currentAdjustedMagnifierPosition.dx, |
| top: _currentAdjustedMagnifierPosition.dy, |
| child: CupertinoMagnifier( |
| inOutAnimation: _ioAnimation, |
| additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment), |
| ), |
| ); |
| } |
| } |
| |
| /// A [RawMagnifier] used for magnifying text in cases where a user's |
| /// finger may be blocking the point of interest, like a selection handle. |
| /// |
| /// [CupertinoMagnifier] is a wrapper around [RawMagnifier] that handles styling |
| /// and transitions. |
| /// |
| /// {@macro flutter.widgets.magnifier.intro} |
| /// |
| /// See also: |
| /// |
| /// * [RawMagnifier], the backing implementation. |
| /// * [CupertinoTextMagnifier], a widget that positions [CupertinoMagnifier] based on |
| /// [MagnifierInfo]. |
| /// * [MagnifierController], the controller for this magnifier. |
| class CupertinoMagnifier extends StatelessWidget { |
| /// Creates a [RawMagnifier] in the Cupertino style. |
| /// |
| /// The default constructor parameters and constants were eyeballed on |
| /// an iPhone XR iOS v15.5. |
| const CupertinoMagnifier({ |
| super.key, |
| this.size = kDefaultSize, |
| this.borderRadius = const BorderRadius.all(Radius.elliptical(60, 50)), |
| this.additionalFocalPointOffset = Offset.zero, |
| this.shadows = const <BoxShadow>[ |
| BoxShadow( |
| color: Color.fromARGB(25, 0, 0, 0), |
| blurRadius: 11, |
| spreadRadius: 0.2, |
| ), |
| ], |
| this.borderSide = |
| const BorderSide(color: Color.fromARGB(255, 232, 232, 232)), |
| this.inOutAnimation, |
| }); |
| |
| /// The shadows displayed under the magnifier. |
| final List<BoxShadow> shadows; |
| |
| /// The border, or "rim", of this magnifier. |
| final BorderSide borderSide; |
| |
| /// The vertical offset that the magnifier is along the Y axis above |
| /// the focal point. |
| @visibleForTesting |
| static const double kMagnifierAboveFocalPoint = -26; |
| |
| /// The default size of the magnifier. |
| /// |
| /// This is public so that positioners can choose to depend on it, although |
| /// it is overridable. |
| @visibleForTesting |
| static const Size kDefaultSize = Size(80, 47.5); |
| |
| /// The duration that this magnifier animates in / out for. |
| /// |
| /// The animation is a translation and a fade. The translation |
| /// begins at the focal point, and ends at [kMagnifierAboveFocalPoint]. |
| /// The opacity begins at 0 and ends at 1. |
| static const Duration _kInOutAnimationDuration = Duration(milliseconds: 150); |
| |
| /// The size of this magnifier. |
| final Size size; |
| |
| /// The border radius of this magnifier. |
| final BorderRadius borderRadius; |
| |
| /// This [RawMagnifier]'s controller. |
| /// |
| /// Since [CupertinoMagnifier] has no knowledge of shown / hidden state, |
| /// this animation should be driven by an external actor. |
| final Animation<double>? inOutAnimation; |
| |
| /// Any additional focal point offset, applied over the regular focal |
| /// point offset defined in [kMagnifierAboveFocalPoint]. |
| final Offset additionalFocalPointOffset; |
| |
| @override |
| Widget build(BuildContext context) { |
| Offset focalPointOffset = |
| Offset(0, (kDefaultSize.height / 2) - kMagnifierAboveFocalPoint); |
| focalPointOffset.scale(1, inOutAnimation?.value ?? 1); |
| focalPointOffset += additionalFocalPointOffset; |
| |
| return Transform.translate( |
| offset: Offset.lerp( |
| const Offset(0, -kMagnifierAboveFocalPoint), |
| Offset.zero, |
| inOutAnimation?.value ?? 1, |
| )!, |
| child: RawMagnifier( |
| size: size, |
| focalPointOffset: focalPointOffset, |
| decoration: MagnifierDecoration( |
| opacity: inOutAnimation?.value ?? 1, |
| shape: RoundedRectangleBorder( |
| borderRadius: borderRadius, |
| side: borderSide, |
| ), |
| shadows: shadows, |
| ), |
| ), |
| ); |
| } |
| } |