Unverified Commit ad946f84 authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Add ability to show magnifier on long press (#111224)

parent d339517b
...@@ -65,7 +65,9 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete ...@@ -65,7 +65,9 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
@override @override
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.selectionEnabled) { if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) { final TargetPlatform targetPlatform = Theme.of(_state.context).platform;
switch (targetPlatform) {
case TargetPlatform.iOS: case TargetPlatform.iOS:
case TargetPlatform.macOS: case TargetPlatform.macOS:
renderEditable.selectPositionAt( renderEditable.selectPositionAt(
...@@ -84,6 +86,18 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete ...@@ -84,6 +86,18 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
); );
break; break;
} }
switch (targetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
} }
} }
...@@ -97,7 +111,9 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete ...@@ -97,7 +111,9 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
@override @override
void onSingleLongTapStart(LongPressStartDetails details) { void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.selectionEnabled) { if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) { final TargetPlatform targetPlatform = Theme.of(_state.context).platform;
switch (targetPlatform) {
case TargetPlatform.iOS: case TargetPlatform.iOS:
case TargetPlatform.macOS: case TargetPlatform.macOS:
renderEditable.selectPositionAt( renderEditable.selectPositionAt(
...@@ -113,6 +129,18 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete ...@@ -113,6 +129,18 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
Feedback.forLongPress(_state.context); Feedback.forLongPress(_state.context);
break; break;
} }
switch (targetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
} }
} }
} }
......
...@@ -3324,6 +3324,37 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3324,6 +3324,37 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
/// Shows the magnifier at the position given by `positionToShow`,
/// if there is no magnifier visible.
///
/// Updates the magnifier to the position given by `positionToShow`,
/// if there is a magnifier visible.
///
/// Does nothing if a magnifier couldn't be shown, such as when the selection
/// overlay does not currently exist.
void showMagnifier(Offset positionToShow) {
if (_selectionOverlay == null) {
return;
}
if (_selectionOverlay!.magnifierIsVisible) {
_selectionOverlay!.updateMagnifier(positionToShow);
} else {
_selectionOverlay!.showMagnifier(positionToShow);
}
}
/// Hides the magnifier if it is visible.
void hideMagnifier({required bool shouldShowToolbar}) {
if (_selectionOverlay == null) {
return;
}
if (_selectionOverlay!.magnifierIsVisible) {
_selectionOverlay!.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
}
}
// Tracks the location a [_ScribblePlaceholder] should be rendered in the // Tracks the location a [_ScribblePlaceholder] should be rendered in the
// text. // text.
// //
......
...@@ -408,6 +408,37 @@ class TextSelectionOverlay { ...@@ -408,6 +408,37 @@ class TextSelectionOverlay {
_selectionOverlay.showToolbar(); _selectionOverlay.showToolbar();
} }
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
void showMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow);
_updateSelectionOverlay();
_selectionOverlay.showMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: positionToShow,
renderEditable: renderObject,
),
);
}
/// {@macro flutter.widgets.SelectionOverlay.updateMagnifier}
void updateMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow);
_updateSelectionOverlay();
_selectionOverlay.updateMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: positionToShow,
renderEditable: renderObject,
),
);
}
/// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
void hideMagnifier({required bool shouldShowToolbar}) {
_selectionOverlay.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
}
/// Updates the overlay after the selection has changed. /// Updates the overlay after the selection has changed.
/// ///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is /// If this method is called while the [SchedulerBinding.schedulerPhase] is
...@@ -457,6 +488,9 @@ class TextSelectionOverlay { ...@@ -457,6 +488,9 @@ class TextSelectionOverlay {
/// Whether the toolbar is currently visible. /// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _selectionOverlay._toolbar != null; bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
/// Whether the magnifier is currently visible.
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
/// {@macro flutter.widgets.SelectionOverlay.hide} /// {@macro flutter.widgets.SelectionOverlay.hide}
void hide() => _selectionOverlay.hide(); void hide() => _selectionOverlay.hide();
...@@ -554,11 +588,13 @@ class TextSelectionOverlay { ...@@ -554,11 +588,13 @@ class TextSelectionOverlay {
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height); _dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
_selectionOverlay.showMagnifier(_buildMagnifier( _selectionOverlay.showMagnifier(
currentTextPosition: position, _buildMagnifier(
globalGesturePosition: details.globalPosition, currentTextPosition: position,
renderEditable: renderObject, globalGesturePosition: details.globalPosition,
)); renderEditable: renderObject,
),
);
} }
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
...@@ -629,11 +665,13 @@ class TextSelectionOverlay { ...@@ -629,11 +665,13 @@ class TextSelectionOverlay {
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height); _dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
_selectionOverlay.showMagnifier(_buildMagnifier( _selectionOverlay.showMagnifier(
currentTextPosition: position, _buildMagnifier(
globalGesturePosition: details.globalPosition, currentTextPosition: position,
renderEditable: renderObject, globalGesturePosition: details.globalPosition,
)); renderEditable: renderObject,
),
);
} }
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
...@@ -788,6 +826,7 @@ class SelectionOverlay { ...@@ -788,6 +826,7 @@ class SelectionOverlay {
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration; final TextMagnifierConfiguration magnifierConfiguration;
/// {@template flutter.widgets.SelectionOverlay.showMagnifier}
/// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier] /// 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 /// was called. This is safe to call on platforms not mobile, since
/// a magnifierBuilder will not be provided, or the magnifierBuilder will return null /// a magnifierBuilder will not be provided, or the magnifierBuilder will return null
...@@ -796,6 +835,7 @@ class SelectionOverlay { ...@@ -796,6 +835,7 @@ class SelectionOverlay {
/// This is NOT the source of truth for if the magnifier is up or not, /// This is NOT the source of truth for if the magnifier is up or not,
/// since magnifiers may hide themselves. If this info is needed, check /// since magnifiers may hide themselves. If this info is needed, check
/// [MagnifierController.shown]. /// [MagnifierController.shown].
/// {@endtemplate}
void showMagnifier(MagnifierOverlayInfoBearer initialInfoBearer) { void showMagnifier(MagnifierOverlayInfoBearer initialInfoBearer) {
if (_toolbar != null) { if (_toolbar != null) {
hideToolbar(); hideToolbar();
...@@ -813,7 +853,7 @@ class SelectionOverlay { ...@@ -813,7 +853,7 @@ class SelectionOverlay {
_magnifierOverlayInfoBearer, _magnifierOverlayInfoBearer,
); );
if (builtMagnifier == null) { if (builtMagnifier == null || _handles == null) {
return; return;
} }
...@@ -825,10 +865,12 @@ class SelectionOverlay { ...@@ -825,10 +865,12 @@ class SelectionOverlay {
builder: (_) => builtMagnifier); builder: (_) => builtMagnifier);
} }
/// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
/// Hide the current magnifier, optionally immediately showing /// Hide the current magnifier, optionally immediately showing
/// the toolbar. /// the toolbar.
/// ///
/// This does nothing if there is no magnifier. /// This does nothing if there is no magnifier.
/// {@endtemplate}
void hideMagnifier({required bool shouldShowToolbar}) { void hideMagnifier({required bool shouldShowToolbar}) {
// This cannot be a check on `MagnifierController.shown`, since // This cannot be a check on `MagnifierController.shown`, since
// it's possible that the magnifier is still in the overlay, but // it's possible that the magnifier is still in the overlay, but
...@@ -1250,6 +1292,7 @@ class SelectionOverlay { ...@@ -1250,6 +1292,7 @@ class SelectionOverlay {
); );
} }
/// {@template flutter.widgets.SelectionOverlay.updateMagnifier}
/// Update the current magnifier with new selection data, so the magnifier /// Update the current magnifier with new selection data, so the magnifier
/// can respond accordingly. /// can respond accordingly.
/// ///
...@@ -1258,6 +1301,7 @@ class SelectionOverlay { ...@@ -1258,6 +1301,7 @@ class SelectionOverlay {
/// itself. /// itself.
/// ///
/// If there is no magnifier in the overlay, this does nothing, /// If there is no magnifier in the overlay, this does nothing,
/// {@endtemplate}
void updateMagnifier(MagnifierOverlayInfoBearer magnifierOverlayInfoBearer) { void updateMagnifier(MagnifierOverlayInfoBearer magnifierOverlayInfoBearer) {
if (_magnifierController.overlayEntry == null) { if (_magnifierController.overlayEntry == null) {
return; return;
...@@ -1919,6 +1963,18 @@ class TextSelectionGestureDetectorBuilder { ...@@ -1919,6 +1963,18 @@ class TextSelectionGestureDetectorBuilder {
from: details.globalPosition, from: details.globalPosition,
cause: SelectionChangedCause.longPress, cause: SelectionChangedCause.longPress,
); );
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
} }
} }
...@@ -1938,6 +1994,18 @@ class TextSelectionGestureDetectorBuilder { ...@@ -1938,6 +1994,18 @@ class TextSelectionGestureDetectorBuilder {
from: details.globalPosition, from: details.globalPosition,
cause: SelectionChangedCause.longPress, cause: SelectionChangedCause.longPress,
); );
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
} }
} }
...@@ -1951,6 +2019,17 @@ class TextSelectionGestureDetectorBuilder { ...@@ -1951,6 +2019,17 @@ class TextSelectionGestureDetectorBuilder {
/// callback. /// callback.
@protected @protected
void onSingleLongTapEnd(LongPressEndDetails details) { void onSingleLongTapEnd(LongPressEndDetails details) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.hideMagnifier(shouldShowToolbar: false);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
if (shouldShowSelectionToolbar) { if (shouldShowSelectionToolbar) {
editableText.showToolbar(); editableText.showToolbar();
} }
......
...@@ -6178,6 +6178,66 @@ void main() { ...@@ -6178,6 +6178,66 @@ void main() {
expect(find.byKey(fakeMagnifier.key!), findsNothing); expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('Can long press to show, unshow, and update magnifier', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android;
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: 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);
await tester.pumpAndSettle();
// Tap at 'e' to set the selection to position 5 on Android.
// Tap at 'e' to set the selection to the closest word edge, which is position 4 on iOS.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 4);
expect(find.byKey(fakeMagnifier.key!), findsNothing);
// Long press the 'e' to move the cursor in front of the 'e' and show the magnifier.
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
expect(controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset, 5);
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
final Offset firstLongPressGesturePosition = infoBearer.value.globalGesturePosition;
// Move the gesture to 'h' to update the magnifier and move the cursor to 'h'.
await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h')));
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 9);
expect(controller.selection.extentOffset, 9);
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
// Expect the position the magnifier gets to have moved.
expect(firstLongPressGesturePosition, isNot(infoBearer.value.globalGesturePosition));
// End the long press to hide the magnifier.
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, 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');
......
...@@ -12254,6 +12254,70 @@ void main() { ...@@ -12254,6 +12254,70 @@ void main() {
expect(find.byKey(fakeMagnifier.key!), findsNothing); expect(find.byKey(fakeMagnifier.key!), findsNothing);
}); });
testWidgets('Can long press to show, unshow, and update magnifier', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
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);
// Tap at 'e' to set the selection to position 5 on Android.
// Tap at 'e' to set the selection to the closest word edge, which is position 4 on iOS.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 4);
expect(find.byKey(fakeMagnifier.key!), findsNothing);
// Long press the 'e' to select 'def' on Android and show magnifier.
// Long press the 'e' to move the cursor in front of the 'e' on iOS and show the magnifier.
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, testValue.indexOf('e')));
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 4 : 5);
expect(controller.selection.extentOffset, isTargetPlatformAndroid ? 7 : 5);
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
final Offset firstLongPressGesturePosition = infoBearer.value.globalGesturePosition;
// Move the gesture to 'h' on Android to update the magnifier and select 'ghi'.
// Move the gesture to 'h' on iOS to update the magnifier and move the cursor to 'h'.
await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h')));
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 4 : 9);
expect(controller.selection.extentOffset, isTargetPlatformAndroid ? 11 : 9);
expect(find.byKey(fakeMagnifier.key!), findsOneWidget);
// Expect the position the magnifier gets to have moved.
expect(firstLongPressGesturePosition, isNot(infoBearer.value.globalGesturePosition));
// End the long press to hide the magnifier.
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, 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');
......
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