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

Hide toolbar when selection is out of view (#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: 's avatarRenzo Olivares <roliv@google.com>
parent a454da02
...@@ -268,9 +268,9 @@ class TextSelectionOverlay { ...@@ -268,9 +268,9 @@ class TextSelectionOverlay {
assert(handlesVisible != null), assert(handlesVisible != null),
_handlesVisible = handlesVisible, _handlesVisible = handlesVisible,
_value = value { _value = value {
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities); renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities); renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
_updateHandleVisibilities(); _updateTextSelectionOverlayVisibilities();
_selectionOverlay = SelectionOverlay( _selectionOverlay = SelectionOverlay(
context: context, context: context,
debugRequiredFor: debugRequiredFor, debugRequiredFor: debugRequiredFor,
...@@ -285,6 +285,7 @@ class TextSelectionOverlay { ...@@ -285,6 +285,7 @@ class TextSelectionOverlay {
lineHeightAtEnd: 0.0, lineHeightAtEnd: 0.0,
onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
toolbarVisible: _effectiveToolbarVisibility,
selectionEndPoints: const <TextSelectionPoint>[], selectionEndPoints: const <TextSelectionPoint>[],
selectionControls: selectionControls, selectionControls: selectionControls,
selectionDelegate: selectionDelegate, selectionDelegate: selectionDelegate,
...@@ -321,9 +322,11 @@ class TextSelectionOverlay { ...@@ -321,9 +322,11 @@ 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);
void _updateHandleVisibilities() { final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
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;
_effectiveToolbarVisibility.value = renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
} }
/// Whether selection handles are visible. /// Whether selection handles are visible.
...@@ -339,7 +342,7 @@ class TextSelectionOverlay { ...@@ -339,7 +342,7 @@ class TextSelectionOverlay {
if (_handlesVisible == visible) if (_handlesVisible == visible)
return; return;
_handlesVisible = visible; _handlesVisible = visible;
_updateHandleVisibilities(); _updateTextSelectionOverlayVisibilities();
} }
/// {@macro flutter.widgets.SelectionOverlay.showHandles} /// {@macro flutter.widgets.SelectionOverlay.showHandles}
...@@ -413,9 +416,12 @@ class TextSelectionOverlay { ...@@ -413,9 +416,12 @@ class TextSelectionOverlay {
/// {@macro flutter.widgets.SelectionOverlay.dispose} /// {@macro flutter.widgets.SelectionOverlay.dispose}
void dispose() { void dispose() {
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
_selectionOverlay.dispose(); _selectionOverlay.dispose();
renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
_effectiveToolbarVisibility.dispose();
_effectiveStartHandleVisibility.dispose();
_effectiveEndHandleVisibility.dispose();
} }
double _getStartGlyphHeight() { double _getStartGlyphHeight() {
...@@ -562,6 +568,7 @@ class SelectionOverlay { ...@@ -562,6 +568,7 @@ class SelectionOverlay {
this.onEndHandleDragStart, this.onEndHandleDragStart,
this.onEndHandleDragUpdate, this.onEndHandleDragUpdate,
this.onEndHandleDragEnd, this.onEndHandleDragEnd,
this.toolbarVisible,
required List<TextSelectionPoint> selectionEndPoints, required List<TextSelectionPoint> selectionEndPoints,
required this.selectionControls, required this.selectionControls,
required this.selectionDelegate, required this.selectionDelegate,
...@@ -585,7 +592,6 @@ class SelectionOverlay { ...@@ -585,7 +592,6 @@ class SelectionOverlay {
'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your ' 'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your '
'app content was created above the Navigator with the WidgetsApp builder parameter.', 'app content was created above the Navigator with the WidgetsApp builder parameter.',
); );
_toolbarController = AnimationController(duration: fadeDuration, vsync: overlay!);
} }
/// The context in which the selection handles should appear. /// The context in which the selection handles should appear.
...@@ -682,6 +688,14 @@ class SelectionOverlay { ...@@ -682,6 +688,14 @@ class SelectionOverlay {
/// handles. /// handles.
final ValueChanged<DragEndDetails>? onEndHandleDragEnd; final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
/// Whether the toolbar is visible.
///
/// If the value changes, the toolbar uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null the toolbar will always be visible.
final ValueListenable<bool>? toolbarVisible;
/// The text selection positions of selection start and end. /// The text selection positions of selection start and end.
List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints; List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints;
List<TextSelectionPoint> _selectionEndPoints; List<TextSelectionPoint> _selectionEndPoints;
...@@ -780,9 +794,6 @@ class SelectionOverlay { ...@@ -780,9 +794,6 @@ class SelectionOverlay {
/// Controls the fade-in and fade-out animations for the toolbar and handles. /// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150); static const Duration fadeDuration = Duration(milliseconds: 150);
late final AnimationController _toolbarController;
Animation<double> get _toolbarOpacity => _toolbarController.view;
/// A pair of handles. If this is non-null, there are always 2, though the /// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed. /// second is hidden when the selection is collapsed.
List<OverlayEntry>? _handles; List<OverlayEntry>? _handles;
...@@ -826,7 +837,6 @@ class SelectionOverlay { ...@@ -826,7 +837,6 @@ class SelectionOverlay {
} }
_toolbar = OverlayEntry(builder: _buildToolbar); _toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!); Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!);
_toolbarController.forward(from: 0.0);
} }
bool _buildScheduled = false; bool _buildScheduled = false;
...@@ -878,7 +888,6 @@ class SelectionOverlay { ...@@ -878,7 +888,6 @@ class SelectionOverlay {
void hideToolbar() { void hideToolbar() {
if (_toolbar == null) if (_toolbar == null)
return; return;
_toolbarController.stop();
_toolbar?.remove(); _toolbar?.remove();
_toolbar = null; _toolbar = null;
} }
...@@ -888,7 +897,6 @@ class SelectionOverlay { ...@@ -888,7 +897,6 @@ class SelectionOverlay {
/// {@endtemplate} /// {@endtemplate}
void dispose() { void dispose() {
hide(); hide();
_toolbarController.dispose();
} }
Widget _buildStartHandle(BuildContext context) { Widget _buildStartHandle(BuildContext context) {
...@@ -967,26 +975,115 @@ class SelectionOverlay { ...@@ -967,26 +975,115 @@ class SelectionOverlay {
return Directionality( return Directionality(
textDirection: Directionality.of(this.context), textDirection: Directionality.of(this.context),
child: FadeTransition( child: _SelectionToolbarOverlay(
opacity: _toolbarOpacity, preferredLineHeight: lineHeightAtStart,
child: CompositedTransformFollower( toolbarLocation: toolbarLocation,
link: toolbarLayerLink, layerLink: toolbarLayerLink,
showWhenUnlinked: false, editingRegion: editingRegion,
offset: -editingRegion.topLeft, selectionControls: selectionControls,
child: Builder( midpoint: midpoint,
builder: (BuildContext context) { selectionEndpoints: selectionEndPoints,
return selectionControls!.buildToolbar( visibility: toolbarVisible,
context, selectionDelegate: selectionDelegate,
editingRegion, clipboardStatus: clipboardStatus,
lineHeightAtStart, ),
midpoint, );
selectionEndPoints, }
selectionDelegate, }
clipboardStatus!,
toolbarLocation, /// This widget represents a selection toolbar.
); class _SelectionToolbarOverlay extends StatefulWidget {
}, /// Creates a toolbar overlay.
), const _SelectionToolbarOverlay({
Key? key,
required this.preferredLineHeight,
required this.toolbarLocation,
required this.layerLink,
required this.editingRegion,
required this.selectionControls,
this.visibility,
required this.midpoint,
required this.selectionEndpoints,
required this.selectionDelegate,
required this.clipboardStatus,
}) : super(key: key);
final double preferredLineHeight;
final Offset? toolbarLocation;
final LayerLink layerLink;
final Rect editingRegion;
final TextSelectionControls? selectionControls;
final ValueListenable<bool>? visibility;
final Offset midpoint;
final List<TextSelectionPoint> selectionEndpoints;
final TextSelectionDelegate? selectionDelegate;
final ClipboardStatusNotifier? clipboardStatus;
@override
_SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
}
class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.visibility == widget.visibility) {
return;
}
oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void dispose() {
widget.visibility?.removeListener(_toolbarVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _toolbarVisibilityChanged() {
if (widget.visibility?.value != false) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
offset: -widget.editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return widget.selectionControls!.buildToolbar(
context,
widget.editingRegion,
widget.preferredLineHeight,
widget.midpoint,
widget.selectionEndpoints,
widget.selectionDelegate!,
widget.clipboardStatus!,
widget.toolbarLocation,
);
},
), ),
), ),
); );
......
...@@ -4697,6 +4697,75 @@ void main() { ...@@ -4697,6 +4697,75 @@ void main() {
expect(renderEditable.text!.style!.decoration, isNull); expect(renderEditable.text!.style!.decoration, isNull);
}); });
testWidgets('text selection toolbar visibility', (WidgetTester tester) async {
const String testText = 'hello \n world \n this \n is \n text';
final TextEditingController controller = TextEditingController(text: testText);
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: Container(
height: 50,
color: Colors.white,
child: EditableText(
showSelectionHandles: true,
controller: controller,
focusNode: FocusNode(),
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
selectionControls: materialTextSelectionControls,
keyboardType: TextInputType.text,
selectionColor: Colors.lightBlueAccent,
maxLines: 3,
),
),
),
));
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
// Select the first word. And show the toolbar.
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
expect(state.showToolbar(), true);
await tester.pumpAndSettle();
// Find the toolbar fade transition while the toolbar is still visible.
final List<FadeTransition> transitionsBefore = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitionsBefore.length, 1);
final FadeTransition toolbarBefore = transitionsBefore[0];
expect(toolbarBefore.opacity.value, 1.0);
// Scroll until the selection is no longer within view.
scrollable.controller!.jumpTo(50.0);
await tester.pumpAndSettle();
// Find the toolbar fade transition after the toolbar has been hidden.
final List<FadeTransition> transitionsAfter = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitionsAfter.length, 1);
final FadeTransition toolbarAfter = transitionsAfter[0];
expect(toolbarAfter.opacity.value, 0.0);
// On web, we don't show the Flutter toolbar and instead rely on the browser
// toolbar. Until we change that, this test should remain skipped.
}, skip: kIsWeb); // [intended]
testWidgets('text selection handle visibility', (WidgetTester tester) async { testWidgets('text selection handle visibility', (WidgetTester tester) async {
// Text with two separate words to select. // Text with two separate words to select.
const String testText = 'XXXXX XXXXX'; const String testText = 'XXXXX XXXXX';
......
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