diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index a8bdb95225..c94ea0490b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; import 'box.dart'; import 'object.dart'; +import 'semantics.dart'; import 'viewport_offset.dart'; const double _kCaretGap = 1.0; // pixels @@ -105,6 +106,7 @@ class RenderEditable extends RenderBox { TextAlign textAlign: TextAlign.start, Color cursorColor, ValueNotifier showCursor, + bool hasFocus, int maxLines: 1, Color selectionColor, double textScaleFactor: 1.0, @@ -125,6 +127,7 @@ class RenderEditable extends RenderBox { ), _cursorColor = cursorColor, _showCursor = showCursor ?? new ValueNotifier(false), + _hasFocus = hasFocus ?? false, _maxLines = maxLines, _selection = selection, _offset = offset { @@ -227,6 +230,17 @@ class RenderEditable extends RenderBox { markNeedsPaint(); } + /// Whether the editable is currently focused. + bool get hasFocus => _hasFocus; + bool _hasFocus; + set hasFocus(bool value) { + assert(value != null); + if (_hasFocus == value) + return; + _hasFocus = value; + markNeedsSemanticsUpdate(); + } + /// The maximum number of lines for the text to span, wrapping if necessary. /// /// If this is 1 (the default), the text will not wrap, but will extend @@ -303,6 +317,15 @@ class RenderEditable extends RenderBox { markNeedsLayout(); } + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + config + ..isFocused = hasFocus + ..isTextField = true; + } + @override void attach(PipelineOwner owner) { super.attach(owner); diff --git a/packages/flutter/lib/src/rendering/semantics.dart b/packages/flutter/lib/src/rendering/semantics.dart index e8e2bc0dfc..266c67cc22 100644 --- a/packages/flutter/lib/src/rendering/semantics.dart +++ b/packages/flutter/lib/src/rendering/semantics.dart @@ -783,7 +783,9 @@ class SemanticsNode extends AbstractNode with DiagnosticableTreeMixin { if (_hasFlag(SemanticsFlags.hasCheckedState)) properties.add(new FlagProperty('isChecked', value: _hasFlag(SemanticsFlags.isChecked), ifTrue: 'checked', ifFalse: 'unchecked')); properties.add(new FlagProperty('isSelected', value: _hasFlag(SemanticsFlags.isSelected), ifTrue: 'selected')); + properties.add(new FlagProperty('isFocused', value: _hasFlag(SemanticsFlags.isFocused), ifTrue: 'focused')); properties.add(new FlagProperty('isButton', value: _hasFlag(SemanticsFlags.isButton), ifTrue: 'button')); + properties.add(new FlagProperty('isTextField', value: _hasFlag(SemanticsFlags.isTextField), ifTrue: 'textField')); properties.add(new StringProperty('label', _label, defaultValue: '')); properties.add(new StringProperty('value', _value, defaultValue: '')); properties.add(new StringProperty('increasedValue', _increasedValue, defaultValue: '')); @@ -1233,11 +1235,21 @@ class SemanticsConfiguration { _setFlag(SemanticsFlags.isChecked, value); } + /// Whether the owning [RenderObject] currently holds the user's focus. + set isFocused(bool value) { + _setFlag(SemanticsFlags.isFocused, value); + } + /// Whether the owning [RenderObject] is a button (true) or not (false). set isButton(bool value) { _setFlag(SemanticsFlags.isButton, value); } + /// Whether the owning [RenderObject] is a text field. + set isTextField(bool value) { + _setFlag(SemanticsFlags.isTextField, value); + } + // TAGS /// The set of tags that this configuration wants to add to all child diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 87f73bf2ae..559e8bb72d 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -638,6 +638,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien style: widget.style, cursorColor: widget.cursorColor, showCursor: _showCursor, + hasFocus: _hasFocus, maxLines: widget.maxLines, selectionColor: widget.selectionColor, textScaleFactor: widget.textScaleFactor ?? MediaQuery.of(context, nullOk: true)?.textScaleFactor ?? 1.0, @@ -663,6 +664,7 @@ class _Editable extends LeafRenderObjectWidget { this.style, this.cursorColor, this.showCursor, + this.hasFocus, this.maxLines, this.selectionColor, this.textScaleFactor, @@ -681,6 +683,7 @@ class _Editable extends LeafRenderObjectWidget { final TextStyle style; final Color cursorColor; final ValueNotifier showCursor; + final bool hasFocus; final int maxLines; final Color selectionColor; final double textScaleFactor; @@ -699,6 +702,7 @@ class _Editable extends LeafRenderObjectWidget { text: _styledTextSpan, cursorColor: cursorColor, showCursor: showCursor, + hasFocus: hasFocus, maxLines: maxLines, selectionColor: selectionColor, textScaleFactor: textScaleFactor, @@ -717,6 +721,7 @@ class _Editable extends LeafRenderObjectWidget { ..text = _styledTextSpan ..cursorColor = cursorColor ..showCursor = showCursor + ..hasFocus = hasFocus ..maxLines = maxLines ..selectionColor = selectionColor ..textScaleFactor = textScaleFactor diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 5cc9ce648b..1a846b2d3d 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -3,12 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:ui' show SemanticsFlags; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; class MockClipboard { @@ -1637,4 +1639,25 @@ void main() { expect(find.text('5 / 10'), findsOneWidget); }); + + testWidgets('TextField identifies as text field in semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + await tester.pumpWidget( + new MaterialApp( + home: const Material( + child: const DefaultTextStyle( + style: const TextStyle(fontFamily: 'Ahem', fontSize: 10.0), + child: const Center( + child: const TextField( + maxLength: 10, + ), + ), + ), + ), + ), + ); + + expect(semantics, includesNodeWith(flags: [SemanticsFlags.isTextField])); + }); } diff --git a/packages/flutter/test/rendering/semantics_test.dart b/packages/flutter/test/rendering/semantics_test.dart index 22373dff76..06ac77b08c 100644 --- a/packages/flutter/test/rendering/semantics_test.dart +++ b/packages/flutter/test/rendering/semantics_test.dart @@ -198,7 +198,7 @@ void main() { expect( minimalProperties.toStringDeep(minLevel: DiagnosticLevel.hidden), - 'SemanticsNode#16(owner: null, isPartOfNodeMerging: false, Rect.fromLTRB(0.0, 0.0, 0.0, 0.0), actions: [], isSelected: false, isButton: false, label: "", value: "", increasedValue: "", decreasedValue: "", hint: "", textDirection: null)\n', + 'SemanticsNode#16(owner: null, isPartOfNodeMerging: false, Rect.fromLTRB(0.0, 0.0, 0.0, 0.0), actions: [], isSelected: false, isFocused: false, isButton: false, isTextField: false, label: "", value: "", increasedValue: "", decreasedValue: "", hint: "", textDirection: null)\n' ); final SemanticsConfiguration config = new SemanticsConfiguration() diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 2907a00103..3e4cf5231e 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2,11 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show SemanticsFlags; + +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +import 'semantics_tester.dart'; + void main() { final TextEditingController controller = new TextEditingController(); final FocusNode focusNode = new FocusNode(); @@ -250,4 +255,33 @@ void main() { }), ]); }); + + testWidgets('EditableText identifies as text field (w/ focus) in semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new FocusScope( + node: focusScopeNode, + autofocus: true, + child: new EditableText( + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ); + + expect(semantics, includesNodeWith(flags: [SemanticsFlags.isTextField])); + + await tester.tap(find.byType(EditableText)); + await tester.idle(); + await tester.pump(); + + expect(semantics, includesNodeWith(flags: [SemanticsFlags.isTextField, SemanticsFlags.isFocused])); + + }); } diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 0e2437dfac..16327034ba 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show SemanticsFlags; + import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -314,11 +316,13 @@ class _IncludesNodeWith extends Matcher { this.label, this.textDirection, this.actions, -}) : assert(label != null || actions != null); + this.flags, +}) : assert(label != null || actions != null || flags != null); final String label; final TextDirection textDirection; final List actions; + final List flags; @override bool matches(covariant SemanticsTester item, Map matchState) { @@ -348,6 +352,12 @@ class _IncludesNodeWith extends Matcher { if (expectedActions != actualActions) return false; } + if (flags != null) { + final int expectedFlags = flags.fold(0, (int value, SemanticsFlags flag) => value | flag.index); + final int actualFlags = node.getSemanticsData().flags; + if (expectedFlags != actualFlags) + return false; + } return true; } @@ -362,22 +372,16 @@ class _IncludesNodeWith extends Matcher { } String get _configAsString { - String string = ''; - if (label != null) { - string += 'label "$label"'; - if (textDirection != null) - string += ' (${describeEnum(textDirection)})'; - if (actions != null) - string += ' and '; - } else if (textDirection != null) { - string += 'direction ${describeEnum(textDirection)}'; - if (actions != null) - string += ' and '; - } - if (actions != null) { - string += 'actions "${actions.join(', ')}"'; - } - return string; + final List strings = []; + if (label != null) + strings.add('label "$label"'); + if (textDirection != null) + strings.add(' (${describeEnum(textDirection)})'); + if (actions != null) + strings.add('actions "${actions.join(', ')}"'); + if (flags != null) + strings.add('flags "${flags.join(', ')}"'); + return strings.join(', '); } } @@ -385,10 +389,16 @@ class _IncludesNodeWith extends Matcher { /// `textDirection`, and `actions`. /// /// If null is provided for an argument, it will match against any value. -Matcher includesNodeWith({ String label, TextDirection textDirection, List actions }) { +Matcher includesNodeWith({ + String label, + TextDirection textDirection, + List actions, + List flags, +}) { return new _IncludesNodeWith( label: label, textDirection: textDirection, actions: actions, + flags: flags, ); }