| // 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. |
| |
| /// @docImport 'package:flutter/material.dart'; |
| library; |
| |
| import 'dart:math' as math; |
| |
| import 'package:flutter/widgets.dart'; |
| |
| import 'theme.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. |
| /// |
| /// {@tool dartpad} |
| /// This sample demonstrates how to use [CupertinoTextMagnifier]. |
| /// |
| /// ** See code in examples/api/lib/widgets/magnifier/cupertino_text_magnifier.0.dart ** |
| /// {@end-tool} |
| /// |
| /// 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 final AnimationController _ioAnimationController; |
| late final Animation<double> _ioAnimation; |
| late final CurvedAnimation _ioCurvedAnimation; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _ioAnimationController = AnimationController( |
| value: 0, |
| vsync: this, |
| duration: CupertinoMagnifier._kInOutAnimationDuration, |
| )..addListener(() => setState(() {})); |
| |
| widget.controller.animationController = _ioAnimationController; |
| widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); |
| _ioCurvedAnimation = CurvedAnimation( |
| parent: _ioAnimationController, |
| curve: widget.animationCurve, |
| ); |
| _ioAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(_ioCurvedAnimation); |
| } |
| |
| @override |
| void dispose() { |
| widget.controller.animationController = null; |
| _ioAnimationController.dispose(); |
| _ioCurvedAnimation.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 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) { |
| final CupertinoThemeData themeData = CupertinoTheme.of(context); |
| return AnimatedPositioned( |
| duration: CupertinoTextMagnifier._kDragAnimationDuration, |
| curve: widget.animationCurve, |
| left: _currentAdjustedMagnifierPosition.dx, |
| top: _currentAdjustedMagnifierPosition.dy, |
| child: CupertinoMagnifier( |
| inOutAnimation: _ioAnimation, |
| additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment), |
| borderSide: BorderSide(color: themeData.primaryColor, width: 2.0), |
| ), |
| ); |
| } |
| } |
| |
| /// A [RawMagnifier] used for magnifying text in cases where a user's |
| /// finger may be blocking the point of interest, like a selection handle. |
| /// |
| /// {@tool dartpad} |
| /// This sample demonstrates how to use [CupertinoMagnifier]. |
| /// |
| /// ** See code in examples/api/lib/widgets/magnifier/cupertino_magnifier.0.dart ** |
| /// {@end-tool} |
| /// |
| /// [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 16 iOS v18.1. |
| 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, |
| blurStyle: BlurStyle.outer, |
| ), |
| ], |
| this.clipBehavior = Clip.none, |
| this.borderSide = const BorderSide(color: Color.fromARGB(255, 0, 124, 255), width: 2.0), |
| this.inOutAnimation, |
| this.magnificationScale = 1.0, |
| }) : assert(magnificationScale > 0, 'The magnification scale should be greater than zero.'); |
| |
| /// A list of shadows cast by the [Magnifier]. |
| /// |
| /// If the shadows use a [BlurStyle] that paints inside the shape, or if they |
| /// are offset, then a [clipBehavior] that enables clipping (such as |
| /// [Clip.hardEdge]) is recommended, otherwise the shadow will occlude the |
| /// magnifier (the shadow is drawn above the magnifier so as to not be |
| /// included in the magnified image). |
| /// |
| /// A shadow that uses [BlurStyle.outer] and is not offset does not need |
| /// clipping. |
| /// |
| /// By default, the [shadows] are not offset and use [BlurStyle.outer], and |
| /// correspondingly the default [clipBehavior] is [Clip.none]. |
| final List<BoxShadow> shadows; |
| |
| /// Whether and how to clip the [shadows] that render inside the loupe. |
| /// |
| /// Defaults to [Clip.none], which is useful if the shadow will not paint |
| /// where the magnified image appears, or if doing so is intentional (e.g. to |
| /// blur the edges of the magnified image). |
| /// |
| /// The default configuration of [CupertinoMagnifier] does not render inside |
| /// the loupe (the shadows are not offset and use [BlurStyle.outer]). |
| /// |
| /// Other values (e.g. [Clip.hardEdge]) are recommended when the [shadows] |
| /// have an offset. |
| /// |
| /// See the discussion at [shadows]. |
| final Clip clipBehavior; |
| |
| /// The border, or "rim", of this magnifier. |
| /// |
| /// This border is drawn on a [RoundedRectangleBorder] with radius |
| /// [borderRadius], and increases the [size] of the magnifier by the |
| /// [BorderSide.width]. |
| final BorderSide borderSide; |
| |
| /// The vertical offset that the magnifier is along the Y axis above |
| /// the focal point. |
| 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. |
| 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. |
| /// |
| /// The size does not include the [borderSide] or [shadows]. |
| final Size size; |
| |
| /// The border radius of this magnifier. |
| /// |
| /// The magnifier's shape is a [RoundedRectangleBorder] with this radius. |
| 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; |
| |
| /// The magnification scale for the magnifier. |
| /// |
| /// Defaults to 1.0, which indicates that the magnifier does not apply any magnification. |
| final double magnificationScale; |
| |
| @override |
| Widget build(BuildContext context) { |
| var 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, |
| ), |
| clipBehavior: clipBehavior, |
| magnificationScale: magnificationScale, |
| ), |
| ); |
| } |
| } |