blob: 3ebd41187fcc224a5f7fcc339b7691c32efa2812 [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:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
/// A [Magnifier] positioned by rules dictated by the native Android magnifier.
///
/// The positioning rules are based on [magnifierInfo], as follows:
///
/// - The loupe tracks the gesture's _x_ coordinate, clamping to the beginning
/// and end of the currently editing line.
///
/// - The focal point never contains anything out of the bounds of the text
/// field or other widget being magnified (the [MagnifierInfo.fieldBounds]).
///
/// - The focal point always remains aligned with the _y_ coordinate of the touch.
///
/// - The loupe always remains on the screen.
///
/// - When the line targeted by the touch's _y_ coordinate changes, the position
/// is animated over [jumpBetweenLinesAnimationDuration].
///
/// This behavior was based on the Android 12 source code, where possible, and
/// on eyeballing a Pixel 6 running Android 12 otherwise.
class TextMagnifier extends StatefulWidget {
/// Creates a [TextMagnifier].
///
/// The [magnifierInfo] must be provided, and must be updated with new values
/// as the user's touch changes.
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.
static const Duration jumpBetweenLinesAnimationDuration = Duration(milliseconds: 70);
/// The current status of the user's touch.
///
/// As the value of the [magnifierInfo] changes, the position of the loupe is
/// adjusted automatically, according to the rules described in the
/// [TextMagnifier] class description.
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);
}
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], which uses [Magnifier].
///
/// The styles implemented in this widget were based on the Android 12 source
/// code, where possible, and on eyeballing a Pixel 6 running Android 12
/// otherwise.
class Magnifier extends StatelessWidget {
/// Creates a [RawMagnifier] in the Material style.
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.0, 2.0),
spreadRadius: 0.75,
color: Color.fromARGB(25, 0, 0, 0),
)
],
this.clipBehavior = Clip.hardEdge,
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.
static const Size kDefaultMagnifierSize = Size(77.37, 37.9);
/// The vertical distance that the magnifier should be above the focal point.
///
/// The [kStandardVerticalFocalPointShift] value is a constant so that
/// positioning of this [Magnifier] can be done with a guaranteed size, as
/// opposed to an estimate.
static const double kStandardVerticalFocalPointShift = 22.0;
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 value is added to [kStandardVerticalFocalPointShift] to obtain the
/// actual offset.
///
/// 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.
///
/// The magnifier's shape is a [RoundedRectangleBorder] with this radius.
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;
/// 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 the
/// default [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).
///
/// By default, the shadows are offset vertically by two logical pixels, so
/// clipping is recommended.
///
/// A shadow that uses [BlurStyle.outer] and is not offset does not need
/// clipping; in that case, consider setting [clipBehavior] to [Clip.none].
final List<BoxShadow> shadows;
/// Whether and how to clip the [shadows] that render inside the loupe.
///
/// Defaults to [Clip.hardEdge].
///
/// A value of [Clip.none] can be used 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).
///
/// See the discussion at [shadows].
final Clip clipBehavior;
/// The [Size] of this [Magnifier].
///
/// The [shadows] are drawn outside of the [size].
final Size size;
@override
Widget build(BuildContext context) {
return RawMagnifier(
decoration: MagnifierDecoration(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
shadows: shadows,
),
clipBehavior: clipBehavior,
magnificationScale: _magnification,
focalPointOffset: additionalFocalPointOffset +
Offset(0, kStandardVerticalFocalPointShift + kDefaultMagnifierSize.height / 2),
size: size,
child: ColoredBox(
// This couldn't be part of the decoration (even if the
// MagnifierDecoration supported specifying a color) because the
// decoration's shadows are offset and therefore we set a clipBehavior
// that clips the inner part of the decoration to avoid occluding the
// magnified image with the shadow.
color: filmColor,
),
);
}
}