From 4de692a2b93e5f27ef8710a70dc8d2a797268cfa Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Tue, 7 Aug 2018 10:11:21 -0700 Subject: [PATCH] Add AccessibilityFeatures to media query and fix Snackbar a11y behavior (#19336) --- .../flutter/lib/src/material/scaffold.dart | 29 ++-- .../flutter/lib/src/material/snack_bar.dart | 76 +++++----- .../flutter/lib/src/rendering/binding.dart | 6 + packages/flutter/lib/src/widgets/app.dart | 10 ++ packages/flutter/lib/src/widgets/binding.dart | 29 +++- .../flutter/lib/src/widgets/media_query.dart | 69 ++++++++- .../flutter/test/material/snack_bar_test.dart | 132 ++++++++++++++++++ .../test/widgets/media_query_test.dart | 24 ++++ 8 files changed, 330 insertions(+), 45 deletions(-) diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 1364be9d42..a04514717d 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -1162,12 +1162,18 @@ class ScaffoldState extends State with TickerProviderStateMixin { assert(reason != null); if (_snackBars.isEmpty || _snackBarController.status == AnimationStatus.dismissed) return; + final MediaQueryData mediaQuery = MediaQuery.of(context); final Completer completer = _snackBars.first._completer; - _snackBarController.reverse().then((Null _) { - assert(mounted); - if (!completer.isCompleted) - completer.complete(reason); - }); + if (mediaQuery.accessibleNavigation) { + _snackBarController.value = 0.0; + completer.complete(reason); + } else { + _snackBarController.reverse().then((Null _) { + assert(mounted); + if (!completer.isCompleted) + completer.complete(reason); + }); + } _snackBarTimer?.cancel(); _snackBarTimer = null; } @@ -1480,12 +1486,19 @@ class ScaffoldState extends State with TickerProviderStateMixin { if (_snackBars.isNotEmpty) { final ModalRoute route = ModalRoute.of(context); if (route == null || route.isCurrent) { - if (_snackBarController.isCompleted && _snackBarTimer == null) - _snackBarTimer = new Timer(_snackBars.first._widget.duration, () { + if (_snackBarController.isCompleted && _snackBarTimer == null) { + final SnackBar snackBar = _snackBars.first._widget; + _snackBarTimer = new Timer.periodic(snackBar.duration, (Timer timer) { assert(_snackBarController.status == AnimationStatus.forward || - _snackBarController.status == AnimationStatus.completed); + _snackBarController.status == AnimationStatus.completed); + // Look up MediaQuery again in case the setting changed. + final MediaQueryData mediaQuery = MediaQuery.of(context); + if (mediaQuery.accessibleNavigation && snackBar.action != null) + return; + timer.cancel(); hideCurrentSnackBar(reason: SnackBarClosedReason.timeout); }); + } } else { _snackBarTimer?.cancel(); _snackBarTimer = null; diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index 5a8563c6a4..406a883952 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'button_theme.dart'; @@ -46,6 +47,9 @@ enum SnackBarClosedReason { /// The snack bar was closed after the user tapped a [SnackBarAction]. action, + /// The snack bar was closed through a [SemanticAction.dismiss]. + dismiss, + /// The snack bar was closed by a user's swipe. swipe, @@ -126,6 +130,9 @@ class _SnackBarActionState extends State { /// /// To control how long the [SnackBar] remains visible, specify a [duration]. /// +/// A SnackBar with an action will not time out when TalkBack or VoiceOver are +/// enabled. This is controlled by [AccessibilityFeatures.accessibleNavigation]. +/// /// See also: /// /// * [Scaffold.of], to obtain the current [ScaffoldState], which manages the @@ -183,6 +190,7 @@ class SnackBar extends StatelessWidget { @override Widget build(BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); assert(animation != null); final ThemeData theme = Theme.of(context); final ThemeData darkTheme = new ThemeData( @@ -213,8 +221,41 @@ class SnackBar extends StatelessWidget { } final CurvedAnimation heightAnimation = new CurvedAnimation(parent: animation, curve: _snackBarHeightCurve); final CurvedAnimation fadeAnimation = new CurvedAnimation(parent: animation, curve: _snackBarFadeCurve, reverseCurve: const Threshold(0.0)); + Widget snackbar = new SafeArea( + top: false, + child: new Row( + children: children, + crossAxisAlignment: CrossAxisAlignment.center, + ), + ); + snackbar = new Semantics( + container: true, + liveRegion: true, + onDismiss: () { + Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss); + }, + child: new Dismissible( + key: const Key('dismissible'), + direction: DismissDirection.down, + resizeDuration: null, + onDismissed: (DismissDirection direction) { + Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe); + }, + child: new Material( + elevation: 6.0, + color: backgroundColor ?? _kSnackBackground, + child: new Theme( + data: darkTheme, + child: mediaQueryData.accessibleNavigation ? snackbar : new FadeTransition( + opacity: fadeAnimation, + child: snackbar, + ), + ), + ), + ), + ); return new ClipRect( - child: new AnimatedBuilder( + child: mediaQueryData.accessibleNavigation ? snackbar : new AnimatedBuilder( animation: heightAnimation, builder: (BuildContext context, Widget child) { return new Align( @@ -223,38 +264,7 @@ class SnackBar extends StatelessWidget { child: child, ); }, - child: new Semantics( - liveRegion: true, - container: true, - onDismiss: () { - Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe); - }, - child: new Dismissible( - key: const Key('dismissible'), - direction: DismissDirection.down, - resizeDuration: null, - onDismissed: (DismissDirection direction) { - Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe); - }, - child: new Material( - elevation: 6.0, - color: backgroundColor ?? _kSnackBackground, - child: new Theme( - data: darkTheme, - child: new FadeTransition( - opacity: fadeAnimation, - child: new SafeArea( - top: false, - child: new Row( - children: children, - crossAxisAlignment: CrossAxisAlignment.center, - ), - ), - ), - ), - ), - ), - ), + child: snackbar, ), ); } diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index 4dcc3040ce..88b1c2e1b1 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -153,6 +153,12 @@ abstract class RendererBinding extends BindingBase with ServicesBinding, Schedul @protected void handleTextScaleFactorChanged() { } + /// Called when the platform accessibility features change. + /// + /// See [Window.onAccessibilityFeaturesChanged]. + @protected + void handleAccessibilityFeaturesChanged() {} + /// Returns a [ViewConfiguration] configured for the [RenderView] based on the /// current environment. /// diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index e9edccb55d..12ea869576 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -527,6 +527,16 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv yield DefaultWidgetsLocalizations.delegate; } + // ACCESSIBILITY + + @override + void didChangeAccessibilityFeatures() { + setState(() { + // The properties of ui.window have changed. We use them in our build + // function, so we need setState(), but we don't cache anything locally. + }); + } + // METRICS diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index e789ea4898..db4bd07ca3 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'dart:developer' as developer; -import 'dart:ui' show AppLifecycleState, Locale; +import 'dart:ui' show AppLifecycleState, Locale, AccessibilityFeatures; import 'dart:ui' as ui show window; import 'package:flutter/foundation.dart'; @@ -229,6 +229,12 @@ abstract class WidgetsBindingObserver { /// This method exposes the `memoryPressure` notification from /// [SystemChannels.system]. void didHaveMemoryPressure() { } + + /// Called when the system changes the set of currently active accessibility + /// features. + /// + /// This method exposes notifications from [Window.onAccessibilityFeaturesChanged]. + void didChangeAccessibilityFeatures() {} } /// The glue between the widgets layer and the Flutter engine. @@ -243,6 +249,7 @@ abstract class WidgetsBinding extends BindingBase with SchedulerBinding, Gesture _instance = this; buildOwner.onBuildScheduled = _handleBuildScheduled; ui.window.onLocaleChanged = handleLocaleChanged; + ui.window.onAccessibilityFeaturesChanged = handleAccessibilityFeaturesChanged; SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation); SystemChannels.system.setMessageHandler(_handleSystemMessage); } @@ -368,6 +375,13 @@ abstract class WidgetsBinding extends BindingBase with SchedulerBinding, Gesture observer.didChangeTextScaleFactor(); } + @override + void handleAccessibilityFeaturesChanged() { + super.handleAccessibilityFeaturesChanged(); + for (WidgetsBindingObserver observer in _observers) + observer.didChangeAccessibilityFeatures(); + } + /// Called when the system locale changes. /// /// Calls [dispatchLocaleChanged] to notify the binding observers. @@ -392,6 +406,19 @@ abstract class WidgetsBinding extends BindingBase with SchedulerBinding, Gesture observer.didChangeLocale(locale); } + /// Notify all the observers that the active set of [AccessibilityFeatures] + /// has changed (using [WidgetsBindingObserver.didChangeAccessibilityFeatures]), + /// giving them the `features` argument. + /// + /// This is called by [handleAccessibilityFeaturesChanged] when the + /// [Window.onAccessibilityFeaturesChanged] notification is recieved. + @protected + @mustCallSuper + void dispatchAccessibilityFeaturesChanged() { + for (WidgetsBindingObserver observer in _observers) + observer.didChangeAccessibilityFeatures(); + } + /// Called when the system pops the current route. /// /// This first notifies the binding observers (using diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart index a9440e0167..d8fb50c780 100644 --- a/packages/flutter/lib/src/widgets/media_query.dart +++ b/packages/flutter/lib/src/widgets/media_query.dart @@ -43,6 +43,9 @@ class MediaQueryData { this.padding = EdgeInsets.zero, this.viewInsets = EdgeInsets.zero, this.alwaysUse24HourFormat = false, + this.accessibleNavigation = false, + this.invertColors = false, + this.disableAnimations = false, }); /// Creates data for a media query based on the given window. @@ -57,6 +60,9 @@ class MediaQueryData { textScaleFactor = window.textScaleFactor, padding = new EdgeInsets.fromWindowPadding(window.padding, window.devicePixelRatio), viewInsets = new EdgeInsets.fromWindowPadding(window.viewInsets, window.devicePixelRatio), + accessibleNavigation = window.accessibilityFeatures.accessibleNavigation, + invertColors = window.accessibilityFeatures.accessibleNavigation, + disableAnimations = window.accessibilityFeatures.disableAnimations, alwaysUse24HourFormat = window.alwaysUse24HourFormat; /// The size of the media in logical pixel (e.g, the size of the screen). @@ -120,6 +126,33 @@ class MediaQueryData { /// formatting. final bool alwaysUse24HourFormat; + /// Whether the user is using an accessibility service like TalkBack or + /// VoiceOver to interact with the application. + /// + /// When this setting is true, features such as timeouts should be disabled or + /// have minimum durations increased. + /// + /// See also: + /// + /// * [Window.AccessibilityFeatures], where the setting originates. + final bool accessibleNavigation; + + /// Whether the device is inverting the colors of the platform. + /// + /// This flag is currently only updated on iOS devices. + /// + /// See also: + /// + /// * [Window.AccessibilityFeatures], where the setting originates. + final bool invertColors; + + /// Whether the platform is requesting that animations be disabled or reduced + /// as much as possible. + /// + /// * [Window.AccessibilityFeatures], where the setting originates. + /// + final bool disableAnimations; + /// The orientation of the media (e.g., whether the device is in landscape or portrait mode). Orientation get orientation { return size.width > size.height ? Orientation.landscape : Orientation.portrait; @@ -134,6 +167,9 @@ class MediaQueryData { EdgeInsets padding, EdgeInsets viewInsets, bool alwaysUse24HourFormat, + bool disableAnimations, + bool invertColors, + bool accessibleNavigation, }) { return new MediaQueryData( size: size ?? this.size, @@ -142,6 +178,9 @@ class MediaQueryData { padding: padding ?? this.padding, viewInsets: viewInsets ?? this.viewInsets, alwaysUse24HourFormat: alwaysUse24HourFormat ?? this.alwaysUse24HourFormat, + invertColors: invertColors ?? this.invertColors, + disableAnimations: disableAnimations ?? this.disableAnimations, + accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation, ); } @@ -179,6 +218,9 @@ class MediaQueryData { ), viewInsets: viewInsets, alwaysUse24HourFormat: alwaysUse24HourFormat, + disableAnimations: disableAnimations, + invertColors: invertColors, + accessibleNavigation: accessibleNavigation, ); } @@ -214,6 +256,9 @@ class MediaQueryData { bottom: removeBottom ? 0.0 : null, ), alwaysUse24HourFormat: alwaysUse24HourFormat, + disableAnimations: disableAnimations, + invertColors: invertColors, + accessibleNavigation: accessibleNavigation, ); } @@ -227,11 +272,26 @@ class MediaQueryData { && typedOther.textScaleFactor == textScaleFactor && typedOther.padding == padding && typedOther.viewInsets == viewInsets - && typedOther.alwaysUse24HourFormat == alwaysUse24HourFormat; + && typedOther.alwaysUse24HourFormat == alwaysUse24HourFormat + && typedOther.disableAnimations == disableAnimations + && typedOther.invertColors == invertColors + && typedOther.accessibleNavigation == accessibleNavigation; } @override - int get hashCode => hashValues(size, devicePixelRatio, textScaleFactor, padding, viewInsets, alwaysUse24HourFormat); + int get hashCode { + return hashValues( + size, + devicePixelRatio, + textScaleFactor, + padding, + viewInsets, + alwaysUse24HourFormat, + disableAnimations, + invertColors, + accessibleNavigation, + ); + } @override String toString() { @@ -241,7 +301,10 @@ class MediaQueryData { 'textScaleFactor: $textScaleFactor, ' 'padding: $padding, ' 'viewInsets: $viewInsets, ' - 'alwaysUse24HourFormat: $alwaysUse24HourFormat' + 'alwaysUse24HourFormat: $alwaysUse24HourFormat, ' + 'accessibleNavigation: $accessibleNavigation' + 'disableAnimations: $disableAnimations' + 'invertColors: $invertColors' ')'; } } diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index 7cae85f1f0..0a5a9842dc 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -482,6 +482,90 @@ void main() { expect(closedReason, equals(SnackBarClosedReason.timeout)); }); + testWidgets('accessible navigation behavior with action', (WidgetTester tester) async { + final GlobalKey scaffoldKey = new GlobalKey(); + + await tester.pumpWidget(new MaterialApp( + home: new MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: Scaffold( + key: scaffoldKey, + body: new Builder( + builder: (BuildContext context) { + return new GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(new SnackBar( + content: const Text('snack'), + duration: const Duration(seconds: 1), + action: new SnackBarAction( + label: 'ACTION', + onPressed: () {} + ), + )); + }, + child: const Text('X') + ); + }, + ) + ) + ) + )); + await tester.tap(find.text('X')); + await tester.pump(); + // Find action immediately + expect(find.text('ACTION'), findsOneWidget); + // Snackbar doesn't close + await tester.pump(const Duration(seconds: 10)); + expect(find.text('ACTION'), findsOneWidget); + await tester.tap(find.text('ACTION')); + await tester.pump(); + // Snackbar closes immediately + expect(find.text('ACTION'), findsNothing); + }); + + testWidgets('contributes dismiss semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey scaffoldKey = new GlobalKey(); + + await tester.pumpWidget(new MaterialApp( + home: new MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: Scaffold( + key: scaffoldKey, + body: new Builder( + builder: (BuildContext context) { + return new GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(new SnackBar( + content: const Text('snack'), + duration: const Duration(seconds: 1), + action: new SnackBarAction( + label: 'ACTION', + onPressed: () {} + ), + )); + }, + child: const Text('X') + ); + }, + ) + ) + ) + )); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSemanticsData(find.text('snack')), matchesSemanticsData( + isLiveRegion: true, + hasDismissAction: true, + hasScrollDownAction: true, + hasScrollUpAction: true, + label: 'snack', + textDirection: TextDirection.ltr, + )); + handle.dispose(); + }); + testWidgets('SnackBar default display duration test', (WidgetTester tester) async { const String helloSnackBar = 'Hello SnackBar'; const Key tapTarget = Key('tap-target'); @@ -530,4 +614,52 @@ void main() { expect(find.text(helloSnackBar), findsNothing); }); + testWidgets('SnackBar handles updates to accessibleNavigation', (WidgetTester tester) async { + Future boilerplate({bool accessibleNavigation}) { + return tester.pumpWidget(new MaterialApp( + home: new MediaQuery( + data: new MediaQueryData(accessibleNavigation: accessibleNavigation), + child: new Scaffold( + body: new Builder( + builder: (BuildContext context) { + return new GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(new SnackBar( + content: const Text('test'), + action: new SnackBarAction(label: 'foo', onPressed: () {}), + )); + }, + behavior: HitTestBehavior.opaque, + child: const Text('X'), + ); + } + ) + ) + ) + )); + } + + await boilerplate(accessibleNavigation: false); + expect(find.text('test'), findsNothing); + await tester.tap(find.text('X')); + await tester.pump(); // schedule animation + expect(find.text('test'), findsOneWidget); + await tester.pump(); // begin animation + await tester.pump(const Duration(milliseconds: 4750)); // 4.75s + expect(find.text('test'), findsOneWidget); + + // Enabled accessible navigation + await boilerplate(accessibleNavigation: true); + + await tester.pump(const Duration(milliseconds: 4000)); // 8.75s + await tester.pump(); + expect(find.text('test'), findsOneWidget); + + // disable accessible navigation + await boilerplate(accessibleNavigation: false); + await tester.pumpAndSettle(const Duration(milliseconds: 5750)); + + expect(find.text('test'), findsNothing); + }); + } diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart index 7e76ad9cf4..d7e79287d6 100644 --- a/packages/flutter/test/widgets/media_query_test.dart +++ b/packages/flutter/test/widgets/media_query_test.dart @@ -43,6 +43,9 @@ void main() { expect(data, hasOneLineDescription); expect(data.hashCode, equals(data.copyWith().hashCode)); expect(data.size, equals(ui.window.physicalSize / ui.window.devicePixelRatio)); + expect(data.accessibleNavigation, false); + expect(data.invertColors, false); + expect(data.disableAnimations, false); }); testWidgets('MediaQueryData.copyWith defaults to source', (WidgetTester tester) async { @@ -54,6 +57,9 @@ void main() { expect(copied.padding, data.padding); expect(copied.viewInsets, data.viewInsets); expect(copied.alwaysUse24HourFormat, data.alwaysUse24HourFormat); + expect(copied.accessibleNavigation, data.accessibleNavigation); + expect(copied.invertColors, data.invertColors); + expect(copied.disableAnimations, data.disableAnimations); }); testWidgets('MediaQuery.copyWith copies specified values', (WidgetTester tester) async { @@ -65,6 +71,9 @@ void main() { padding: const EdgeInsets.all(9.10938), viewInsets: const EdgeInsets.all(1.67262), alwaysUse24HourFormat: true, + accessibleNavigation: true, + invertColors: true, + disableAnimations: true, ); expect(copied.size, const Size(3.14, 2.72)); expect(copied.devicePixelRatio, 1.41); @@ -72,6 +81,9 @@ void main() { expect(copied.padding, const EdgeInsets.all(9.10938)); expect(copied.viewInsets, const EdgeInsets.all(1.67262)); expect(copied.alwaysUse24HourFormat, true); + expect(copied.accessibleNavigation, true); + expect(copied.invertColors, true); + expect(copied.disableAnimations, true); }); testWidgets('MediaQuery.removePadding removes specified padding', (WidgetTester tester) async { @@ -91,6 +103,9 @@ void main() { padding: padding, viewInsets: viewInsets, alwaysUse24HourFormat: true, + accessibleNavigation: true, + invertColors: true, + disableAnimations: true, ), child: new Builder( builder: (BuildContext context) { @@ -118,6 +133,9 @@ void main() { expect(unpadded.padding, EdgeInsets.zero); expect(unpadded.viewInsets, viewInsets); expect(unpadded.alwaysUse24HourFormat, true); + expect(unpadded.accessibleNavigation, true); + expect(unpadded.invertColors, true); + expect(unpadded.disableAnimations, true); }); testWidgets('MediaQuery.removeViewInsets removes specified viewInsets', (WidgetTester tester) async { @@ -137,6 +155,9 @@ void main() { padding: padding, viewInsets: viewInsets, alwaysUse24HourFormat: true, + accessibleNavigation: true, + invertColors: true, + disableAnimations: true, ), child: new Builder( builder: (BuildContext context) { @@ -164,6 +185,9 @@ void main() { expect(unpadded.padding, padding); expect(unpadded.viewInsets, EdgeInsets.zero); expect(unpadded.alwaysUse24HourFormat, true); + expect(unpadded.accessibleNavigation, true); + expect(unpadded.invertColors, true); + expect(unpadded.disableAnimations, true); }); testWidgets('MediaQuery.textScaleFactorOf', (WidgetTester tester) async {