Skip to content
12 changes: 5 additions & 7 deletions packages/flutter/lib/src/services/autofill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -807,13 +807,11 @@ mixin AutofillScopeMixin implements AutofillScope {
!autofillClients.any((AutofillClient client) => client.textInputConfiguration.autofillConfiguration == null),
'Every client in AutofillScope.autofillClients must enable autofill',
);
return TextInput.attach(
trigger,
_AutofillScopeTextInputConfiguration(
allConfigurations: autofillClients
.map((AutofillClient client) => client.textInputConfiguration),
currentClientConfiguration: configuration,
),

final TextInputConfiguration inputConfiguration = _AutofillScopeTextInputConfiguration(
allConfigurations: autofillClients.map((AutofillClient client) => client.textInputConfiguration),
currentClientConfiguration: configuration,
);
return TextInput.attach(trigger, inputConfiguration);
}
}
66 changes: 61 additions & 5 deletions packages/flutter/lib/src/services/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -850,12 +850,14 @@ class TextInputConnection {
TextInput._instance._show();
}

/// Requests the platform autofill UI to appear.
/// Requests the system autofill UI to appear.
///
/// The call has no effect unless the currently attached client supports
/// autofill, and the platform has a standalone autofill UI (for example, this
/// call has no effect on iOS since its autofill UI is part of the software
/// keyboard).
/// Currently only works on Android. Other platforms do not respond to this
/// message.
///
/// See also:
///
/// * [EditableText], a [TextInputClient] that calls this method when focused.
void requestAutofill() {
assert(attached);
TextInput._instance._requestAutofill();
Expand Down Expand Up @@ -1213,4 +1215,58 @@ class TextInput {
args,
);
}

/// Finishes the current autofill context, and potentially saves the user
/// input for future use if `shouldSave` is true.
///
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a rather long and informative comment. It might help to briefly summarize how a typical app will use finishAutofillContext, before going deep. The paragraph at the end of this would serve the purpose.

/// Typically, this method should be called when the user has finalized their
/// input. For example, in a [Form], it's typically done immediately before or
/// after its content is submitted.
///
/// The topmost [AutofillGroup]s also call [finishAutofillContext]
/// automatically when they are disposed. The default behavior can be
/// overridden in [AutofillGroup.onDisposeAction].
///
/// {@template flutter.services.autofill.autofillContext}
/// An autofill context is a collection of input fields that live in the
/// platform's text input plugin. The platform is encouraged to save the user
/// input stored in the current autofill context before the context is
/// destroyed, when [finishAutofillContext] is called with `shouldSave` set to
/// true.
///
/// Currently, there can only be at most one autofill context at any given
/// time. When any input field in an [AutofillGroup] requests for autofill
/// (which is done automatically when an autofillable [EditableText] gains
/// focus), the current autofill context will merge the content of that
/// [AutofillGroup] into itself. When there isn't an existing autofill context,
/// one will be created to hold the newly added input fields from the group.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Since we have only one autofill context multiple AutofillGroups can end up in the same context right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

///
/// Once added to an autofill context, an input field will stay in the context
/// until the context is destroyed. To prevent leaks, call [finishAutofillContext]
/// to signal the text input plugin that the user has finalized their input in
/// the current autofill context. The platform text input plugin either
/// encourages or discourages the platform from saving the user input based on
/// the value of the `shouldSave` parameter. The platform usually shows a
/// "Save for autofill?" prompt for user confirmation.
/// {@endtemplate}
///
/// On many platforms, calling [finishAutofillContext] shows the save user
/// input dialog and disrupts the user's flow. Ideally the dialog should only
/// be shown no more than once for every screen. Consider removing premature
/// [finishAutofillContext] calls to prevent showing the save user input UI
/// too frequently. However, calling [finishAutofillContext] when there's no
/// existing autofill context usually does not bring up the save user input
/// UI.
///
/// See also:
///
/// * [AutofillGroup.onDisposeAction], a configurable action that runs when a
/// topmost [AutofillGroup] is getting disposed.
static void finishAutofillContext({ bool shouldSave = true }) {
assert(shouldSave != null);
TextInput._instance._channel.invokeMethod<void>(
'TextInput.finishAutofillContext',
shouldSave ,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also change this method to send "commit/cancel" information as an argument to make it similar to the other methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update to take a bool. Let me update the engine PR.

}
}
95 changes: 85 additions & 10 deletions packages/flutter/lib/src/widgets/autofill.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,54 @@ import 'framework.dart';

export 'package:flutter/services.dart' show AutofillHints;

/// Predefined autofill context clean up actions.
enum AutofillContextAction {
/// Destroys the current autofill context after informing the platform to save
/// the user input from it.
///
/// Corresponds to calling [TextInput.finishAutofillContext] with
/// `shouldSave == true`.
commit,

/// Destroys the current autofill context without saving the user input.
///
/// Corresponds to calling [TextInput.finishAutofillContext] with
/// `shouldSave == false`.
cancel,
}

/// An [AutofillScope] widget that groups [AutofillClient]s together.
///
/// [AutofillClient]s within the same [AutofillScope] must be built together, and
/// they be will be autofilled together.
/// [AutofillClient]s that share the same closest [AutofillGroup] ancestor must
/// be built together, and they be will be autofilled together.
///
/// {@macro flutter.services.autofill.AutofillScope}
///
/// The [AutofillGroup] widget only knows about [AutofillClient]s registered to
/// it using the [AutofillGroupState.register] API. Typically, [AutofillGroup]
/// will not pick up [AutofillClient]s that are not mounted, for example, an
/// [AutofillClient] within a [Scrollable] that has never been scrolled into the
/// viewport. To workaround this problem, ensure clients in the same [AutofillGroup]
/// are built together:
/// viewport. To workaround this problem, ensure clients in the same
/// [AutofillGroup] are built together.
///
/// The topmost [AutofillGroup] widgets (the ones that are closest to the root
/// widget) can be used to clean up the current autofill context when the
/// current autofill context is no longer relevant.
///
/// {@macro flutter.services.autofill.autofillContext}
///
/// By default, [onDisposeAction] is set to [AutofillContextAction.commit], in
/// which case when any of the topmost [AutofillGroup]s is being disposed, the
/// platform will be informed to save the user input from the current autofill
/// context, then the current autofill context will be destroyed, to free
/// resources. You can, for example, wrap a route that contains a [Form] full of
/// autofillable input fields in an [AutofillGroup], so the user input of the
/// [Form] can be saved for future autofill by the platform.
///
/// {@tool dartpad --template=stateful_widget_scaffold}
///
/// An example form with autofillable fields grouped into different `AutofillGroup`s.
/// An example form with autofillable fields grouped into different
/// `AutofillGroup`s.
///
/// ```dart
/// bool isSameAddress = true;
Expand All @@ -44,8 +75,8 @@ export 'package:flutter/services.dart' show AutofillHints;
/// return ListView(
/// children: <Widget>[
/// const Text('Shipping address'),
/// // The address fields are grouped together as some platforms are capable
/// // of autofilling all these fields in one go.
/// // The address fields are grouped together as some platforms are
/// // capable of autofilling all of these fields in one go.
/// AutofillGroup(
/// child: Column(
/// children: <Widget>[
Expand Down Expand Up @@ -83,8 +114,8 @@ export 'package:flutter/services.dart' show AutofillHints;
/// ),
/// ),
/// const Text('Credit Card Information'),
/// // The credit card number and the security code are grouped together as
/// // some platforms are capable of autofilling both fields.
/// // The credit card number and the security code are grouped together
/// // as some platforms are capable of autofilling both fields.
/// AutofillGroup(
/// child: Column(
/// children: <Widget>[
Expand All @@ -111,13 +142,19 @@ export 'package:flutter/services.dart' show AutofillHints;
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [AutofillContextAction], an enum that contains predefined autofill context
/// clean up actions to be run when a topmost [AutofillGroup] is disposed.
class AutofillGroup extends StatefulWidget {
/// Creates a scope for autofillable input fields.
///
/// The [child] argument must not be null.
const AutofillGroup({
Key key,
@required this.child,
this.onDisposeAction = AutofillContextAction.commit,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert that this is not null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nullable, and on dispose action is going to be a no-op when it's null.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document what no-op in that case means (and instead of using null, can we define a AutofillContextAction for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why is a separate enum case preferred over null? I think in this case using null to stand for "no action should be taken" makes sense too. Is it because the analyzer has exhaustiveness check for enum cases?

}) : assert(child != null),
super(key: key);

Expand All @@ -137,6 +174,17 @@ class AutofillGroup extends StatefulWidget {
/// {@macro flutter.widgets.child}
final Widget child;

/// The [AutofillContextAction] to be run when this [AutofillGroup] is the
/// topmost [AutofillGroup] and it's being disposed, in order to clean up the
/// current autofill context.
///
/// {@macro flutter.services.autofill.autofillContext}
///
/// Defaults to [AutofillContextAction.commit], which prompts the platform to
/// save the user input and destroy the current autofill context. No action
/// will be taken if [onDisposeAction] is set to null.
final AutofillContextAction onDisposeAction;

@override
AutofillGroupState createState() => AutofillGroupState();
}
Expand All @@ -160,6 +208,11 @@ class AutofillGroup extends StatefulWidget {
class AutofillGroupState extends State<AutofillGroup> with AutofillScopeMixin {
final Map<String, AutofillClient> _clients = <String, AutofillClient>{};

// Whether this AutofillGroup widget is the topmost AutofillGroup (i.e., it
// has no AutofillGroup ancestor). Each topmost AutofillGroup runs its
// `AutofillGroup.onDisposeAction` when it gets disposed.
bool _isTopmostAutofillGroup = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The definition on topmost (the ones that are closest to the root widget) is in the comments on line 54. It is not very close to this variable. In order to increase readability of the code, and make it easier to understand by developers checking this variable, let's document topmost here as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment in the code.


@override
AutofillClient getAutofillClient(String tag) => _clients[tag];

Expand All @@ -184,7 +237,7 @@ class AutofillGroupState extends State<AutofillGroup> with AutofillScopeMixin {
_clients.putIfAbsent(client.autofillId, () => client);
}

/// Removes an [AutofillClient] with the given [autofillId] from this
/// Removes an [AutofillClient] with the given `autofillId` from this
/// [AutofillGroup].
///
/// Typically, this should be called by autofillable [TextInputClient]s in
Expand All @@ -203,13 +256,35 @@ class AutofillGroupState extends State<AutofillGroup> with AutofillScopeMixin {
_clients.remove(autofillId);
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_isTopmostAutofillGroup = AutofillGroup.of(context) == null;
}

@override
Widget build(BuildContext context) {
return _AutofillScope(
autofillScopeState: this,
child: widget.child,
);
}

@override
void dispose() {
super.dispose();

if (!_isTopmostAutofillGroup || widget.onDisposeAction == null)
return;
switch (widget.onDisposeAction) {
case AutofillContextAction.cancel:
TextInput.finishAutofillContext(shouldSave: false);
break;
case AutofillContextAction.commit:
TextInput.finishAutofillContext(shouldSave: true);
break;
}
}
}

class _AutofillScope extends InheritedWidget {
Expand Down
42 changes: 32 additions & 10 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
AutofillScope get currentAutofillScope => _currentAutofillScope;

// Is this field in the current autofill context.
bool _isInAutofillContext = false;

// This value is an eyeball estimation of the time it takes for the iOS cursor
// to ease in and out.
static const Duration _fadeDuration = Duration(milliseconds: 250);
Expand Down Expand Up @@ -1404,6 +1407,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_currentAutofillScope?.unregister(autofillId);
_currentAutofillScope = newAutofillGroup;
newAutofillGroup?.register(this);
_isInAutofillContext = _isInAutofillContext || _shouldBeInAutofillContext;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_isInAutofillContext can never be set to false once it becomes true, is that right?

Copy link
Contributor Author

@LongCatIsLooong LongCatIsLooong Jul 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. If a text field's was in the current autofill context, and then its autofill gets disabled (by setting autofillHints to null), the engine needs to remove it from the autofill context. It lets the engine know by sending its new TextInputConfiguration, instead of remain silent in which case the engine will just assume the text field is gone and it will keep the old user input (See line 1528).

}

if (!_didAutoFocus && widget.autofocus) {
Expand All @@ -1428,6 +1432,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_selectionOverlay?.update(_value);
}
_selectionOverlay?.handlesVisible = widget.showSelectionHandles;
_isInAutofillContext = _isInAutofillContext || _shouldBeInAutofillContext;

if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach();
Expand Down Expand Up @@ -1710,6 +1716,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}

bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached;
bool get _needsAutofill => widget.autofillHints?.isNotEmpty ?? false;
bool get _shouldBeInAutofillContext => _needsAutofill && currentAutofillScope != null;

void _openInputConnection() {
if (widget.readOnly) {
Expand All @@ -1719,14 +1727,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final TextEditingValue localValue = _value;
_lastFormattedUnmodifiedTextEditingValue = localValue;

_textInputConnection = (widget.autofillHints?.isNotEmpty ?? false) && currentAutofillScope != null
// When _needsAutofill == true && currentAutofillScope == null, autofill
// is allowed but saving the user input from the text field is
// discouraged.
//
// In case the autofillScope changes from a non-null value to null, or
// _needsAutofill changes to false from true, the platform needs to be
// notified to exclude this field from the autofill context. So we need to
// provide the autofillId.
_textInputConnection = _needsAutofill && currentAutofillScope != null
? currentAutofillScope.attach(this, textInputConfiguration)
: TextInput.attach(this, textInputConfiguration);
: TextInput.attach(this, _createTextInputConfiguration(_isInAutofillContext || _needsAutofill));
_textInputConnection.show();
_updateSizeAndTransform();
// Request autofill AFTER the size and the transform have been sent to the
// platform side.
_textInputConnection.requestAutofill();
if (_needsAutofill) {
// Request autofill AFTER the size and the transform have been sent to
// the platform text input plugin.
_textInputConnection.requestAutofill();
}

final TextStyle style = widget.style;
_textInputConnection
Expand Down Expand Up @@ -2157,9 +2175,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
String get autofillId => 'EditableText-$hashCode';

@override
TextInputConfiguration get textInputConfiguration {
final bool isAutofillEnabled = widget.autofillHints?.isNotEmpty ?? false;
TextInputConfiguration _createTextInputConfiguration(bool needsAutofillConfiguration) {
assert(needsAutofillConfiguration != null);
return TextInputConfiguration(
inputType: widget.keyboardType,
obscureText: widget.obscureText,
Expand All @@ -2173,14 +2190,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
),
textCapitalization: widget.textCapitalization,
keyboardAppearance: widget.keyboardAppearance,
autofillConfiguration: !isAutofillEnabled ? null : AutofillConfiguration(
autofillConfiguration: !needsAutofillConfiguration ? null : AutofillConfiguration(
uniqueIdentifier: autofillId,
autofillHints: widget.autofillHints.toList(growable: false),
autofillHints: widget.autofillHints?.toList(growable: false) ?? <String>[],
currentEditingValue: currentTextEditingValue,
),
);
}

@override
TextInputConfiguration get textInputConfiguration {
return _createTextInputConfiguration(_needsAutofill);
}

// null if no promptRect should be shown.
TextRange _currentPromptRectRange;

Expand Down
Loading