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: Renzo Olivares <roliv@google.com>
This commit is contained in:
parent
a454da0220
commit
8386344962
@ -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 {
|
|||||||
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 {
|
|||||||
|
|
||||||
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 {
|
|||||||
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 {
|
|||||||
|
|
||||||
/// {@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 {
|
|||||||
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 {
|
|||||||
'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 {
|
|||||||
/// 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 {
|
|||||||
/// 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 {
|
|||||||
}
|
}
|
||||||
_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 {
|
|||||||
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 {
|
|||||||
/// {@endtemplate}
|
/// {@endtemplate}
|
||||||
void dispose() {
|
void dispose() {
|
||||||
hide();
|
hide();
|
||||||
_toolbarController.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStartHandle(BuildContext context) {
|
Widget _buildStartHandle(BuildContext context) {
|
||||||
@ -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() {
|
|||||||
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';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user