Skip to content

Commit

Permalink
Reland "flutter#143249 Autocomplete options width" (flutter#161695)
Browse files Browse the repository at this point in the history
Original PR: flutter#143249
Revert PR: flutter#161666

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] 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
  • Loading branch information
victorsanni authored Jan 17, 2025
1 parent f545df5 commit 3297454
Show file tree
Hide file tree
Showing 4 changed files with 1,688 additions and 263 deletions.
8 changes: 4 additions & 4 deletions packages/flutter/lib/src/material/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
onSelected: onSelected,
options: options,
openDirection: optionsViewOpenDirection,
maxOptionsHeight: optionsMaxHeight,
optionsMaxHeight: optionsMaxHeight,
);
},
onSelected: onSelected,
Expand Down Expand Up @@ -176,7 +176,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
required this.optionsMaxHeight,
});

final AutocompleteOptionToString<T> displayStringForOption;
Expand All @@ -185,7 +185,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
final OptionsViewOpenDirection openDirection;

final Iterable<T> options;
final double maxOptionsHeight;
final double optionsMaxHeight;

@override
Widget build(BuildContext context) {
Expand All @@ -198,7 +198,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
constraints: BoxConstraints(maxHeight: optionsMaxHeight),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
Expand Down
276 changes: 239 additions & 37 deletions packages/flutter/lib/src/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@
library;

import 'dart:async';
import 'dart:math' show max, min;

import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';

import 'actions.dart';
import 'basic.dart';
import 'constants.dart';
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;
Expand Down Expand Up @@ -213,10 +219,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
/// Builds the selectable options widgets from a list of options objects.
///
/// The options are displayed floating below or above the field using a
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
/// place in the widget tree as [RawAutocomplete]. To control whether it opens
/// upward or downward, use [optionsViewOpenDirection].
/// The options are displayed floating below or above the field inside of an
/// [Overlay], not at the same place in the widget tree as [RawAutocomplete].
/// To control whether it opens upward or downward, use
/// [optionsViewOpenDirection].
///
/// In order to track which item is highlighted by keyboard navigation, the
/// resulting options will be wrapped in an inherited
Expand Down Expand Up @@ -307,6 +313,10 @@ 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',
);
Expand Down Expand Up @@ -439,30 +449,22 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
}

Widget _buildOptionsView(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
final Alignment followerAlignment = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
}.resolve(textDirection);
final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
}.resolve(textDirection);

return CompositedTransformFollower(
link: _optionsLayerLink,
showWhenUnlinked: false,
targetAnchor: targetAnchor,
followerAnchor: followerAlignment,
child: TextFieldTapRegion(
child: AutocompleteHighlightedOption(
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,
child: Builder(
builder:
(BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
),
),
),
fieldConstraints: _fieldBoxConstraints.value!,
builder: (BuildContext context) {
return widget.optionsViewBuilder(context, _select, _options);
},
);
},
);
}

Expand Down Expand Up @@ -504,6 +506,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
_internalFocusNode?.dispose();
_highlightedOptionIndex.dispose();
_fieldBoxConstraints.dispose();
super.dispose();
}

Expand All @@ -517,25 +520,224 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
_onFieldSubmitted,
) ??
const SizedBox.shrink();
return OverlayPortal.targetsRootOverlay(
controller: _optionsViewController,
overlayChildBuilder: _buildOptionsView,
child: TextFieldTapRegion(
child: SizedBox(
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,
child: Shortcuts(
shortcuts: _shortcuts,
child: Actions(
actions: _actionMap,
child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
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),
),
),
),
);
}
}

/// 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});

Expand Down
1 change: 1 addition & 0 deletions packages/flutter/test/material/autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ void main() {

await tester.tap(find.byType(RawAutocomplete<String>));
await tester.enterText(find.byType(RawAutocomplete<String>), 'a');
await tester.pump();
expect(find.text('aa').hitTestable(), findsOneWidget);
});
});
Expand Down
Loading

0 comments on commit 3297454

Please sign in to comment.