Autocomplete Options Width#143249
Conversation
795e79b to
d93bb04
Compare
|
@LongCatIsLooong Would something like this work in OverlayPortal? Or maybe better, |
| builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options), | ||
| builder: (BuildContext context) { | ||
| final Widget options = widget.optionsViewBuilder(context, _select, _options); | ||
| final RenderBox? fieldRenderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; |
There was a problem hiding this comment.
Is it guaranteed at this point that the render object is already laid out?
There was a problem hiding this comment.
I was hoping you would know :) This method is the overlayChildBuilder of an OverlayPortal, and _fieldKey is in the child of the same OverlayPortal.
There was a problem hiding this comment.
If the sizing rule (BoxConstraints => Size) is known (in other words the child is sized by the parent) I think you could use a layout builder as the ancestor of the text field to get the size of the text field. Otherwise I think it's a bit trickier.
There was a problem hiding this comment.
Done and it seems to work, thanks.
0e94b95 to
e4bce30
Compare
| width: _fieldBoxConstraints.maxWidth, | ||
| child: widget.optionsViewBuilder(context, _select, _options), |
There was a problem hiding this comment.
Question: Should I add a flag for this, or even pass the Size to the optionsViewBuilder and make users include the UnconstrainedBox and SizedBox? I figure most users do want this width-matching behavior, which is why I made it mandatory, but it could be a breaking change for some...
There was a problem hiding this comment.
I agree that most users will want this width-matching behavior — I think it's the standard expectation which most end users are accustomed to from other GUIs.
A flag would be reasonable, because I do sometimes see UIs where there's a small dropdown field but when you open it the options are much wider, so there'll surely at some point be a Flutter developer who wants that behavior. I think it'd be best for width-matching to be the default, though, because that's the behavior most people will expect. My suggestion would be to go ahead and make it the only behavior, and then adding a flag can be a follow-up if and when people ask for it.
If users had to include the UnconstrainedBox and SizedBox themselves, I think that'd make this only a like 20% solution, because that'd sound complicated and I expect most developers just wouldn't do it. (With enough docs, it could be maybe a 70% solution; even then, I'd expect to continue getting reports of #78746.) It definitely feels to me like the kind of detail I'd expect the autocomplete widget itself to have responsibility for.
There was a problem hiding this comment.
Thanks for the analysis, I'm on board with this. I'll leave this as-is but add a TODO with a link to this thread if/when someone starts asking for it.
| final OverlayPortalController _optionsViewController = OverlayPortalController(debugLabel: '_RawAutocompleteState'); | ||
|
|
||
| // The box constraints that the field was last built with. | ||
| late BoxConstraints _fieldBoxConstraints; |
There was a problem hiding this comment.
I'm surprised that no test caught this, but I think this will crash if the autocomplete widget is not in a layout builder. The _fieldBoxConstraints variable won't be assigned until the layout phase.
As a workaround, putting the widget that takes that BoxConstraints in a layout builder will defer the evaluation of the late variable to the layout phase.
Overlay portal always lays out the child branch before the overlayChild branch so I think this should be safe (but I think the trick makes enough assumptions about layout builder's implementation and overlayportal's implementation, to merit throughout comments/testing)
There was a problem hiding this comment.
Here's my attempt: 49f98d6. Can you take a look at that and tell me if it's possible to get OverlayPortal to build overlayChildBuilder in the same frame as it builds child? I wasn't able to reproduce it, though I get what you're saying and it is worrying.
There was a problem hiding this comment.
Try letting it build a field view that has a Focus(autofocus: true)?
There was a problem hiding this comment.
Ah that probably doesn't work since the focus callback won't get called until the next microtask flush. If it's guaranteed that the options view won't show the same frame the field view shows, that should be safe (but _fieldBoxConstraints will still have a one frame delay).
There was a problem hiding this comment.
Ok got it, I've removed the LayoutBuilder, the test, and adjusted my comments. I will keep an eye out though in case anyone runs into this.
There was a problem hiding this comment.
I think the layout builder is probably still needed. If the text field gets a set of different input constraints, then this variable is going to change, but nothing is telling the menu to resize if it's already open?
There was a problem hiding this comment.
Well I guess OverlayPortal needs a new constructor that takes a overlayChildLayoutBuilder which gives you the size of the child before you build the overlay child.
There was a problem hiding this comment.
(Because the text field itself may change size, e.g., when there's an animation that resizes it)
There was a problem hiding this comment.
Good point, I confirmed that the options weren't rebuilding with the new width when the field constraints changed. Now I've updated it with a ValueNotifier so that they do, but I agree a new constructor on OverlayPortal would be nice. What do you think of my approach, and do you think I should add something similar to OverlayPortal or keep that for a separate PR?
Screencast.from.2024-02-21.12-57-46.webm
| width: _fieldBoxConstraints.maxWidth, | ||
| child: widget.optionsViewBuilder(context, _select, _options), |
There was a problem hiding this comment.
I agree that most users will want this width-matching behavior — I think it's the standard expectation which most end users are accustomed to from other GUIs.
A flag would be reasonable, because I do sometimes see UIs where there's a small dropdown field but when you open it the options are much wider, so there'll surely at some point be a Flutter developer who wants that behavior. I think it'd be best for width-matching to be the default, though, because that's the behavior most people will expect. My suggestion would be to go ahead and make it the only behavior, and then adding a flag can be a follow-up if and when people ask for it.
If users had to include the UnconstrainedBox and SizedBox themselves, I think that'd make this only a like 20% solution, because that'd sound complicated and I expect most developers just wouldn't do it. (With enough docs, it could be maybe a 70% solution; even then, I'd expect to continue getting reports of #78746.) It definitely feels to me like the kind of detail I'd expect the autocomplete widget itself to have responsibility for.
| final OverlayPortalController _optionsViewController = OverlayPortalController(debugLabel: '_RawAutocompleteState'); | ||
|
|
||
| // The box constraints that the field was last built with. | ||
| late BoxConstraints _fieldBoxConstraints; |
There was a problem hiding this comment.
I think the layout builder is probably still needed. If the text field gets a set of different input constraints, then this variable is going to change, but nothing is telling the menu to resize if it's already open?
| final OverlayPortalController _optionsViewController = OverlayPortalController(debugLabel: '_RawAutocompleteState'); | ||
|
|
||
| // The box constraints that the field was last built with. | ||
| late BoxConstraints _fieldBoxConstraints; |
There was a problem hiding this comment.
Well I guess OverlayPortal needs a new constructor that takes a overlayChildLayoutBuilder which gives you the size of the child before you build the overlay child.
| highlightIndexNotifier: _highlightedOptionIndex, | ||
| child: Builder( | ||
| builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options), | ||
| child: UnconstrainedBox( |
There was a problem hiding this comment.
Why the UnconstrainedBox? The overlay should be large enough to accommodate the menu I think?
There was a problem hiding this comment.
Without that the width of the SizedBox is not respected and the options are always full width. I think being in an Overlay gives it weird constraints.
There was a problem hiding this comment.
Maybe use Positioned instead?
There was a problem hiding this comment.
That doesn't seem to work because of the CompositedTransformFollower widget. Without that Positioned does work like you said.
There was a problem hiding this comment.
Positioned has to be placed as the root of the overlay child. I think the problem is the overlay is giving the overlay child tight constraints.
There was a problem hiding this comment.
Ah that worked, thanks.
There was a problem hiding this comment.
Actually, with Positioned it was completely unconstrained in both directions, which caused layout errors in the options builder. I changed it to use ConstraintsTransformBox to just loosen the constraints in both directions, what do you think of that?
There was a problem hiding this comment.
Talked offline: I should try setting the width at the Positioned widget instead of below in a SizedBox.
| highlightIndexNotifier: _highlightedOptionIndex, | ||
| child: Builder( | ||
| builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options), | ||
| child: UnconstrainedBox( |
There was a problem hiding this comment.
Maybe use Positioned instead?
| ?? (_internalTextEditingController = TextEditingController.fromValue(widget.initialValue)); | ||
| initialController.addListener(_onChangedField); | ||
| widget.focusNode?.addListener(_updateOptionsViewVisibility); | ||
| if (widget.focusNode?.hasFocus ?? false) { |
There was a problem hiding this comment.
Is this still needed?
| // overlayChildBuilder. | ||
| child: LayoutBuilder( | ||
| builder: (BuildContext context, BoxConstraints boxConstraints) { | ||
| _fieldBoxConstraints.value = boxConstraints; |
There was a problem hiding this comment.
If the input field has textWidthBasis = TextWidthBasis.longestLine (in which case the text field is not going to take the full available width) does this work still?
There was a problem hiding this comment.
You're right, and this comment is making me realize that what we're doing here isn't matching the width of the field necessarily, it's just matching the width of the constraints given to the field. Here's a screenshot of your idea where yellow is the Autocomplete and blue is the EditableText:
It doesn't even need to be anything as fancy as TextWidthBasis; if you just hardcode the width of the field to something other than the constraints, then it's the same kind of result:
However, I think that's kind of the best that we can do. If users are sizing their text field in a weird way and they want the options to match that width, they'll need to look up its width using a GlobalKey and a post frame callback. Most people are probably doing that today without this PR.
My best guess is that giving the same constraints as the Autocomplete is probably what most users will want and expect.
| child: CompositedTransformTarget( | ||
| link: _optionsLayerLink, | ||
| child: fieldView, | ||
| // This LayoutBuilder ensures that _fieldBoxConstraints is assigned |
There was a problem hiding this comment.
If the options view is also in a layout builder I don't think the post-frame callback is needed?
There was a problem hiding this comment.
I get the "setState called during build" error if I remove it.
There was a problem hiding this comment.
I think in that case the setState isn't necessary either. If the overlay portal and its child get a different BoxConstraints, the overlay child will relayout too. And the layout builder will rebuild. I think that happens in the same frame so it would work better than using a post frame callback.
There was a problem hiding this comment.
Hmm it doesn't seem to work when I remove that. When the constraints change, the LayoutBuilder in OverlayPortal.child is called with the new constraints, but the overlayChildBuilder is not called.
There was a problem hiding this comment.
I was wrong about using a layout builder. If child gets a different set of constraints, the layout builder callback will be called with the new constraints. But overlay child is laid out using the Overlay's constraints so its layout builder won't be called.
|
|
||
| return Positioned( | ||
| width: widget.fieldBoxConstraints.value.maxWidth, | ||
| // TODO(justinmc): If I don't give a height, height constraints are |
There was a problem hiding this comment.
IIRC the Overlay does not give its children unbounded constraints (it should at least be bound by the physical size of the screen). Do you mean a tight height constraint or a loose height constraint?
There was a problem hiding this comment.
Loose: 0.0<=h<=Infinity
There was a problem hiding this comment.
Without the Positioned widget the constraints are tight to the size of the screen.
There was a problem hiding this comment.
Ahhh I see, for positioned children it's not bounded. But why didn't the options view child no like 0.0<=h<=Infinity? Because it's using Align so it doesn't know how to vertically align with infinite amount of vertical space? In that case I guess we have to set Positioned widget's top and bottom to 0
There was a problem hiding this comment.
Thanks, that seems to work!
The test that failed has a ListView in the optionsViewBuilder, which I think was the problem.
| controller: _optionsViewController, | ||
| overlayChildBuilder: _buildOptionsView, | ||
| overlayChildBuilder: (BuildContext context) { | ||
| return _OptionsView<T>( |
There was a problem hiding this comment.
Use a ValueListenableBuilder instead?
There was a problem hiding this comment.
also consider adding an assert to verify the constraints is not unbounded.
There was a problem hiding this comment.
Thanks, I forgot ValueListenableBuilder was a thing. Much cleaner.
|
I've tracked down the bug from the failing Google tests. Disposing the field's TextEditingController at the time that an option is selected results in: The bug happens like this:
The cause is the LayoutBuilder added in this PR. It delays the build until after the controller has been disposed but before it's been replaced. I think the question is: Is this something that should work, or should we discourage messing with the controller like this? @LongCatIsLooong Full errorRepro codeimport 'package:flutter/material.dart';
void main() => runApp(const AutocompleteExampleApp());
class AutocompleteExampleApp extends StatelessWidget {
const AutocompleteExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Autocomplete Basic'),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: _MyAutocomplete(),
),
],
),
),
),
);
}
}
class _MyAutocomplete extends StatefulWidget {
const _MyAutocomplete();
@override
State<_MyAutocomplete> createState() => _MyAutocompleteState();
}
class _MyAutocompleteState extends State<_MyAutocomplete> {
final FocusNode focusNode = FocusNode();
TextEditingController controller = TextEditingController(text: 'First text');
static const List<String> _kOptions = <String>[
'aardvark',
'bobcat',
'chameleon',
];
@override
Widget build(BuildContext context) {
return RawAutocomplete<String>(
textEditingController: controller,
focusNode: focusNode,
fieldViewBuilder: (BuildContext buildContext, TextEditingController controller, FocusNode focusNode, VoidCallback onSubmitted) {
debugPrint('fieldviewbuilder ${controller.text}');
// TODO(justinmc): This must be TFF to reproduce, not TF, because TFF
// listens to the controller and rebuilds if it changes.
return TextFormField(
controller: controller,
focusNode: focusNode,
onFieldSubmitted: (String value) => onSubmitted(),
);
},
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
return Column(
children: <Widget>[
...options.map((String option) {
return TextButton(
onPressed: () {
onSelected(option);
},
child: Text(option),
);
}),
],
);
},
optionsBuilder: (TextEditingValue textEditingValue) {
return _kOptions;
},
onSelected: (String selection) {
debugPrint('You just selected $selection');
setState(() {
controller.dispose();
controller = TextEditingController(text: 'Last text');
});
},
);
}
} |
|
Ah interesting. |
|
@LongCatIsLooong Yes (though I could use the keyboard or something if I wanted to). Also, the bug doesn't happen if I do the whole controller swap based on a separate button click or something, only when it happens as a part of onSelected. That's because the RawAutocomplete code updates the TextEditingController before calling onSelected. |
So the onSelect callback is called between, say, frame
|
|
Any progress here? The Autocomplete widget isn't really usable as it is |
|
@justinmc you can solve this problem much more simply, without using the The issue is actually the That way you don't need the postframe callback, which should simplify the tests |
|
Incidentally, and I suppose this should be a separate bug ticket, the Autocomplete You can reproduce using the live example on the Autocomplete docs page, just set |
|
@wmadden You're right about the OptionsViewOpenDirection bug, can you open a separate issue? I'll investigate if this fix is related to whatever we should do to fix that bug too. About your proposed solution, I believe it won't update the options when the size of the field changes. Also, using a GlobalKey to get the size of another widget requires that you wait until that widget has been laid out, so I think they would still be 1 frame delayed from each other (maybe not a terrible problem). |
|
This PR deserves a bow 🙇 🚀 |
Roll Flutter from 72db8f6 to 40c2b86 (33 revisions) flutter/flutter@72db8f6...40c2b86 2025-01-14 [email protected] update changelog for 3.27.2 release (flutter/flutter#161569) 2025-01-14 [email protected] Fix `showLicensePage` does not inherit ambient `Theme` (flutter/flutter#161599) 2025-01-14 [email protected] Added special case for fat width arcs (flutter/flutter#161255) 2025-01-14 [email protected] Replace `fetch `with `gclient sync`. (flutter/flutter#161565) 2025-01-14 [email protected] Roll Packages from 3c3bc68 to d1fd623 (4 revisions) (flutter/flutter#161597) 2025-01-14 [email protected] Remove unused method (flutter/flutter#161572) 2025-01-14 [email protected] Fix crash when closing a window with `Alt+F4` in multi-win Flutter on Windows (flutter/flutter#161375) 2025-01-14 [email protected] Update InputDecoration.border documentation (flutter/flutter#161415) 2025-01-14 [email protected] [Web] Allow specifying the strategy on when to use <img> element to display images (flutter/flutter#159917) 2025-01-14 [email protected] Roll Dart to Version 3.7.0-323.0.dev (flutter/flutter#161567) 2025-01-14 [email protected] Use wildcards (flutter/flutter#161548) 2025-01-14 [email protected] Autocomplete Options Width (flutter/flutter#143249) 2025-01-13 [email protected] Move the analyzer_benchmark to Mac arm64 devicelab bots (flutter/flutter#161405) 2025-01-13 [email protected] Remove references to `cirrus`, mostly in doc comments. (flutter/flutter#161529) 2025-01-13 [email protected] Fix paths when running clang-tidy on git diffs (flutter/flutter#161496) 2025-01-13 [email protected] [web:a11y] treat empty tappables as buttons (flutter/flutter#161360) 2025-01-13 [email protected] Add route settings to CupertinoSheetRoute (flutter/flutter#161528) 2025-01-13 [email protected] Copy `linux_host_engine` as `linux_host_engine_test`, removing `archives: [...]`. (flutter/flutter#161532) 2025-01-13 [email protected] Remove last two references to Cirrus CI. (flutter/flutter#161530) 2025-01-13 [email protected] Mark `Mac_mokey microbenchmarks` as flakey (flutter/flutter#161550) 2025-01-13 98614782+auto-submit[bot]@users.noreply.github.com Reverts "Match CupertinoPageTransitionsBuilder animation duration to CupertinoPageRoute (#160241)" (flutter/flutter#161555) 2025-01-13 [email protected] Add validator execution times to `flutter doctor --verbose` (flutter/flutter#158124) 2025-01-13 [email protected] Explain more specifically how to use `flutter drive`/what it does (flutter/flutter#161450) 2025-01-13 [email protected] Fixed repeated strings for incompatible Gradle or AGP version in `create` command (flutter/flutter#161223) 2025-01-13 [email protected] Remove `WEB_SHARD_COUNT`, which no longer exists post-Cirrus. (flutter/flutter#161527) 2025-01-13 [email protected] [Impeller] Update guidance on prebuilt artifacts. (flutter/flutter#161251) 2025-01-13 [email protected] Migrate DisplayList unit tests to DL/Impeller geometry classes (flutter/flutter#161453) 2025-01-13 [email protected] Context menu button callback docs clarification (flutter/flutter#161451) 2025-01-13 [email protected] Match CupertinoPageTransitionsBuilder animation duration to CupertinoPageRoute (flutter/flutter#160241) 2025-01-13 [email protected] Udpate documentation on the third_party directories (flutter/flutter#161407) 2025-01-13 [email protected] Propagate environment variables when `flutter drive` is invoked. (flutter/flutter#161452) 2025-01-13 [email protected] Convert base application name handling to kotlin source (start of FGP kt conversion) (flutter/flutter#155963) 2025-01-13 [email protected] [Impeller] remove API 30 restriction for SurfaceControl testing. (flutter/flutter#161438) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC [email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: ...
|
Reason for revert: Blocking google3 roll as this broke some google3 tests - see b/390128156 These tests are easy to fix forward, but I'll roll this back to unblock the roll without waiting for review of the fix forward. |
|
Time to revert pull request flutter/flutter/143249 has elapsed. |
This reverts commit 1b0441c.
| Problem | Before | After | | --- | --- | --- | | Width | <img width="797" alt="Screenshot 2024-02-09 at 1 29 09 PM" src="proxy.php?url=https://github.com/flutter/flutter/assets/389558/c49fa584-2550-41f6-ab80-6c20d01412b1"> | <img width="794" alt="Screenshot 2024-02-09 at 1 23 59 PM" src="proxy.php?url=https://github.com/flutter/flutter/assets/389558/1326f797-9883-4916-9de3-1939e7648d46"> | | Overflow |  |  | Fixes flutter#78746 Fixes flutter#92851 Part of flutter#101620 Fixes flutter#147483 Fixes flutter#153274 Part of Google b/317115348 Fixes optionsViewOpenDirection not working, mentioned in flutter#143249 (comment). ### Requirements * [x] By default, the width of the options matches the width of the field. * [x] Options can be aligned to the start or end for LTR/RTL languages. * [x] The optionsViewOpenDirection parameter is respected. * [x] If the options would vertically exceed the top or bottom of the screen, they reposition themselves to fit on the screen while covering the field. At least enough to tap an option. This has accessibility implications, because sometimes an Autocomplete near the edge of a narrow screen can be unusable. * [x] If the Autocomplete is in a ScrollView, then the options move along with the field during scrolling. * [x] When the field moves or resizes, the options position and size change to match. Even if the field is animated. * [ ] The options layout updates on the same frame that the field layout changes. It's probably not possible to check all of these boxes so we'll probably need to compromise. #### Tools that I've used to try to achieve this * LayoutBuilder to provide the field constraints[^1]. * Looking up layout information of a widget via GlobalKey. * CompositedTransformFollower/Target. * CustomSingleChildLayout. [^1]: Originally this didn't work due to a bug when using LayoutBuilder with OverlayPortal (flutter#147856). That has now been fixed. Co-authored-by: Victor Sanni <[email protected]>


Fixes #78746
Fixes #92851
Part of #101620
Fixes #147483
Fixes #153274
Part of Google b/317115348
Fixes optionsViewOpenDirection not working, mentioned in #143249 (comment).
Requirements
It's probably not possible to check all of these boxes so we'll probably need to compromise.
Tools that I've used to try to achieve this
Footnotes
Originally this didn't work due to a bug when using LayoutBuilder with OverlayPortal (https://github.com/flutter/flutter/pull/147856). That has now been fixed. ↩