diff --git a/packages/flutter/lib/src/material/feedback.dart b/packages/flutter/lib/src/material/feedback.dart index dd9ad63387..6562eb6c4f 100644 --- a/packages/flutter/lib/src/material/feedback.dart +++ b/packages/flutter/lib/src/material/feedback.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -86,6 +88,7 @@ class Feedback { /// * [wrapForTap] to trigger platform-specific feedback before executing a /// [GestureTapCallback]. static Future forTap(BuildContext context) async { + context.findRenderObject().sendSemanticsEvent(const TapSemanticEvent()); switch (_platform(context)) { case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -124,6 +127,7 @@ class Feedback { /// * [wrapForLongPress] to trigger platform-specific feedback before /// executing a [GestureLongPressCallback]. static Future forLongPress(BuildContext context) { + context.findRenderObject().sendSemanticsEvent(const LongPressSemanticsEvent()); switch (_platform(context)) { case TargetPlatform.android: case TargetPlatform.fuchsia: diff --git a/packages/flutter/lib/src/material/toggleable.dart b/packages/flutter/lib/src/material/toggleable.dart index a09a0227b5..fa874a7a32 100644 --- a/packages/flutter/lib/src/material/toggleable.dart +++ b/packages/flutter/lib/src/material/toggleable.dart @@ -291,6 +291,7 @@ abstract class RenderToggleable extends RenderConstrainedBox { onChanged(false); break; } + sendSemanticsEvent(const TapSemanticEvent()); } void _handleTapUp(TapUpDetails details) { diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index f49c46f1d6..6aad662027 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -2186,6 +2186,25 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im // Nothing to do by default. } + /// Sends a [SemanticsEvent] associated with this render object's [SemanticsNode]. + /// + /// If this render object has no semantics information, the first parent + /// render object with a non-null semantic node is used. + /// + /// If semantics are disabled, no events are dispatched. + /// + /// See [SemanticsNode.sendEvent] for a full description of the behavior. + void sendSemanticsEvent(SemanticsEvent semanticsEvent) { + if (owner.semanticsOwner == null) + return; + if (_semantics != null) { + _semantics.sendEvent(semanticsEvent); + } else if (parent != null) { + final RenderObject renderParent = parent; + renderParent.sendSemanticsEvent(semanticsEvent); + } + } + // Use [_semanticsConfiguration] to access. SemanticsConfiguration _cachedSemanticsConfiguration; diff --git a/packages/flutter/lib/src/semantics/semantics_event.dart b/packages/flutter/lib/src/semantics/semantics_event.dart index 139e876142..45b1b3d359 100644 --- a/packages/flutter/lib/src/semantics/semantics_event.dart +++ b/packages/flutter/lib/src/semantics/semantics_event.dart @@ -107,3 +107,29 @@ class TooltipSemanticsEvent extends SemanticsEvent { }; } } + +/// An event which triggers long press semantic feedback. +/// +/// Currently only honored on Android. Triggers a long-press specific sound +/// when TalkBack is enabled. +class LongPressSemanticsEvent extends SemanticsEvent { + + /// Constructs an event that triggers a long-press semantic feedback by the platform. + const LongPressSemanticsEvent() : super('longPress'); + + @override + Map getDataMap() => const {}; +} + +/// An event which triggers tap semantic feedback. +/// +/// Currently only honored on Android. Triggers a tap specific sound when +/// TalkBack is enabled. +class TapSemanticEvent extends SemanticsEvent { + + /// Constructs an event that triggers a long-press semantic feedback by the platform. + const TapSemanticEvent() : super('tap'); + + @override + Map getDataMap() => const {}; +} diff --git a/packages/flutter/lib/src/semantics/semantics_service.dart b/packages/flutter/lib/src/semantics/semantics_service.dart index ebd40dac7f..815ae78db6 100644 --- a/packages/flutter/lib/src/semantics/semantics_service.dart +++ b/packages/flutter/lib/src/semantics/semantics_service.dart @@ -34,7 +34,8 @@ class SemanticsService { /// Sends a semantic announcement of a tooltip. /// - /// This is only used by Android. + /// Currently only honored on Android. The contents of [message] will be + /// read by TalkBack. static Future tooltip(String message) async { final TooltipSemanticsEvent event = new TooltipSemanticsEvent(message); await SystemChannels.accessibility.send(event.toMap()); diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index ba37c163df..88e2e62722 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; @@ -200,4 +201,44 @@ void main() { await tester.pumpAndSettle(); expect(checkBoxValue, null); }); + + testWidgets('has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + bool checkboxValue = false; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) { + semanticEvent = message; + }); + final SemanticsTester semanticsTester = new SemanticsTester(tester); + + await tester.pumpWidget( + new Material( + child: new StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return new Checkbox( + value: checkboxValue, + onChanged: (bool value) { + setState(() { + checkboxValue = value; + }); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.byType(Checkbox)); + final RenderObject object = tester.firstRenderObject(find.byType(Checkbox)); + + expect(checkboxValue, true); + expect(semanticEvent, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true); + + SystemChannels.accessibility.setMockMessageHandler(null); + semanticsTester.dispose(); + }); } diff --git a/packages/flutter/test/material/feedback_test.dart b/packages/flutter/test/material/feedback_test.dart index c741ab78c0..c4470652b7 100644 --- a/packages/flutter/test/material/feedback_test.dart +++ b/packages/flutter/test/material/feedback_test.dart @@ -2,9 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; void main () { @@ -21,8 +25,23 @@ void main () { }); group('Feedback on Android', () { + List> semanticEvents; + + setUp(() { + semanticEvents = >[]; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) { + final Map typedMessage = message; + semanticEvents.add(typedMessage.cast()); + }); + }); + + tearDown(() { + SystemChannels.accessibility.setMockMessageHandler(null); + }); testWidgets('forTap', (WidgetTester tester) async { + final SemanticsTester semanticsTester = new SemanticsTester(tester); + await tester.pumpWidget(new TestWidget( tapHandler: (BuildContext context) { return () => Feedback.forTap(context); @@ -31,14 +50,27 @@ void main () { await tester.pumpAndSettle(kWaitDuration); expect(feedback.hapticCount, 0); expect(feedback.clickSoundCount, 0); + expect(semanticEvents, isEmpty); await tester.tap(find.text('X')); await tester.pumpAndSettle(kWaitDuration); + final RenderObject object = tester.firstRenderObject(find.byType(GestureDetector)); + expect(feedback.hapticCount, 0); expect(feedback.clickSoundCount, 1); + expect(semanticEvents.single, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); }); testWidgets('forTap Wrapper', (WidgetTester tester) async { + final SemanticsTester semanticsTester = new SemanticsTester(tester); + int callbackCount = 0; final VoidCallback callback = () { callbackCount++; @@ -56,12 +88,24 @@ void main () { await tester.tap(find.text('X')); await tester.pumpAndSettle(kWaitDuration); + final RenderObject object = tester.firstRenderObject(find.byType(GestureDetector)); + expect(feedback.hapticCount, 0); expect(feedback.clickSoundCount, 1); expect(callbackCount, 1); + expect(semanticEvents.single, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); }); testWidgets('forLongPress', (WidgetTester tester) async { + final SemanticsTester semanticsTester = new SemanticsTester(tester); + await tester.pumpWidget(new TestWidget( longPressHandler: (BuildContext context) { return () => Feedback.forLongPress(context); @@ -73,11 +117,22 @@ void main () { await tester.longPress(find.text('X')); await tester.pumpAndSettle(kWaitDuration); + final RenderObject object = tester.firstRenderObject(find.byType(GestureDetector)); + expect(feedback.hapticCount, 1); expect(feedback.clickSoundCount, 0); + expect(semanticEvents.single, { + 'type': 'longPress', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.longPress), true); + + semanticsTester.dispose(); }); testWidgets('forLongPress Wrapper', (WidgetTester tester) async { + final SemanticsTester semanticsTester = new SemanticsTester(tester); int callbackCount = 0; final VoidCallback callback = () { callbackCount++; @@ -89,6 +144,8 @@ void main () { }, )); await tester.pumpAndSettle(kWaitDuration); + final RenderObject object = tester.firstRenderObject(find.byType(GestureDetector)); + expect(feedback.hapticCount, 0); expect(feedback.clickSoundCount, 0); expect(callbackCount, 0); @@ -98,6 +155,14 @@ void main () { expect(feedback.hapticCount, 1); expect(feedback.clickSoundCount, 0); expect(callbackCount, 1); + expect(semanticEvents.single, { + 'type': 'longPress', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.longPress), true); + + semanticsTester.dispose(); }); }); diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart index e857d33a4d..811c11e5b2 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; @@ -182,4 +183,40 @@ void main() { semantics.dispose(); }); + + testWidgets('has semantic events', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + final Key key = new UniqueKey(); + dynamic semanticEvent; + int radioValue = 2; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) { + semanticEvent = message; + }); + + await tester.pumpWidget(new Material( + child: new Radio( + key: key, + value: 1, + groupValue: radioValue, + onChanged: (int i) { + radioValue = i; + }, + ), + )); + + await tester.tap(find.byKey(key)); + final RenderObject object = tester.firstRenderObject(find.byKey(key)); + + expect(radioValue, 1); + expect(semanticEvent, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semantics.dispose(); + SystemChannels.accessibility.setMockMessageHandler(null); + }); } + diff --git a/packages/flutter/test/material/switch_test.dart b/packages/flutter/test/material/switch_test.dart index 38eb5fcfc9..48a72949e0 100644 --- a/packages/flutter/test/material/switch_test.dart +++ b/packages/flutter/test/material/switch_test.dart @@ -4,9 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; void main() { testWidgets('Switch can toggle on tap', (WidgetTester tester) async { @@ -228,4 +230,48 @@ void main() { expect(value, isTrue); expect(tester.hasRunningAnimations, false); }); + + testWidgets('switch has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + bool value = false; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) { + semanticEvent = message; + }); + final SemanticsTester semanticsTester = new SemanticsTester(tester); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return new Material( + child: new Center( + child: new Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(Switch)); + final RenderObject object = tester.firstRenderObject(find.byType(Switch)); + + expect(value, true); + expect(semanticEvent, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); + SystemChannels.accessibility.setMockMessageHandler(null); + }); } diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 2cf59b65ee..f0c8972835 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -4,6 +4,7 @@ import 'dart:ui'; // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -676,4 +677,51 @@ void main() { semantics.dispose(); }); + testWidgets('has semantic events', (WidgetTester tester) async { + final List semanticEvents = []; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) { + semanticEvents.add(message); + }); + final SemanticsTester semantics = new SemanticsTester(tester); + + await tester.pumpWidget( + new MaterialApp( + home: new Center( + child: new Tooltip( + message: 'Foo', + child: new Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + final RenderObject object = tester.firstRenderObject(find.byType(Tooltip)); + + expect(semanticEvents, unorderedEquals([ + { + 'type': 'longPress', + 'nodeId': findDebugSemantics(object).id, + 'data': {}, + }, + { + 'type': 'tooltip', + 'data': { + 'message': 'Foo', + }, + }, + ])); + semantics.dispose(); + SystemChannels.accessibility.setMockMessageHandler(null); + }); +} + +SemanticsNode findDebugSemantics(RenderObject object) { + if (object.debugSemantics != null) + return object.debugSemantics; + return findDebugSemantics(object.parent); }