diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 2a56826c49..25bc23206f 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -730,9 +730,14 @@ class _TabBarState extends State { // then give all of the tabs equal flexibility so that their widths // reflect the intrinsic width of their labels. for (int index = 0; index < widget.tabs.length; index++) { - wrappedTabs[index] = new InkWell( - onTap: () { _handleTap(index); }, - child: wrappedTabs[index], + wrappedTabs[index] = new MergeSemantics( + child: new Semantics( + selected: index == _currentIndex, + child: new InkWell( + onTap: () { _handleTap(index); }, + child: wrappedTabs[index], + ), + ), ); if (!widget.isScrollable) wrappedTabs[index] = new Expanded(child: wrappedTabs[index]); diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 06872dc648..86e59a3ec9 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2873,10 +2873,12 @@ class RenderSemanticsAnnotations extends RenderProxyBox { RenderBox child, bool container: false, bool checked, - String label + bool selected, + String label, }) : assert(container != null), _container = container, _checked = checked, + _selected = selected, _label = label, super(child); @@ -2900,8 +2902,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { markNeedsSemanticsUpdate(); } - /// If non-null, sets the "hasCheckedState" semantic to true and the - /// "isChecked" semantic to the given value. + /// If non-null, sets the [SemanticsNode.hasCheckedState] semantic to true and + /// the [SemanticsNode.isChecked] semantic to the given value. bool get checked => _checked; bool _checked; set checked(bool value) { @@ -2912,7 +2914,19 @@ class RenderSemanticsAnnotations extends RenderProxyBox { markNeedsSemanticsUpdate(onlyChanges: (value != null) == hadValue); } - /// If non-null, sets the "label" semantic to the given value. + /// If non-null, sets the [SemanticsNode.isSelected] semantic to the given + /// value. + bool get selected => _selected; + bool _selected; + set selected(bool value) { + if (selected == value) + return; + final bool hadValue = selected != null; + _selected = value; + markNeedsSemanticsUpdate(onlyChanges: (value != null) == hadValue); + } + + /// If non-null, sets the [SemanticsNode.label] semantic to the given value. String get label => _label; String _label; set label(String value) { @@ -2927,7 +2941,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox { bool get isSemanticBoundary => container; @override - SemanticsAnnotator get semanticsAnnotator => checked != null || label != null ? _annotate : null; + SemanticsAnnotator get semanticsAnnotator => checked != null || selected != null || label != null ? _annotate : null; void _annotate(SemanticsNode node) { if (checked != null) { @@ -2935,6 +2949,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { ..hasCheckedState = true ..isChecked = checked; } + if (selected != null) + node.isSelected = selected; if (label != null) node.label = label; } diff --git a/packages/flutter/lib/src/rendering/semantics.dart b/packages/flutter/lib/src/rendering/semantics.dart index a2bc8edd85..cddd67779e 100644 --- a/packages/flutter/lib/src/rendering/semantics.dart +++ b/packages/flutter/lib/src/rendering/semantics.dart @@ -292,6 +292,10 @@ class SemanticsNode extends AbstractNode { bool get isChecked => (_flags & SemanticsFlags.isChecked.index) != 0; set isChecked(bool value) => _setFlag(SemanticsFlags.isChecked, value); + /// Whether the current node is selected (true) or not (false). + bool get isSelected => (_flags & SemanticsFlags.isSelected.index) != 0; + set isSelected(bool value) => _setFlag(SemanticsFlags.isSelected, value); + /// A textual description of this node. String get label => _label; String _label = ''; @@ -595,6 +599,8 @@ class SemanticsNode extends AbstractNode { else buffer.write('; unchecked'); } + if (isSelected) + buffer.write('; selected'); if (label.isNotEmpty) buffer.write('; "$label"'); buffer.write(')'); diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index f492c84a13..bf462de3ff 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -3635,7 +3635,8 @@ class Semantics extends SingleChildRenderObjectWidget { Widget child, this.container: false, this.checked, - this.label + this.selected, + this.label, }) : assert(container != null), super(key: key, child: child); @@ -3656,6 +3657,13 @@ class Semantics extends SingleChildRenderObjectWidget { /// state is. final bool checked; + /// If non-null indicates that this subtree represents something that can be + /// in a selected or unselected state, and what its current state is. + /// + /// The active tab in a tab bar for example is considered "selected", whereas + /// all other tabs are unselected. + final bool selected; + /// Provides a textual description of the widget. final String label; @@ -3663,7 +3671,8 @@ class Semantics extends SingleChildRenderObjectWidget { RenderSemanticsAnnotations createRenderObject(BuildContext context) => new RenderSemanticsAnnotations( container: container, checked: checked, - label: label + selected: selected, + label: label, ); @override @@ -3671,6 +3680,7 @@ class Semantics extends SingleChildRenderObjectWidget { renderObject ..container = container ..checked = checked + ..selected = selected ..label = label; } @@ -3680,6 +3690,8 @@ class Semantics extends SingleChildRenderObjectWidget { description.add('container: $container'); if (checked != null) description.add('checked: $checked'); + if (selected != null) + description.add('selected: $selected'); if (label != null) description.add('label: "$label"'); } diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index 4893b5f3ee..47c32922e0 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -2,12 +2,15 @@ // 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, SemanticsAction; + import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import '../rendering/mock_canvas.dart'; import '../rendering/recording_canvas.dart'; +import '../widgets/semantics_tester.dart'; class StateMarker extends StatefulWidget { const StateMarker({ Key key, this.child }) : super(key: key); @@ -900,6 +903,62 @@ void main() { rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight) )); }); + + testWidgets('correct semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + final List tabs = new List.generate(2, (int index) { + return new Tab(text: 'TAB #$index'); + }); + + final TabController controller = new TabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: 0, + ); + + await tester.pumpWidget( + new Material( + child: new Semantics( + container: true, + child: new TabBar( + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final TestSemantics expectedSemantics = new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: [ + new TestSemantics( + id: 2, + actions: SemanticsAction.tap.index, + flags: SemanticsFlags.isSelected.index, + label: 'TAB #0', + rect: new Rect.fromLTRB(0.0, 0.0, 108.0, 46.0), + transform: new Matrix4.translationValues(0.0, 276.0, 0.0), + ), + new TestSemantics( + id: 4, + actions: SemanticsAction.tap.index, + label: 'TAB #1', + rect: new Rect.fromLTRB(0.0, 0.0, 108.0, 46.0), + transform: new Matrix4.translationValues(108.0, 276.0, 0.0), + ), + ]), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); testWidgets('TabBar etc with zero tabs', (WidgetTester tester) async { final TabController controller = new TabController(