blob: 64ab312eccfc1af3349efc7c5c11612ecc9e8288 [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';
/// {@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 inital render, which would mean that the inital
// 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.of(context).size;
// 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,
),
);
}
}