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),
|
||||
_handlesVisible = handlesVisible,
|
||||
_value = value {
|
||||
renderObject.selectionStartInViewport.addListener(_updateHandleVisibilities);
|
||||
renderObject.selectionEndInViewport.addListener(_updateHandleVisibilities);
|
||||
_updateHandleVisibilities();
|
||||
renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
|
||||
renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
|
||||
_updateTextSelectionOverlayVisibilities();
|
||||
_selectionOverlay = SelectionOverlay(
|
||||
context: context,
|
||||
debugRequiredFor: debugRequiredFor,
|
||||
@ -285,6 +285,7 @@ class TextSelectionOverlay {
|
||||
lineHeightAtEnd: 0.0,
|
||||
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
|
||||
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
|
||||
toolbarVisible: _effectiveToolbarVisibility,
|
||||
selectionEndPoints: const <TextSelectionPoint>[],
|
||||
selectionControls: selectionControls,
|
||||
selectionDelegate: selectionDelegate,
|
||||
@ -321,9 +322,11 @@ class TextSelectionOverlay {
|
||||
|
||||
final ValueNotifier<bool> _effectiveStartHandleVisibility = 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;
|
||||
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
|
||||
_effectiveToolbarVisibility.value = renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
|
||||
}
|
||||
|
||||
/// Whether selection handles are visible.
|
||||
@ -339,7 +342,7 @@ class TextSelectionOverlay {
|
||||
if (_handlesVisible == visible)
|
||||
return;
|
||||
_handlesVisible = visible;
|
||||
_updateHandleVisibilities();
|
||||
_updateTextSelectionOverlayVisibilities();
|
||||
}
|
||||
|
||||
/// {@macro flutter.widgets.SelectionOverlay.showHandles}
|
||||
@ -413,9 +416,12 @@ class TextSelectionOverlay {
|
||||
|
||||
/// {@macro flutter.widgets.SelectionOverlay.dispose}
|
||||
void dispose() {
|
||||
renderObject.selectionStartInViewport.removeListener(_updateHandleVisibilities);
|
||||
renderObject.selectionEndInViewport.removeListener(_updateHandleVisibilities);
|
||||
_selectionOverlay.dispose();
|
||||
renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
|
||||
renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
|
||||
_effectiveToolbarVisibility.dispose();
|
||||
_effectiveStartHandleVisibility.dispose();
|
||||
_effectiveEndHandleVisibility.dispose();
|
||||
}
|
||||
|
||||
double _getStartGlyphHeight() {
|
||||
@ -562,6 +568,7 @@ class SelectionOverlay {
|
||||
this.onEndHandleDragStart,
|
||||
this.onEndHandleDragUpdate,
|
||||
this.onEndHandleDragEnd,
|
||||
this.toolbarVisible,
|
||||
required List<TextSelectionPoint> selectionEndPoints,
|
||||
required this.selectionControls,
|
||||
required this.selectionDelegate,
|
||||
@ -585,7 +592,6 @@ class SelectionOverlay {
|
||||
'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your '
|
||||
'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.
|
||||
@ -682,6 +688,14 @@ class SelectionOverlay {
|
||||
/// handles.
|
||||
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.
|
||||
List<TextSelectionPoint> get selectionEndPoints => _selectionEndPoints;
|
||||
List<TextSelectionPoint> _selectionEndPoints;
|
||||
@ -780,9 +794,6 @@ class SelectionOverlay {
|
||||
/// Controls the fade-in and fade-out animations for the toolbar and handles.
|
||||
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
|
||||
/// second is hidden when the selection is collapsed.
|
||||
List<OverlayEntry>? _handles;
|
||||
@ -826,7 +837,6 @@ class SelectionOverlay {
|
||||
}
|
||||
_toolbar = OverlayEntry(builder: _buildToolbar);
|
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insert(_toolbar!);
|
||||
_toolbarController.forward(from: 0.0);
|
||||
}
|
||||
|
||||
bool _buildScheduled = false;
|
||||
@ -878,7 +888,6 @@ class SelectionOverlay {
|
||||
void hideToolbar() {
|
||||
if (_toolbar == null)
|
||||
return;
|
||||
_toolbarController.stop();
|
||||
_toolbar?.remove();
|
||||
_toolbar = null;
|
||||
}
|
||||
@ -888,7 +897,6 @@ class SelectionOverlay {
|
||||
/// {@endtemplate}
|
||||
void dispose() {
|
||||
hide();
|
||||
_toolbarController.dispose();
|
||||
}
|
||||
|
||||
Widget _buildStartHandle(BuildContext context) {
|
||||
@ -967,26 +975,115 @@ class SelectionOverlay {
|
||||
|
||||
return Directionality(
|
||||
textDirection: Directionality.of(this.context),
|
||||
child: FadeTransition(
|
||||
opacity: _toolbarOpacity,
|
||||
child: CompositedTransformFollower(
|
||||
link: toolbarLayerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: -editingRegion.topLeft,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return selectionControls!.buildToolbar(
|
||||
context,
|
||||
editingRegion,
|
||||
lineHeightAtStart,
|
||||
midpoint,
|
||||
selectionEndPoints,
|
||||
selectionDelegate,
|
||||
clipboardStatus!,
|
||||
toolbarLocation,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _SelectionToolbarOverlay(
|
||||
preferredLineHeight: lineHeightAtStart,
|
||||
toolbarLocation: toolbarLocation,
|
||||
layerLink: toolbarLayerLink,
|
||||
editingRegion: editingRegion,
|
||||
selectionControls: selectionControls,
|
||||
midpoint: midpoint,
|
||||
selectionEndpoints: selectionEndPoints,
|
||||
visibility: toolbarVisible,
|
||||
selectionDelegate: selectionDelegate,
|
||||
clipboardStatus: clipboardStatus,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
});
|
||||
|
||||
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 {
|
||||
// Text with two separate words to select.
|
||||
const String testText = 'XXXXX XXXXX';
|
||||
|
Loading…
x
Reference in New Issue
Block a user