blob: c7b4b6d54c8586df698123f5e99b5e15864e99d9 [file] [log] [blame]
// 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 {
// Initalize to dummy values for the event that the inital 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.of(context).size;
// 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,
),
),
);
}
}