Feat: Add a11y for loading indicators (#165173)
Feat: Add a11y for loading indicators
fixes: #161631
## 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.
diff --git a/engine/src/flutter/lib/ui/fixtures/ui_test.dart b/engine/src/flutter/lib/ui/fixtures/ui_test.dart
index 24da51e..ce6af8e 100644
--- a/engine/src/flutter/lib/ui/fixtures/ui_test.dart
+++ b/engine/src/flutter/lib/ui/fixtures/ui_test.dart
@@ -258,6 +258,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
_semanticsUpdate(builder.build());
}
@@ -319,6 +321,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
_semanticsUpdate(builder.build());
}
@@ -380,6 +384,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: Locale('es', 'MX'),
+ minValue: '0',
+ maxValue: '0',
);
_semanticsUpdate(builder.build());
}
@@ -436,6 +442,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: Locale('es', 'MX'),
+ minValue: '0',
+ maxValue: '0',
);
_semanticsUpdate(builder.build());
}
diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart
index 8d496fa..d5db605 100644
--- a/engine/src/flutter/lib/ui/semantics.dart
+++ b/engine/src/flutter/lib/ui/semantics.dart
@@ -1975,6 +1975,8 @@
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
required SemanticsInputType inputType,
required Locale? locale,
+ required String minValue,
+ required String maxValue,
});
/// Update the custom semantics action associated with the given `id`.
@@ -2056,6 +2058,8 @@
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
required SemanticsInputType inputType,
required Locale? locale,
+ required String minValue,
+ required String maxValue,
}) {
assert(_matrix4IsValid(transform));
assert(
@@ -2107,6 +2111,8 @@
hitTestBehavior.index,
inputType.index,
locale?.toLanguageTag() ?? '',
+ minValue,
+ maxValue,
);
}
@@ -2157,6 +2163,8 @@
Int32,
Int32,
Handle,
+ Handle,
+ Handle,
)
>(symbol: 'SemanticsUpdateBuilder::updateNode')
external void _updateNode(
@@ -2204,6 +2212,8 @@
int hitTestBehaviorIndex,
int inputType,
String locale,
+ String minValue,
+ String maxValue,
);
@override
diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h
index 6bbf7e0..1bdec7c 100644
--- a/engine/src/flutter/lib/ui/semantics/semantics_node.h
+++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h
@@ -145,6 +145,8 @@
double scrollPosition = std::nan("");
double scrollExtentMax = std::nan("");
double scrollExtentMin = std::nan("");
+ std::string minValue;
+ std::string maxValue;
std::string identifier;
std::string label;
StringAttributes labelAttributes;
diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc
index a2cc6d6..b23096c 100644
--- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc
+++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc
@@ -74,7 +74,9 @@
int validationResult,
int hitTestBehavior,
int inputType,
- std::string locale) {
+ std::string locale,
+ std::string minValue,
+ std::string maxValue) {
FML_CHECK(scrollChildren == 0 ||
(scrollChildren > 0 && childrenInHitTestOrder.data()))
<< "Semantics update contained scrollChildren but did not have "
@@ -96,6 +98,8 @@
node.scrollPosition = scrollPosition;
node.scrollExtentMax = scrollExtentMax;
node.scrollExtentMin = scrollExtentMin;
+ node.minValue = std::move(minValue);
+ node.maxValue = std::move(maxValue);
node.rect = SkRect::MakeLTRB(SafeNarrow(left), SafeNarrow(top),
SafeNarrow(right), SafeNarrow(bottom));
node.identifier = std::move(identifier);
diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h
index 14dfcca..e53b9c0 100644
--- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h
+++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h
@@ -73,7 +73,9 @@
int validationResult,
int hitTestBehavior,
int inputType,
- std::string locale);
+ std::string locale,
+ std::string minValue,
+ std::string maxValue);
void updateCustomAction(int id,
std::string label,
diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart
index 7435290..395cbc4 100644
--- a/engine/src/flutter/lib/web_ui/lib/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart
@@ -753,6 +753,8 @@
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
required SemanticsInputType inputType,
required Locale? locale,
+ required String minValue,
+ required String maxValue,
}) {
if (transform.length != 16) {
throw ArgumentError('transform argument must have 16 entries.');
@@ -800,6 +802,8 @@
hitTestBehavior: hitTestBehavior,
inputType: inputType,
locale: locale,
+ minValue: minValue,
+ maxValue: maxValue,
),
);
}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
index d56b9ec..1eac876 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
@@ -116,6 +116,7 @@
export 'engine/semantics/live_region.dart';
export 'engine/semantics/menus.dart';
export 'engine/semantics/platform_view.dart';
+export 'engine/semantics/progress_bar.dart';
export 'engine/semantics/requirable.dart';
export 'engine/semantics/route.dart';
export 'engine/semantics/scrollable.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
index 600fc3d..ea30b9e 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart
@@ -20,6 +20,7 @@
export 'semantics/live_region.dart';
export 'semantics/menus.dart';
export 'semantics/platform_view.dart';
+export 'semantics/progress_bar.dart';
export 'semantics/requirable.dart';
export 'semantics/scrollable.dart';
export 'semantics/semantics.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart
new file mode 100644
index 0000000..c5e40d1
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/progress_bar.dart
@@ -0,0 +1,66 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import 'label_and_value.dart';
+import 'semantics.dart';
+
+/// Indicates a progress bar element.
+///
+/// Uses aria progressbar role to convey this semantic information to the element.
+///
+/// Screen-readers take advantage of "aria-label" to describe the visual.
+class SemanticsProgressBar extends SemanticRole {
+ SemanticsProgressBar(SemanticsObject semanticsObject)
+ : super.withBasics(
+ EngineSemanticsRole.progressBar,
+ semanticsObject,
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
+ ) {
+ setAriaRole('progressbar');
+
+ // Set ARIA attributes for min, max and current value.
+ if (semanticsObject.minValue != null) {
+ setAttribute('aria-valuemin', semanticsObject.minValue!);
+ }
+ if (semanticsObject.maxValue != null) {
+ setAttribute('aria-valuemax', semanticsObject.maxValue!);
+ }
+
+ if (semanticsObject.value != null) {
+ setAttribute('aria-valuenow', semanticsObject.value!);
+ }
+ }
+
+ @override
+ void update() {
+ super.update();
+
+ if (semanticsObject.minValue != null) {
+ setAttribute('aria-valuemin', semanticsObject.minValue!);
+ }
+
+ if (semanticsObject.maxValue != null) {
+ setAttribute('aria-valuemax', semanticsObject.maxValue!);
+ }
+
+ if (semanticsObject.value != null) {
+ setAttribute('aria-valuenow', semanticsObject.value!);
+ }
+ }
+
+ @override
+ bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
+}
+
+/// Indicates a loading spinner element.
+class SementicsLoadingSpinner extends SemanticRole {
+ SementicsLoadingSpinner(SemanticsObject semanticsObject)
+ : super.withBasics(
+ EngineSemanticsRole.loadingSpinner,
+ semanticsObject,
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
+ );
+
+ @override
+ bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
+}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
index e68c414..01cca11 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
@@ -37,6 +37,7 @@
import 'live_region.dart';
import 'menus.dart';
import 'platform_view.dart';
+import 'progress_bar.dart';
import 'requirable.dart';
import 'route.dart';
import 'scrollable.dart';
@@ -275,6 +276,8 @@
this.hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
required this.inputType,
required this.locale,
+ required this.minValue,
+ required this.maxValue,
});
/// See [ui.SemanticsUpdateBuilder.updateNode].
@@ -399,6 +402,12 @@
/// See [ui.SemanticsUpdateBuilder.updateNode].
final ui.Locale? locale;
+
+ /// See [ui.SemanticsUpdateBuilder.updateNode].
+ final String minValue;
+
+ /// See [ui.SemanticsUpdateBuilder.updateNode].
+ final String maxValue;
}
/// Identifies [SemanticRole] implementations.
@@ -503,6 +512,12 @@
/// An item in a [list].
listItem,
+ /// A graphic object that shows progress with a numeric number.
+ progressBar,
+
+ /// A graphic object that spins to indicate the application is busy.
+ loadingSpinner,
+
/// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject].
///
@@ -1550,6 +1565,31 @@
_dirtyFields |= _hitTestBehaviorIndex;
}
+ String? get minValue => _minValue;
+ String? _minValue;
+
+ static const int _minValueIndex = 1 << 29;
+
+ /// Whether the [minValue] field has been updated but has not been
+ /// applied to the DOM yet.
+ bool get isMinValueDirty => _isDirty(_minValueIndex);
+ void _markMinValueDirty() {
+ _dirtyFields |= _minValueIndex;
+ }
+
+ /// See [ui.SemanticsUpdateBuilder.updateNode].
+ String? get maxValue => _maxValue;
+ String? _maxValue;
+
+ static const int _maxValueIndex = 1 << 30;
+
+ /// Whether the [maxValue] field has been updated but has not been
+ /// applied to the DOM yet.
+ bool get isMaxValueDirty => _isDirty(_maxValueIndex);
+ void _markMaxValueDirty() {
+ _dirtyFields |= _maxValueIndex;
+ }
+
/// A unique permanent identifier of the semantics node in the tree.
final int id;
@@ -1887,6 +1927,16 @@
_markHitTestBehaviorDirty();
}
+ if (_minValue != update.minValue) {
+ _minValue = update.minValue;
+ _markMinValueDirty();
+ }
+
+ if (_maxValue != update.maxValue) {
+ _maxValue = update.maxValue;
+ _markMaxValueDirty();
+ }
+
role = update.role;
inputType = update.inputType;
@@ -2139,14 +2189,16 @@
return EngineSemanticsRole.region;
case ui.SemanticsRole.form:
return EngineSemanticsRole.form;
+ case ui.SemanticsRole.loadingSpinner:
+ return EngineSemanticsRole.loadingSpinner;
+ case ui.SemanticsRole.progressBar:
+ return EngineSemanticsRole.progressBar;
// TODO(chunhtai): implement these roles.
// https://github.com/flutter/flutter/issues/159741.
case ui.SemanticsRole.dragHandle:
case ui.SemanticsRole.spinButton:
case ui.SemanticsRole.comboBox:
case ui.SemanticsRole.tooltip:
- case ui.SemanticsRole.loadingSpinner:
- case ui.SemanticsRole.progressBar:
case ui.SemanticsRole.hotKey:
case ui.SemanticsRole.none:
// fallback to checking semantics properties.
@@ -2213,6 +2265,8 @@
EngineSemanticsRole.menuItemRadio => SemanticMenuItemRadio(this),
EngineSemanticsRole.alert => SemanticAlert(this),
EngineSemanticsRole.status => SemanticStatus(this),
+ EngineSemanticsRole.progressBar => SemanticsProgressBar(this),
+ EngineSemanticsRole.loadingSpinner => SementicsLoadingSpinner(this),
EngineSemanticsRole.generic => GenericRole(this),
EngineSemanticsRole.complementary => SemanticComplementary(this),
EngineSemanticsRole.contentInfo => SemanticContentInfo(this),
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
index 704da94..990c3af 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart
@@ -160,6 +160,12 @@
group('forms', () {
_testForms();
});
+ group('progressBar', () {
+ _testProgressBar();
+ });
+ group('loadingSpinner', () {
+ _testLoadingSpinner();
+ });
}
void _testSemanticRole() {
@@ -6153,6 +6159,55 @@
semantics().semanticsEnabled = false;
}
+void _testProgressBar() {
+ test('nodes with progress bar role', () {
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+
+ SemanticsObject pumpSemantics() {
+ final SemanticsTester tester = SemanticsTester(owner());
+ tester.updateNode(
+ id: 0,
+ role: ui.SemanticsRole.progressBar,
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
+ );
+ tester.apply();
+ return tester.getSemanticsObject(0);
+ }
+
+ final SemanticsObject object = pumpSemantics();
+ expect(object.semanticRole?.kind, EngineSemanticsRole.progressBar);
+ expect(object.element.getAttribute('role'), 'progressbar');
+ });
+
+ semantics().semanticsEnabled = false;
+}
+
+void _testLoadingSpinner() {
+ test('nodes with loading spinner role', () {
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+
+ SemanticsObject pumpSemantics() {
+ final SemanticsTester tester = SemanticsTester(owner());
+ tester.updateNode(
+ id: 0,
+ role: ui.SemanticsRole.loadingSpinner,
+ rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
+ );
+ tester.apply();
+ return tester.getSemanticsObject(0);
+ }
+
+ final SemanticsObject object = pumpSemantics();
+ expect(object.semanticRole?.kind, EngineSemanticsRole.loadingSpinner);
+ });
+
+ semantics().semanticsEnabled = false;
+}
+
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(
@@ -6197,6 +6252,8 @@
ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
ui.SemanticsInputType inputType = ui.SemanticsInputType.none,
ui.Locale? locale,
+ String minValue = '0',
+ String maxValue = '0',
}) {
transform ??= Float64List.fromList(Matrix4.identity().storage);
hitTestTransform ??= Float64List.fromList(Matrix4.identity().storage);
@@ -6244,6 +6301,8 @@
hitTestBehavior: hitTestBehavior,
inputType: inputType,
locale: locale,
+ minValue: minValue,
+ maxValue: maxValue,
);
}
diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
index ddc190e..f062265 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart
@@ -92,6 +92,8 @@
ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
ui.SemanticsInputType inputType = ui.SemanticsInputType.none,
ui.Locale? locale,
+ String? minValue,
+ String? maxValue,
}) {
// Actions
if (hasTap ?? false) {
@@ -228,6 +230,8 @@
hitTestBehavior: hitTestBehavior,
inputType: inputType,
locale: locale,
+ minValue: minValue ?? '0',
+ maxValue: maxValue ?? '0',
);
_nodeUpdates.add(update);
return update;
diff --git a/engine/src/flutter/runtime/fixtures/runtime_test.dart b/engine/src/flutter/runtime/fixtures/runtime_test.dart
index f561835..ce20bf4 100644
--- a/engine/src/flutter/runtime/fixtures/runtime_test.dart
+++ b/engine/src/flutter/runtime/fixtures/runtime_test.dart
@@ -316,6 +316,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
_semanticsUpdate(builder.build());
}
diff --git a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart
index 276df9d..6a12310 100644
--- a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart
+++ b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart
@@ -200,6 +200,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
)
..updateNode(
id: 84,
@@ -238,6 +240,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
)
..updateNode(
id: 96,
@@ -276,6 +280,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
)
..updateNode(
id: 128,
@@ -314,6 +320,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
)
..updateCustomAction(id: 21, label: 'Archive', hint: 'archive message');
@@ -399,6 +407,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
PlatformDispatcher.instance.setSemanticsTreeEnabled(true);
@@ -1690,6 +1700,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
}
diff --git a/engine/src/flutter/shell/platform/windows/fixtures/main.dart b/engine/src/flutter/shell/platform/windows/fixtures/main.dart
index 39a80db..705aac4 100644
--- a/engine/src/flutter/shell/platform/windows/fixtures/main.dart
+++ b/engine/src/flutter/shell/platform/windows/fixtures/main.dart
@@ -478,6 +478,8 @@
controlsNodes: null,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
return builder.build();
}
diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart
index 607a0f6..4e27a36 100644
--- a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart
+++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart
@@ -77,6 +77,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build();
@@ -139,6 +141,8 @@
controlsNodes: null,
inputType: SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build();
diff --git a/packages/flutter/lib/src/material/progress_indicator.dart b/packages/flutter/lib/src/material/progress_indicator.dart
index b29452b..8d0616d 100644
--- a/packages/flutter/lib/src/material/progress_indicator.dart
+++ b/packages/flutter/lib/src/material/progress_indicator.dart
@@ -8,6 +8,7 @@
library;
import 'dart:math' as math;
+import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
@@ -141,11 +142,20 @@
}
Widget _buildSemanticsWrapper({required BuildContext context, required Widget child}) {
+ bool isProgressBar = false;
String? expandedSemanticsValue = semanticsValue;
if (value != null) {
- expandedSemanticsValue ??= '${(value! * 100).round()}%';
+ expandedSemanticsValue ??= '${(value! * 100).round()}';
+ isProgressBar = true;
}
- return Semantics(label: semanticsLabel, value: expandedSemanticsValue, child: child);
+ return Semantics(
+ label: semanticsLabel,
+ role: isProgressBar ? SemanticsRole.progressBar : SemanticsRole.loadingSpinner,
+ minValue: isProgressBar ? '0' : null,
+ maxValue: isProgressBar ? '100' : null,
+ value: expandedSemanticsValue,
+ child: child,
+ );
}
}
@@ -1185,28 +1195,32 @@
@override
Widget build(BuildContext context) {
- switch (widget._indicatorType) {
- case _ActivityIndicatorType.material:
- if (widget.value != null) {
- return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0);
- }
- return _buildAnimation();
- case _ActivityIndicatorType.adaptive:
- final ThemeData theme = Theme.of(context);
- switch (theme.platform) {
- case TargetPlatform.iOS:
- case TargetPlatform.macOS:
- return _buildCupertinoIndicator(context);
- case TargetPlatform.android:
- case TargetPlatform.fuchsia:
- case TargetPlatform.linux:
- case TargetPlatform.windows:
+ return Builder(
+ builder: (BuildContext context) {
+ switch (widget._indicatorType) {
+ case _ActivityIndicatorType.material:
if (widget.value != null) {
return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0);
}
return _buildAnimation();
+ case _ActivityIndicatorType.adaptive:
+ final ThemeData theme = Theme.of(context);
+ switch (theme.platform) {
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ return _buildCupertinoIndicator(context);
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ if (widget.value != null) {
+ return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0);
+ }
+ return _buildAnimation();
+ }
}
- }
+ },
+ );
}
}
diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart
index a3d5a07..2173557 100644
--- a/packages/flutter/lib/src/rendering/custom_paint.dart
+++ b/packages/flutter/lib/src/rendering/custom_paint.dart
@@ -1051,6 +1051,12 @@
if (properties.inputType != null) {
config.inputType = properties.inputType!;
}
+ if (properties.minValue != null) {
+ config.minValue = properties.minValue;
+ }
+ if (properties.maxValue != null) {
+ config.maxValue = properties.maxValue;
+ }
if (properties.onTap != null) {
config.onTap = properties.onTap;
}
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index 32669a3..52a1358 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -4938,6 +4938,12 @@
if (_properties.inputType != null) {
config.inputType = _properties.inputType!;
}
+ if (_properties.minValue != null) {
+ config.minValue = _properties.minValue;
+ }
+ if (_properties.maxValue != null) {
+ config.maxValue = _properties.maxValue;
+ }
// Registering _perform* as action handlers instead of the user provided
// ones to ensure that changing a user provided handler from a non-null to
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index b45ea0f..0d09006 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -182,14 +182,14 @@
SemanticsRole.navigation => _semanticsNavigation,
SemanticsRole.region => _semanticsRegion,
SemanticsRole.form => _noCheckRequired,
+ SemanticsRole.loadingSpinner => _noCheckRequired,
+ SemanticsRole.progressBar => _semanticsProgressBar,
// TODO(chunhtai): add checks when the roles are used in framework.
// https://github.com/flutter/flutter/issues/159741.
SemanticsRole.dragHandle => _unimplemented,
SemanticsRole.spinButton => _unimplemented,
SemanticsRole.comboBox => _unimplemented,
SemanticsRole.tooltip => _unimplemented,
- SemanticsRole.loadingSpinner => _unimplemented,
- SemanticsRole.progressBar => _unimplemented,
SemanticsRole.hotKey => _unimplemented,
}(node);
@@ -205,6 +205,48 @@
static FlutterError? _noCheckRequired(SemanticsNode node) => null;
+ static FlutterError? _semanticsProgressBar(SemanticsNode node) {
+ final SemanticsData data = node.getSemanticsData();
+
+ // Check if value is present
+ if (data.value.isEmpty) {
+ return FlutterError('A progress bar must have a value');
+ }
+
+ // Check if minValue and maxValue are present
+ if (data.minValue == null) {
+ return FlutterError('A progress bar must have a minValue');
+ }
+
+ if (data.maxValue == null) {
+ return FlutterError('A progress bar must have a maxValue');
+ }
+
+ // Validate that value is within min and max range
+ try {
+ final double currentValue = double.parse(data.value);
+ final double minVal = double.parse(data.minValue!);
+ final double maxVal = double.parse(data.maxValue!);
+
+ if (currentValue < minVal || currentValue > maxVal) {
+ return FlutterError(
+ 'Progress bar value ($currentValue) must be between minValue ($minVal) and maxValue ($maxVal)',
+ );
+ }
+
+ if (minVal >= maxVal) {
+ return FlutterError('Progress bar minValue ($minVal) must be less than maxValue ($maxVal)');
+ }
+ } catch (e) {
+ return FlutterError(
+ 'Progress bar value, minValue, and maxValue must be valid numbers. '
+ 'value: "${data.value}", minValue: "${data.minValue}", maxValue: "${data.maxValue}"',
+ );
+ }
+
+ return null;
+ }
+
static FlutterError? _semanticsTab(SemanticsNode node) {
final SemanticsData data = node.getSemanticsData();
if (data.flagsCollection.isSelected == Tristate.none) {
@@ -1022,6 +1064,8 @@
required this.validationResult,
required this.inputType,
required this.locale,
+ required this.minValue,
+ required this.maxValue,
this.tags,
this.transform,
this.customSemanticsActionIds,
@@ -1297,6 +1341,12 @@
/// content of this semantics node.
final Locale? locale;
+ /// {@macro flutter.semantics.SemanticsProperties.maxValue}
+ final String? maxValue;
+
+ /// {@macro flutter.semantics.SemanticsProperties.minValue}
+ final String? minValue;
+
/// Whether [flags] contains the given flag.
@Deprecated(
'Use flagsCollection instead. '
@@ -1381,6 +1431,8 @@
),
);
}
+ properties.add(StringProperty('minValue', minValue, defaultValue: null));
+ properties.add(StringProperty('maxValue', maxValue, defaultValue: null));
}
@override
@@ -1416,7 +1468,9 @@
other.validationResult == validationResult &&
other.inputType == inputType &&
_sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) &&
- setEquals<String>(controlsNodes, other.controlsNodes);
+ setEquals<String>(controlsNodes, other.controlsNodes) &&
+ minValue == other.minValue &&
+ maxValue == other.maxValue;
}
@override
@@ -1453,6 +1507,8 @@
inputType,
traversalParentIdentifier,
traversalChildIdentifier,
+ minValue,
+ maxValue,
),
);
@@ -1635,6 +1691,8 @@
this.onExpand,
this.onCollapse,
this.customSemanticsActions,
+ this.minValue,
+ this.maxValue,
}) : assert(
label == null || attributedLabel == null,
'Only one of label or attributedLabel should be provided',
@@ -2566,6 +2624,28 @@
/// {@endtemplate}
final SemanticsInputType? inputType;
+ /// {@template flutter.semantics.SemanticsProperties.maxValue}
+ /// The maximum value of the node.
+ ///
+ /// Used in conjunction with [value] to define the current value and range
+ /// of a node. A typical usage is for progress indicators, where [value]
+ /// represents the current progress and [maxValue] defines the maximum
+ /// possible value.
+ ///
+ /// {@endtemplate}
+ final String? maxValue;
+
+ /// {@template flutter.semantics.SemanticsProperties.minValue}
+ /// The minimum value of the node.
+ ///
+ /// Used in conjunction with [value] to define the current value and range
+ /// of a node. A typical usage is for progress indicators, where [value]
+ /// represents the current progress and [minValue] defines the minimum
+ /// possible value.
+ ///
+ /// {@endtemplate}
+ final String? minValue;
+
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
@@ -3227,7 +3307,9 @@
_headingLevel != config._headingLevel ||
_linkUrl != config._linkUrl ||
_role != config.role ||
- _validationResult != config.validationResult;
+ _validationResult != config.validationResult ||
+ _minValue != config._minValue ||
+ _maxValue != config._maxValue;
}
// TAGS, LABELS, ACTIONS
@@ -3517,6 +3599,14 @@
Set<String>? get controlsNodes => _controlsNodes;
Set<String>? _controlsNodes = _kEmptyConfig.controlsNodes;
+ /// {@macro flutter.semantics.SemanticsProperties.minValue}
+ String? get minValue => _minValue;
+ String? _minValue;
+
+ /// {@macro flutter.semantics.SemanticsProperties.maxValue}
+ String? get maxValue => _maxValue;
+ String? _maxValue;
+
/// {@macro flutter.semantics.SemanticsProperties.validationResult}
SemanticsValidationResult get validationResult => _validationResult;
SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult;
@@ -3604,6 +3694,8 @@
_inputType = config._inputType;
_locale = config.locale;
+ _minValue = config.minValue;
+ _maxValue = config.maxValue;
_replaceChildren(childrenInInversePaintOrder ?? const <SemanticsNode>[]);
if (mergeAllDescendantsIntoThisNodeValueChanged) {
@@ -3657,6 +3749,8 @@
SemanticsValidationResult validationResult = _validationResult;
SemanticsInputType inputType = _inputType;
final Locale? locale = _locale;
+ String? minValue = _minValue;
+ String? maxValue = _maxValue;
final Set<int> customSemanticsActionIds = <int>{};
for (final CustomSemanticsAction action in _customSemanticsActions.keys) {
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
@@ -3764,6 +3858,9 @@
controlsNodes = <String>{...controlsNodes!, ...node._controlsNodes!};
}
+ minValue ??= node._minValue;
+ maxValue ??= node._maxValue;
+
if (validationResult == SemanticsValidationResult.none) {
validationResult = node._validationResult;
} else if (validationResult == SemanticsValidationResult.valid) {
@@ -3812,6 +3909,8 @@
validationResult: validationResult,
inputType: inputType,
locale: locale,
+ minValue: minValue,
+ maxValue: maxValue,
);
}
@@ -4003,6 +4102,8 @@
validationResult: data.validationResult,
inputType: data.inputType,
locale: data.locale,
+ minValue: data.minValue ?? '0',
+ maxValue: data.maxValue ?? '0',
);
_dirty = false;
}
@@ -4296,6 +4397,8 @@
),
);
}
+ properties.add(StringProperty('minValue', _minValue, defaultValue: null));
+ properties.add(StringProperty('maxValue', _maxValue, defaultValue: null));
}
/// Returns a string representation of this node and its descendants.
@@ -6434,6 +6537,22 @@
_hasBeenAnnotated = true;
}
+ /// {@macro flutter.semantics.SemanticsProperties.maxValue}
+ String? get maxValue => _maxValue;
+ String? _maxValue;
+ set maxValue(String? value) {
+ _maxValue = value;
+ _hasBeenAnnotated = true;
+ }
+
+ /// {@macro flutter.semantics.SemanticsProperties.minValue}
+ String? get minValue => _minValue;
+ String? _minValue;
+ set minValue(String? value) {
+ _minValue = value;
+ _hasBeenAnnotated = true;
+ }
+
// TAGS
/// The set of tags that this configuration wants to add to all child
@@ -6534,6 +6653,12 @@
if (_hasExplicitRole && other._hasExplicitRole) {
return false;
}
+ if (_minValue != null && other._minValue != null) {
+ return false;
+ }
+ if (_maxValue != null && other._maxValue != null) {
+ return false;
+ }
return true;
}
@@ -6643,6 +6768,8 @@
_accessiblityFocusBlockType = _accessiblityFocusBlockType._merge(
child._accessiblityFocusBlockType,
);
+ _minValue ??= child._minValue;
+ _maxValue ??= child._maxValue;
_hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated;
}
@@ -6689,7 +6816,9 @@
.._role = _role
.._controlsNodes = _controlsNodes
.._validationResult = _validationResult
- .._inputType = _inputType;
+ .._inputType = _inputType
+ .._minValue = _minValue
+ .._maxValue = _maxValue;
}
}
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index 089907c..e8f4cc1 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -4050,6 +4050,8 @@
required SemanticsValidationResult validationResult,
required ui.SemanticsInputType? inputType,
required Locale? localeForSubtree,
+ required String? minValue,
+ required String? maxValue,
}) : this.fromProperties(
key: key,
child: child,
@@ -4134,6 +4136,8 @@
controlsNodes: controlsNodes,
validationResult: validationResult,
inputType: inputType,
+ minValue: minValue,
+ maxValue: maxValue,
),
);
@@ -4384,6 +4388,8 @@
super.validationResult = SemanticsValidationResult.none,
super.inputType,
super.localeForSubtree,
+ super.minValue,
+ super.maxValue,
}) : super(child: sliver);
/// {@macro flutter.widgets.SemanticsBase.fromProperties}
@@ -7962,6 +7968,8 @@
super.validationResult = SemanticsValidationResult.none,
super.inputType,
super.localeForSubtree,
+ super.minValue,
+ super.maxValue,
});
/// {@macro flutter.widgets.SemanticsBase.fromProperties}
diff --git a/packages/flutter/test/material/progress_indicator_test.dart b/packages/flutter/test/material/progress_indicator_test.dart
index d181102..1355078 100644
--- a/packages/flutter/test/material/progress_indicator_test.dart
+++ b/packages/flutter/test/material/progress_indicator_test.dart
@@ -378,7 +378,12 @@
expect(
tester.getSemantics(find.byType(CircularProgressIndicator)),
- matchesSemantics(value: '0%', textDirection: TextDirection.ltr),
+ matchesSemantics(
+ value: '0',
+ textDirection: TextDirection.ltr,
+ minValue: '0',
+ maxValue: '100',
+ ),
);
handle.dispose();
},
@@ -940,7 +945,7 @@
final SemanticsHandle handle = tester.ensureSemantics();
final GlobalKey key = GlobalKey();
const String label = 'Label';
- const String value = '25%';
+ const String value = '25';
await tester.pumpWidget(
Theme(
data: theme,
@@ -982,7 +987,7 @@
expect(
tester.getSemantics(find.byKey(key)),
- matchesSemantics(textDirection: TextDirection.ltr, label: label, value: '25%'),
+ matchesSemantics(textDirection: TextDirection.ltr, label: label, value: '25'),
);
handle.dispose();
@@ -1037,7 +1042,7 @@
final SemanticsHandle handle = tester.ensureSemantics();
final GlobalKey key = GlobalKey();
const String label = 'Label';
- const String value = '25%';
+ const String value = '25';
await tester.pumpWidget(
Theme(
data: theme,
@@ -1065,7 +1070,7 @@
final SemanticsHandle handle = tester.ensureSemantics();
final GlobalKey key = GlobalKey();
const String label = 'Label';
- const String value = '25%';
+ const String value = '25';
await tester.pumpWidget(
Theme(
data: theme,
diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart
index 2d70a03..e7f62cd 100644
--- a/packages/flutter/test/semantics/semantics_test.dart
+++ b/packages/flutter/test/semantics/semantics_test.dart
@@ -723,7 +723,9 @@
' scrollPosition: null\n'
' scrollExtentMax: null\n'
' indexInParent: null\n'
- ' headingLevel: 0\n',
+ ' headingLevel: 0\n'
+ ' minValue: null\n'
+ ' maxValue: null\n',
);
final SemanticsConfiguration config = SemanticsConfiguration()
@@ -871,7 +873,9 @@
' scrollPosition: null\n'
' scrollExtentMax: null\n'
' indexInParent: null\n'
- ' headingLevel: 0\n',
+ ' headingLevel: 0\n'
+ ' minValue: null\n'
+ ' maxValue: null\n',
);
});
diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart
index 7ba38a0..c1aeb06 100644
--- a/packages/flutter/test/semantics/semantics_update_test.dart
+++ b/packages/flutter/test/semantics/semantics_update_test.dart
@@ -234,6 +234,8 @@
ui.SemanticsHitTestBehavior hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
required ui.SemanticsInputType inputType,
required ui.Locale? locale,
+ required String minValue,
+ required String maxValue,
}) {
// Makes sure we don't send the same id twice.
assert(!observations.containsKey(id));
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart
index 14ff6c8..9515e5c 100644
--- a/packages/flutter/test/widgets/semantics_tester.dart
+++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -713,6 +713,8 @@
double? scrollExtentMin,
int? currentValueLength,
int? maxValueLength,
+ String? maxValue,
+ String? minValue,
SemanticsNode? ancestor,
SemanticsInputType? inputType,
}) {
@@ -810,6 +812,12 @@
if (inputType != null && node.inputType != inputType) {
return false;
}
+ if (maxValue != null && node.maxValue != maxValue) {
+ return false;
+ }
+ if (minValue != null && node.minValue != minValue) {
+ return false;
+ }
return true;
}
@@ -1120,6 +1128,8 @@
this.maxValueLength,
this.currentValueLength,
this.inputType,
+ this.minValue,
+ this.maxValue,
}) : assert(
label != null ||
value != null ||
@@ -1135,6 +1145,7 @@
maxValueLength != null ||
currentValueLength != null ||
inputType != null,
+ minValue != null || maxValue != null,
);
final AttributedString? attributedLabel;
final AttributedString? attributedValue;
@@ -1155,6 +1166,8 @@
final int? currentValueLength;
final int? maxValueLength;
final SemanticsInputType? inputType;
+ final String? minValue;
+ final String? maxValue;
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
@@ -1179,6 +1192,8 @@
currentValueLength: currentValueLength,
maxValueLength: maxValueLength,
inputType: inputType,
+ minValue: minValue,
+ maxValue: maxValue,
)
.isNotEmpty;
}
@@ -1215,6 +1230,8 @@
if (currentValueLength != null) 'currentValueLength "$currentValueLength"',
if (maxValueLength != null) 'maxValueLength "$maxValueLength"',
if (inputType != null) 'inputType $inputType',
+ if (minValue != null) 'minValue "$minValue"',
+ if (maxValue != null) 'maxValue "$maxValue"',
];
return strings.join(', ');
}
@@ -1244,6 +1261,8 @@
int? maxValueLength,
int? currentValueLength,
SemanticsInputType? inputType,
+ String? minValue,
+ String? maxValue,
}) {
return _IncludesNodeWith(
label: label,
@@ -1265,5 +1284,7 @@
maxValueLength: maxValueLength,
currentValueLength: currentValueLength,
inputType: inputType,
+ minValue: minValue,
+ maxValue: maxValue,
);
}
diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart
index 0a7b373..b613434 100644
--- a/packages/flutter_test/lib/src/matchers.dart
+++ b/packages/flutter_test/lib/src/matchers.dart
@@ -687,6 +687,8 @@
int? currentValueLength,
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
ui.SemanticsInputType? inputType,
+ String? maxValue,
+ String? minValue,
// Flags //
bool hasCheckedState = false,
bool isChecked = false,
@@ -772,6 +774,8 @@
currentValueLength: currentValueLength,
validationResult: validationResult,
inputType: inputType,
+ minValue: minValue,
+ maxValue: maxValue,
// Flags
hasCheckedState: hasCheckedState,
isChecked: isChecked,
@@ -887,6 +891,8 @@
int? currentValueLength,
SemanticsValidationResult? validationResult,
ui.SemanticsInputType? inputType,
+ String? maxValue,
+ String? minValue,
// Flags
bool? hasCheckedState,
bool? isChecked,
@@ -972,6 +978,8 @@
currentValueLength: currentValueLength,
validationResult: validationResult,
inputType: inputType,
+ minValue: minValue,
+ maxValue: maxValue,
// Flags
hasCheckedState: hasCheckedState,
isChecked: isChecked,
@@ -2404,6 +2412,8 @@
required this.currentValueLength,
required this.validationResult,
required this.inputType,
+ required this.minValue,
+ required this.maxValue,
// Flags
required bool? hasCheckedState,
required bool? isChecked,
@@ -2551,6 +2561,8 @@
final ui.SemanticsInputType? inputType;
final List<Matcher>? children;
final SemanticsValidationResult? validationResult;
+ final String? maxValue;
+ final String? minValue;
/// There are three possible states for these two maps:
///
@@ -2660,6 +2672,12 @@
if (validationResult != null) {
description.add(' with validation result: $validationResult');
}
+ if (minValue != null) {
+ description.add(' with minValue: $minValue');
+ }
+ if (maxValue != null) {
+ description.add(' with maxValue: $maxValue');
+ }
if (children != null) {
description.add(' with children:\n ');
final List<_MatchesSemanticsData> childMatches = children!.cast<_MatchesSemanticsData>();
@@ -2796,6 +2814,12 @@
if (inputType != null && inputType != data.inputType) {
return failWithDescription(matchState, 'inputType was: ${data.inputType}');
}
+ if (minValue != null && minValue != data.minValue) {
+ return failWithDescription(matchState, 'minValue was: ${data.minValue}');
+ }
+ if (maxValue != null && maxValue != data.maxValue) {
+ return failWithDescription(matchState, 'maxValue was: ${data.maxValue}');
+ }
if (actions.isNotEmpty) {
final List<SemanticsAction> unexpectedActions = <SemanticsAction>[];
final List<SemanticsAction> missingActions = <SemanticsAction>[];
diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart
index e5d9c1f..6d4b12b 100644
--- a/packages/flutter_test/test/matchers_test.dart
+++ b/packages/flutter_test/test/matchers_test.dart
@@ -755,6 +755,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@@ -1057,6 +1059,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@@ -1159,6 +1163,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@@ -1266,6 +1272,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData);
@@ -1301,6 +1309,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData);
@@ -1435,6 +1445,8 @@
validationResult: SemanticsValidationResult.none,
inputType: ui.SemanticsInputType.none,
locale: null,
+ minValue: '0',
+ maxValue: '0',
);
final _FakeSemanticsNode node = _FakeSemanticsNode(data);