Unverified Commit f014c1e6 authored by Anthony Oleinik's avatar Anthony Oleinik Committed by GitHub

Loupe Android + iOS (#107477)

* added Magnifier for iOS and Android
parent 5bf6ac18
...@@ -41,6 +41,7 @@ export 'src/cupertino/interface_level.dart'; ...@@ -41,6 +41,7 @@ export 'src/cupertino/interface_level.dart';
export 'src/cupertino/list_section.dart'; export 'src/cupertino/list_section.dart';
export 'src/cupertino/list_tile.dart'; export 'src/cupertino/list_tile.dart';
export 'src/cupertino/localizations.dart'; export 'src/cupertino/localizations.dart';
export 'src/cupertino/magnifier.dart';
export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/nav_bar.dart';
export 'src/cupertino/page_scaffold.dart'; export 'src/cupertino/page_scaffold.dart';
export 'src/cupertino/picker.dart'; export 'src/cupertino/picker.dart';
......
...@@ -102,6 +102,7 @@ export 'src/material/input_date_picker_form_field.dart'; ...@@ -102,6 +102,7 @@ export 'src/material/input_date_picker_form_field.dart';
export 'src/material/input_decorator.dart'; export 'src/material/input_decorator.dart';
export 'src/material/list_tile.dart'; export 'src/material/list_tile.dart';
export 'src/material/list_tile_theme.dart'; export 'src/material/list_tile_theme.dart';
export 'src/material/magnifier.dart';
export 'src/material/material.dart'; export 'src/material/material.dart';
export 'src/material/material_button.dart'; export 'src/material/material_button.dart';
export 'src/material/material_localizations.dart'; export 'src/material/material_localizations.dart';
......
// 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
/// [magnifierOverlayInfoBearer].
///
/// 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 {
/// Construct a [RawMagnifier] in the Cupertino style, positioning with respect to
/// [magnifierOverlayInfoBearer].
///
/// 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.magnifierOverlayInfoBearer,
});
/// 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 [MagnifierOverlayInfoBearer] of this notifier.
final ValueNotifier<MagnifierOverlayInfoBearer>
magnifierOverlayInfoBearer;
/// 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.magnifierOverlayInfoBearer
.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.magnifierOverlayInfoBearer
.removeListener(_determineMagnifierPositionAndFocalPoint);
super.dispose();
}
@override
void didUpdateWidget(CupertinoTextMagnifier oldWidget) {
if (oldWidget.magnifierOverlayInfoBearer != widget.magnifierOverlayInfoBearer) {
oldWidget.magnifierOverlayInfoBearer.removeListener(_determineMagnifierPositionAndFocalPoint);
widget.magnifierOverlayInfoBearer.addListener(_determineMagnifierPositionAndFocalPoint);
}
super.didUpdateWidget(oldWidget);
}
@override
void didChangeDependencies() {
_determineMagnifierPositionAndFocalPoint();
super.didChangeDependencies();
}
void _determineMagnifierPositionAndFocalPoint() {
final MagnifierOverlayInfoBearer textEditingContext =
widget.magnifierOverlayInfoBearer.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
/// [MagnifierOverlayInfoBearer].
/// * [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 overrideable.
@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,
),
),
);
}
}
...@@ -13,6 +13,7 @@ import 'package:flutter/widgets.dart'; ...@@ -13,6 +13,7 @@ import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
import 'desktop_text_selection.dart'; import 'desktop_text_selection.dart';
import 'icons.dart'; import 'icons.dart';
import 'magnifier.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -273,6 +274,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -273,6 +274,7 @@ class CupertinoTextField extends StatefulWidget {
this.restorationId, this.restorationId,
this.scribbleEnabled = true, this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
this.magnifierConfiguration,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
assert(autofocus != null), assert(autofocus != null),
...@@ -434,6 +436,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -434,6 +436,7 @@ class CupertinoTextField extends StatefulWidget {
this.restorationId, this.restorationId,
this.scribbleEnabled = true, this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
this.magnifierConfiguration,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
assert(autofocus != null), assert(autofocus != null),
...@@ -783,6 +786,21 @@ class CupertinoTextField extends StatefulWidget { ...@@ -783,6 +786,21 @@ class CupertinoTextField extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning; final bool enableIMEPersonalizedLearning;
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and Android nothing on all other
/// platforms. If it is desired to supress the magnifier, consider passing
/// [TextMagnifierConfiguration.disabled].
///
// TODO(antholeole): https://github.com/flutter/flutter/issues/108041
// once the magnifier PR lands, I should enrich this area of the
// docs with images of what a magnifier is.
final TextMagnifierConfiguration? magnifierConfiguration;
@override @override
State<CupertinoTextField> createState() => _CupertinoTextFieldState(); State<CupertinoTextField> createState() => _CupertinoTextFieldState();
...@@ -827,6 +845,27 @@ class CupertinoTextField extends StatefulWidget { ...@@ -827,6 +845,27 @@ class CupertinoTextField extends StatefulWidget {
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
} }
static final TextMagnifierConfiguration _iosMagnifierConfiguration = TextMagnifierConfiguration(
magnifierBuilder: (
BuildContext context,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer> magnifierOverlayInfoBearer
) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return CupertinoTextMagnifier(
controller: controller,
magnifierOverlayInfoBearer: magnifierOverlayInfoBearer,
);
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return null;
}
});
} }
class _CupertinoTextFieldState extends State<CupertinoTextField> with RestorationMixin, AutomaticKeepAliveClientMixin<CupertinoTextField> implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { class _CupertinoTextFieldState extends State<CupertinoTextField> with RestorationMixin, AutomaticKeepAliveClientMixin<CupertinoTextField> implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient {
...@@ -1274,6 +1313,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1274,6 +1313,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
maxLines: widget.maxLines, maxLines: widget.maxLines,
minLines: widget.minLines, minLines: widget.minLines,
expands: widget.expands, expands: widget.expands,
magnifierConfiguration: widget.magnifierConfiguration ?? CupertinoTextField._iosMagnifierConfiguration,
// Only show the selection highlight when the text field is focused. // Only show the selection highlight when the text field is focused.
selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null, selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null,
selectionControls: widget.selectionEnabled selectionControls: widget.selectionEnabled
......
// 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<MagnifierOverlayInfoBearer> magnifierOverlayInfoBearer,
) {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
return CupertinoTextMagnifier(
controller: controller,
magnifierOverlayInfoBearer: magnifierOverlayInfoBearer,
);
case TargetPlatform.android:
return TextMagnifier(
magnifierInfo: magnifierOverlayInfoBearer,
);
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<MagnifierOverlayInfoBearer>
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 MagnifierOverlayInfoBearer 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.currentLineBoundries.left,
selectionInfo.currentLineBoundries.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 currentLineBoundries, 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 garunteed 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,
),
);
}
}
...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
import 'desktop_text_selection.dart'; import 'desktop_text_selection.dart';
import 'feedback.dart'; import 'feedback.dart';
import 'magnifier.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -203,6 +204,7 @@ class SelectableText extends StatefulWidget { ...@@ -203,6 +204,7 @@ class SelectableText extends StatefulWidget {
this.textHeightBehavior, this.textHeightBehavior,
this.textWidthBasis, this.textWidthBasis,
this.onSelectionChanged, this.onSelectionChanged,
this.magnifierConfiguration,
}) : assert(showCursor != null), }) : assert(showCursor != null),
assert(autofocus != null), assert(autofocus != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
...@@ -260,6 +262,7 @@ class SelectableText extends StatefulWidget { ...@@ -260,6 +262,7 @@ class SelectableText extends StatefulWidget {
this.textHeightBehavior, this.textHeightBehavior,
this.textWidthBasis, this.textWidthBasis,
this.onSelectionChanged, this.onSelectionChanged,
this.magnifierConfiguration,
}) : assert(showCursor != null), }) : assert(showCursor != null),
assert(autofocus != null), assert(autofocus != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
...@@ -427,6 +430,17 @@ class SelectableText extends StatefulWidget { ...@@ -427,6 +430,17 @@ class SelectableText extends StatefulWidget {
/// {@macro flutter.widgets.editableText.onSelectionChanged} /// {@macro flutter.widgets.editableText.onSelectionChanged}
final SelectionChangedCallback? onSelectionChanged; final SelectionChangedCallback? onSelectionChanged;
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on
/// Android, and builds nothing on all other platforms. If it is desired to supress
/// the magnifier, consider passing [TextMagnifierConfiguration.disabled].
final TextMagnifierConfiguration? magnifierConfiguration;
@override @override
State<SelectableText> createState() => _SelectableTextState(); State<SelectableText> createState() => _SelectableTextState();
...@@ -705,6 +719,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio ...@@ -705,6 +719,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
paintCursorAboveText: paintCursorAboveText, paintCursorAboveText: paintCursorAboveText,
backgroundCursorColor: CupertinoColors.inactiveGray, backgroundCursorColor: CupertinoColors.inactiveGray,
enableInteractiveSelection: widget.enableInteractiveSelection, enableInteractiveSelection: widget.enableInteractiveSelection,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
dragStartBehavior: widget.dragStartBehavior, dragStartBehavior: widget.dragStartBehavior,
scrollPhysics: widget.scrollPhysics, scrollPhysics: widget.scrollPhysics,
autofillHints: null, autofillHints: null,
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'desktop_text_selection.dart'; import 'desktop_text_selection.dart';
import 'magnifier.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -34,9 +35,21 @@ class SelectionArea extends StatefulWidget { ...@@ -34,9 +35,21 @@ class SelectionArea extends StatefulWidget {
super.key, super.key,
this.focusNode, this.focusNode,
this.selectionControls, this.selectionControls,
this.magnifierConfiguration,
required this.child, required this.child,
}); });
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on
/// Android, and builds nothing on all other platforms. If it is desired to supress
/// the magnifier, consider passing [TextMagnifierConfiguration.disabled].
final TextMagnifierConfiguration? magnifierConfiguration;
/// {@macro flutter.widgets.Focus.focusNode} /// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode; final FocusNode? focusNode;
...@@ -92,6 +105,7 @@ class _SelectionAreaState extends State<SelectionArea> { ...@@ -92,6 +105,7 @@ class _SelectionAreaState extends State<SelectionArea> {
return SelectableRegion( return SelectableRegion(
focusNode: _effectiveFocusNode, focusNode: _effectiveFocusNode,
selectionControls: controls, selectionControls: controls,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
child: widget.child, child: widget.child,
); );
} }
......
...@@ -14,7 +14,7 @@ import 'debug.dart'; ...@@ -14,7 +14,7 @@ import 'debug.dart';
import 'desktop_text_selection.dart'; import 'desktop_text_selection.dart';
import 'feedback.dart'; import 'feedback.dart';
import 'input_decorator.dart'; import 'input_decorator.dart';
import 'material.dart'; import 'magnifier.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
import 'material_state.dart'; import 'material_state.dart';
import 'selectable_text.dart' show iOSHorizontalOffset; import 'selectable_text.dart' show iOSHorizontalOffset;
...@@ -330,6 +330,7 @@ class TextField extends StatefulWidget { ...@@ -330,6 +330,7 @@ class TextField extends StatefulWidget {
this.restorationId, this.restorationId,
this.scribbleEnabled = true, this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
this.magnifierConfiguration,
}) : assert(textAlign != null), }) : assert(textAlign != null),
assert(readOnly != null), assert(readOnly != null),
assert(autofocus != null), assert(autofocus != null),
...@@ -392,6 +393,17 @@ class TextField extends StatefulWidget { ...@@ -392,6 +393,17 @@ class TextField extends StatefulWidget {
paste: true, paste: true,
))); )));
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on
/// Android, and builds nothing on all other platforms. If it is desired to supress
/// the magnifier, consider passing [TextMagnifierConfiguration.disabled].
final TextMagnifierConfiguration? magnifierConfiguration;
/// Controls the text being edited. /// Controls the text being edited.
/// ///
/// If null, this widget will create its own [TextEditingController]. /// If null, this widget will create its own [TextEditingController].
...@@ -1312,6 +1324,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1312,6 +1324,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
restorationId: 'editable', restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled, scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
), ),
), ),
); );
......
...@@ -636,6 +636,7 @@ class EditableText extends StatefulWidget { ...@@ -636,6 +636,7 @@ class EditableText extends StatefulWidget {
this.scrollBehavior, this.scrollBehavior,
this.scribbleEnabled = true, this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : assert(controller != null), }) : assert(controller != null),
assert(focusNode != null), assert(focusNode != null),
assert(obscuringCharacter != null && obscuringCharacter.length == 1), assert(obscuringCharacter != null && obscuringCharacter.length == 1),
...@@ -1547,6 +1548,13 @@ class EditableText extends StatefulWidget { ...@@ -1547,6 +1548,13 @@ class EditableText extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning; final bool enableIMEPersonalizedLearning;
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration;
bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText); bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
// Infer the keyboard type of an `EditableText` if it's not specified. // Infer the keyboard type of an `EditableText` if it's not specified.
...@@ -2629,6 +2637,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2629,6 +2637,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionDelegate: this, selectionDelegate: this,
dragStartBehavior: widget.dragStartBehavior, dragStartBehavior: widget.dragStartBehavior,
onSelectionHandleTapped: widget.onSelectionHandleTapped, onSelectionHandleTapped: widget.onSelectionHandleTapped,
magnifierConfiguration: widget.magnifierConfiguration,
); );
} }
......
// 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 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'container.dart';
import 'framework.dart';
import 'inherited_theme.dart';
import 'navigator.dart';
import 'overlay.dart';
/// [MagnifierController]'s main benefit over holding a raw [OverlayEntry] is that
/// [MagnifierController] will handle logic around waiting for a magnifier to animate in or out.
///
/// If a magnifier chooses to have an entry / exit animation, it should provide the animation
/// controller to [MagnifierController.animationController]. [MagnifierController] will then drive
/// the [AnimationController] and wait for it to be complete before removing it from the
/// [Overlay].
///
/// To check the status of the magnifier, see [MagnifierController.shown].
// TODO(antholeole): This whole paradigm can be removed once portals
// lands - then the magnifier can be controlled though a widget in the tree.
// https://github.com/flutter/flutter/pull/105335
class MagnifierController {
/// If there is no in / out animation for the magnifier, [animationController] should be left
/// null.
MagnifierController({this.animationController}) {
animationController?.value = 0;
}
/// The controller that will be driven in / out when show / hide is triggered,
/// respectively.
AnimationController? animationController;
/// The magnifier's [OverlayEntry], if currently in the overlay.
///
/// This is public in case other overlay entries need to be positioned
/// above or below this [overlayEntry]. Anything in the paint order after
/// the [RawMagnifier] will not be displayed in the magnifier; this means that if it
/// is desired for an overlay entry to be displayed in the magnifier,
/// it _must_ be positioned below the magnifier.
///
/// {@tool snippet}
/// ```dart
/// void magnifierShowExample(BuildContext context) {
/// final MagnifierController myMagnifierController = MagnifierController();
///
/// // Placed below the magnifier, so it will show.
/// Overlay.of(context)!.insert(OverlayEntry(
/// builder: (BuildContext context) => const Text('I WILL display in the magnifier')));
///
/// // Will display in the magnifier, since this entry was passed to show.
/// final OverlayEntry displayInMagnifier = OverlayEntry(
/// builder: (BuildContext context) =>
/// const Text('I WILL display in the magnifier'));
///
/// Overlay.of(context)!
/// .insert(displayInMagnifier);
/// myMagnifierController.show(
/// context: context,
/// below: displayInMagnifier,
/// builder: (BuildContext context) => const RawMagnifier(
/// size: Size(100, 100),
/// ));
///
/// // By default, new entries will be placed over the top entry.
/// Overlay.of(context)!.insert(OverlayEntry(
/// builder: (BuildContext context) => const Text('I WILL NOT display in the magnifier')));
///
/// Overlay.of(context)!.insert(
/// below:
/// myMagnifierController.overlayEntry, // Explicitly placed below the magnifier.
/// OverlayEntry(
/// builder: (BuildContext context) => const Text('I WILL display in the magnifier')));
/// }
/// ```
/// {@end-tool}
///
/// A null check on [overlayEntry] will not suffice to check if a magnifier is in the
/// overlay or not; instead, you should check [shown]. This is because it is possible,
/// such as in cases where [hide] was called with `removeFromOverlay` false, that the magnifier
/// is not shown, but the entry is not null.
OverlayEntry? get overlayEntry => _overlayEntry;
OverlayEntry? _overlayEntry;
/// If the magnifier is shown or not.
///
/// [shown] is:
/// - false when nothing is in the overlay.
/// - false when [animationController] is [AnimationStatus.dismissed].
/// - false when [animationController] is animating out.
/// and true in all other circumstances.
bool get shown {
if (overlayEntry == null) {
return false;
}
if (animationController != null) {
return animationController!.status == AnimationStatus.completed ||
animationController!.status == AnimationStatus.forward;
}
return true;
}
/// Shows the [RawMagnifier] that this controller controls.
///
/// Returns a future that completes when the magnifier is fully shown, i.e. done
/// with its entry animation.
///
/// To control what overlays are shown in the magnifier, utilize [below]. See
/// [overlayEntry] for more details on how to utilize [below].
///
/// If the magnifier already exists (i.e. [overlayEntry] != null), then [show] will
/// override the old overlay and not play an exit animation. Consider awaiting [hide]
/// first, to guarantee
Future<void> show({
required BuildContext context,
required WidgetBuilder builder,
Widget? debugRequiredFor,
OverlayEntry? below,
}) async {
if (overlayEntry != null) {
overlayEntry!.remove();
}
final OverlayState? overlayState = Overlay.of(
context,
rootOverlay: true,
debugRequiredFor: debugRequiredFor,
);
final CapturedThemes capturedThemes = InheritedTheme.capture(
from: context,
to: Navigator.maybeOf(context)?.context,
);
_overlayEntry = OverlayEntry(
builder: (BuildContext context) => capturedThemes.wrap(builder(context)),
);
overlayState!.insert(overlayEntry!, below: below);
if (animationController != null) {
await animationController?.forward();
}
}
/// Schedules a hide of the magnifier.
///
/// If this [MagnifierController] has an [AnimationController],
/// then [hide] reverses the animation controller and waits
/// for the animation to complete. Then, if [removeFromOverlay]
/// is true, remove the magnifier from the overlay.
///
/// In general, [removeFromOverlay] should be true, unless
/// the magnifier needs to preserve states between shows / hides.
Future<void> hide({bool removeFromOverlay = true}) async {
if (overlayEntry == null) {
return;
}
if (animationController != null) {
await animationController?.reverse();
}
if (removeFromOverlay) {
this.removeFromOverlay();
}
}
/// Remove the [OverlayEntry] from the [Overlay].
///
/// This method removes the [OverlayEntry] synchronously,
/// regardless of exit animation: this leads to abrupt removals
/// of [OverlayEntry]s with animations.
///
/// To allow the [OverlayEntry] to play its exit animation, consider calling
/// [hide] with `removeFromOverlay` true, and optionally awaiting the future
@visibleForTesting
void removeFromOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
/// A utility for calculating a new [Rect] from [rect] such that
/// [rect] is fully constrained within [bounds].
///
/// Any point in the output rect is guaranteed to also be a point contained in [bounds].
///
/// It is a runtime error for [rect].width to be greater than [bounds].width,
/// and it is also an error for [rect].height to be greater than [bounds].height.
///
/// This algorithm translates [rect] the shortest distance such that it is entirely within
/// [bounds].
///
/// If [rect] is already within [bounds], no shift will be applied to [rect] and
/// [rect] will be returned as-is.
///
/// It is perfectly valid for the output rect to have a point along the edge of the
/// [bounds]. If the desired output rect requires that no edges are parallel to edges
/// of [bounds], see [Rect.deflate] by 1 on [bounds] to achieve this effect.
static Rect shiftWithinBounds({
required Rect rect,
required Rect bounds,
}) {
assert(rect.width <= bounds.width,
'attempted to shift $rect within $bounds, but the rect has a greater width.');
assert(rect.height <= bounds.height,
'attempted to shift $rect within $bounds, but the rect has a greater height.');
Offset rectShift = Offset.zero;
if (rect.left < bounds.left) {
rectShift += Offset(bounds.left - rect.left, 0);
} else if (rect.right > bounds.right) {
rectShift += Offset(bounds.right - rect.right, 0);
}
if (rect.top < bounds.top) {
rectShift += Offset(0, bounds.top - rect.top);
} else if (rect.bottom > bounds.bottom) {
rectShift += Offset(0, bounds.bottom - rect.bottom);
}
return rect.shift(rectShift);
}
}
/// A decoration for a [RawMagnifier].
///
/// [MagnifierDecoration] does not expose [ShapeDecoration.color], [ShapeDecoration.image],
/// or [ShapeDecoration.gradient], since they will be covered by the [RawMagnifier]'s lens.
///
/// Also takes an [opacity] (see https://github.com/flutter/engine/pull/34435).
class MagnifierDecoration extends ShapeDecoration {
/// Constructs a [MagnifierDecoration].
///
/// By default, [MagnifierDecoration] is a rectangular magnifier with no shadows, and
/// fully opaque.
const MagnifierDecoration({
this.opacity = 1,
super.shadows,
super.shape = const RoundedRectangleBorder(),
});
/// The magnifier's opacity.
final double opacity;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return super == other && other is MagnifierDecoration && other.opacity == opacity;
}
@override
int get hashCode => Object.hash(super.hashCode, opacity);
}
/// A common base class for magnifiers.
///
/// {@template flutter.widgets.magnifier.intro}
/// This magnifying glass is useful for scenarios on mobile devices where
/// the user's finger may be covering part of the screen where a granular
/// action is being performed, such as navigating a small cursor with a drag
/// gesture, on an image or text.
/// {@endtemplate}
///
/// A magnifier can be convienently managed by [MagnifierController], which handles
/// showing and hiding the magnifier, with an optional entry / exit animation.
///
/// See:
/// * [MagnifierController], a controller to handle magnifiers in an overlay.
class RawMagnifier extends StatelessWidget {
/// Constructs a [RawMagnifier].
///
/// {@template flutter.widgets.magnifier.RawMagnifier.invisibility_warning}
/// By default, this magnifier uses the default [MagnifierDecoration],
/// the focal point is directly under the magnifier, and there is no magnification:
/// This means that a default magnifier will be entirely invisible to the naked eye,
/// since it is painting exactly what is under it, exactly where it was painted
/// orignally.
/// {@endtemplate}
const RawMagnifier({
super.key,
this.child,
this.decoration = const MagnifierDecoration(),
this.focalPointOffset = Offset.zero,
this.magnificationScale = 1,
required this.size,
}) : assert(magnificationScale != 0,
'Magnification scale of 0 results in undefined behavior.');
/// An optional widget to posiiton inside the len of the [RawMagnifier].
///
/// This is positioned over the [RawMagnifier] - it may be useful for tinting the
/// [RawMagnifier], or drawing a crosshair like UI.
final Widget? child;
/// This magnifier's decoration.
///
/// {@macro flutter.widgets.magnifier.RawMagnifier.invisibility_warning}
final MagnifierDecoration decoration;
/// The offset of the magnifier from [RawMagnifier]'s center.
///
/// {@template flutter.widgets.magnifier.offset}
/// For example, if [RawMagnifier] is globally positioned at Offset(100, 100),
/// and [focalPointOffset] is Offset(-20, -20), then [RawMagnifier] will see
/// the content at global offset (80, 80).
///
/// If left as [Offset.zero], the [RawMagnifier] will show the content that
/// is directly below it.
/// {@endtemplate}
final Offset focalPointOffset;
/// How "zoomed in" the magnification subject is in the lens.
final double magnificationScale;
/// The size of the magnifier.
///
/// This does not include added border; it only includes
/// the size of the magnifier.
final Size size;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: <Widget>[
ClipPath.shape(
shape: decoration.shape,
child: Opacity(
opacity: decoration.opacity,
child: _Magnifier(
shape: decoration.shape,
focalPointOffset: focalPointOffset,
magnificationScale: magnificationScale,
child: SizedBox.fromSize(
size: size,
child: child,
),
),
),
),
// Because `BackdropFilter` will filter any widgets before it, we should
// apply the style after (i.e. in a younger sibling) to avoid the magnifier
// from seeing its own styling.
Opacity(
opacity: decoration.opacity,
child: _MagnifierStyle(
decoration,
size: size,
),
)
],
);
}
}
class _MagnifierStyle extends StatelessWidget {
const _MagnifierStyle(this.decoration, {required this.size});
final MagnifierDecoration decoration;
final Size size;
@override
Widget build(BuildContext context) {
double largestShadow = 0;
for (final BoxShadow shadow in decoration.shadows ?? <BoxShadow>[]) {
largestShadow = math.max(
largestShadow,
(shadow.blurRadius + shadow.spreadRadius) +
math.max(shadow.offset.dy.abs(), shadow.offset.dx.abs()));
}
return ClipPath(
clipBehavior: Clip.hardEdge,
clipper: _DonutClip(
shape: decoration.shape,
spreadRadius: largestShadow,
),
child: DecoratedBox(
decoration: decoration,
child: SizedBox.fromSize(
size: size,
),
),
);
}
}
/// A `clipPath` that looks like a donut if you were to fill its area.
///
/// This is necessary because the shadow must be added after the magnifier is drawn,
/// so that the shadow does not end up in the magnifier. Without this clip, the magnifier would be
/// entirely covered by the shadow.
///
/// The negative space of the donut is clipped out (the donut hole, outside the donut).
/// The donut hole is cut out exactly like the shape of the magnifier.
class _DonutClip extends CustomClipper<Path> {
_DonutClip({required this.shape, required this.spreadRadius});
final double spreadRadius;
final ShapeBorder shape;
@override
Path getClip(Size size) {
final Path path = Path();
final Rect rect = Offset.zero & size;
path.fillType = PathFillType.evenOdd;
path.addPath(shape.getOuterPath(rect.inflate(spreadRadius)), Offset.zero);
path.addPath(shape.getInnerPath(rect), Offset.zero);
return path;
}
@override
bool shouldReclip(_DonutClip oldClipper) => oldClipper.shape != shape;
}
class _Magnifier extends SingleChildRenderObjectWidget {
/// Construct a [_Magnifier].
const _Magnifier({
super.child,
required this.shape,
this.magnificationScale = 1,
this.focalPointOffset = Offset.zero,
});
/// [focalPointOffset] is the area the center of the
/// [_Magnifier] points to, relative to the center of the magnifier.
///
/// {@macro flutter.widgets.magnifier.offset}
final Offset focalPointOffset;
/// The scale of the magnification.
///
/// A [magnificationScale] of 1 means that the content in the magnifier
/// is true to it's real size. Anything greater than one will appear bigger
/// in the magnifier, and anything less than one will appear smaller in
/// the magnifier.
final double magnificationScale;
/// The shape of the magnifier is dictated by [shape.getOuterPath].
final ShapeBorder shape;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderMagnification(focalPointOffset, magnificationScale, shape);
}
@override
void updateRenderObject(
BuildContext context, _RenderMagnification renderObject) {
renderObject
..focalPointOffset = focalPointOffset
..shape = shape
..magnificationScale = magnificationScale;
}
}
class _RenderMagnification extends RenderProxyBox {
_RenderMagnification(
this._focalPointOffset,
this._magnificationScale,
this._shape, {
RenderBox? child,
}) : super(child);
Offset get focalPointOffset => _focalPointOffset;
Offset _focalPointOffset;
set focalPointOffset(Offset value) {
if (_focalPointOffset == value) {
return;
}
_focalPointOffset = value;
markNeedsPaint();
}
double get magnificationScale => _magnificationScale;
double _magnificationScale;
set magnificationScale(double value) {
if (_magnificationScale == value) {
return;
}
_magnificationScale = value;
markNeedsPaint();
}
ShapeBorder get shape => _shape;
ShapeBorder _shape;
set shape(ShapeBorder value) {
if (_shape == value) {
return;
}
_shape = value;
markNeedsPaint();
}
@override
bool get alwaysNeedsCompositing => true;
@override
BackdropFilterLayer? get layer => super.layer as BackdropFilterLayer?;
@override
void paint(PaintingContext context, Offset offset) {
final Offset thisCenter = Alignment.center.alongSize(size) + offset;
final Matrix4 matrix = Matrix4.identity()
..translate(
magnificationScale * ((focalPointOffset.dx * -1) - thisCenter.dx) + thisCenter.dx,
magnificationScale * ((focalPointOffset.dy * -1) - thisCenter.dy) + thisCenter.dy)
..scale(magnificationScale);
final ImageFilter filter = ImageFilter.matrix(matrix.storage, filterQuality: FilterQuality.high);
if (layer == null) {
layer = BackdropFilterLayer(
filter: filter,
);
} else {
layer!.filter = filter;
}
context.pushLayer(layer!, super.paint, offset);
}
}
...@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart'; ...@@ -9,6 +9,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:vector_math/vector_math_64.dart';
import 'actions.dart'; import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -179,8 +180,18 @@ class SelectableRegion extends StatefulWidget { ...@@ -179,8 +180,18 @@ class SelectableRegion extends StatefulWidget {
required this.focusNode, required this.focusNode,
required this.selectionControls, required this.selectionControls,
required this.child, required this.child,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}); });
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// By default, [SelectableRegion]'s [TextMagnifierConfiguration] is disabled.
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration;
/// {@macro flutter.widgets.Focus.focusNode} /// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode; final FocusNode focusNode;
...@@ -403,7 +414,12 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -403,7 +414,12 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
}); });
return; return;
} }
} }
void _onAnyDragEnd(DragEndDetails details) {
_selectionOverlay!.hideMagnifier(shouldShowToolbar: true);
_stopSelectionEndEdgeUpdate();
}
void _stopSelectionEndEdgeUpdate() { void _stopSelectionEndEdgeUpdate() {
_scheduledSelectionEndEdgeUpdate = false; _scheduledSelectionEndEdgeUpdate = false;
...@@ -451,11 +467,19 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -451,11 +467,19 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
late Offset _selectionStartHandleDragPosition; late Offset _selectionStartHandleDragPosition;
late Offset _selectionEndHandleDragPosition; late Offset _selectionEndHandleDragPosition;
late List<TextSelectionPoint> points;
void _handleSelectionStartHandleDragStart(DragStartDetails details) { void _handleSelectionStartHandleDragStart(DragStartDetails details) {
assert(_selectionDelegate.value.startSelectionPoint != null); assert(_selectionDelegate.value.startSelectionPoint != null);
final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition;
final Matrix4 globalTransform = _selectable!.getTransformTo(null); final Matrix4 globalTransform = _selectable!.getTransformTo(null);
_selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); _selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition);
_selectionOverlay!.showMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.startSelectionPoint!,
));
} }
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
...@@ -464,6 +488,11 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -464,6 +488,11 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
// Offset it to the center of the line to make it feel more natural. // Offset it to the center of the line to make it feel more natural.
_selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); _selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2);
_triggerSelectionStartEdgeUpdate(); _triggerSelectionStartEdgeUpdate();
_selectionOverlay!.updateMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.startSelectionPoint!,
));
} }
void _handleSelectionEndHandleDragStart(DragStartDetails details) { void _handleSelectionEndHandleDragStart(DragStartDetails details) {
...@@ -471,6 +500,11 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -471,6 +500,11 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition;
final Matrix4 globalTransform = _selectable!.getTransformTo(null); final Matrix4 globalTransform = _selectable!.getTransformTo(null);
_selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); _selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition);
_selectionOverlay!.showMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.endSelectionPoint!,
));
} }
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
...@@ -479,6 +513,30 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -479,6 +513,30 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
// Offset it to the center of the line to make it feel more natural. // Offset it to the center of the line to make it feel more natural.
_selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); _selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2);
_triggerSelectionEndEdgeUpdate(); _triggerSelectionEndEdgeUpdate();
_selectionOverlay!.updateMagnifier(_buildInfoForMagnifier(
details.globalPosition,
_selectionDelegate.value.endSelectionPoint!,
));
}
MagnifierOverlayInfoBearer _buildInfoForMagnifier(Offset globalGesturePosition, SelectionPoint selectionPoint) {
final Vector3 globalTransform = _selectable!.getTransformTo(null).getTranslation();
final Offset globalTransformAsOffset = Offset(globalTransform.x, globalTransform.y);
final Offset globalSelectionPointPosition = selectionPoint.localPosition + globalTransformAsOffset;
final Rect caretRect = Rect.fromLTWH(
globalSelectionPointPosition.dx,
globalSelectionPointPosition.dy - selectionPoint.lineHeight,
0,
selectionPoint.lineHeight
);
return MagnifierOverlayInfoBearer(
globalGesturePosition: globalGesturePosition,
caretRect: caretRect,
fieldBounds: globalTransformAsOffset & _selectable!.size,
currentLineBoundries: globalTransformAsOffset & _selectable!.size,
);
} }
void _createSelectionOverlay() { void _createSelectionOverlay() {
...@@ -488,7 +546,6 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -488,7 +546,6 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
} }
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
late List<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) { if (startLocalPosition.dy > endLocalPosition.dy) {
...@@ -509,12 +566,12 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -509,12 +566,12 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, lineHeightAtStart: start?.lineHeight ?? end!.lineHeight,
onStartHandleDragStart: _handleSelectionStartHandleDragStart, onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onStartHandleDragEnd: (DragEndDetails details) => _stopSelectionStartEdgeUpdate(), onStartHandleDragEnd: _onAnyDragEnd,
endHandleType: end?.handleType ?? TextSelectionHandleType.right, endHandleType: end?.handleType ?? TextSelectionHandleType.right,
lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight,
onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onEndHandleDragEnd: (DragEndDetails details) => _stopSelectionEndEdgeUpdate(), onEndHandleDragEnd: _onAnyDragEnd,
selectionEndpoints: points, selectionEndpoints: points,
selectionControls: widget.selectionControls, selectionControls: widget.selectionControls,
selectionDelegate: this, selectionDelegate: this,
...@@ -522,6 +579,7 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -522,6 +579,7 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
startHandleLayerLink: _startHandleLayerLink, startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink, endHandleLayerLink: _endHandleLayerLink,
toolbarLayerLink: _toolbarLayerLink, toolbarLayerLink: _toolbarLayerLink,
magnifierConfiguration: widget.magnifierConfiguration
); );
} }
...@@ -798,6 +856,9 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD ...@@ -798,6 +856,9 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
_selectable?.removeListener(_updateSelectionStatus); _selectable?.removeListener(_updateSelectionStatus);
_selectable?.pushHandleLayers(null, null); _selectable?.pushHandleLayers(null, null);
_selectionDelegate.dispose(); _selectionDelegate.dispose();
// In case dispose was triggered before gesture end, remove the magnifier
// so it doesn't remain stuck in the overlay forever.
_selectionOverlay?.hideMagnifier(shouldShowToolbar: false);
_selectionOverlay?.dispose(); _selectionOverlay?.dispose();
_selectionOverlay = null; _selectionOverlay = null;
super.dispose(); super.dispose();
......
...@@ -20,6 +20,7 @@ import 'debug.dart'; ...@@ -20,6 +20,7 @@ import 'debug.dart';
import 'editable_text.dart'; import 'editable_text.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'magnifier.dart';
import 'overlay.dart'; import 'overlay.dart';
import 'tap_region.dart'; import 'tap_region.dart';
import 'ticker_provider.dart'; import 'ticker_provider.dart';
...@@ -71,6 +72,159 @@ class ToolbarItemsParentData extends ContainerBoxParentData<RenderBox> { ...@@ -71,6 +72,159 @@ class ToolbarItemsParentData extends ContainerBoxParentData<RenderBox> {
String toString() => '${super.toString()}; shouldPaint=$shouldPaint'; String toString() => '${super.toString()}; shouldPaint=$shouldPaint';
} }
/// {@template flutter.widgets.textSelection.MagnifierBuilder}
/// Signature for a builder that builds a Widget with a [MagnifierController].
///
/// Consuming [MagnifierController] or [ValueNotifier]<[MagnifierOverlayInfoBearer]> is not
/// required, although if a Widget intends to have entry or exit animations, it should take
/// [MagnifierController] and provide it an [AnimationController], so that [MagnifierController]
/// can wait before removing it from the overlay.
/// {@endtemplate}
///
/// See also:
///
/// - [MagnifierOverlayInfoBearer], the dataclass that updates the
/// magnifier.
typedef MagnifierBuilder = Widget? Function(
BuildContext context,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer> textSelectionData
);
/// A data class that allows the [SelectionOverlay] to delegate
/// the magnifier's positioning to the magnifier itself, based on the
/// info in [MagnifierOverlayInfoBearer].
@immutable
class MagnifierOverlayInfoBearer {
/// Construct a [MagnifierOverlayInfoBearer] from raw values.
const MagnifierOverlayInfoBearer({
required this.globalGesturePosition,
required this.caretRect,
required this.fieldBounds,
required this.currentLineBoundries,
});
factory MagnifierOverlayInfoBearer._fromRenderEditable({
required RenderEditable renderEditable,
required Offset globalGesturePosition,
required TextPosition currentTextPosition,
}) {
final Offset globalRenderEditableTopLeft = renderEditable.localToGlobal(Offset.zero);
final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition);
final TextPosition positionAtEndOfLine = TextPosition(
offset: lineAtOffset.extentOffset,
affinity: TextAffinity.upstream,
);
// Default affinity is downstream.
final TextPosition positionAtBeginningOfLine = TextPosition(
offset: lineAtOffset.baseOffset,
);
final Rect lineBoundries = Rect.fromPoints(
renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter,
renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter
);
return MagnifierOverlayInfoBearer(
fieldBounds: globalRenderEditableTopLeft & renderEditable.size,
globalGesturePosition: globalGesturePosition,
caretRect: localCaretRect.shift(globalRenderEditableTopLeft),
currentLineBoundries: lineBoundries.shift(globalRenderEditableTopLeft)
);
}
/// Construct an empty [MagnifierOverlayInfoBearer], with all
/// values set to 0.
const MagnifierOverlayInfoBearer.empty() :
globalGesturePosition = Offset.zero,
caretRect = Rect.zero,
currentLineBoundries = Rect.zero,
fieldBounds = Rect.zero;
/// The offset of the gesture position that the magnifier should be shown at.
final Offset globalGesturePosition;
/// The rect of the current line the magnifier should be shown at. Do not take
/// into account any padding of the field; only the position of the first
/// and last character.
final Rect currentLineBoundries;
/// The rect of the handle that the magnifier should follow.
final Rect caretRect;
/// The bounds of the entire text field that the magnifier is bound to.
final Rect fieldBounds;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! MagnifierOverlayInfoBearer) {
return false;
}
return other.globalGesturePosition == globalGesturePosition &&
other.caretRect == caretRect &&
other.currentLineBoundries == currentLineBoundries &&
other.fieldBounds == fieldBounds;
}
@override
int get hashCode => Object.hash(
globalGesturePosition,
caretRect,
fieldBounds,
currentLineBoundries
);
}
/// {@template flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
/// A configuration object for a magnifier.
/// {@endtemplate}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// {@template flutter.widgets.text_selection.TextMagnifierConfiguration.details}
/// In general, most features of the magnifier can be configured through
/// [MagnifierBuilder]. [TextMagnifierConfiguration] is used to configure
/// the magnifier's behavior through the [SelectionOverlay].
/// {@endtemplate}
class TextMagnifierConfiguration {
/// Construct a [TextMagnifierConfiguration] from parts.
///
/// If [magnifierBuilder] is null, a default [MagnifierBuilder] will be used
/// that never builds a magnifier.
const TextMagnifierConfiguration({
MagnifierBuilder? magnifierBuilder,
this.shouldDisplayHandlesInMagnifier = true
}) : _magnifierBuilder = magnifierBuilder;
/// The passed in [MagnifierBuilder].
///
/// This is nullable because [disabled] needs to be static const,
/// so that it can be used as a default parameter. If left null,
/// the [magnifierBuilder] getter will be a function that always returns
/// null.
final MagnifierBuilder? _magnifierBuilder;
/// {@macro flutter.widgets.textSelection.MagnifierBuilder}
MagnifierBuilder get magnifierBuilder => _magnifierBuilder ?? (_, __, ___) => null;
/// Determines whether a magnifier should show the text editing handles or not.
final bool shouldDisplayHandlesInMagnifier;
/// A constant for a [TextMagnifierConfiguration] that is disabled.
///
/// In particular, this [TextMagnifierConfiguration] is considered disabled
/// because it never builds anything, regardless of platform.
static const TextMagnifierConfiguration disabled = TextMagnifierConfiguration();
}
/// An interface for building the selection UI, to be provided by the /// An interface for building the selection UI, to be provided by the
/// implementer of the toolbar widget. /// implementer of the toolbar widget.
/// ///
...@@ -224,7 +378,7 @@ class TextSelectionOverlay { ...@@ -224,7 +378,7 @@ class TextSelectionOverlay {
/// The [context] must not be null and must have an [Overlay] as an ancestor. /// The [context] must not be null and must have an [Overlay] as an ancestor.
TextSelectionOverlay({ TextSelectionOverlay({
required TextEditingValue value, required TextEditingValue value,
required BuildContext context, required this.context,
Widget? debugRequiredFor, Widget? debugRequiredFor,
required LayerLink toolbarLayerLink, required LayerLink toolbarLayerLink,
required LayerLink startHandleLayerLink, required LayerLink startHandleLayerLink,
...@@ -236,6 +390,7 @@ class TextSelectionOverlay { ...@@ -236,6 +390,7 @@ class TextSelectionOverlay {
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
VoidCallback? onSelectionHandleTapped, VoidCallback? onSelectionHandleTapped,
ClipboardStatusNotifier? clipboardStatus, ClipboardStatusNotifier? clipboardStatus,
required TextMagnifierConfiguration magnifierConfiguration,
}) : assert(value != null), }) : assert(value != null),
assert(context != null), assert(context != null),
assert(handlesVisible != null), assert(handlesVisible != null),
...@@ -245,6 +400,7 @@ class TextSelectionOverlay { ...@@ -245,6 +400,7 @@ class TextSelectionOverlay {
renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities); renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
_updateTextSelectionOverlayVisibilities(); _updateTextSelectionOverlayVisibilities();
_selectionOverlay = SelectionOverlay( _selectionOverlay = SelectionOverlay(
magnifierConfiguration: magnifierConfiguration,
context: context, context: context,
debugRequiredFor: debugRequiredFor, debugRequiredFor: debugRequiredFor,
// The metrics will be set when show handles. // The metrics will be set when show handles.
...@@ -253,11 +409,13 @@ class TextSelectionOverlay { ...@@ -253,11 +409,13 @@ class TextSelectionOverlay {
lineHeightAtStart: 0.0, lineHeightAtStart: 0.0,
onStartHandleDragStart: _handleSelectionStartHandleDragStart, onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onEndHandleDragEnd: _handleAnyDragEnd,
endHandleType: TextSelectionHandleType.collapsed, endHandleType: TextSelectionHandleType.collapsed,
endHandlesVisible: _effectiveEndHandleVisibility, endHandlesVisible: _effectiveEndHandleVisibility,
lineHeightAtEnd: 0.0, lineHeightAtEnd: 0.0,
onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onStartHandleDragEnd: _handleAnyDragEnd,
toolbarVisible: _effectiveToolbarVisibility, toolbarVisible: _effectiveToolbarVisibility,
selectionEndpoints: const <TextSelectionPoint>[], selectionEndpoints: const <TextSelectionPoint>[],
selectionControls: selectionControls, selectionControls: selectionControls,
...@@ -303,6 +461,13 @@ class TextSelectionOverlay { ...@@ -303,6 +461,13 @@ class TextSelectionOverlay {
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false); final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false); final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false); final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
/// The context in which the selection handles should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
final BuildContext context;
void _updateTextSelectionOverlayVisibilities() { void _updateTextSelectionOverlayVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value; _effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value; _effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
...@@ -451,7 +616,15 @@ class TextSelectionOverlay { ...@@ -451,7 +616,15 @@ class TextSelectionOverlay {
final Size handleSize = selectionControls!.getHandleSize( final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight, renderObject.preferredLineHeight,
); );
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height); _dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
_selectionOverlay.showMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
} }
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
...@@ -459,10 +632,18 @@ class TextSelectionOverlay { ...@@ -459,10 +632,18 @@ class TextSelectionOverlay {
return; return;
} }
_dragEndPosition += details.delta; _dragEndPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
final TextSelection currentSelection = TextSelection.fromPosition(position);
if (_selection.isCollapsed) { if (_selection.isCollapsed) {
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: true); _selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(currentSelection, isEnd: true);
return; return;
} }
...@@ -494,6 +675,12 @@ class TextSelectionOverlay { ...@@ -494,6 +675,12 @@ class TextSelectionOverlay {
} }
_handleSelectionHandleChanged(newSelection, isEnd: true); _handleSelectionHandleChanged(newSelection, isEnd: true);
_selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: newSelection.extent,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
} }
late Offset _dragStartPosition; late Offset _dragStartPosition;
...@@ -506,6 +693,13 @@ class TextSelectionOverlay { ...@@ -506,6 +693,13 @@ class TextSelectionOverlay {
renderObject.preferredLineHeight, renderObject.preferredLineHeight,
); );
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height); _dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
_selectionOverlay.showMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
} }
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
...@@ -516,6 +710,12 @@ class TextSelectionOverlay { ...@@ -516,6 +710,12 @@ class TextSelectionOverlay {
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
if (_selection.isCollapsed) { if (_selection.isCollapsed) {
_selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false); _handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false);
return; return;
} }
...@@ -547,9 +747,17 @@ class TextSelectionOverlay { ...@@ -547,9 +747,17 @@ class TextSelectionOverlay {
break; break;
} }
_selectionOverlay.updateMagnifier(MagnifierOverlayInfoBearer._fromRenderEditable(
currentTextPosition: newSelection.extent.offset < newSelection.base.offset ? newSelection.extent : newSelection.base,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(newSelection, isEnd: false); _handleSelectionHandleChanged(newSelection, isEnd: false);
} }
void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) { void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) {
final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base; final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base;
selectionDelegate.userUpdateTextEditingValue( selectionDelegate.userUpdateTextEditingValue(
...@@ -612,6 +820,7 @@ class SelectionOverlay { ...@@ -612,6 +820,7 @@ class SelectionOverlay {
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped, this.onSelectionHandleTapped,
Offset? toolbarLocation, Offset? toolbarLocation,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : _startHandleType = startHandleType, }) : _startHandleType = startHandleType,
_lineHeightAtStart = lineHeightAtStart, _lineHeightAtStart = lineHeightAtStart,
_endHandleType = endHandleType, _endHandleType = endHandleType,
...@@ -626,6 +835,81 @@ class SelectionOverlay { ...@@ -626,6 +835,81 @@ class SelectionOverlay {
/// will display the text selection handles in that [Overlay]. /// will display the text selection handles in that [Overlay].
final BuildContext context; final BuildContext context;
final ValueNotifier<MagnifierOverlayInfoBearer> _magnifierOverlayInfoBearer =
ValueNotifier<MagnifierOverlayInfoBearer>(const MagnifierOverlayInfoBearer.empty());
/// [MagnifierController.show] and [MagnifierController.hide] should not be called directly, except
/// from inside [showMagnifier] and [hideMagnifier]. If it is desired to show or hide the magnifier,
/// call [showMagnifier] or [hideMagnifier]. This is because the magnifier needs to orchestrate
/// with other properties in [SelectionOverlay].
final MagnifierController _magnifierController = MagnifierController();
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled.
///
/// {@macro flutter.widgets.text_selection.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration;
/// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier]
/// was called. This is safe to call on platforms not mobile, since
/// a magnifierBuilder will not be provided, or the magnifierBuilder will return null
/// on platforms not mobile.
///
/// This is NOT the souce of truth for if the magnifier is up or not,
/// since magnifiers may hide themselves. If this info is needed, check
/// [MagnifierController.shown].
void showMagnifier(MagnifierOverlayInfoBearer initalInfoBearer) {
if (_toolbar != null) {
hideToolbar();
}
// Start from empty, so we don't utilize any rememnant values.
_magnifierOverlayInfoBearer.value = initalInfoBearer;
// Pre-build the magnifiers so we can tell if we've built something
// or not. If we don't build a magnifiers, then we should not
// insert anything in the overlay.
final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder(
context,
_magnifierController,
_magnifierOverlayInfoBearer,
);
if (builtMagnifier == null) {
return;
}
_magnifierController.show(
context: context,
below: magnifierConfiguration.shouldDisplayHandlesInMagnifier
? null
: _handles!.first,
builder: (_) => builtMagnifier);
}
/// Hide the current magnifier, optionally immediately showing
/// the toolbar.
///
/// This does nothing if there is no magnifier.
void hideMagnifier({required bool shouldShowToolbar}) {
// This cannot be a check on `MagnifierController.shown`, since
// it's possible that the magnifier is still in the overlay, but
// not shown in cases where the magnifier hides itself.
if (_magnifierController.overlayEntry == null) {
return;
}
_magnifierController.hide();
if (shouldShowToolbar) {
showToolbar();
}
}
/// The type of start selection handle. /// The type of start selection handle.
/// ///
/// Changing the value while the handles are visible causes them to rebuild. /// Changing the value while the handles are visible causes them to rebuild.
...@@ -903,6 +1187,7 @@ class SelectionOverlay { ...@@ -903,6 +1187,7 @@ class SelectionOverlay {
/// Hides the entire overlay including the toolbar and the handles. /// Hides the entire overlay including the toolbar and the handles.
/// {@endtemplate} /// {@endtemplate}
void hide() { void hide() {
_magnifierController.hide();
if (_handles != null) { if (_handles != null) {
_handles![0].remove(); _handles![0].remove();
_handles![1].remove(); _handles![1].remove();
...@@ -1031,6 +1316,22 @@ class SelectionOverlay { ...@@ -1031,6 +1316,22 @@ class SelectionOverlay {
), ),
); );
} }
/// Update the current magnifier with new selection data, so the magnifier
/// can respond accordingly.
///
/// If the magnifier is not shown, this still updates the magnifier position
/// because the magnifier may have hidden itself and is looking for a cue to reshow
/// itself.
///
/// If there is no magnifier in the overlay, this does nothing,
void updateMagnifier(MagnifierOverlayInfoBearer magnifierOverlayInfoBearer) {
if (_magnifierController.overlayEntry == null) {
return;
}
_magnifierOverlayInfoBearer.value = magnifierOverlayInfoBearer;
}
} }
/// This widget represents a selection toolbar. /// This widget represents a selection toolbar.
......
...@@ -70,6 +70,7 @@ export 'src/widgets/keyboard_listener.dart'; ...@@ -70,6 +70,7 @@ export 'src/widgets/keyboard_listener.dart';
export 'src/widgets/layout_builder.dart'; export 'src/widgets/layout_builder.dart';
export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/list_wheel_scroll_view.dart';
export 'src/widgets/localizations.dart'; export 'src/widgets/localizations.dart';
export 'src/widgets/magnifier.dart';
export 'src/widgets/media_query.dart'; export 'src/widgets/media_query.dart';
export 'src/widgets/modal_barrier.dart'; export 'src/widgets/modal_barrier.dart';
export 'src/widgets/navigation_toolbar.dart'; export 'src/widgets/navigation_toolbar.dart';
......
// 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.
@Tags(<String>['reduced-test-set'])
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final Offset basicOffset = Offset(CupertinoMagnifier.kDefaultSize.width / 2,
CupertinoMagnifier.kDefaultSize.height - CupertinoMagnifier.kMagnifierAboveFocalPoint);
const Rect reasonableTextField = Rect.fromLTRB(0, 100, 200, 200);
final MagnifierController magnifierController = MagnifierController();
// Make sure that your gesture in infoBearer is within the line in infoBearer,
// or else the magnifier status will stay hidden and this will not complete.
Future<void> showCupertinoMagnifier(
BuildContext context,
WidgetTester tester,
ValueNotifier<MagnifierOverlayInfoBearer> infoBearer,
) async {
final Future<void> magnifierShown = magnifierController.show(
context: context,
builder: (_) => CupertinoTextMagnifier(
controller: magnifierController,
magnifierOverlayInfoBearer: infoBearer,
));
WidgetsBinding.instance.scheduleFrame();
await tester.pumpAndSettle();
await magnifierShown;
}
tearDown(() async {
magnifierController.removeFromOverlay();
});
group('CupertinoTextEditingMagnifier', () {
group('position', () {
Offset getMagnifierPosition(WidgetTester tester) {
final AnimatedPositioned animatedPositioned =
tester.firstWidget(find.byType(AnimatedPositioned));
return Offset(
animatedPositioned.left ?? 0, animatedPositioned.top ?? 0);
}
testWidgets('should be at gesture position if does not violate any positioning rules', (WidgetTester tester) async {
final Key fakeTextFieldKey = UniqueKey();
final Key outerKey = UniqueKey();
await tester.pumpWidget(
Container(
key: outerKey,
color: const Color.fromARGB(255, 0, 255, 179),
child: MaterialApp(
home: Center(
child: Container(
key: fakeTextFieldKey,
width: 10,
height: 10,
color: Colors.red,
child: const Placeholder(),
),
),
),
),
);
final BuildContext context = tester.element(find.byType(Placeholder));
// Magnifier should be positioned directly over the red square.
final RenderBox tapPointRenderBox =
tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox;
final Rect fakeTextFieldRect =
tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size;
final ValueNotifier<MagnifierOverlayInfoBearer> magnifier =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: fakeTextFieldRect,
fieldBounds: fakeTextFieldRect,
caretRect: fakeTextFieldRect,
// The tap position is dragBelow units below the text field.
globalGesturePosition: fakeTextFieldRect.center,
),
);
await showCupertinoMagnifier(context, tester, magnifier);
// Should show two red squares; original, and one in the magnifier,
// directly ontop of one another.
await expectLater(
find.byKey(outerKey),
matchesGoldenFile('cupertino_magnifier.position.default.png'),
);
});
testWidgets('should never horizontally be outside of Screen Padding', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
color: Color.fromARGB(7, 0, 129, 90),
home: Placeholder(),
),
);
final BuildContext context = tester.firstElement(find.byType(Placeholder));
await showCupertinoMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
// The tap position is far out of the right side of the app.
globalGesturePosition:
Offset(MediaQuery.of(context).size.width + 100, 0),
),
),
);
// Should be less than the right edge, since we have padding.
expect(getMagnifierPosition(tester).dx,
lessThan(MediaQuery.of(context).size.width));
});
testWidgets('should have some vertical drag', (WidgetTester tester) async {
final double dragPositionBelowTextField = reasonableTextField.center.dy + 30;
await tester.pumpWidget(
const MaterialApp(
color: Color.fromARGB(7, 0, 129, 90),
home: Placeholder(),
),
);
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showCupertinoMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
// The tap position is dragBelow units below the text field.
globalGesturePosition: Offset(
MediaQuery.of(context).size.width / 2,
dragPositionBelowTextField),
),
),
);
// The magnifier Y should be greater than the text field, since we "dragged" it down.
expect(getMagnifierPosition(tester).dy + basicOffset.dy,
greaterThan(reasonableTextField.center.dy));
expect(getMagnifierPosition(tester).dy + basicOffset.dy,
lessThan(dragPositionBelowTextField));
});
});
group('status', () {
testWidgets('should hide if gesture is far below the text field', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
color: Color.fromARGB(7, 0, 129, 90),
home: Placeholder(),
),
);
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierinfo =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
// The tap position is dragBelow units below the text field.
globalGesturePosition: Offset(
MediaQuery.of(context).size.width / 2, reasonableTextField.top),
),
);
// Show the magnifier initally, so that we get it in a not hidden state.
await showCupertinoMagnifier(context, tester, magnifierinfo);
// Move the gesture to one that should hide it.
magnifierinfo.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: magnifierinfo.value.globalGesturePosition + const Offset(0, 100),
);
await tester.pumpAndSettle();
expect(magnifierController.shown, false);
expect(magnifierController.overlayEntry, isNotNull);
});
testWidgets('should re-show if gesture moves back up',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
color: Color.fromARGB(7, 0, 129, 90),
home: Placeholder(),
),
);
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierInfo =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
// The tap position is dragBelow units below the text field.
globalGesturePosition: Offset(MediaQuery.of(context).size.width / 2, reasonableTextField.top),
),
);
// Show the magnifier initally, so that we get it in a not hidden state.
await showCupertinoMagnifier(context, tester, magnifierInfo);
// Move the gesture to one that should hide it.
magnifierInfo.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition:
magnifierInfo.value.globalGesturePosition + const Offset(0, 100));
await tester.pumpAndSettle();
expect(magnifierController.shown, false);
expect(magnifierController.overlayEntry, isNotNull);
// Return the gesture to one that shows it.
magnifierInfo.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: Offset(MediaQuery.of(context).size.width / 2,
reasonableTextField.top));
await tester.pumpAndSettle();
expect(magnifierController.shown, true);
expect(magnifierController.overlayEntry, isNotNull);
});
});
});
}
...@@ -5961,6 +5961,148 @@ void main() { ...@@ -5961,6 +5961,148 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}); });
group('magnifier', () {
late ValueNotifier<MagnifierOverlayInfoBearer> infoBearer;
final Widget fakeMagnifier = Container(key: UniqueKey());
group('magnifier builder', () {
testWidgets('should build custom magnifier if given', (WidgetTester tester) async {
final Widget customMagnifier = Container(
key: UniqueKey(),
);
final CupertinoTextField defaultCupertinoTextField = CupertinoTextField(
magnifierConfiguration: TextMagnifierConfiguration(magnifierBuilder: (_, __, ___) => customMagnifier),
);
await tester.pumpWidget(const CupertinoApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
expect(
defaultCupertinoTextField.magnifierConfiguration!.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isA<Widget>().having(
(Widget widget) => widget.key, 'key', equals(customMagnifier.key)));
});
group('defaults', () {
testWidgets('should build CupertinoMagnifier on iOS and Android', (WidgetTester tester) async {
await tester.pumpWidget(const CupertinoApp(
home: CupertinoTextField(),
));
final BuildContext context = tester.firstElement(find.byType(CupertinoTextField));
final EditableText editableText = tester.widget(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isA<CupertinoTextMagnifier>());
},
variant: const TargetPlatformVariant(
<TargetPlatform>{TargetPlatform.iOS, TargetPlatform.android}));
});
testWidgets('should build nothing on all platforms but iOS and Android', (WidgetTester tester) async {
await tester.pumpWidget(const CupertinoApp(
home: CupertinoTextField(),
));
final BuildContext context = tester.firstElement(find.byType(CupertinoTextField));
final EditableText editableText = tester.widget(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isNull);
},
variant: TargetPlatformVariant.all(
excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.android}));
});
testWidgets('Can drag handles to show, unshow, and update magnifier',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
child: Builder(
builder: (BuildContext context) => CupertinoTextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder: (_,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer>
localInfoBearer) {
infoBearer = localInfoBearer;
return fakeMagnifier;
}),
),
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(CupertinoTextField), testValue);
// Double tap the 'e' to select 'def'.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
// Drag the right handle 2 letters to the right.
final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0);
final TestGesture gesture =
await tester.startGesture(handlePos, pointer: 7);
Offset? firstDragGesturePosition;
await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2));
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
firstDragGesturePosition = infoBearer.value.globalGesturePosition;
await gesture.moveTo(textOffsetToPosition(tester, testValue.length));
await tester.pump();
// Expect the position the magnifier gets to have moved.
expect(firstDragGesturePosition,
isNot(infoBearer.value.globalGesturePosition));
await gesture.up();
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
group('TapRegion integration', () { group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
...@@ -6073,31 +6215,34 @@ void main() { ...@@ -6073,31 +6215,34 @@ void main() {
variant: TargetPlatformVariant.all(), variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser. skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser.
); );
testWidgets("Tapping on border doesn't lose focus", (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); testWidgets("Tapping on border doesn't lose focus",
await tester.pumpWidget( (WidgetTester tester) async {
CupertinoApp( final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
home: Center( await tester.pumpWidget(
child: SizedBox( CupertinoApp(
width: 100, home: Center(
height: 100, child: SizedBox(
child: CupertinoTextField( width: 100,
autofocus: true, height: 100,
focusNode: focusNode, child: CupertinoTextField(
autofocus: true,
focusNode: focusNode,
),
), ),
), ),
), ),
), );
); await tester.pump();
await tester.pump(); expect(focusNode.hasPrimaryFocus, isTrue);
expect(focusNode.hasPrimaryFocus, isTrue);
final Rect borderBox = tester.getRect(find.byType(CupertinoTextField)); final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
// Tap just inside the border, but not inside the EditableText. // Tap just inside the border, but not inside the EditableText.
await tester.tapAt(borderBox.topLeft + const Offset(1, 1)); await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
await tester.pump(); await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue); expect(focusNode.hasPrimaryFocus, isTrue);
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
});
}); });
} }
// 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.
@Tags(<String>['reduced-test-set'])
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final MagnifierController magnifierController = MagnifierController();
const Rect reasonableTextField = Rect.fromLTRB(50, 100, 200, 100);
final Offset basicOffset = Offset(Magnifier.kDefaultMagnifierSize.width / 2,
Magnifier.kStandardVerticalFocalPointShift + Magnifier.kDefaultMagnifierSize.height);
Offset getMagnifierPosition(WidgetTester tester, [bool animated = false]) {
if (animated) {
final AnimatedPositioned animatedPositioned =
tester.firstWidget(find.byType(AnimatedPositioned));
return Offset(animatedPositioned.left ?? 0, animatedPositioned.top ?? 0);
} else {
final Positioned positioned = tester.firstWidget(find.byType(Positioned));
return Offset(positioned.left ?? 0, positioned.top ?? 0);
}
}
Future<void> showMagnifier(
BuildContext context,
WidgetTester tester,
ValueNotifier<MagnifierOverlayInfoBearer> infoBearer,
) async {
final Future<void> magnifierShown = magnifierController.show(
context: context,
builder: (_) => TextMagnifier(
magnifierInfo: infoBearer,
));
WidgetsBinding.instance.scheduleFrame();
await tester.pumpAndSettle();
// Verify that the magnifier is shown.
await magnifierShown;
}
tearDown(() {
magnifierController.removeFromOverlay();
magnifierController.animationController = null;
});
group('adaptiveMagnifierControllerBuilder', () {
testWidgets('should return a TextEditingMagnifier on Android',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final Widget? builtWidget =
TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
),
);
expect(builtWidget, isA<TextMagnifier>());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('should return a CupertinoMagnifier on iOS',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final Widget? builtWidget =
TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty()));
expect(builtWidget, isA<CupertinoTextMagnifier>());
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('should return null on all platforms not Android, iOS',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final Widget? builtWidget =
TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
),
);
expect(builtWidget, isNull);
},
variant: TargetPlatformVariant.all(
excluding: <TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.android
}),
);
});
group('magnifier', () {
group('position', () {
testWidgets(
'should be at gesture position if does not violate any positioning rules',
(WidgetTester tester) async {
final Key textField = UniqueKey();
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
await tester.pumpWidget(
Container(
color: const Color.fromARGB(255, 0, 255, 179),
child: MaterialApp(
home: Center(
child: Container(
key: textField,
width: 10,
height: 10,
color: Colors.red,
child: const Placeholder(),
)),
),
),
);
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
// Magnifier should be positioned directly over the red square.
final RenderBox tapPointRenderBox =
tester.firstRenderObject(find.byKey(textField)) as RenderBox;
final Rect fakeTextFieldRect =
tapPointRenderBox.localToGlobal(Offset.zero) &
tapPointRenderBox.size;
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierInfo =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: fakeTextFieldRect,
fieldBounds: fakeTextFieldRect,
caretRect: fakeTextFieldRect,
// The tap position is dragBelow units below the text field.
globalGesturePosition: fakeTextFieldRect.center,
));
await showMagnifier(context, tester, magnifierInfo);
// Should show two red squares; original, and one in the magnifier,
// directly ontop of one another.
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('magnifier.position.default.png'),
);
});
testWidgets(
'should never move outside the right bounds of the editing line',
(WidgetTester tester) async {
const double gestureOutsideLine = 100;
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
// Inflate these two to make sure we're bounding on the
// current line boundries, not anything else.
fieldBounds: reasonableTextField.inflate(gestureOutsideLine),
caretRect: reasonableTextField.inflate(gestureOutsideLine),
// The tap position is far out of the right side of the app.
globalGesturePosition: Offset(reasonableTextField.right + gestureOutsideLine, 0),
),
),
);
// Should be less than the right edge, since we have padding.
expect(getMagnifierPosition(tester).dx,
lessThanOrEqualTo(reasonableTextField.right));
});
testWidgets(
'should never move outside the left bounds of the editing line',
(WidgetTester tester) async {
const double gestureOutsideLine = 100;
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
// Inflate these two to make sure we're bounding on the
// current line boundries, not anything else.
fieldBounds: reasonableTextField.inflate(gestureOutsideLine),
caretRect: reasonableTextField.inflate(gestureOutsideLine),
// The tap position is far out of the left side of the app.
globalGesturePosition: Offset(reasonableTextField.left - gestureOutsideLine, 0),
),
),
);
expect(getMagnifierPosition(tester).dx + basicOffset.dx,
greaterThanOrEqualTo(reasonableTextField.left));
});
testWidgets('should position vertically at the center of the line', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: reasonableTextField.center,
)));
expect(getMagnifierPosition(tester).dy,
reasonableTextField.center.dy - basicOffset.dy);
});
testWidgets('should reposition vertically if mashed against the ceiling',
(WidgetTester tester) async {
final Rect topOfScreenTextFieldRect =
Rect.fromPoints(Offset.zero, const Offset(200, 0));
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: topOfScreenTextFieldRect,
fieldBounds: topOfScreenTextFieldRect,
caretRect: topOfScreenTextFieldRect,
globalGesturePosition: topOfScreenTextFieldRect.topCenter,
),
),
);
expect(getMagnifierPosition(tester).dy, greaterThanOrEqualTo(0));
});
});
group('focal point', () {
Offset getMagnifierAdditionalFocalPoint(WidgetTester tester) {
final Magnifier magnifier = tester.firstWidget(find.byType(Magnifier));
return magnifier.additionalFocalPointOffset;
}
testWidgets(
'should shift focal point so that the lens sees nothing out of bounds',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
// Gesture on the far right of the magnifier.
globalGesturePosition: reasonableTextField.topLeft,
),
),
);
expect(getMagnifierAdditionalFocalPoint(tester).dx,
lessThan(reasonableTextField.left));
});
testWidgets(
'focal point should shift if mashed against the top to always point to text',
(WidgetTester tester) async {
final Rect topOfScreenTextFieldRect =
Rect.fromPoints(Offset.zero, const Offset(200, 0));
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: topOfScreenTextFieldRect,
fieldBounds: topOfScreenTextFieldRect,
caretRect: topOfScreenTextFieldRect,
globalGesturePosition: topOfScreenTextFieldRect.topCenter,
),
),
);
expect(getMagnifierAdditionalFocalPoint(tester).dy, lessThan(0));
});
});
group('animation state', () {
bool getIsAnimated(WidgetTester tester) {
final AnimatedPositioned animatedPositioned =
tester.firstWidget(find.byType(AnimatedPositioned));
return animatedPositioned.duration.compareTo(Duration.zero) != 0;
}
testWidgets('should not be animated on the inital state',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
await showMagnifier(
context,
tester,
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: reasonableTextField.center,
),
),
);
expect(getIsAnimated(tester), false);
});
testWidgets('should not be animated on horizontal shifts',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierPositioner =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: reasonableTextField.center,
),
);
await showMagnifier(context, tester, magnifierPositioner);
// New position has a horizontal shift.
magnifierPositioner.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition:
reasonableTextField.center + const Offset(200, 0),
);
await tester.pumpAndSettle();
expect(getIsAnimated(tester), false);
});
testWidgets('should be animated on vertical shifts',
(WidgetTester tester) async {
const Offset verticalShift = Offset(0, 200);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierPositioner =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: reasonableTextField.center,
),
);
await showMagnifier(context, tester, magnifierPositioner);
// New position has a vertical shift.
magnifierPositioner.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField.shift(verticalShift),
fieldBounds: Rect.fromPoints(reasonableTextField.topLeft,
reasonableTextField.bottomRight + verticalShift),
caretRect: reasonableTextField.shift(verticalShift),
globalGesturePosition: reasonableTextField.center + verticalShift,
);
await tester.pump();
expect(getIsAnimated(tester), true);
});
testWidgets('should stop being animated when timer is up',
(WidgetTester tester) async {
const Offset verticalShift = Offset(0, 200);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
final ValueNotifier<MagnifierOverlayInfoBearer> magnifierPositioner =
ValueNotifier<MagnifierOverlayInfoBearer>(
MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField,
fieldBounds: reasonableTextField,
caretRect: reasonableTextField,
globalGesturePosition: reasonableTextField.center,
),
);
await showMagnifier(context, tester, magnifierPositioner);
// New position has a vertical shift.
magnifierPositioner.value = MagnifierOverlayInfoBearer(
currentLineBoundries: reasonableTextField.shift(verticalShift),
fieldBounds: Rect.fromPoints(reasonableTextField.topLeft,
reasonableTextField.bottomRight + verticalShift),
caretRect: reasonableTextField.shift(verticalShift),
globalGesturePosition: reasonableTextField.center + verticalShift,
);
await tester.pump();
expect(getIsAnimated(tester), true);
await tester.pump(TextMagnifier.jumpBetweenLinesAnimationDuration +
const Duration(seconds: 2));
expect(getIsAnimated(tester), false);
});
});
});
}
...@@ -11785,6 +11785,170 @@ void main() { ...@@ -11785,6 +11785,170 @@ void main() {
expect(controller.selection.extentOffset, 5); expect(controller.selection.extentOffset, 5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}); });
group('magnifier builder', () {
testWidgets('should build custom magnifier if given',
(WidgetTester tester) async {
final Widget customMagnifier = Container(
key: UniqueKey(),
);
final TextField textField = TextField(
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder: (_, __, ___) => customMagnifier,
),
);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
expect(
textField.magnifierConfiguration!.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isA<Widget>().having(
(Widget widget) => widget.key,
'built magnifier key equal to passed in magnifier key',
equals(customMagnifier.key)));
});
group('defaults', () {
testWidgets('should build Magnifier on Android', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Scaffold(body: TextField()))
);
final BuildContext context = tester.firstElement(find.byType(TextField));
final EditableText editableText = tester.widget(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isA<TextMagnifier>());
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('should build CupertinoMagnifier on iOS',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Scaffold(body: TextField()))
);
final BuildContext context = tester.firstElement(find.byType(TextField));
final EditableText editableText = tester.widget(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isA<CupertinoTextMagnifier>());
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('should build nothing on Android and iOS',
(WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Scaffold(body: TextField()))
);
final BuildContext context = tester.firstElement(find.byType(TextField));
final EditableText editableText = tester.widget(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(
const MagnifierOverlayInfoBearer.empty(),
)),
isNull);
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.android
}));
});
});
group('magnifier', () {
late ValueNotifier<MagnifierOverlayInfoBearer> infoBearer;
final Widget fakeMagnifier = Container(key: UniqueKey());
testWidgets(
'Can drag handles to show, unshow, and update magnifier',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder: (
_,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer> localInfoBearer
) {
infoBearer = localInfoBearer;
return fakeMagnifier;
},
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// Double tap the 'e' to select 'def'.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
final TextSelection selection = controller.selection;
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
// Drag the right handle 2 letters to the right.
final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0);
final TestGesture gesture = await tester.startGesture(handlePos);
await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2));
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
final Offset firstDragGesturePosition = infoBearer.value.globalGesturePosition;
await gesture.moveTo(textOffsetToPosition(tester, testValue.length));
await tester.pump();
// Expect the position the magnifier gets to have moved.
expect(firstDragGesturePosition,
isNot(infoBearer.value.globalGesturePosition));
await gesture.up();
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
});
group('TapRegion integration', () { group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
...@@ -12001,8 +12165,9 @@ void main() { ...@@ -12001,8 +12165,9 @@ void main() {
case PointerDeviceKind.unknown: case PointerDeviceKind.unknown:
expect(focusNode.hasPrimaryFocus, isFalse); expect(focusNode.hasPrimaryFocus, isFalse);
break; break;
} }
}, variant: TargetPlatformVariant.all()); }, variant: TargetPlatformVariant.all());
} }
});
}); });
} }
...@@ -12563,6 +12563,40 @@ void main() { ...@@ -12563,6 +12563,40 @@ void main() {
); );
}); });
}); });
group('magnifier', () {
testWidgets('should build nothing by default', (WidgetTester tester) async {
final EditableText editableText = EditableText(
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: FocusNode(),
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
selectionControls: materialTextSelectionControls,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
);
await tester.pumpWidget(
MaterialApp(
home: editableText,
),
);
final BuildContext context = tester.firstElement(find.byType(EditableText));
expect(
editableText.magnifierConfiguration.magnifierBuilder(
context,
MagnifierController(),
ValueNotifier<MagnifierOverlayInfoBearer>(const MagnifierOverlayInfoBearer.empty())
),
isNull
);
});
});
} }
class UnsettableController extends TextEditingController { class UnsettableController extends TextEditingController {
......
// 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.
@Tags(<String>['reduced-test-set'])
import 'package:fake_async/fake_async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class _MockAnimationController extends AnimationController {
_MockAnimationController()
: super(duration: const Duration(minutes: 1), vsync: const TestVSync());
int forwardCalls = 0;
int reverseCalls = 0;
@override
TickerFuture forward({double? from}) {
forwardCalls++;
return super.forward(from: from);
}
@override
TickerFuture reverse({double? from}) {
reverseCalls++;
return super.reverse(from: from);
}
}
void main() {
Future<T> runFakeAsync<T>(Future<T> Function(FakeAsync time) f) async {
return FakeAsync().run((FakeAsync time) async {
bool pump = true;
final Future<T> future = f(time).whenComplete(() => pump = false);
while (pump) {
time.flushMicrotasks();
}
return future;
});
}
group('Raw Magnifier', () {
testWidgets('should render with correct focal point and decoration',
(WidgetTester tester) async {
final Key appKey = UniqueKey();
const Size magnifierSize = Size(100, 100);
const Offset magnifierFocalPoint = Offset(50, 50);
const Offset magnifierPosition = Offset(200, 200);
const double magnificationScale = 2;
await tester.pumpWidget(MaterialApp(
key: appKey,
home: Container(
color: Colors.orange,
width: double.infinity,
height: double.infinity,
child: Stack(
children: <Widget>[
Positioned(
// Positioned so that it is right in the center of the magnifier
// focal point.
left: magnifierPosition.dx + magnifierFocalPoint.dx,
top: magnifierPosition.dy + magnifierFocalPoint.dy,
child: Container(
color: Colors.pink,
// Since it is the size of the magnifier but over its
// magnificationScale, it should take up the whole magnifier.
width: (magnifierSize.width * 1.5) / magnificationScale,
height: (magnifierSize.height * 1.5) / magnificationScale,
),
),
Positioned(
left: magnifierPosition.dx,
top: magnifierPosition.dy,
child: const RawMagnifier(
size: magnifierSize,
focalPointOffset: magnifierFocalPoint,
magnificationScale: magnificationScale,
decoration: MagnifierDecoration(shadows: <BoxShadow>[
BoxShadow(
spreadRadius: 10,
blurRadius: 10,
color: Colors.green,
offset: Offset(5, 5),
),
]),
),
),
],
),
)));
await tester.pumpAndSettle();
// Should look like an orange screen, with two pink boxes.
// One pink box is in the magnifier (so has a green shadow) and is double
// size (from magnification). Also, the magnifier should be slightly orange
// since it has opacity.
await expectLater(
find.byKey(appKey),
matchesGoldenFile('widgets.magnifier.styled.png'),
);
}, skip: kIsWeb); // [intended] Bdf does not display on web.
group('transition states', () {
final AnimationController animationController = AnimationController(
vsync: const TestVSync(), duration: const Duration(minutes: 2));
final MagnifierController magnifierController = MagnifierController();
tearDown(() {
animationController.value = 0;
magnifierController.hide();
magnifierController.removeFromOverlay();
});
testWidgets(
'should immediately remove from overlay on no animation controller',
(WidgetTester tester) async {
await runFakeAsync((FakeAsync async) async {
const RawMagnifier testMagnifier = RawMagnifier(
size: Size(100, 100),
);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
magnifierController.show(
context: context,
builder: (BuildContext context) => testMagnifier,
);
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
expect(magnifierController.overlayEntry, isNot(isNull));
magnifierController.hide();
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
expect(magnifierController.overlayEntry, isNull);
});
});
testWidgets('should update shown based on animation status',
(WidgetTester tester) async {
await runFakeAsync((FakeAsync async) async {
final MagnifierController magnifierController =
MagnifierController(animationController: animationController);
const RawMagnifier testMagnifier = RawMagnifier(
size: Size(100, 100),
);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
magnifierController.show(
context: context,
builder: (BuildContext context) => testMagnifier,
);
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
// No time has passed, so the animation controller has not completed.
expect(magnifierController.animationController?.status,
AnimationStatus.forward);
expect(magnifierController.shown, true);
async.elapse(animationController.duration!);
await tester.pumpAndSettle();
expect(magnifierController.animationController?.status,
AnimationStatus.completed);
expect(magnifierController.shown, true);
magnifierController.hide();
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
expect(magnifierController.animationController?.status,
AnimationStatus.reverse);
expect(magnifierController.shown, false);
async.elapse(animationController.duration!);
await tester.pumpAndSettle();
expect(magnifierController.animationController?.status,
AnimationStatus.dismissed);
expect(magnifierController.shown, false);
});
});
});
});
group('magnifier controller', () {
final MagnifierController magnifierController = MagnifierController();
tearDown(() {
magnifierController.removeFromOverlay();
});
group('show', () {
testWidgets('should insert below below widget', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Text('text'),
));
final BuildContext context = tester.firstElement(find.byType(Text));
final Widget fakeMagnifier = Placeholder(key: UniqueKey());
final Widget fakeBefore = Placeholder(key: UniqueKey());
final OverlayEntry fakeBeforeOverlayEntry =
OverlayEntry(builder: (_) => fakeBefore);
Overlay.of(context)!.insert(fakeBeforeOverlayEntry);
magnifierController.show(
context: context,
builder: (_) => fakeMagnifier,
below: fakeBeforeOverlayEntry);
WidgetsBinding.instance.scheduleFrame();
await tester.pumpAndSettle();
final Iterable<Element> allOverlayChildren = find
.descendant(
of: find.byType(Overlay), matching: find.byType(Placeholder))
.evaluate();
// Expect the magnifier to be the first child, even though it was inserted
// after the fakeBefore.
expect(allOverlayChildren.last.widget.key, fakeBefore.key);
expect(allOverlayChildren.first.widget.key, fakeMagnifier.key);
});
testWidgets('should insert newly built widget without animating out if overlay != null',
(WidgetTester tester) async {
await runFakeAsync((FakeAsync async) async {
final _MockAnimationController animationController =
_MockAnimationController();
const RawMagnifier testMagnifier = RawMagnifier(
size: Size(100, 100),
);
const RawMagnifier testMagnifier2 = RawMagnifier(
size: Size(100, 100),
);
await tester.pumpWidget(const MaterialApp(
home: Placeholder(),
));
final BuildContext context =
tester.firstElement(find.byType(Placeholder));
magnifierController.show(
context: context,
builder: (BuildContext context) => testMagnifier,
);
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
async.elapse(animationController.duration!);
await tester.pumpAndSettle();
magnifierController.show(context: context, builder: (_) => testMagnifier2);
WidgetsBinding.instance.scheduleFrame();
await tester.pump();
expect(animationController.reverseCalls, 0,
reason:
'should not have called reverse on animation controller due to force remove');
expect(find.byWidget(testMagnifier2), findsOneWidget);
});
});
});
group('shift within bounds', () {
final List<Rect> boundsRects = <Rect>[
const Rect.fromLTRB(0, 0, 100, 100),
const Rect.fromLTRB(0, 0, 100, 100),
const Rect.fromLTRB(0, 0, 100, 100),
const Rect.fromLTRB(0, 0, 100, 100),
];
final List<Rect> inputRects = <Rect>[
const Rect.fromLTRB(-100, -100, -80, -80),
const Rect.fromLTRB(0, 0, 20, 20),
const Rect.fromLTRB(110, 0, 120, 10),
const Rect.fromLTRB(110, 110, 120, 120)
];
final List<Rect> outputRects = <Rect>[
const Rect.fromLTRB(0, 0, 20, 20),
const Rect.fromLTRB(0, 0, 20, 20),
const Rect.fromLTRB(90, 0, 100, 10),
const Rect.fromLTRB(90, 90, 100, 100)
];
for (int i = 0; i < boundsRects.length; i++) {
test(
'should shift ${inputRects[i]} to ${outputRects[i]} for bounds ${boundsRects[i]}',
() {
final Rect outputRect = MagnifierController.shiftWithinBounds(
bounds: boundsRects[i], rect: inputRects[i]);
expect(outputRect, outputRects[i]);
});
}
});
});
}
...@@ -1099,6 +1099,74 @@ void main() { ...@@ -1099,6 +1099,74 @@ void main() {
final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>; final Map<String, dynamic> clipboardData = mockClipboard.clipboardData as Map<String, dynamic>;
expect(clipboardData['text'], 'thank'); expect(clipboardData['text'], 'thank');
}, skip: kIsWeb); // [intended] Web uses its native context menu. }, skip: kIsWeb); // [intended] Web uses its native context menu.
group('magnifier', () {
late ValueNotifier<MagnifierOverlayInfoBearer> infoBearer;
final Widget fakeMagnifier = Container(key: UniqueKey());
testWidgets('Can drag handles to show, unshow, and update magnifier',
(WidgetTester tester) async {
const String text = 'Monkies and rabbits in my soup';
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder: (_,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer>
localInfoBearer) {
infoBearer = localInfoBearer;
return fakeMagnifier;
},
),
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Text(text),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.text(text), matching: find.byType(RichText)));
// Show the selection handles.
final TestGesture activateSelectionGesture = await tester
.startGesture(textOffsetToPosition(paragraph, text.length ~/ 2));
addTearDown(activateSelectionGesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
await activateSelectionGesture.up();
await tester.pump(const Duration(milliseconds: 500));
// Drag the handle around so that the magnifier shows.
final TextBox selectionBox =
paragraph.getBoxesForSelection(paragraph.selections.first).first;
final Offset leftHandlePos =
globalize(selectionBox.toRect().bottomLeft, paragraph);
final TestGesture gesture = await tester.startGesture(leftHandlePos);
await gesture.moveTo(textOffsetToPosition(paragraph, text.length - 2));
await tester.pump();
// Expect the magnifier to show and then store it's position.
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
final Offset firstDragGesturePosition =
infoBearer.value.globalGesturePosition;
await gesture.moveTo(textOffsetToPosition(paragraph, text.length));
await tester.pump();
// Expect the position the magnifier gets to have moved.
expect(firstDragGesturePosition,
isNot(infoBearer.value.globalGesturePosition));
// Lift the pointer and expect the magnifier to disappear.
await gesture.up();
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
});
});
}); });
testWidgets('toolbar is hidden on mobile when orientation changes', (WidgetTester tester) async { testWidgets('toolbar is hidden on mobile when orientation changes', (WidgetTester tester) async {
......
...@@ -5152,6 +5152,79 @@ void main() { ...@@ -5152,6 +5152,79 @@ void main() {
expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget); expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget);
}); });
group('magnifier', () {
late ValueNotifier<MagnifierOverlayInfoBearer> infoBearer;
final Widget fakeMagnifier = Container(key: UniqueKey());
testWidgets(
'Can drag handles to show, unshow, and update magnifier',
(WidgetTester tester) async {
const String testValue = 'abc def ghi';
final SelectableText selectableText = SelectableText(
testValue,
magnifierConfiguration: TextMagnifierConfiguration(
magnifierBuilder: (
_,
MagnifierController controller,
ValueNotifier<MagnifierOverlayInfoBearer> localInfoBearer
) {
infoBearer = localInfoBearer;
return fakeMagnifier;
},
)
);
await tester.pumpWidget(
overlay(
child: selectableText,
),
);
await skipPastScrollingAnimation(tester);
// Double tap the 'e' to select 'def'.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pump(const Duration(milliseconds: 30));
final TextSelection selection = TextSelection(
baseOffset: testValue.indexOf('d'),
extentOffset: testValue.indexOf('f')
);
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
// Drag the right handle 2 letters to the right.
final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0);
final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
Offset? firstDragGesturePosition;
await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2));
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
firstDragGesturePosition = infoBearer.value.globalGesturePosition;
await gesture.moveTo(textOffsetToPosition(tester, testValue.length));
await tester.pump();
// Expect the position the magnifier gets to have moved.
expect(firstDragGesturePosition,
isNot(infoBearer.value.globalGesturePosition));
await gesture.up();
await tester.pump();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
});
});
testWidgets('SelectableText text span style is merged with default text style', (WidgetTester tester) async { testWidgets('SelectableText text span style is merged with default text style', (WidgetTester tester) async {
// This is a regression test for https://github.com/flutter/flutter/issues/71389 // This is a regression test for https://github.com/flutter/flutter/issues/71389
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment