Skip to content

Commit c62f889

Browse files
authored
Add support for reduced motion/disable animations on the web (#180041)
This PR listens to changes to the value of the `(prefers-reduced-motion: reduce)` media query to update the `reduceMotion` and `disableAnimations` properties of the `AccessibilityFeatures` configuration object. This is achieved by introducing a new `MediaQueryManager` object to the `EnginePlatformDispatcher` that handles handles all the engine configuration values that are driven by media queries: "reduced motion", "high contrast" and "dark mode". Unifying the code paths of all these values, allows this PR to slightly increase testing coverage of those values, by injecting fake browser events in the `platform_dispatcher_test.dart` file. ### Issues * Fixes #167566 ### Testing * Added browser tests for new (and existing) media-queries * Deployed test app to: https://dit-tests.web.app (may get offline!) ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 8af1837 commit c62f889

8 files changed

Lines changed: 409 additions & 121 deletions

File tree

engine/src/flutter/lib/web_ui/lib/src/engine.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export 'engine/font_fallbacks.dart';
5959
export 'engine/fonts.dart';
6060
export 'engine/frame_service.dart';
6161
export 'engine/frame_timing_recorder.dart';
62-
export 'engine/high_contrast.dart';
6362
export 'engine/html_image_element_codec.dart';
6463
export 'engine/image_decoder.dart';
6564
export 'engine/image_format_detector.dart';
@@ -87,6 +86,8 @@ export 'engine/occlusion_map.dart';
8786
export 'engine/onscreen_logging.dart';
8887
export 'engine/platform_dispatcher.dart';
8988
export 'engine/platform_dispatcher/app_lifecycle_state.dart';
89+
export 'engine/platform_dispatcher/media_query_manager.dart';
90+
export 'engine/platform_dispatcher/system_color_palette_detector.dart';
9091
export 'engine/platform_dispatcher/view_focus_binding.dart';
9192
export 'engine/platform_views/content_manager.dart';
9293
export 'engine/platform_views/embedder.dart';

engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,9 +1801,17 @@ extension type DomMediaQueryList._(JSObject _) implements DomEventTarget {
18011801

18021802
@JS('MediaQueryListEvent')
18031803
extension type DomMediaQueryListEvent._(JSObject _) implements DomEvent {
1804+
/// https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryListEvent/MediaQueryListEvent
1805+
@visibleForTesting
1806+
external DomMediaQueryListEvent(String type, [JSAny initDict]);
18041807
external bool? get matches;
18051808
}
18061809

1810+
@visibleForTesting
1811+
DomMediaQueryListEvent createDomMediaQueryListEvent(String type, Map<dynamic, dynamic> init) {
1812+
return DomMediaQueryListEvent(type, init.toJSAnyDeep);
1813+
}
1814+
18071815
@JS('Path2D')
18081816
extension type DomPath2D._(JSObject _) implements JSObject {
18091817
external DomPath2D([JSAny path]);

engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ typedef _KeyDataResponseCallback = void Function(bool handled);
1818
const StandardMethodCodec standardCodec = StandardMethodCodec();
1919
const JSONMethodCodec jsonCodec = JSONMethodCodec();
2020

21+
// An object to listen to values coming from media queries in the browser, like
22+
// prefers-color-scheme or prefers-reduced-motion
23+
@visibleForTesting
24+
final MediaQueryManager mediaQueries = MediaQueryManager();
25+
2126
/// Platform event dispatcher.
2227
///
2328
/// This is the central entry point for platform messages and configuration
@@ -26,8 +31,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
2631
/// Private constructor, since only dart:ui is supposed to create one of
2732
/// these.
2833
EnginePlatformDispatcher() {
29-
_addBrightnessMediaQueryListener();
30-
HighContrastSupport.instance.addListener(_updateHighContrast);
34+
_registerMediaQueryListeners();
3135
_addTypographySettingsObserver();
3236
_addLocaleChangedListener();
3337
registerHotRestartListener(dispose);
@@ -71,17 +75,16 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
7175
/// Compute accessibility features based on the current value of high contrast flag
7276
static EngineAccessibilityFeatures computeAccessibilityFeatures() {
7377
final builder = EngineAccessibilityFeaturesBuilder(0);
74-
if (HighContrastSupport.instance.isHighContrastEnabled) {
78+
if (_isHighContrastEnabled) {
7579
builder.highContrast = true;
7680
}
7781
return builder.build();
7882
}
7983

8084
void dispose() {
81-
_removeBrightnessMediaQueryListener();
85+
mediaQueries.detachAll();
8286
_disconnectTypographySettingsObserver();
8387
_removeLocaleChangedListener();
84-
HighContrastSupport.instance.removeListener(_updateHighContrast);
8588
_appLifecycleState.removeListener(_setAppLifecycleState);
8689
_viewFocusBinding.dispose();
8790
accessibilityPlaceholder.remove();
@@ -1219,9 +1222,10 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
12191222

12201223
/// Updates [_platformBrightness] and invokes [onPlatformBrightnessChanged]
12211224
/// callback if [_platformBrightness] changed.
1222-
void _updatePlatformBrightness(ui.Brightness value) {
1223-
if (configuration.platformBrightness != value) {
1224-
configuration = configuration.copyWith(platformBrightness: value);
1225+
void _updatePlatformBrightness(bool prefersDark) {
1226+
final ui.Brightness brightness = prefersDark ? ui.Brightness.dark : ui.Brightness.light;
1227+
if (configuration.platformBrightness != brightness) {
1228+
configuration = configuration.copyWith(platformBrightness: brightness);
12251229
invokeOnPlatformConfigurationChanged();
12261230
invokeOnPlatformBrightnessChanged();
12271231
}
@@ -1231,45 +1235,52 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
12311235
@override
12321236
String? get systemFontFamily => configuration.systemFontFamily;
12331237

1238+
/// Whether high contrast mode is enabled by the platform.
1239+
///
1240+
/// Used statically by [computeAccessibilityFeatures] to create the initial
1241+
/// [configuration] object.
1242+
static bool _isHighContrastEnabled = false;
1243+
12341244
/// Updates [_highContrast] and invokes [onHighContrastModeChanged]
12351245
/// callback if [_highContrast] changed.
1236-
void _updateHighContrast(bool value) {
1237-
if (configuration.accessibilityFeatures.highContrast != value) {
1246+
void _updateHighContrast(bool enabled) {
1247+
_isHighContrastEnabled = enabled;
1248+
if (configuration.accessibilityFeatures.highContrast != enabled) {
12381249
final original = configuration.accessibilityFeatures as EngineAccessibilityFeatures;
12391250
configuration = configuration.copyWith(
1240-
accessibilityFeatures: original.copyWith(highContrast: value),
1251+
accessibilityFeatures: original.copyWith(highContrast: enabled),
12411252
);
12421253
invokeOnPlatformConfigurationChanged();
1254+
invokeOnAccessibilityFeaturesChanged();
12431255
}
12441256
}
12451257

1246-
/// Reference to css media query that indicates the user theme preference on the web.
1247-
final DomMediaQueryList _brightnessMediaQuery = domWindow.matchMedia(
1248-
'(prefers-color-scheme: dark)',
1249-
);
1250-
1251-
/// A callback that is invoked whenever [_brightnessMediaQuery] changes value.
1258+
/// Updates [AccessibilityFeatures] `reduceMotion` and `disableAnimations` to
1259+
/// [reduced], and notifies the framework of the change.
12521260
///
1253-
/// Updates the [_platformBrightness] with the new user preference.
1254-
DomEventListener? _brightnessMediaQueryListener;
1255-
1256-
/// Set the callback function for listening changes in [_brightnessMediaQuery] value.
1257-
void _addBrightnessMediaQueryListener() {
1258-
_updatePlatformBrightness(
1259-
_brightnessMediaQuery.matches ? ui.Brightness.dark : ui.Brightness.light,
1260-
);
1261-
1262-
_brightnessMediaQueryListener = (DomEvent event) {
1263-
final mqEvent = event as DomMediaQueryListEvent;
1264-
_updatePlatformBrightness(mqEvent.matches! ? ui.Brightness.dark : ui.Brightness.light);
1265-
}.toJS;
1266-
_brightnessMediaQuery.addListener(_brightnessMediaQueryListener);
1261+
/// The web doesn't seem to distinguish between "reduced motion" and "disable
1262+
/// animations", so we set both at the same time in this update.
1263+
void _updateReducedMotion(bool reduced) {
1264+
if (configuration.accessibilityFeatures.reduceMotion != reduced) {
1265+
final original = configuration.accessibilityFeatures as EngineAccessibilityFeatures;
1266+
configuration = configuration.copyWith(
1267+
accessibilityFeatures: original.copyWith(
1268+
// There's no distinction on the web between "reduceMotion" and
1269+
// "disableAnimations", so we set both at the same time.
1270+
reduceMotion: reduced,
1271+
disableAnimations: reduced,
1272+
),
1273+
);
1274+
invokeOnPlatformConfigurationChanged();
1275+
invokeOnAccessibilityFeaturesChanged();
1276+
}
12671277
}
12681278

1269-
/// Remove the callback function for listening changes in [_brightnessMediaQuery] value.
1270-
void _removeBrightnessMediaQueryListener() {
1271-
_brightnessMediaQuery.removeListener(_brightnessMediaQueryListener);
1272-
_brightnessMediaQueryListener = null;
1279+
// Configures the [_mediaQueries] object.
1280+
void _registerMediaQueryListeners() {
1281+
mediaQueries.addListener(MediaQueryManager.DARK_MODE, onMatch: _updatePlatformBrightness);
1282+
mediaQueries.addListener(MediaQueryManager.REDUCED_MOTION, onMatch: _updateReducedMotion);
1283+
mediaQueries.addListener(MediaQueryManager.FORCED_COLORS, onMatch: _updateHighContrast);
12731284
}
12741285

12751286
/// A callback that is invoked whenever [platformBrightness] changes value.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:js_interop';
6+
7+
import 'package:meta/meta.dart';
8+
9+
import '../../engine.dart';
10+
11+
/// The type of a function that handles whether a media query matches or not.
12+
typedef MediaQueryMatchHandler = void Function(bool matches);
13+
14+
/// Manages all the [_MediaQueryListeners]s attached to media query tests.
15+
///
16+
/// This is used by the [EnginePlatformDispatcher] to detect some properties
17+
/// from the browser (light/dark mode or reduced motion)
18+
class MediaQueryManager {
19+
/// Detects dark mode.
20+
static const DARK_MODE = '(prefers-color-scheme: dark)';
21+
22+
/// Detects forced colors (high contrast).
23+
static const FORCED_COLORS = '(forced-colors: active)';
24+
25+
/// Detects reduced motion.
26+
static const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
27+
28+
final Map<String, _MediaQueryListeners> _listeners = {};
29+
30+
/// Used in tests to inject mock objects that can dispatch arbitrary
31+
/// [DomMediaQueryListEvent]s.
32+
///
33+
/// When this is not set, [domWindow.matchMedia] is used by default.
34+
///
35+
/// This is used to ensure the connection of the [MediaQueryManager] with
36+
/// incoming events from the browser.
37+
@visibleForTesting
38+
MediaQueryBuilder? debugOverrideMediaQueryBuilder;
39+
40+
/// Used in tests to trigger [event] on all the registered listeners of
41+
/// [mediaQueryString].
42+
///
43+
/// This is used to test the connection between the [EnginePlatformDispatcher]
44+
/// and the [MediaQueryManager], without the browser having to dispatch real
45+
/// events.
46+
@visibleForTesting
47+
void debugTriggerListener(String mediaQueryString, {required DomMediaQueryListEvent event}) {
48+
final _MediaQueryListeners? listeners = _listeners[mediaQueryString];
49+
assert(listeners != null, 'Cannot find listeners for $mediaQueryString');
50+
listeners!.trigger(event);
51+
}
52+
53+
// Creates a [DomMediaQueryList] object from a [mediaQueryString].
54+
//
55+
// This uses [debugOverrideMediaQueryBuilder] when set for tests.
56+
// In production, this uses [domWindow.matchMedia].
57+
DomEventTarget _createMediaQuery(String mediaQueryString) {
58+
if (debugOverrideMediaQueryBuilder != null) {
59+
return debugOverrideMediaQueryBuilder!(mediaQueryString);
60+
}
61+
return domWindow.matchMedia(mediaQueryString);
62+
}
63+
64+
/// Adds a listener for [mediaQueryString], and triggers [onMatch] as needed.
65+
///
66+
/// This function calls [onMatch] synchronously with the initial value of the
67+
/// match, and then, through an event listener, every time the value changes.
68+
void addListener(String mediaQueryString, {required MediaQueryMatchHandler onMatch}) {
69+
// Wrap `onMatch` in a [DomEventListener]
70+
final DomEventListener mediaQueryListener = (DomEvent event) {
71+
final mqEvent = event as DomMediaQueryListEvent;
72+
onMatch(mqEvent.matches ?? false);
73+
}.toJS;
74+
75+
// Attach the listener
76+
final _MediaQueryListeners listeners = _listeners.putIfAbsent(mediaQueryString, () {
77+
// Create a proper media query object
78+
final DomEventTarget mediaQuery = _createMediaQuery(mediaQueryString);
79+
return _MediaQueryListeners(mediaQuery);
80+
})..addListener(mediaQueryListener);
81+
82+
// Call onMatch with the immediate value of the media query
83+
onMatch(listeners.matches);
84+
}
85+
86+
/// Detaches all registered listeners.
87+
void detachAll() {
88+
final Iterable<String> mediaQueryStrings = _listeners.keys.toList();
89+
mediaQueryStrings.forEach(_removeListeners);
90+
}
91+
92+
/// Detaches all listeners for [mediaQueryString].
93+
void _removeListeners(String mediaQueryString) {
94+
final _MediaQueryListeners? listeners = _listeners.remove(mediaQueryString);
95+
listeners?.detachAll();
96+
}
97+
}
98+
99+
/// Groups the listeners for a media query
100+
class _MediaQueryListeners {
101+
_MediaQueryListeners(this._mediaQuery);
102+
103+
final DomEventTarget _mediaQuery;
104+
final List<DomEventListener> _listeners = [];
105+
106+
// Returns whether or not the listened [DomMediaQueryList] matches now.
107+
bool get matches {
108+
// On tests we inject something that is a raw DomEventTarget so we can
109+
// dispatch events from it directly, so we check for that case now.
110+
if (!_mediaQuery.isA<DomMediaQueryList>()) {
111+
return false;
112+
}
113+
return (_mediaQuery as DomMediaQueryList).matches;
114+
}
115+
116+
void addListener(DomEventListener listener) {
117+
_mediaQuery.addEventListener('change', listener);
118+
_listeners.add(listener);
119+
}
120+
121+
void detachAll() {
122+
_listeners.forEach(_removeListener);
123+
_listeners.clear();
124+
}
125+
126+
void _removeListener(DomEventListener listener) {
127+
_mediaQuery.removeEventListener('change', listener);
128+
}
129+
130+
/// Triggers [event] on all registered listeners.
131+
@visibleForTesting
132+
void trigger(DomMediaQueryListEvent event) {
133+
for (final JSFunction listener in _listeners) {
134+
// This is directly calling the registered JSFunction with [event].
135+
listener.callAsFunction(null, event);
136+
}
137+
}
138+
}
139+
140+
/// A function to create a fake MediaQuery event from a String.
141+
@visibleForTesting
142+
typedef MediaQueryBuilder = DomEventTarget Function(String mediaQuery);

engine/src/flutter/lib/web_ui/lib/src/engine/high_contrast.dart renamed to engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/system_color_palette_detector.dart

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,9 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
import 'dart:js_interop';
5-
64
import 'package:ui/ui.dart' as ui;
75

8-
import 'dom.dart';
9-
10-
/// Signature of functions added as a listener to high contrast changes
11-
typedef HighContrastListener = void Function(bool enabled);
12-
13-
/// Determines if high contrast is enabled using media query 'forced-colors: active' for Windows
14-
class HighContrastSupport {
15-
static HighContrastSupport instance = HighContrastSupport();
16-
static const String _highContrastMediaQueryString = '(forced-colors: active)';
17-
18-
final List<HighContrastListener> _listeners = <HighContrastListener>[];
19-
20-
/// Reference to css media query that indicates whether high contrast is on.
21-
final DomMediaQueryList _highContrastMediaQuery = domWindow.matchMedia(
22-
_highContrastMediaQueryString,
23-
);
24-
late final DomEventListener _onHighContrastChangeListener = _onHighContrastChange.toJS;
25-
26-
bool get isHighContrastEnabled => _highContrastMediaQuery.matches;
27-
28-
/// Adds function to the list of listeners on high contrast changes
29-
void addListener(HighContrastListener listener) {
30-
if (_listeners.isEmpty) {
31-
_highContrastMediaQuery.addListener(_onHighContrastChangeListener);
32-
}
33-
_listeners.add(listener);
34-
}
35-
36-
/// Removes function from the list of listeners on high contrast changes
37-
void removeListener(HighContrastListener listener) {
38-
_listeners.remove(listener);
39-
if (_listeners.isEmpty) {
40-
_highContrastMediaQuery.removeListener(_onHighContrastChangeListener);
41-
}
42-
}
43-
44-
void _onHighContrastChange(DomEvent event) {
45-
final mqEvent = event as DomMediaQueryListEvent;
46-
final bool isHighContrastEnabled = mqEvent.matches!;
47-
for (final HighContrastListener listener in _listeners) {
48-
listener(isHighContrastEnabled);
49-
}
50-
}
51-
}
6+
import '../dom.dart';
527

538
const List<String> systemColorNames = <String>[
549
'AccentColor',

0 commit comments

Comments
 (0)