Implement Autocomplete layout with the new OverlayPortal childLayoutBuilder API (#165249)
Fixes https://github.com/flutter/flutter/issues/160625 This also makes it very easy to implement https://github.com/flutter/flutter/issues/101620. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
@@ -6,11 +6,10 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' show max, min;
|
||||
import 'dart:math' as math show max;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'actions.dart';
|
||||
@@ -20,11 +19,9 @@ import 'editable_text.dart';
|
||||
import 'focus_manager.dart';
|
||||
import 'framework.dart';
|
||||
import 'inherited_notifier.dart';
|
||||
import 'layout_builder.dart';
|
||||
import 'overlay.dart';
|
||||
import 'shortcuts.dart';
|
||||
import 'tap_region.dart';
|
||||
import 'value_listenable_builder.dart';
|
||||
|
||||
// Examples can assume:
|
||||
// late BuildContext context;
|
||||
@@ -312,12 +309,6 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
|
||||
final GlobalKey _fieldKey = GlobalKey();
|
||||
final LayerLink _optionsLayerLink = LayerLink();
|
||||
|
||||
/// The box constraints that the field was last built with.
|
||||
final ValueNotifier<BoxConstraints?> _fieldBoxConstraints = ValueNotifier<BoxConstraints?>(null);
|
||||
|
||||
final OverlayPortalController _optionsViewController = OverlayPortalController(
|
||||
debugLabel: '_RawAutocompleteState',
|
||||
);
|
||||
@@ -511,23 +502,70 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOptionsView(BuildContext context) {
|
||||
return ValueListenableBuilder<BoxConstraints?>(
|
||||
valueListenable: _fieldBoxConstraints,
|
||||
builder: (BuildContext context, BoxConstraints? constraints, Widget? child) {
|
||||
return _RawAutocompleteOptions(
|
||||
fieldKey: _fieldKey,
|
||||
optionsLayerLink: _optionsLayerLink,
|
||||
optionsViewOpenDirection: widget.optionsViewOpenDirection,
|
||||
overlayContext: context,
|
||||
textDirection: Directionality.maybeOf(context),
|
||||
highlightIndexNotifier: _highlightedOptionIndex,
|
||||
fieldConstraints: _fieldBoxConstraints.value!,
|
||||
builder: (BuildContext context) {
|
||||
return widget.optionsViewBuilder(context, _select, _options);
|
||||
},
|
||||
);
|
||||
},
|
||||
// A big enough height for about one item in the default
|
||||
// Autocomplete.optionsViewBuilder. The assumption is that the user likely
|
||||
// wants the list of options to move to stay on the screen rather than get any
|
||||
// smaller than this. Allows Autocomplete to work when it has very little
|
||||
// screen height available (as in b/317115348) by positioning itself on top of
|
||||
// the field, while in other cases to size itself based on the height under
|
||||
// the field.
|
||||
static const double _kMinUsableHeight = kMinInteractiveDimension;
|
||||
|
||||
Widget _buildOptionsView(BuildContext context, OverlayChildLayoutInfo layoutInfo) {
|
||||
if (layoutInfo.childPaintTransform.determinant() == 0.0) {
|
||||
// The child is not visible.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final Size fieldSize = layoutInfo.childSize;
|
||||
final Matrix4 invertTransform = layoutInfo.childPaintTransform.clone()..invert();
|
||||
|
||||
// This may not work well if the paint transform has rotation in it.
|
||||
// MatrixUtils.transformRect returns the bounding rect of the rotated overlay
|
||||
// rect.
|
||||
final Rect overlayRectInField = MatrixUtils.transformRect(
|
||||
invertTransform,
|
||||
Offset.zero & layoutInfo.overlaySize,
|
||||
);
|
||||
|
||||
final double optionsViewMaxHeight = switch (widget.optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.up => -overlayRectInField.top,
|
||||
OptionsViewOpenDirection.down => overlayRectInField.bottom - fieldSize.height,
|
||||
};
|
||||
|
||||
final Size optionsViewBoundingBox = Size(
|
||||
fieldSize.width,
|
||||
math.max(optionsViewMaxHeight, _kMinUsableHeight),
|
||||
);
|
||||
|
||||
final double originY = switch (widget.optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.up => overlayRectInField.top,
|
||||
OptionsViewOpenDirection.down => overlayRectInField.bottom - optionsViewBoundingBox.height,
|
||||
};
|
||||
|
||||
final Matrix4 transform = layoutInfo.childPaintTransform.clone()..translate(0.0, originY);
|
||||
final Widget child = Builder(
|
||||
builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
|
||||
);
|
||||
return Transform(
|
||||
transform: transform,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.tight(optionsViewBoundingBox),
|
||||
child: Align(
|
||||
alignment: switch (widget.optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
|
||||
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
|
||||
},
|
||||
child: TextFieldTapRegion(
|
||||
child: AutocompleteHighlightedOption(
|
||||
highlightIndexNotifier: _highlightedOptionIndex,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -569,7 +607,6 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
||||
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
|
||||
_internalFocusNode?.dispose();
|
||||
_highlightedOptionIndex.dispose();
|
||||
_fieldBoxConstraints.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -582,225 +619,21 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
||||
_focusNode,
|
||||
_onFieldSubmitted,
|
||||
) ??
|
||||
const SizedBox.shrink();
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// TODO(victorsanni): Also track the width of the field box so that the
|
||||
// options view maintains the same width as the field if its width
|
||||
// changes but its constraints remain unchanged.
|
||||
_fieldBoxConstraints.value = constraints;
|
||||
return OverlayPortal.targetsRootOverlay(
|
||||
key: _fieldKey,
|
||||
controller: _optionsViewController,
|
||||
overlayChildBuilder: _buildOptionsView,
|
||||
child: TextFieldTapRegion(
|
||||
child: Shortcuts(
|
||||
shortcuts: _shortcuts,
|
||||
child: Actions(
|
||||
actions: _actionMap,
|
||||
child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RawAutocompleteOptions extends StatefulWidget {
|
||||
const _RawAutocompleteOptions({
|
||||
required this.fieldKey,
|
||||
required this.optionsLayerLink,
|
||||
required this.optionsViewOpenDirection,
|
||||
required this.overlayContext,
|
||||
required this.textDirection,
|
||||
required this.highlightIndexNotifier,
|
||||
required this.builder,
|
||||
required this.fieldConstraints,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
final GlobalKey fieldKey;
|
||||
|
||||
final LayerLink optionsLayerLink;
|
||||
final OptionsViewOpenDirection optionsViewOpenDirection;
|
||||
final BuildContext overlayContext;
|
||||
final TextDirection? textDirection;
|
||||
final ValueNotifier<int> highlightIndexNotifier;
|
||||
final BoxConstraints fieldConstraints;
|
||||
|
||||
@override
|
||||
State<_RawAutocompleteOptions> createState() => _RawAutocompleteOptionsState();
|
||||
}
|
||||
|
||||
class _RawAutocompleteOptionsState extends State<_RawAutocompleteOptions> {
|
||||
VoidCallback? removeCompositionCallback;
|
||||
Offset fieldOffset = Offset.zero;
|
||||
|
||||
// Get the field offset if the field's position changes when its layer tree
|
||||
// is composited, which occurs for example if the field is in a scroll view.
|
||||
Offset _getFieldOffset() {
|
||||
final RenderBox? fieldRenderBox =
|
||||
widget.fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? overlay =
|
||||
Overlay.of(widget.overlayContext).context.findRenderObject() as RenderBox?;
|
||||
return fieldRenderBox?.localToGlobal(Offset.zero, ancestor: overlay) ?? Offset.zero;
|
||||
}
|
||||
|
||||
void _onLeaderComposition(Layer leaderLayer) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final Offset nextFieldOffset = _getFieldOffset();
|
||||
if (nextFieldOffset != fieldOffset) {
|
||||
setState(() {
|
||||
fieldOffset = nextFieldOffset;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
|
||||
_onLeaderComposition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_RawAutocompleteOptions oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.optionsLayerLink.leader != oldWidget.optionsLayerLink.leader) {
|
||||
removeCompositionCallback?.call();
|
||||
removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
|
||||
_onLeaderComposition,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
removeCompositionCallback?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformFollower(
|
||||
link: widget.optionsLayerLink,
|
||||
followerAnchor: switch (widget.optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.up => Alignment.bottomLeft,
|
||||
OptionsViewOpenDirection.down => Alignment.topLeft,
|
||||
},
|
||||
// When the field goes offscreen, don't show the options.
|
||||
showWhenUnlinked: false,
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _RawAutocompleteOptionsLayoutDelegate(
|
||||
layerLink: widget.optionsLayerLink,
|
||||
fieldOffset: fieldOffset,
|
||||
optionsViewOpenDirection: widget.optionsViewOpenDirection,
|
||||
textDirection: Directionality.of(context),
|
||||
fieldConstraints: widget.fieldConstraints,
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
child: AutocompleteHighlightedOption(
|
||||
highlightIndexNotifier: widget.highlightIndexNotifier,
|
||||
// optionsViewBuilder must be able to look up
|
||||
// AutocompleteHighlightedOption in its context.
|
||||
child: Builder(builder: widget.builder),
|
||||
),
|
||||
// Horizontally expand to make sure the options view's width won't be zero.
|
||||
const SizedBox(width: double.infinity, height: 0.0);
|
||||
return OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: _optionsViewController,
|
||||
overlayChildBuilder: _buildOptionsView,
|
||||
child: TextFieldTapRegion(
|
||||
child: Shortcuts(
|
||||
shortcuts: _shortcuts,
|
||||
child: Actions(actions: _actionMap, child: fieldView),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions the options view.
|
||||
class _RawAutocompleteOptionsLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
_RawAutocompleteOptionsLayoutDelegate({
|
||||
required this.layerLink,
|
||||
required this.fieldOffset,
|
||||
required this.optionsViewOpenDirection,
|
||||
required this.textDirection,
|
||||
required this.fieldConstraints,
|
||||
}) : assert(layerLink.leaderSize != null);
|
||||
|
||||
/// Links the options in [RawAutocomplete.optionsViewBuilder] to the field in
|
||||
/// [RawAutocomplete.fieldViewBuilder].
|
||||
final LayerLink layerLink;
|
||||
|
||||
/// The position of the field in [RawAutocomplete.fieldViewBuilder].
|
||||
final Offset fieldOffset;
|
||||
|
||||
/// A direction in which to open the options view overlay.
|
||||
final OptionsViewOpenDirection optionsViewOpenDirection;
|
||||
|
||||
/// The [TextDirection] of this part of the widget tree.
|
||||
final TextDirection textDirection;
|
||||
|
||||
/// The [BoxConstraints] for the field in [RawAutocomplete.fieldViewBuilder].
|
||||
final BoxConstraints fieldConstraints;
|
||||
|
||||
// A big enough height for about one item in the default
|
||||
// Autocomplete.optionsViewBuilder. The assumption is that the user likely
|
||||
// wants the list of options to move to stay on the screen rather than get any
|
||||
// smaller than this. Allows Autocomplete to work when it has very little
|
||||
// screen height available (as in b/317115348) by positioning itself on top of
|
||||
// the field, while in other cases to size itself based on the height under
|
||||
// the field.
|
||||
static const double _kMinUsableHeight = kMinInteractiveDimension;
|
||||
|
||||
// Limits the child to the space above/below the field, with a minimum, and
|
||||
// with the same maxWidth constraint as the field has.
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final Size fieldSize = layerLink.leaderSize!;
|
||||
return BoxConstraints(
|
||||
// The field width may be zero if this is a split RawAutocomplete with no
|
||||
// field of its own. In that case, don't change the constraints width.
|
||||
maxWidth: fieldSize.width == 0.0 ? constraints.maxWidth : fieldSize.width,
|
||||
maxHeight: max(_kMinUsableHeight, switch (optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.down => constraints.maxHeight - fieldOffset.dy - fieldSize.height,
|
||||
OptionsViewOpenDirection.up => fieldOffset.dy,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Positions the child above/below the field and aligned with the left/right
|
||||
// side based on text direction.
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final Size fieldSize = layerLink.leaderSize!;
|
||||
final double dx = switch (textDirection) {
|
||||
TextDirection.ltr => 0.0,
|
||||
TextDirection.rtl => fieldSize.width - childSize.width,
|
||||
};
|
||||
final double dy = switch (optionsViewOpenDirection) {
|
||||
OptionsViewOpenDirection.down => min(
|
||||
fieldSize.height,
|
||||
size.height - childSize.height - fieldOffset.dy,
|
||||
),
|
||||
OptionsViewOpenDirection.up => size.height - min(childSize.height, fieldOffset.dy),
|
||||
};
|
||||
return Offset(dx, dy);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_RawAutocompleteOptionsLayoutDelegate oldDelegate) {
|
||||
if (!fieldOffset.isFinite || !layerLink.leaderSize!.isFinite) {
|
||||
return false;
|
||||
}
|
||||
return layerLink != oldDelegate.layerLink ||
|
||||
fieldOffset != oldDelegate.fieldOffset ||
|
||||
optionsViewOpenDirection != oldDelegate.optionsViewOpenDirection ||
|
||||
textDirection != oldDelegate.textDirection ||
|
||||
fieldConstraints != oldDelegate.fieldConstraints;
|
||||
}
|
||||
}
|
||||
|
||||
class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
|
||||
_AutocompleteCallbackAction({required super.onInvoke, required this.isEnabledCallback});
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ abstract class ShortcutActivator {
|
||||
///
|
||||
/// If provided, trigger keys can be used as a first-pass filter for incoming
|
||||
/// events in order to optimize lookups, as [Intent]s are stored in a [Map]
|
||||
/// and indexed by trigger keys. It is up to the individual implementors of
|
||||
/// and indexed by trigger keys. It is up to the individual implementers of
|
||||
/// this interface to decide if they ignore triggers or not.
|
||||
///
|
||||
/// Subclasses should make sure that the return value of this method does not
|
||||
|
||||
@@ -326,8 +326,6 @@ void main() {
|
||||
|
||||
// Tap on the text field to open the options.
|
||||
await tester.tap(find.byKey(fieldKey));
|
||||
// Two pumps required due to post frame callback.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, kOptions.length);
|
||||
@@ -446,17 +444,10 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Add an extra pump to account for any potential frame delays introduced
|
||||
// by the post frame callback in the _RawAutocompleteOptions
|
||||
// implementation.
|
||||
await tester.pump();
|
||||
setState(() {
|
||||
alignment = Alignment.topCenter;
|
||||
});
|
||||
|
||||
// One frame for the field to move and one frame for the options to
|
||||
// follow.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
@@ -596,8 +587,6 @@ void main() {
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
// Two pumps required due to post frame callback.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
@@ -1129,10 +1118,6 @@ void main() {
|
||||
final Size fieldSize = tester.getSize(find.byKey(fieldKey));
|
||||
expect(optionsTopLeft.dy, fieldOffset.dy + fieldSize.height);
|
||||
|
||||
// Add an extra pump to account for any potential frame delays introduced by
|
||||
// the post frame callback in the _RawAutocompleteOptions implementation.
|
||||
await tester.pump();
|
||||
|
||||
// Move the field (similar to as if the keyboard opened). The options move
|
||||
// to follow the field.
|
||||
setState(() {
|
||||
@@ -2210,9 +2195,6 @@ void main() {
|
||||
final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey));
|
||||
expect(optionsBox.size.width, 100.0);
|
||||
|
||||
// Add an extra pump to account for any potential frame delays introduced by
|
||||
// the post frame callback in the _RawAutocompleteOptions implementation.
|
||||
await tester.pump();
|
||||
setState(() {
|
||||
width = 200.0;
|
||||
});
|
||||
@@ -2248,21 +2230,26 @@ void main() {
|
||||
FocusNode focusNode,
|
||||
VoidCallback onSubmitted,
|
||||
) {
|
||||
return TextField(key: fieldKey, focusNode: focusNode, controller: textEditingController);
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter localStateSetter) {
|
||||
setState = localStateSetter;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: TextField(
|
||||
key: fieldKey,
|
||||
focusNode: focusNode,
|
||||
controller: textEditingController,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter localStateSetter) {
|
||||
setState = localStateSetter;
|
||||
return SizedBox(width: width, child: autocomplete);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Padding(padding: const EdgeInsets.symmetric(horizontal: 32.0), child: autocomplete),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2283,9 +2270,6 @@ void main() {
|
||||
expect(fieldBox.size.width, 100.0);
|
||||
expect(optionsBox.size.width, 100.0);
|
||||
|
||||
// Add an extra pump to account for any potential frame delays introduced by
|
||||
// the post frame callback in the _RawAutocompleteOptions implementation.
|
||||
await tester.pump();
|
||||
setState(() {
|
||||
width = 200.0;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user