Skip to content

Commit f1042e5

Browse files
Renzo-OlivaresRenzo Olivares
authored andcommitted
Hide toolbar when selection is out of view (flutter#98152)
* Hide toolbar when selection is out of view * properly dispose of toolbar visibility listener * Add test * rename toolbarvisibility * Make visibility for toolbar nullable * Properly dispose of toolbar visibility listener * Merge visibility methods into one * properly dispose of start selection view listener * Add some docs * remove unnecessary null check * more docs * Update dispose order Co-authored-by: Renzo Olivares <[email protected]>
1 parent 0f34b68 commit f1042e5

2 files changed

Lines changed: 200 additions & 34 deletions

File tree

packages/flutter/lib/src/widgets/text_selection.dart

Lines changed: 131 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,9 @@ class TextSelectionOverlay {
268268
assert(handlesVisible != null),
269269
_handlesVisible = handlesVisible,
270270
_value = value {
271-
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities);
272-
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities);
273-
_updateHandleVisibilities();
271+
renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
272+
renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
273+
_updateTextSelectionOverlayVisibilities();
274274
_selectionOverlay = SelectionOverlay(
275275
context: context,
276276
debugRequiredFor: debugRequiredFor,
@@ -285,6 +285,7 @@ class TextSelectionOverlay {
285285
lineHeightAtEnd: 0.0,
286286
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
287287
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
288+
toolbarVisible: _effectiveToolbarVisibility,
288289
selectionEndPoints: const <TextSelectionPoint>[],
289290
selectionControls: selectionControls,
290291
selectionDelegate: selectionDelegate,
@@ -321,9 +322,11 @@ class TextSelectionOverlay {
321322

322323
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
323324
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
324-
void _updateHandleVisibilities() {
325+
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
326+
void _updateTextSelectionOverlayVisibilities() {
325327
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
326328
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
329+
_effectiveToolbarVisibility.value = renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
327330
}
328331

329332
/// Whether selection handles are visible.
@@ -339,7 +342,7 @@ class TextSelectionOverlay {
339342
if (_handlesVisible == visible)
340343
return;
341344
_handlesVisible = visible;
342-
_updateHandleVisibilities();
345+
_updateTextSelectionOverlayVisibilities();
343346
}
344347

345348
/// {@macro flutter.widgets.SelectionOverlay.showHandles}
@@ -413,9 +416,12 @@ class TextSelectionOverlay {
413416

414417
/// {@macro flutter.widgets.SelectionOverlay.dispose}
415418
void dispose() {
416-
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
417-
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
418419
_selectionOverlay.dispose();
420+
renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
421+
renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
422+
_effectiveToolbarVisibility.dispose();
423+
_effectiveStartHandleVisibility.dispose();
424+
_effectiveEndHandleVisibility.dispose();
419425
}
420426

421427
double _getStartGlyphHeight() {
@@ -562,6 +568,7 @@ class SelectionOverlay {
562568
this.onEndHandleDragStart,
563569
this.onEndHandleDragUpdate,
564570
this.onEndHandleDragEnd,
571+
this.toolbarVisible,
565572
required List<TextSelectionPoint> selectionEndPoints,
566573
required this.selectionControls,
567574
required this.selectionDelegate,
@@ -585,7 +592,6 @@ class SelectionOverlay {
585592
'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your '
586593
'app content was created above the Navigator with the WidgetsApp builder parameter.',
587594
);
588-
_toolbarController = AnimationController(duration: fadeDuration, vsync: overlay!);
589595
}
590596

591597
/// The context in which the selection handles should appear.
@@ -682,6 +688,14 @@ class SelectionOverlay {
682688
/// handles.
683689
final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
684690

691+
/// Whether the toolbar is visible.
692+
///
693+
/// If the value changes, the toolbar uses [FadeTransition] to transition
694+
/// itself on and off the screen.
695+
///
696+
/// If this is null the toolbar will always be visible.
697+
final ValueListenable<bool>? toolbarVisible;
698+
685699
/// The text selection positions of selection start and end.
686700
List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints;
687701
List<TextSelectionPoint> _selectionEndPoints;
@@ -780,9 +794,6 @@ class SelectionOverlay {
780794
/// Controls the fade-in and fade-out animations for the toolbar and handles.
781795
static const Duration fadeDuration = Duration(milliseconds: 150);
782796

783-
late final AnimationController _toolbarController;
784-
Animation<double> get _toolbarOpacity => _toolbarController.view;
785-
786797
/// A pair of handles. If this is non-null, there are always 2, though the
787798
/// second is hidden when the selection is collapsed.
788799
List<OverlayEntry>? _handles;
@@ -826,7 +837,6 @@ class SelectionOverlay {
826837
}
827838
_toolbar = OverlayEntry(builder: _buildToolbar);
828839
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!);
829-
_toolbarController.forward(from: 0.0);
830840
}
831841

832842
bool _buildScheduled = false;
@@ -878,7 +888,6 @@ class SelectionOverlay {
878888
void hideToolbar() {
879889
if (_toolbar == null)
880890
return;
881-
_toolbarController.stop();
882891
_toolbar?.remove();
883892
_toolbar = null;
884893
}
@@ -888,7 +897,6 @@ class SelectionOverlay {
888897
/// {@endtemplate}
889898
void dispose() {
890899
hide();
891-
_toolbarController.dispose();
892900
}
893901

894902
Widget _buildStartHandle(BuildContext context) {
@@ -967,26 +975,115 @@ class SelectionOverlay {
967975

968976
return Directionality(
969977
textDirection: Directionality.of(this.context),
970-
child: FadeTransition(
971-
opacity: _toolbarOpacity,
972-
child: CompositedTransformFollower(
973-
link: toolbarLayerLink,
974-
showWhenUnlinked: false,
975-
offset: -editingRegion.topLeft,
976-
child: Builder(
977-
builder: (BuildContext context) {
978-
return selectionControls!.buildToolbar(
979-
context,
980-
editingRegion,
981-
lineHeightAtStart,
982-
midpoint,
983-
selectionEndPoints,
984-
selectionDelegate,
985-
clipboardStatus!,
986-
toolbarLocation,
987-
);
988-
},
989-
),
978+
child: _SelectionToolbarOverlay(
979+
preferredLineHeight: lineHeightAtStart,
980+
toolbarLocation: toolbarLocation,
981+
layerLink: toolbarLayerLink,
982+
editingRegion: editingRegion,
983+
selectionControls: selectionControls,
984+
midpoint: midpoint,
985+
selectionEndpoints: selectionEndPoints,
986+
visibility: toolbarVisible,
987+
selectionDelegate: selectionDelegate,
988+
clipboardStatus: clipboardStatus,
989+
),
990+
);
991+
}
992+
}
993+
994+
/// This widget represents a selection toolbar.
995+
class _SelectionToolbarOverlay extends StatefulWidget {
996+
/// Creates a toolbar overlay.
997+
const _SelectionToolbarOverlay({
998+
Key? key,
999+
required this.preferredLineHeight,
1000+
required this.toolbarLocation,
1001+
required this.layerLink,
1002+
required this.editingRegion,
1003+
required this.selectionControls,
1004+
this.visibility,
1005+
required this.midpoint,
1006+
required this.selectionEndpoints,
1007+
required this.selectionDelegate,
1008+
required this.clipboardStatus,
1009+
}) : super(key: key);
1010+
1011+
final double preferredLineHeight;
1012+
final Offset? toolbarLocation;
1013+
final LayerLink layerLink;
1014+
final Rect editingRegion;
1015+
final TextSelectionControls? selectionControls;
1016+
final ValueListenable<bool>? visibility;
1017+
final Offset midpoint;
1018+
final List<TextSelectionPoint> selectionEndpoints;
1019+
final TextSelectionDelegate? selectionDelegate;
1020+
final ClipboardStatusNotifier? clipboardStatus;
1021+
1022+
@override
1023+
_SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
1024+
}
1025+
1026+
class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
1027+
late AnimationController _controller;
1028+
Animation<double> get _opacity => _controller.view;
1029+
1030+
@override
1031+
void initState() {
1032+
super.initState();
1033+
1034+
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
1035+
1036+
_toolbarVisibilityChanged();
1037+
widget.visibility?.addListener(_toolbarVisibilityChanged);
1038+
}
1039+
1040+
@override
1041+
void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
1042+
super.didUpdateWidget(oldWidget);
1043+
if (oldWidget.visibility == widget.visibility) {
1044+
return;
1045+
}
1046+
oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
1047+
_toolbarVisibilityChanged();
1048+
widget.visibility?.addListener(_toolbarVisibilityChanged);
1049+
}
1050+
1051+
@override
1052+
void dispose() {
1053+
widget.visibility?.removeListener(_toolbarVisibilityChanged);
1054+
_controller.dispose();
1055+
super.dispose();
1056+
}
1057+
1058+
void _toolbarVisibilityChanged() {
1059+
if (widget.visibility?.value != false) {
1060+
_controller.forward();
1061+
} else {
1062+
_controller.reverse();
1063+
}
1064+
}
1065+
1066+
@override
1067+
Widget build(BuildContext context) {
1068+
return FadeTransition(
1069+
opacity: _opacity,
1070+
child: CompositedTransformFollower(
1071+
link: widget.layerLink,
1072+
showWhenUnlinked: false,
1073+
offset: -widget.editingRegion.topLeft,
1074+
child: Builder(
1075+
builder: (BuildContext context) {
1076+
return widget.selectionControls!.buildToolbar(
1077+
context,
1078+
widget.editingRegion,
1079+
widget.preferredLineHeight,
1080+
widget.midpoint,
1081+
widget.selectionEndpoints,
1082+
widget.selectionDelegate!,
1083+
widget.clipboardStatus!,
1084+
widget.toolbarLocation,
1085+
);
1086+
},
9901087
),
9911088
),
9921089
);

packages/flutter/test/widgets/editable_text_test.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4697,6 +4697,75 @@ void main() {
46974697
expect(renderEditable.text!.style!.decoration, isNull);
46984698
});
46994699

4700+
testWidgets('text selection toolbar visibility', (WidgetTester tester) async {
4701+
const String testText = 'hello \n world \n this \n is \n text';
4702+
final TextEditingController controller = TextEditingController(text: testText);
4703+
4704+
await tester.pumpWidget(MaterialApp(
4705+
home: Align(
4706+
alignment: Alignment.topLeft,
4707+
child: Container(
4708+
height: 50,
4709+
color: Colors.white,
4710+
child: EditableText(
4711+
showSelectionHandles: true,
4712+
controller: controller,
4713+
focusNode: FocusNode(),
4714+
style: Typography.material2018().black.subtitle1!,
4715+
cursorColor: Colors.blue,
4716+
backgroundCursorColor: Colors.grey,
4717+
selectionControls: materialTextSelectionControls,
4718+
keyboardType: TextInputType.text,
4719+
selectionColor: Colors.lightBlueAccent,
4720+
maxLines: 3,
4721+
),
4722+
),
4723+
),
4724+
));
4725+
4726+
final EditableTextState state =
4727+
tester.state<EditableTextState>(find.byType(EditableText));
4728+
final RenderEditable renderEditable = state.renderEditable;
4729+
final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
4730+
4731+
// Select the first word. And show the toolbar.
4732+
await tester.tapAt(const Offset(20, 10));
4733+
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
4734+
expect(state.showToolbar(), true);
4735+
await tester.pumpAndSettle();
4736+
4737+
// Find the toolbar fade transition while the toolbar is still visible.
4738+
final List<FadeTransition> transitionsBefore = find.descendant(
4739+
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
4740+
matching: find.byType(FadeTransition),
4741+
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
4742+
4743+
expect(transitionsBefore.length, 1);
4744+
4745+
final FadeTransition toolbarBefore = transitionsBefore[0];
4746+
4747+
expect(toolbarBefore.opacity.value, 1.0);
4748+
4749+
// Scroll until the selection is no longer within view.
4750+
scrollable.controller!.jumpTo(50.0);
4751+
await tester.pumpAndSettle();
4752+
4753+
// Find the toolbar fade transition after the toolbar has been hidden.
4754+
final List<FadeTransition> transitionsAfter = find.descendant(
4755+
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
4756+
matching: find.byType(FadeTransition),
4757+
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
4758+
4759+
expect(transitionsAfter.length, 1);
4760+
4761+
final FadeTransition toolbarAfter = transitionsAfter[0];
4762+
4763+
expect(toolbarAfter.opacity.value, 0.0);
4764+
4765+
// On web, we don't show the Flutter toolbar and instead rely on the browser
4766+
// toolbar. Until we change that, this test should remain skipped.
4767+
}, skip: kIsWeb); // [intended]
4768+
47004769
testWidgets('text selection handle visibility', (WidgetTester tester) async {
47014770
// Text with two separate words to select.
47024771
const String testText = 'XXXXX XXXXX';

0 commit comments

Comments
 (0)