From 2c4e7ebfd9fd86bf5c63553cd4031c7211829d32 Mon Sep 17 00:00:00 2001 From: Pacific <39942340+prash4931@users.noreply.github.com> Date: Thu, 11 Dec 2025 02:23:33 +0530 Subject: [PATCH 01/29] Fixed RenderFlex overflow in RouteObserver Example (#170980) Fixed RenderOverflow in RouteObserver Example: https://api.flutter.dev/flutter/widgets/RouteObserver-class.html This PR is about solving render overflow issue in Route Observer Example in Live Website. I've wrapped the body with a Scrollable View to solve this issue | | | | --- | --- | | B | ![image](https://github.com/user-attachments/assets/c8fa78e7-62ca-4bd1-9798-588c636cd61a) | | A | ![image](https://github.com/user-attachments/assets/3a2b4697-121a-43e3-9c85-b1379a575af1) | ## 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]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] 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]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. [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 --------- Co-authored-by: Victor Sanni --- .../lib/widgets/routes/route_observer.0.dart | 60 ++++++++++--------- .../widgets/routes/route_observer.0_test.dart | 20 +++++++ 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/examples/api/lib/widgets/routes/route_observer.0.dart b/examples/api/lib/widgets/routes/route_observer.0.dart index fdb44fdce451c..e1ce6adc72eff 100644 --- a/examples/api/lib/widgets/routes/route_observer.0.dart +++ b/examples/api/lib/widgets/routes/route_observer.0.dart @@ -63,37 +63,39 @@ class _RouteObserverExampleState extends State @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'RouteObserver log:', - style: Theme.of(context).textTheme.headlineSmall, - ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300.0), - child: ListView.builder( - itemCount: log.length, - itemBuilder: (BuildContext context, int index) { - if (log.isEmpty) { - return const SizedBox.shrink(); - } - return Text(log[index], textAlign: TextAlign.center); + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'RouteObserver log:', + style: Theme.of(context).textTheme.headlineSmall, + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300.0), + child: ListView.builder( + itemCount: log.length, + itemBuilder: (BuildContext context, int index) { + if (log.isEmpty) { + return const SizedBox.shrink(); + } + return Text(log[index], textAlign: TextAlign.center); + }, + ), + ), + OutlinedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const NextPage(), + ), + ); }, + child: const Text('Go to next page'), ), - ), - OutlinedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const NextPage(), - ), - ); - }, - child: const Text('Go to next page'), - ), - ], + ], + ), ), ), ); diff --git a/examples/api/test/widgets/routes/route_observer.0_test.dart b/examples/api/test/widgets/routes/route_observer.0_test.dart index 3c79c47639837..91e60952e3a07 100644 --- a/examples/api/test/widgets/routes/route_observer.0_test.dart +++ b/examples/api/test/widgets/routes/route_observer.0_test.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_api_samples/widgets/routes/route_observer.0.dart' as example; import 'package:flutter_test/flutter_test.dart'; @@ -38,5 +39,24 @@ void main() { // Check the RouteObserver logs after the route is popped again. expect(find.text('didPush'), findsOneWidget); expect(find.text('didPopNext'), findsNWidgets(2)); + + // Check if any overflow or layout exceptions occurred. + expect(tester.takeException(), isNull); }); + + testWidgets( + 'RouteObserver example renders without overflow on small screens', + (WidgetTester tester) async { + // Set the screen size to a smaller value. + tester.view.physicalSize = const Size(200, 200); + addTearDown(tester.view.reset); + + // Build the RouteObserver example widget. + await tester.pumpWidget(const example.RouteObserverApp()); + await tester.pumpAndSettle(); + + // Verify there are no layout exceptions. + expect(tester.takeException(), isNull); + }, + ); } From aaafeaf18dc3546f5e261958ff8d85026f732c73 Mon Sep 17 00:00:00 2001 From: akashefrath Date: Thu, 11 Dec 2025 02:27:38 +0530 Subject: [PATCH 02/29] added onUserInteractionIfError for form (#175515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new `AutovalidateMode` value: `onUserInteractionIfError`. This mode allows `Form` and `FormField` widgets to auto-validate **only when a field already has an error and the user interacts with it again**. ### Why - Current modes (`disabled`, `always`, `onUserInteraction`, `onUnfocus`) do not cover the case where developers want validation **only when correcting an error**. - This improves UX by reducing unnecessary validation calls while still ensuring errors disappear as soon as the user fixes them. ### How - Added `onUserInteractionIfError` to `AutovalidateMode`. - Updated Dartdoc with detailed description and example snippet. - Added new unit tests to validate behavior. - Verified no regressions in other modes (`always`, `onUnfocus`, `onUserInteraction`). ### Example ```dart TextFormField( autovalidateMode: AutovalidateMode.onUserInteractionIfError, validator: (value) => value!.isEmpty ? 'Required field' : null, ) ```` --- ## Related Issues Fixes #\ --- ## Tests * Added widget tests to confirm that: * No validation occurs until a field has an error. * Once an error exists, validation runs automatically on user interaction. * Resetting the form clears errors correctly. * Regression tests ensure that `always`, `onUserInteraction`, and `onUnfocus` modes still behave correctly. --- ## Pre-launch / Reviewer Checklist * [x] I have read the [[Contributor Guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md)](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md) * [x] I have read the \[Tree Hygiene] wiki page and followed its guidance * [x] I have read and followed the \[Flutter Style Guide] * [x] I signed the \[CLA] * [x] I added a new enum value with clear Dartdoc (`onUserInteractionIfError`) * [x] I added example code in the Dartdoc * [x] I added new widget/unit tests for the new mode * [x] All existing and new tests pass * [x] I ran `flutter analyze` and fixed all issues * [x] No breaking changes introduced (or marked accordingly) --- ## Breaking Change * [x] Yes * [x] No --- ## Screenshots / Demo (if applicable) *(N/A — logic-only change)* --- ## Reviewer Notes * The new mode reduces unnecessary validation until the user corrects an error. * All tests confirm correct behavior and no regressions in existing modes. --------- Co-authored-by: Loïc Sharma <737941+loic-sharma@users.noreply.github.com> --- packages/flutter/lib/src/widgets/form.dart | 19 +++ packages/flutter/test/widgets/form_test.dart | 161 +++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index e04735b3dc850..7015aededc6e3 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -251,6 +251,7 @@ class FormState extends State
{ _hasInteractedByUser = _fields.any( (FormFieldState field) => field._hasInteractedByUser.value, ); + _forceRebuild(); } @@ -271,6 +272,8 @@ class FormState extends State { @protected @override Widget build(BuildContext context) { + final bool hasError = _fields.any((FormFieldState field) => field.hasError); + switch (widget.autovalidateMode) { case AutovalidateMode.always: _validate(View.of(context)); @@ -278,6 +281,10 @@ class FormState extends State { if (_hasInteractedByUser) { _validate(View.of(context)); } + case AutovalidateMode.onUserInteractionIfError: + if (_hasInteractedByUser && hasError) { + _validate(View.of(context)); + } case AutovalidateMode.onUnfocus: case AutovalidateMode.disabled: break; @@ -751,6 +758,7 @@ class FormFieldState extends State> with RestorationMixin { }); case AutovalidateMode.onUnfocus: case AutovalidateMode.onUserInteraction: + case AutovalidateMode.onUserInteractionIfError: case AutovalidateMode.disabled: case null: break; @@ -776,6 +784,10 @@ class FormFieldState extends State> with RestorationMixin { if (_hasInteractedByUser.value) { _validate(); } + case AutovalidateMode.onUserInteractionIfError: + if (_hasInteractedByUser.value && hasError) { + _validate(); + } case AutovalidateMode.onUnfocus: case AutovalidateMode.disabled: break; @@ -831,4 +843,11 @@ enum AutovalidateMode { /// In order to validate all fields of a [Form] after the first time the user interacts /// with one, use [always] instead. onUnfocus, + + /// Used to auto-validate [Form] and [FormField] after each user + /// interaction, only if the the field already has an error. + /// + /// This is useful for reducing unnecessary validation calls while + /// still ensuring errors are re-checked when the user attempts to fix them. + onUserInteractionIfError, } diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 1aea5d31dcd50..a3f97eb406584 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -840,6 +840,99 @@ void main() { }, ); + testWidgets( + 'Does not auto-validate before value changes when autovalidateMode is set to onUserInteractionIfError', + (WidgetTester tester) async { + late FormFieldState formFieldState; + + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: FormField( + initialValue: 'foo', + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + builder: (FormFieldState state) { + formFieldState = state; + return Container(); + }, + validator: errorText, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // The form field has no error. + expect(formFieldState.hasError, isFalse); + // No "Required" error is visible. + expect(find.text('Required'), findsNothing); + }, + ); + + testWidgets( + 'Does not auto-validate before value changes even when initialValue is empty and autovalidateMode is set to onUserInteractionIfError', + (WidgetTester tester) async { + late FormFieldState formFieldState; + + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: FormField( + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + builder: (FormFieldState state) { + formFieldState = state; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(), + if (state.errorText != null) Text(state.errorText!), + ], + ); + }, + validator: errorText, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + expect(formFieldState.hasError, isFalse); + + expect(find.text('Required'), findsNothing); + + expect(formFieldState.errorText, isNull); + + formFieldState.validate(); + await tester.pump(); + + expect(formFieldState.hasError, isTrue); + + expect(find.text('Required'), findsOneWidget); + }, + ); + testWidgets('auto-validate before value changes if autovalidateMode was set to always', ( WidgetTester tester, ) async { @@ -998,6 +1091,74 @@ void main() { }, ); + testWidgets( + 'Form with AutovalidateMode.onUserInteractionIfError only revalidates when user interacts after an error exists', + (WidgetTester tester) async { + final formState = GlobalKey(); + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + theme: ThemeData(), + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Form( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + child: Material( + child: TextFormField(initialValue: 'foo', validator: errorText), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // No error text is visible yet. (Initial valid state). + expect(find.text('Required'), findsNothing); + + // User types valid input 'bar' → autovalidate is disabled → still no error. + await tester.enterText(find.byType(TextFormField), 'bar'); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Clear the input (invalid). + await tester.enterText(find.byType(TextFormField), ''); + await tester.pump(); + + // Manually submit form to show the initial error (AutovalidateMode is now active). + formState.currentState!.validate(); + expect(find.text('Required'), findsNothing); + await tester.pump(); + + // Verify error is shown. + expect(find.text('Required'), findsOneWidget); + + // Now user interacts again with valid text ('baz') → validation auto-runs and clears the error. + await tester.enterText(find.byType(TextFormField), 'baz'); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Check the behavior of a manual validate when the text is already valid. + // This should *confirm* the error is cleared, not re-introduce it. + formState.currentState!.validate(); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Resetting should clear form (already cleared, but a safety check). + await tester.enterText(find.byType(TextFormField), ''); + formState.currentState!.reset(); + await tester.pump(); + expect(find.text('Required'), findsNothing); + }, + ); + // Regression test for https://github.com/flutter/flutter/issues/63753. testWidgets('Validate form should return correct validation if the value is composing', ( WidgetTester tester, From 8fe76181e34852d78802b1f24e612bded2cecdfe Mon Sep 17 00:00:00 2001 From: yim Date: Thu, 11 Dec 2025 05:11:40 +0800 Subject: [PATCH 03/29] Fix the issue with pinned headers in nested SliverMainAxisGroup. (#179132) Fixes: #178973 ## 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. - [ ] 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. [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 --- .../lib/src/rendering/sliver_group.dart | 16 +++-- .../widgets/sliver_main_axis_group_test.dart | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/flutter/lib/src/rendering/sliver_group.dart b/packages/flutter/lib/src/rendering/sliver_group.dart index 4e99b0e75ea65..feedabb357628 100644 --- a/packages/flutter/lib/src/rendering/sliver_group.dart +++ b/packages/flutter/lib/src/rendering/sliver_group.dart @@ -374,16 +374,22 @@ class RenderSliverMainAxisGroup extends RenderSliver // If the children's paint extent exceeds the remaining scroll extent of the `RenderSliverMainAxisGroup`, // they need to be corrected. if (paintOffset > remainingExtent) { + // Whether the current remaining space can accommodate all pinned children. + final bool pinnedChildrenOverflow = + maxScrollObstructionExtent > remainingExtent - constraints.overlap; final double paintCorrection = paintOffset - remainingExtent; paintOffset = remainingExtent; child = firstChild; while (child != null) { final SliverGeometry childLayoutGeometry = child.geometry!; - final bool childIsTooLarge = childLayoutGeometry.paintExtent > remainingExtent; - final bool pinnedHeadersOverflow = maxScrollObstructionExtent > remainingExtent; - final bool childIsPinnedHeader = childLayoutGeometry.maxScrollObstructionExtent > 0; - if (childIsTooLarge || (pinnedHeadersOverflow && childIsPinnedHeader)) { - final childParentData = child.parentData! as SliverPhysicalParentData; + final childParentData = child.parentData! as SliverPhysicalParentData; + final double childMainAxisPaintOffset = switch (constraints.axis) { + Axis.vertical => childParentData.paintOffset.dy, + Axis.horizontal => childParentData.paintOffset.dx, + }; + final double childPaintEnd = childMainAxisPaintOffset + childLayoutGeometry.paintExtent; + final bool childIsPinned = childLayoutGeometry.maxScrollObstructionExtent > 0; + if (childPaintEnd > remainingExtent || (pinnedChildrenOverflow && childIsPinned)) { childParentData.paintOffset = switch (constraints.axis) { Axis.vertical => Offset(0.0, childParentData.paintOffset.dy - paintCorrection), Axis.horizontal => Offset(childParentData.paintOffset.dx - paintCorrection, 0.0), diff --git a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart index 463050b760dd2..ecdb21ffff049 100644 --- a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart +++ b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart @@ -1518,6 +1518,69 @@ void main() { expect(semantics.nodesWith(label: 'b'), hasLength(1)); semantics.dispose(); }); + + testWidgets( + 'nested SliverMainAxisGroup with multiple PinnedHeaderSlivers positions correctly on scroll', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + final List keys = [GlobalKey(), GlobalKey(), GlobalKey(), GlobalKey()]; + + Future pumpWidget({bool reverse = false}) async { + return tester.pumpWidget( + _buildSliverMainAxisGroup( + controller: controller, + reverse: reverse, + viewportHeight: 300, + precedingSlivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[0])), + ], + otherSlivers: [const SliverToBoxAdapter(child: SizedBox(height: 300))], + slivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[1])), + SliverMainAxisGroup( + slivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[2])), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[3])), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + ], + ), + ); + } + + Future verifyPositions(Map> offsetToPositions) async { + for (final MapEntry> entry in offsetToPositions.entries) { + controller.jumpTo(entry.key); + await tester.pumpAndSettle(); + for (var i = 0; i < keys.length; i++) { + expect(tester.getTopLeft(find.byKey(keys[i])).dy, entry.value[i]); + } + } + } + + // Forward direction + await pumpWidget(); + await verifyPositions({ + 10: [0.0, 30.0, 60.0, 110.0], + 40: [0.0, 30.0, 60.0, 90.0], + 70: [0.0, 30.0, 50.0, 80.0], + 100: [0.0, 30.0, 20.0, 50.0], + }); + + // Reverse direction + await pumpWidget(reverse: true); + await verifyPositions({ + 10: [270.0, 240.0, 210.0, 160.0], + 40: [270.0, 240.0, 210.0, 180.0], + 70: [270.0, 240.0, 220.0, 190.0], + 100: [270.0, 240.0, 250.0, 220.0], + }); + }, + ); } Widget _buildSliverList({ @@ -1559,6 +1622,7 @@ Widget _buildSliverMainAxisGroup({ Axis scrollDirection = Axis.vertical, bool reverse = false, List otherSlivers = const [], + List precedingSlivers = const [], }) { return MaterialApp( home: Directionality( @@ -1573,6 +1637,7 @@ Widget _buildSliverMainAxisGroup({ reverse: reverse, controller: controller, slivers: [ + ...precedingSlivers, SliverMainAxisGroup(slivers: slivers), ...otherSlivers, ], From e25d71b086d65429067cf90dbf81e47d2177ada2 Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Wed, 10 Dec 2025 13:39:24 -0800 Subject: [PATCH 04/29] Ensure that the engine converts std::filesystem::path objects to UTF-8 strings on Windows (#179528) Fixes https://github.com/flutter/flutter/issues/178896 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- engine/src/flutter/fml/string_conversion.cc | 5 +++++ engine/src/flutter/fml/string_conversion.h | 4 ++++ engine/src/flutter/fml/string_conversion_unittests.cc | 5 +++++ .../shell/platform/windows/flutter_project_bundle.cc | 3 ++- .../shell/platform/windows/flutter_windows_engine.cc | 4 ++-- engine/src/flutter/tools/templater/templater_main.cc | 7 ++++--- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/engine/src/flutter/fml/string_conversion.cc b/engine/src/flutter/fml/string_conversion.cc index d55577c4e6471..e2f62c0fb117e 100644 --- a/engine/src/flutter/fml/string_conversion.cc +++ b/engine/src/flutter/fml/string_conversion.cc @@ -44,4 +44,9 @@ std::u16string Utf8ToUtf16(const std::string_view string) { return converter.from_bytes(string.data()); } +std::string PathToUtf8(const std::filesystem::path& path) { + const std::u8string path_u8 = path.u8string(); + return std::string(path_u8.begin(), path_u8.end()); +} + } // namespace fml diff --git a/engine/src/flutter/fml/string_conversion.h b/engine/src/flutter/fml/string_conversion.h index eb7d840c3c717..48fb450a577d3 100644 --- a/engine/src/flutter/fml/string_conversion.h +++ b/engine/src/flutter/fml/string_conversion.h @@ -5,6 +5,7 @@ #ifndef FLUTTER_FML_STRING_CONVERSION_H_ #define FLUTTER_FML_STRING_CONVERSION_H_ +#include #include #include @@ -19,6 +20,9 @@ std::string Utf16ToUtf8(const std::u16string_view string); // Returns a UTF-16 encoded equivalent of a UTF-8 encoded input string. std::u16string Utf8ToUtf16(const std::string_view string); +// Returns the pathname encoded in UTF-8. +std::string PathToUtf8(const std::filesystem::path& path); + } // namespace fml #endif // FLUTTER_FML_STRING_CONVERSION_H_ diff --git a/engine/src/flutter/fml/string_conversion_unittests.cc b/engine/src/flutter/fml/string_conversion_unittests.cc index 4d12075709014..2369e4b39e6a9 100644 --- a/engine/src/flutter/fml/string_conversion_unittests.cc +++ b/engine/src/flutter/fml/string_conversion_unittests.cc @@ -33,5 +33,10 @@ TEST(StringConversion, Utf16ToUtf8Unicode) { EXPECT_EQ(Utf16ToUtf8(u"\x2603"), "\xe2\x98\x83"); } +TEST(StringConversion, PathToUtf8) { + EXPECT_EQ(PathToUtf8(std::filesystem::path("abc")), "abc"); + EXPECT_EQ(PathToUtf8(std::filesystem::path(u"\x2603")), "\xe2\x98\x83"); +} + } // namespace testing } // namespace fml diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc index b63dad588a551..f2fc0be8756d0 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc @@ -7,6 +7,7 @@ #include #include "flutter/fml/logging.h" +#include "flutter/fml/string_conversion.h" #include "flutter/shell/platform/common/engine_switches.h" // nogncheck #include "flutter/shell/platform/common/path_utils.h" @@ -73,7 +74,7 @@ UniqueAotDataPtr FlutterProjectBundle::LoadAotData( << "; no such file."; return UniqueAotDataPtr(nullptr, nullptr); } - std::string path_string = aot_library_path_.string(); + std::string path_string = fml::PathToUtf8(aot_library_path_); FlutterEngineAOTDataSource source = {}; source.type = kFlutterEngineAOTDataSourceTypeElfPath; source.elf_path = path_string.c_str(); diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc index d579c818ecfa3..70d7364d56e64 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc @@ -274,8 +274,8 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) { FML_LOG(ERROR) << "Missing or unresolvable paths to assets."; return false; } - std::string assets_path_string = project_->assets_path().string(); - std::string icu_path_string = project_->icu_path().string(); + std::string assets_path_string = fml::PathToUtf8(project_->assets_path()); + std::string icu_path_string = fml::PathToUtf8(project_->icu_path()); if (embedder_api_.RunsAOTCompiledDartCode()) { aot_data_ = project_->LoadAotData(embedder_api_); if (!aot_data_) { diff --git a/engine/src/flutter/tools/templater/templater_main.cc b/engine/src/flutter/tools/templater/templater_main.cc index c803d7290b9af..0bff26e581237 100644 --- a/engine/src/flutter/tools/templater/templater_main.cc +++ b/engine/src/flutter/tools/templater/templater_main.cc @@ -12,6 +12,7 @@ #include "flutter/fml/file.h" #include "flutter/fml/logging.h" #include "flutter/fml/mapping.h" +#include "flutter/fml/string_conversion.h" #include "inja/inja.hpp" namespace flutter { @@ -50,9 +51,9 @@ bool TemplaterMain(const fml::CommandLine& command_line) { reinterpret_cast(rendered_template.data()), rendered_template.size()}; - auto current_dir = - fml::OpenDirectory(std::filesystem::current_path().string().c_str(), - false, fml::FilePermission::kReadWrite); + auto current_dir = fml::OpenDirectory( + fml::PathToUtf8(std::filesystem::current_path()).c_str(), false, + fml::FilePermission::kReadWrite); if (!current_dir.is_valid()) { FML_LOG(ERROR) << "Could not open current directory."; return false; From 2a950efec43f089615da4ade5c324bd4ff7156db Mon Sep 17 00:00:00 2001 From: zhongliugo Date: Wed, 10 Dec 2025 14:31:02 -0800 Subject: [PATCH 05/29] Reland: Add framework-side hitTestBehavior support to Semantics (#178817) (#179352) This is a reland of #178817, which was reverted in #179100 due to test failures. The original PR introduced `hitTestBehavior` to the semantics framework but incorrectly applied `opaque` behavior to `ModalRoute`, which blocked platform views from receiving pointer events. Instead of making the entire modal opaque, we: 1. Keep `ModalRoute` without explicit `hitTestBehavior` (defaults to `defer`) 2. Make only the dialog/sheet content opaque (blocks clicks to barrier) 3. Platform views remain clickable because they're outside the opaque content boundary Fixes #149001 Original PR: #177570 Revert: #178744 --- .../lib/src/material/bottom_sheet.dart | 6 +- packages/flutter/lib/src/material/dialog.dart | 4 +- .../lib/src/rendering/custom_paint.dart | 3 + .../flutter/lib/src/rendering/object.dart | 4 + .../lib/src/rendering/platform_view.dart | 11 ++ .../flutter/lib/src/semantics/semantics.dart | 51 ++++++- packages/flutter/lib/src/widgets/basic.dart | 8 +- .../test/material/bottom_sheet_test.dart | 131 +++++++++++++++--- .../flutter/test/material/dialog_test.dart | 89 ++++++++++++ .../flutter/test/material/dropdown_test.dart | 1 + .../test/rendering/platform_view_test.dart | 16 +++ .../test/semantics/semantics_test.dart | 28 ++++ packages/flutter_test/test/matchers_test.dart | 6 + 13 files changed, 330 insertions(+), 28 deletions(-) diff --git a/packages/flutter/lib/src/material/bottom_sheet.dart b/packages/flutter/lib/src/material/bottom_sheet.dart index 00fa0ec6db4f7..77c042db4c06c 100644 --- a/packages/flutter/lib/src/material/bottom_sheet.dart +++ b/packages/flutter/lib/src/material/bottom_sheet.dart @@ -6,6 +6,7 @@ library; import 'dart:math' as math; +import 'dart:ui' show SemanticsHitTestBehavior; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -1117,10 +1118,13 @@ class ModalBottomSheetRoute extends PopupRoute { ), ); - final Widget bottomSheet = useSafeArea + Widget bottomSheet = useSafeArea ? SafeArea(bottom: false, child: content) : MediaQuery.removePadding(context: context, removeTop: true, child: content); + // Prevent clicks inside the bottom sheet from passing through to the barrier + bottomSheet = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: bottomSheet); + return capturedThemes?.wrap(bottomSheet) ?? bottomSheet; } diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index 78d1369e3a5f0..e4c6d2e8017c6 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -10,7 +10,7 @@ /// @docImport 'text_button.dart'; library; -import 'dart:ui' show SemanticsRole, clampDouble, lerpDouble; +import 'dart:ui' show SemanticsHitTestBehavior, SemanticsRole, clampDouble, lerpDouble; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -1676,6 +1676,8 @@ class DialogRoute extends RawDialogRoute { if (useSafeArea) { dialog = SafeArea(child: dialog); } + // Prevent clicks inside the dialog from passing through to the barrier + dialog = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: dialog); return dialog; }, barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 298a34532baf9..d10dfe738a325 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -1043,6 +1043,9 @@ class RenderCustomPaint extends RenderProxyBox { if (config.validationResult != properties.validationResult) { config.validationResult = properties.validationResult; } + if (properties.hitTestBehavior != null) { + config.hitTestBehavior = properties.hitTestBehavior!; + } if (properties.inputType != null) { config.inputType = properties.inputType!; } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index b5c39afa12f9f..15da70fd1af07 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -4938,6 +4938,10 @@ mixin SemanticsAnnotationsMixin on RenderObject { config.validationResult = _properties.validationResult; } + if (_properties.hitTestBehavior != null) { + config.hitTestBehavior = _properties.hitTestBehavior!; + } + if (_properties.inputType != null) { config.inputType = _properties.inputType!; } diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index ca833e3f2a34f..e2830e97f0c33 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -5,6 +5,8 @@ /// @docImport 'package:flutter/widgets.dart'; library; +import 'dart:ui' as ui show SemanticsHitTestBehavior; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; @@ -267,6 +269,9 @@ class RenderAndroidView extends PlatformViewRenderBox { if (_viewController.isCreated) { config.platformViewId = _viewController.viewId; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } } } @@ -372,6 +377,9 @@ abstract class RenderDarwinPlatformView super.describeSemanticsConfiguration(config); config.isSemanticBoundary = true; config.platformViewId = _viewController.id; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } @override @@ -737,6 +745,9 @@ class PlatformViewRenderBox extends RenderBox with _PlatformViewGestureMixin { super.describeSemanticsConfiguration(config); config.isSemanticBoundary = true; config.platformViewId = _controller.viewId; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } } diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 8bf5f290c9a4e..86f44a09247eb 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -28,6 +28,7 @@ import 'dart:ui' StringAttribute, TextDirection, Tristate; +import 'dart:ui' as ui show SemanticsHitTestBehavior; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -1020,6 +1021,7 @@ class SemanticsData with Diagnosticable { required this.role, required this.controlsNodes, required this.validationResult, + required this.hitTestBehavior, required this.inputType, required this.locale, this.tags, @@ -1288,6 +1290,9 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsProperties.validationResult} final SemanticsValidationResult validationResult; + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + final ui.SemanticsHitTestBehavior hitTestBehavior; + /// {@macro flutter.semantics.SemanticsNode.inputType} final SemanticsInputType inputType; @@ -1415,6 +1420,7 @@ class SemanticsData with Diagnosticable { other.role == role && other.validationResult == validationResult && other.inputType == inputType && + other.hitTestBehavior == hitTestBehavior && _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && setEquals(controlsNodes, other.controlsNodes); } @@ -1451,8 +1457,7 @@ class SemanticsData with Diagnosticable { validationResult, controlsNodes == null ? null : Object.hashAll(controlsNodes!), inputType, - traversalParentIdentifier, - traversalChildIdentifier, + hitTestBehavior, ), ); @@ -1611,6 +1616,7 @@ class SemanticsProperties extends DiagnosticableTree { this.controlsNodes, this.inputType, this.validationResult = SemanticsValidationResult.none, + this.hitTestBehavior, this.onTap, this.onLongPress, this.onScrollLeft, @@ -2555,6 +2561,13 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final SemanticsValidationResult validationResult; + /// {@template flutter.semantics.SemanticsProperties.hitTestBehavior} + /// Describes how the semantic node should behave during hit testing. + /// + /// See [ui.SemanticsHitTestBehavior] for more details. + /// {@endtemplate} + final ui.SemanticsHitTestBehavior? hitTestBehavior; + /// {@template flutter.semantics.SemanticsProperties.inputType} /// The input type for of a editable widget. /// @@ -3235,7 +3248,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _headingLevel != config._headingLevel || _linkUrl != config._linkUrl || _role != config.role || - _validationResult != config.validationResult; + _validationResult != config.validationResult || + _hitTestBehavior != config.hitTestBehavior; } // TAGS, LABELS, ACTIONS @@ -3529,6 +3543,10 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsValidationResult get validationResult => _validationResult; SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult; + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + ui.SemanticsHitTestBehavior get hitTestBehavior => _hitTestBehavior; + ui.SemanticsHitTestBehavior _hitTestBehavior = ui.SemanticsHitTestBehavior.defer; + /// {@template flutter.semantics.SemanticsNode.inputType} /// The input type for of a editable node. /// @@ -3609,6 +3627,7 @@ class SemanticsNode with DiagnosticableTreeMixin { _role = config._role; _controlsNodes = config._controlsNodes; _validationResult = config._validationResult; + _hitTestBehavior = config._hitTestBehavior; _inputType = config._inputType; _locale = config.locale; @@ -3663,6 +3682,7 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsRole role = _role; Set? controlsNodes = _controlsNodes; SemanticsValidationResult validationResult = _validationResult; + ui.SemanticsHitTestBehavior hitTestBehavior = _hitTestBehavior; SemanticsInputType inputType = _inputType; final Locale? locale = _locale; final customSemanticsActionIds = {}; @@ -3727,6 +3747,9 @@ class SemanticsNode with DiagnosticableTreeMixin { if (inputType == SemanticsInputType.none) { inputType = node._inputType; } + if (hitTestBehavior == ui.SemanticsHitTestBehavior.defer) { + hitTestBehavior = node._hitTestBehavior; + } if (tooltip == '') { tooltip = node._tooltip; } @@ -3818,6 +3841,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, ); @@ -3989,6 +4013,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: data.role, controlsNodes: data.controlsNodes?.toList(), validationResult: data.validationResult, + hitTestBehavior: data.hitTestBehavior, inputType: data.inputType, locale: data.locale, ); @@ -6412,6 +6437,14 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + ui.SemanticsHitTestBehavior get hitTestBehavior => _hitTestBehavior; + ui.SemanticsHitTestBehavior _hitTestBehavior = ui.SemanticsHitTestBehavior.defer; + set hitTestBehavior(ui.SemanticsHitTestBehavior value) { + _hitTestBehavior = value; + _hasBeenAnnotated = true; + } + /// {@macro flutter.semantics.SemanticsProperties.inputType} SemanticsInputType get inputType => _inputType; SemanticsInputType _inputType = SemanticsInputType.none; @@ -6523,6 +6556,10 @@ class SemanticsConfiguration { if (_hasExplicitRole && other._hasExplicitRole) { return false; } + if (_hitTestBehavior != ui.SemanticsHitTestBehavior.defer || + other._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { + return false; + } return true; } @@ -6633,6 +6670,11 @@ class SemanticsConfiguration { child._accessiblityFocusBlockType, ); + if (_hitTestBehavior == ui.SemanticsHitTestBehavior.defer && + child._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { + _hitTestBehavior = child._hitTestBehavior; + } + _hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated; } @@ -6678,7 +6720,8 @@ class SemanticsConfiguration { .._role = _role .._controlsNodes = _controlsNodes .._validationResult = _validationResult - .._inputType = _inputType; + .._inputType = _inputType + .._hitTestBehavior = _hitTestBehavior; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 779882d749525..ed1c909646119 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -9,7 +9,9 @@ library; import 'dart:math' as math; -import 'dart:ui' as ui show Image, ImageFilter, SemanticsInputType, TextHeightBehavior; +import 'dart:ui' + as ui + show Image, ImageFilter, SemanticsHitTestBehavior, SemanticsInputType, TextHeightBehavior; import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; @@ -4047,6 +4049,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required SemanticsRole? role, required Set? controlsNodes, required SemanticsValidationResult validationResult, + required ui.SemanticsHitTestBehavior? hitTestBehavior, required ui.SemanticsInputType? inputType, required Locale? localeForSubtree, }) : this.fromProperties( @@ -4132,6 +4135,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + hitTestBehavior: hitTestBehavior, inputType: inputType, ), ); @@ -4377,6 +4381,7 @@ class SliverSemantics extends _SemanticsBase { super.role, super.controlsNodes, super.validationResult = SemanticsValidationResult.none, + super.hitTestBehavior, super.inputType, super.localeForSubtree, }) : super(child: sliver); @@ -7959,6 +7964,7 @@ class Semantics extends _SemanticsBase { super.role, super.controlsNodes, super.validationResult = SemanticsValidationResult.none, + super.hitTestBehavior, super.inputType, super.localeForSubtree, }); diff --git a/packages/flutter/test/material/bottom_sheet_test.dart b/packages/flutter/test/material/bottom_sheet_test.dart index 9753cca8b98e5..6c2281dc29a9a 100644 --- a/packages/flutter/test/material/bottom_sheet_test.dart +++ b/packages/flutter/test/material/bottom_sheet_test.dart @@ -893,11 +893,18 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: [ + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], + ), ], ), ], @@ -1069,14 +1076,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.hasImplicitScrolling], + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), ], ), ], @@ -1145,14 +1162,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.hasImplicitScrolling], + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), ], ), ], @@ -1217,17 +1244,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.isButton], - actions: [SemanticsAction.tap], - label: 'Dismiss', + label: 'Dialog', textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: [ + TestSemantics( + flags: [SemanticsFlag.isButton], + actions: [SemanticsAction.tap], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], ), - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), ], ), ], @@ -2958,6 +2992,61 @@ void main() { // Test with theme.platform = iOS on different real platforms. await pumpModalBottomSheetWithTheme(TargetPlatform.iOS); }, variant: TargetPlatformVariant.all()); + + testWidgets('Modal bottom sheet has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + + showModalBottomSheet( + context: savedContext, + builder: (BuildContext context) => Container( + height: 200, + color: Colors.blue, + child: const Center(child: Text('Modal Bottom Sheet')), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Modal Bottom Sheet'), findsOneWidget); + + // Verify the route-level Semantics has opaque hitTestBehavior + // This prevents clicks inside the bottom sheet from passing through to the barrier + final List allSemantics = tester + .widgetList( + find.ancestor(of: find.text('Modal Bottom Sheet'), matching: find.byType(Semantics)), + ) + .toList(); + + final Semantics routeSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.hitTestBehavior == SemanticsHitTestBehavior.opaque, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final Semantics widgetSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.scopesRoute ?? false, + ); + + expect(widgetSemantics.properties.scopesRoute, true); + + semantics.dispose(); + }); } class _TestPage extends StatelessWidget { diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index 85581c6c99009..76f4a98d1218c 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -3311,6 +3311,95 @@ void main() { semantics.dispose(); }, variant: TargetPlatformVariant.all()); }); + + testWidgets('Dialog has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => + const Dialog(child: SizedBox(width: 200, height: 200)), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + + final Semantics routeSemantics = tester.widget( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); + + testWidgets('AlertDialog has hitTestBehavior.opaque via Dialog', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Test Dialog'), + content: const SizedBox(width: 200, height: 100), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + + // Find the route-level Semantics with hitTestBehavior.opaque + // (wraps the entire dialog content, above the Dialog widget) + final Semantics routeSemantics = tester.widget( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); } @pragma('vm:entry-point') diff --git a/packages/flutter/test/material/dropdown_test.dart b/packages/flutter/test/material/dropdown_test.dart index 4b884d503b833..b0917cdb62ca1 100644 --- a/packages/flutter/test/material/dropdown_test.dart +++ b/packages/flutter/test/material/dropdown_test.dart @@ -1460,6 +1460,7 @@ void main() { role: SemanticsRole.menu, flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], label: 'Popup menu', + textDirection: TextDirection.ltr, children: [ TestSemantics( children: [ diff --git a/packages/flutter/test/rendering/platform_view_test.dart b/packages/flutter/test/rendering/platform_view_test.dart index 35780bfabb867..ab28d30b838c7 100644 --- a/packages/flutter/test/rendering/platform_view_test.dart +++ b/packages/flutter/test/rendering/platform_view_test.dart @@ -557,6 +557,22 @@ void main() { }); }); }); + + test('PlatformViewRenderBox has transparent hitTestBehavior in semantics', () { + final controller = FakePlatformViewController(0); + final renderBox = PlatformViewRenderBox( + controller: controller, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: >{}, + ); + + final config = SemanticsConfiguration(); + renderBox.describeSemanticsConfiguration(config); + + expect(config.hitTestBehavior, ui.SemanticsHitTestBehavior.transparent); + expect(config.isSemanticBoundary, true); + expect(config.platformViewId, 0); + }); } ui.PointerData _pointerData( diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart index 9523b53cced89..b34f74baf94c8 100644 --- a/packages/flutter/test/semantics/semantics_test.dart +++ b/packages/flutter/test/semantics/semantics_test.dart @@ -1035,6 +1035,34 @@ void main() { expect(config.customSemanticsActions[customAction], same(onCustomAction)); }); + test('SemanticsConfiguration.copy() preserves hitTestBehavior', () { + final config = SemanticsConfiguration() + ..isSemanticBoundary = true + ..label = 'test' + ..hitTestBehavior = SemanticsHitTestBehavior.opaque; + + expect(config.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final SemanticsConfiguration copy = config.copy(); + + expect(copy.hitTestBehavior, SemanticsHitTestBehavior.opaque); + expect(copy.isSemanticBoundary, isTrue); + expect(copy.label, 'test'); + }); + + test('SemanticsConfiguration.copy() preserves all hitTestBehavior values', () { + final deferConfig = SemanticsConfiguration(); + expect(deferConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.defer); + + final opaqueConfig = SemanticsConfiguration() + ..hitTestBehavior = SemanticsHitTestBehavior.opaque; + expect(opaqueConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final transparentConfig = SemanticsConfiguration() + ..hitTestBehavior = SemanticsHitTestBehavior.transparent; + expect(transparentConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.transparent); + }); + test('SemanticsOwner dispatches memory events', () async { await expectLater( await memoryEvents( diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index bc9f931211fd3..0e6b42b9aa8ee 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -753,6 +753,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1055,6 +1056,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1157,6 +1159,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1264,6 +1267,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1299,6 +1303,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1433,6 +1438,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); From ab089c0e2c5818e70c2360d1ab759ae24e44da54 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 10 Dec 2025 19:51:37 -0500 Subject: [PATCH 06/29] Roll Skia from e61cc6d073fd to 59c6cad539f7 (22 revisions) (#179714) https://skia.googlesource.com/skia.git/+log/e61cc6d073fd..59c6cad539f7 2025-12-10 bungeman@google.com Modernize SkFontMgr_Mac::onMatchFamilyStyleCharacter 2025-12-10 kylechar@google.com Upgrade gradle to version 9.1.0 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 9f7342ee3761 to 50c5f8b4dd57 (8 revisions) 2025-12-10 bungeman@google.com Revert "Pass first family name to paragraph fallback" 2025-12-10 kjlubick@google.com Revert "[rust bmp] Add initial Rust BMP decoder implementation" 2025-12-10 bungeman@google.com Roll HarfBuzz from 08b52ae2 to 31695252 (971 commits) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2025-12-10 sergiog@microsoft.com [rust bmp] Add initial Rust BMP decoder implementation 2025-12-10 bungeman@google.com Pass first family name to paragraph fallback 2025-12-10 kjlubick@google.com Update CanvasKit to use const version of SerialProcs 2025-12-10 kjlubick@google.com Remove legacy SkParagraphBuilder API 2025-12-10 michaelludwig@google.com [wgsl] Stop forcing all function calls into scratch lets 2025-12-10 michaelludwig@google.com [ganesh] Guard division by zero in AAConvexPathRenderer 2025-12-10 nicolettep@google.com [graphite] Use depth/stencil image aspects for default d/s attachments 2025-12-10 nscobie@google.com Make SkDebugf calls log at Android's WARN level in RenderEngine 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll Dawn from 57fca18a1603 to 9e1721615b33 (7 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 3486579f434a to 9f7342ee3761 (5 revisions) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll ANGLE from 78bb5a33a9e9 to c2a048f9a1db (7 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Skia Infra from ca3eb8741f18 to 492dfbfe3bb2 (10 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 8de9b80c5bd9 to 57fca18a1603 (17 revisions) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC jimgraham@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 1822c4d24bae1..94d42ef574c1e 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'e61cc6d073fd3dd217b52e7b91d21151aad61478', + 'skia_revision': '59c6cad539f7fe01a42bded543beb927546739d5', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 811a4d22e6bd16b4c28f97a72daf2a75b0a1903c Mon Sep 17 00:00:00 2001 From: b-luk <97480502+b-luk@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:14:23 -0800 Subject: [PATCH 07/29] Use kPreventOverdraw for arcs with overlapping stroke caps (#179312) Fixes #178746 Before: image After: image ## 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. [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 --- .../display_list/aiks_dl_basic_unittests.cc | 49 ++++++++ .../impeller/entity/entity_unittests.cc | 111 ++++++++++++++++++ .../impeller/entity/geometry/arc_geometry.cc | 56 ++++++++- .../impeller/entity/geometry/arc_geometry.h | 3 + 4 files changed, 218 insertions(+), 1 deletion(-) diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc index 303257008587b..004d47761b937 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc @@ -852,6 +852,25 @@ void RenderArcFarm(DisplayListBuilder& builder, } builder.Restore(); } + +void RenderArcFarmForOverlappingCapsTest(DisplayListBuilder& builder, + const DlPaint& paint) { + builder.Save(); + builder.Translate(40, 30); + const Rect arc_bounds = Rect::MakeLTRB(0, 0, 40, 40); + for (int stroke_width = 10; stroke_width <= 40; stroke_width += 3) { + DlPaint modified_paint = DlPaint(paint); + modified_paint.setStrokeWidth(stroke_width); + builder.Save(); + for (int sweep = 160; sweep <= 360; sweep += 20) { + builder.DrawArc(arc_bounds, 0, sweep, false, modified_paint); + builder.Translate(84, 0); + } + builder.Restore(); + builder.Translate(0, 44 + stroke_width); + } + builder.Restore(); +} } // namespace TEST_P(AiksTest, FilledArcsRenderCorrectly) { @@ -981,6 +1000,21 @@ TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithSquareEnds) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithTranslucencyAndSquareEnds) { + DisplayListBuilder builder; + builder.Scale(GetContentScale().x, GetContentScale().y); + builder.DrawColor(DlColor::kWhite(), DlBlendMode::kSrc); + + DlPaint paint; + paint.setDrawStyle(DlDrawStyle::kStroke); + paint.setStrokeCap(DlStrokeCap::kSquare); + paint.setColor(DlColor::kBlue().modulateOpacity(0.5)); + + RenderArcFarmForOverlappingCapsTest(builder, paint); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithRoundEnds) { DisplayListBuilder builder; builder.Scale(GetContentScale().x, GetContentScale().y); @@ -1001,6 +1035,21 @@ TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithRoundEnds) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithTranslucencyAndRoundEnds) { + DisplayListBuilder builder; + builder.Scale(GetContentScale().x, GetContentScale().y); + builder.DrawColor(DlColor::kWhite(), DlBlendMode::kSrc); + + DlPaint paint; + paint.setDrawStyle(DlDrawStyle::kStroke); + paint.setStrokeCap(DlStrokeCap::kRound); + paint.setColor(DlColor::kBlue().modulateOpacity(0.5)); + + RenderArcFarmForOverlappingCapsTest(builder, paint); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithBevelJoinsAndCenter) { DisplayListBuilder builder; builder.Scale(GetContentScale().x, GetContentScale().y); diff --git a/engine/src/flutter/impeller/entity/entity_unittests.cc b/engine/src/flutter/impeller/entity/entity_unittests.cc index f6ccce5387f6b..580f9a753697b 100644 --- a/engine/src/flutter/impeller/entity/entity_unittests.cc +++ b/engine/src/flutter/impeller/entity/entity_unittests.cc @@ -2313,6 +2313,117 @@ TEST_P(EntityTest, FillPathGeometryGetPositionBufferReturnsExpectedMode) { } } +TEST_P(EntityTest, StrokeArcGeometryGetPositionBufferReturnsExpectedMode) { + RenderTarget target; + testing::MockRenderPass mock_pass(GetContext(), target); + Rect oval_bounds = Rect::MakeLTRB(100, 100, 200, 200); + + // Butt caps never overlap + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kButt}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } + } + } + + // Round caps with 10 stroke width overlap starting at 348.6 degrees + { + StrokeParameters stroke = {.width = 10.0f, .cap = Cap::kRound}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 348.6) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Round caps with 50 stroke width overlap starting at 300.1 degrees + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kRound}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 300.0) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Square caps with 10 stroke width overlap starting at 347.4 degrees + { + StrokeParameters stroke = {.width = 10.0f, .cap = Cap::kSquare}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 347.4) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Square caps with 50 stroke width overlap starting at 270.1 degrees + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kSquare}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 270.1) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } +} + TEST_P(EntityTest, FailOnValidationError) { if (GetParam() != PlaygroundBackend::kVulkan) { GTEST_SKIP() << "Validation is only fatal on Vulkan backend."; diff --git a/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc b/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc index 056f7dacb6e11..926dda3035636 100644 --- a/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc +++ b/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc @@ -5,6 +5,7 @@ #include "flutter/impeller/entity/geometry/arc_geometry.h" #include "flutter/impeller/entity/geometry/line_geometry.h" +#include "fml/logging.h" namespace impeller { @@ -47,7 +48,11 @@ GeometryResult ArcGeometry::GetPositionBuffer(const ContentContext& renderer, auto generator = renderer.GetTessellator().StrokedArc(transform, arc_, cap_, half_width); - return ComputePositionGeometry(renderer, generator, entity, pass); + auto result = ComputePositionGeometry(renderer, generator, entity, pass); + if (CapsOverlap()) { + result.mode = GeometryResult::Mode::kPreventOverdraw; + } + return result; } } @@ -80,4 +85,53 @@ bool ArcGeometry::IsAxisAlignedRect() const { return false; } +bool ArcGeometry::CapsOverlap() const { + FML_DCHECK(arc_.GetSweep().degrees >= 0.0f); + FML_DCHECK(arc_.GetSweep().degrees <= 360.0f); + + if (stroke_width_ < 0 || cap_ == Cap::kButt || + arc_.GetSweep().degrees <= 180) { + return false; + } + + switch (cap_) { + case Cap::kSquare: { + // Square caps overlap if the inner corner of the ending cap extends + // inside the inner edge of the start cap. For a visualization of when + // this occurs, see + // https://github.com/flutter/flutter/issues/178746#issuecomment-3554526727 + // Note that testing for overlap is completely independent of the arc's + // start angle. To simplify the overlap test, we treat the arc as if its + // start angle is 0. This allows the test to only require checking the x + // coordinate of the ending cap, rather than needing to calculate overlap + // based on both x and y positions of both caps. + auto radius = arc_.GetOvalSize().width * 0.5f; + auto half_width = stroke_width_ * 0.5f; + auto inner_radius = radius - half_width; + auto inner_arc_end_x = + cos(Radians(arc_.GetSweep()).radians) * inner_radius; + auto inner_square_cap_end_x = + inner_arc_end_x + + cos(Radians(arc_.GetSweep() + Degrees(90)).radians) * half_width; + return inner_square_cap_end_x > inner_radius; + } + case Cap::kRound: { + // Round caps overlap if the distance between the arc's start and end + // points is less than the stroke width. + // https://github.com/flutter/flutter/issues/178746#issuecomment-3554526727 + // Note that testing for overlap is completely independent of the arc's + // start angle. To simplify the overlap test, we treat the arc as if its + // start angle is 0. + auto radius = arc_.GetOvalSize().width / 2.0f; + auto start_point = Point(radius, 0); + auto sweep_radians = Radians(arc_.GetSweep()).radians; + auto end_point = Point(cos(sweep_radians), sin(sweep_radians)) * radius; + return start_point.GetDistanceSquared(end_point) < + stroke_width_ * stroke_width_; + } + case Cap::kButt: + FML_UNREACHABLE() + } +} + } // namespace impeller diff --git a/engine/src/flutter/impeller/entity/geometry/arc_geometry.h b/engine/src/flutter/impeller/entity/geometry/arc_geometry.h index 4657b2ce9fba0..f1b02d6d027f3 100644 --- a/engine/src/flutter/impeller/entity/geometry/arc_geometry.h +++ b/engine/src/flutter/impeller/entity/geometry/arc_geometry.h @@ -40,6 +40,9 @@ class ArcGeometry final : public Geometry { // |Geometry| std::optional GetCoverage(const Matrix& transform) const override; + // Whether the arc has overlapping stroke caps + bool CapsOverlap() const; + Arc arc_; Scalar stroke_width_; Cap cap_; From 0c6af9d662438ca672d3b2b2b42fb4b33439dbfc Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 10 Dec 2025 22:28:26 -0500 Subject: [PATCH 08/29] Roll Dart SDK from 077062c5e515 to dbcb567e2432 (2 revisions) (#179715) https://dart.googlesource.com/sdk.git/+log/077062c5e515..dbcb567e2432 2025-12-11 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.11.0-232.0.dev 2025-12-10 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.11.0-231.0.dev If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/dart-sdk-flutter Please CC dart-vm-team@google.com,jimgraham@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index 94d42ef574c1e..621d6d858921b 100644 --- a/DEPS +++ b/DEPS @@ -59,7 +59,7 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': '077062c5e5154cee4ffb1d72532428eaa79bc0fa', + 'dart_revision': 'dbcb567e2432c7cf8401ca4d9365a3f040aa5deb', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py @@ -77,7 +77,7 @@ vars = { 'dart_protobuf_rev': '9e30258e0aa6a6430ee36c84b75308a9702fde42', 'dart_pub_rev': 'b297f1f5e42f2569f15a98548f44b4b9a48487c1', 'dart_sync_http_rev': '6666fff944221891182e1f80bf56569338164d72', - 'dart_tools_rev': 'd03c394b24829f662098e590a2c812a076b01199', + 'dart_tools_rev': '97014f33a2aad59385df73f25baf9c304ca5c866', 'dart_vector_math_rev': '70a9a2cb610d040b247f3ca2cd70a94c1c6f6f23', 'dart_web_rev': '35fc98dd8f9da175ed0a2dcf246299e922e1e1e2', 'dart_webdev_rev': '234e44c2ba0aa6cee5a36026538ca89457bf0d55', From d94c0e94da73780db7f0b0bcdd07c5394af8d5e8 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Thu, 11 Dec 2025 02:04:27 -0500 Subject: [PATCH 09/29] Roll Skia from 59c6cad539f7 to deb0153719dd (2 revisions) (#179721) https://skia.googlesource.com/skia.git/+log/59c6cad539f7..deb0153719dd 2025-12-11 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 9e1721615b33 to f39150de4906 (8 revisions) 2025-12-11 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Skia Infra from 492dfbfe3bb2 to 6cd15cab2194 (10 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC jimgraham@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 621d6d858921b..130f609d02988 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '59c6cad539f7fe01a42bded543beb927546739d5', + 'skia_revision': 'deb0153719ddf8895c9c1448a9a6959610785947', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 7f0e5b50310333b032ed95f7462cb6b41813d8d2 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Thu, 11 Dec 2025 04:53:30 -0500 Subject: [PATCH 10/29] Roll Fuchsia Linux SDK from u5vxWTRT0HlxOP5_r... to QssSL8DkxIbMvf89C... (#179729) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC jimgraham@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 130f609d02988..34b4b850afaed 100644 --- a/DEPS +++ b/DEPS @@ -810,7 +810,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'u5vxWTRT0HlxOP5_rIPOkUJXTun41aPDWjrw0MQdakkC' + 'version': 'QssSL8DkxIbMvf89CsxO9AsjjFWhqAZd7XoYRjQxeP8C' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', From 67b63031ddc2a8274304bc4e60d689b843c3a565 Mon Sep 17 00:00:00 2001 From: Pacific <39942340+prash4931@users.noreply.github.com> Date: Thu, 11 Dec 2025 02:23:33 +0530 Subject: [PATCH 11/29] Fixed RenderFlex overflow in RouteObserver Example (#170980) Fixed RenderOverflow in RouteObserver Example: https://api.flutter.dev/flutter/widgets/RouteObserver-class.html This PR is about solving render overflow issue in Route Observer Example in Live Website. I've wrapped the body with a Scrollable View to solve this issue | | | | --- | --- | | B | ![image](https://github.com/user-attachments/assets/c8fa78e7-62ca-4bd1-9798-588c636cd61a) | | A | ![image](https://github.com/user-attachments/assets/3a2b4697-121a-43e3-9c85-b1379a575af1) | ## 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]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] 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]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. [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 --------- Co-authored-by: Victor Sanni --- .../lib/widgets/routes/route_observer.0.dart | 60 ++++++++++--------- .../widgets/routes/route_observer.0_test.dart | 20 +++++++ 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/examples/api/lib/widgets/routes/route_observer.0.dart b/examples/api/lib/widgets/routes/route_observer.0.dart index fdb44fdce451c..e1ce6adc72eff 100644 --- a/examples/api/lib/widgets/routes/route_observer.0.dart +++ b/examples/api/lib/widgets/routes/route_observer.0.dart @@ -63,37 +63,39 @@ class _RouteObserverExampleState extends State @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'RouteObserver log:', - style: Theme.of(context).textTheme.headlineSmall, - ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300.0), - child: ListView.builder( - itemCount: log.length, - itemBuilder: (BuildContext context, int index) { - if (log.isEmpty) { - return const SizedBox.shrink(); - } - return Text(log[index], textAlign: TextAlign.center); + body: SingleChildScrollView( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'RouteObserver log:', + style: Theme.of(context).textTheme.headlineSmall, + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300.0), + child: ListView.builder( + itemCount: log.length, + itemBuilder: (BuildContext context, int index) { + if (log.isEmpty) { + return const SizedBox.shrink(); + } + return Text(log[index], textAlign: TextAlign.center); + }, + ), + ), + OutlinedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const NextPage(), + ), + ); }, + child: const Text('Go to next page'), ), - ), - OutlinedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) => const NextPage(), - ), - ); - }, - child: const Text('Go to next page'), - ), - ], + ], + ), ), ), ); diff --git a/examples/api/test/widgets/routes/route_observer.0_test.dart b/examples/api/test/widgets/routes/route_observer.0_test.dart index 3c79c47639837..91e60952e3a07 100644 --- a/examples/api/test/widgets/routes/route_observer.0_test.dart +++ b/examples/api/test/widgets/routes/route_observer.0_test.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_api_samples/widgets/routes/route_observer.0.dart' as example; import 'package:flutter_test/flutter_test.dart'; @@ -38,5 +39,24 @@ void main() { // Check the RouteObserver logs after the route is popped again. expect(find.text('didPush'), findsOneWidget); expect(find.text('didPopNext'), findsNWidgets(2)); + + // Check if any overflow or layout exceptions occurred. + expect(tester.takeException(), isNull); }); + + testWidgets( + 'RouteObserver example renders without overflow on small screens', + (WidgetTester tester) async { + // Set the screen size to a smaller value. + tester.view.physicalSize = const Size(200, 200); + addTearDown(tester.view.reset); + + // Build the RouteObserver example widget. + await tester.pumpWidget(const example.RouteObserverApp()); + await tester.pumpAndSettle(); + + // Verify there are no layout exceptions. + expect(tester.takeException(), isNull); + }, + ); } From 2f503185c7c751aa2ac01db246769d51de598028 Mon Sep 17 00:00:00 2001 From: akashefrath Date: Thu, 11 Dec 2025 02:27:38 +0530 Subject: [PATCH 12/29] added onUserInteractionIfError for form (#175515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new `AutovalidateMode` value: `onUserInteractionIfError`. This mode allows `Form` and `FormField` widgets to auto-validate **only when a field already has an error and the user interacts with it again**. ### Why - Current modes (`disabled`, `always`, `onUserInteraction`, `onUnfocus`) do not cover the case where developers want validation **only when correcting an error**. - This improves UX by reducing unnecessary validation calls while still ensuring errors disappear as soon as the user fixes them. ### How - Added `onUserInteractionIfError` to `AutovalidateMode`. - Updated Dartdoc with detailed description and example snippet. - Added new unit tests to validate behavior. - Verified no regressions in other modes (`always`, `onUnfocus`, `onUserInteraction`). ### Example ```dart TextFormField( autovalidateMode: AutovalidateMode.onUserInteractionIfError, validator: (value) => value!.isEmpty ? 'Required field' : null, ) ```` --- ## Related Issues Fixes #\ --- ## Tests * Added widget tests to confirm that: * No validation occurs until a field has an error. * Once an error exists, validation runs automatically on user interaction. * Resetting the form clears errors correctly. * Regression tests ensure that `always`, `onUserInteraction`, and `onUnfocus` modes still behave correctly. --- ## Pre-launch / Reviewer Checklist * [x] I have read the [[Contributor Guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md)](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md) * [x] I have read the \[Tree Hygiene] wiki page and followed its guidance * [x] I have read and followed the \[Flutter Style Guide] * [x] I signed the \[CLA] * [x] I added a new enum value with clear Dartdoc (`onUserInteractionIfError`) * [x] I added example code in the Dartdoc * [x] I added new widget/unit tests for the new mode * [x] All existing and new tests pass * [x] I ran `flutter analyze` and fixed all issues * [x] No breaking changes introduced (or marked accordingly) --- ## Breaking Change * [x] Yes * [x] No --- ## Screenshots / Demo (if applicable) *(N/A — logic-only change)* --- ## Reviewer Notes * The new mode reduces unnecessary validation until the user corrects an error. * All tests confirm correct behavior and no regressions in existing modes. --------- Co-authored-by: Loïc Sharma <737941+loic-sharma@users.noreply.github.com> --- packages/flutter/lib/src/widgets/form.dart | 19 +++ packages/flutter/test/widgets/form_test.dart | 161 +++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index e04735b3dc850..7015aededc6e3 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -251,6 +251,7 @@ class FormState extends State { _hasInteractedByUser = _fields.any( (FormFieldState field) => field._hasInteractedByUser.value, ); + _forceRebuild(); } @@ -271,6 +272,8 @@ class FormState extends State { @protected @override Widget build(BuildContext context) { + final bool hasError = _fields.any((FormFieldState field) => field.hasError); + switch (widget.autovalidateMode) { case AutovalidateMode.always: _validate(View.of(context)); @@ -278,6 +281,10 @@ class FormState extends State { if (_hasInteractedByUser) { _validate(View.of(context)); } + case AutovalidateMode.onUserInteractionIfError: + if (_hasInteractedByUser && hasError) { + _validate(View.of(context)); + } case AutovalidateMode.onUnfocus: case AutovalidateMode.disabled: break; @@ -751,6 +758,7 @@ class FormFieldState extends State> with RestorationMixin { }); case AutovalidateMode.onUnfocus: case AutovalidateMode.onUserInteraction: + case AutovalidateMode.onUserInteractionIfError: case AutovalidateMode.disabled: case null: break; @@ -776,6 +784,10 @@ class FormFieldState extends State> with RestorationMixin { if (_hasInteractedByUser.value) { _validate(); } + case AutovalidateMode.onUserInteractionIfError: + if (_hasInteractedByUser.value && hasError) { + _validate(); + } case AutovalidateMode.onUnfocus: case AutovalidateMode.disabled: break; @@ -831,4 +843,11 @@ enum AutovalidateMode { /// In order to validate all fields of a [Form] after the first time the user interacts /// with one, use [always] instead. onUnfocus, + + /// Used to auto-validate [Form] and [FormField] after each user + /// interaction, only if the the field already has an error. + /// + /// This is useful for reducing unnecessary validation calls while + /// still ensuring errors are re-checked when the user attempts to fix them. + onUserInteractionIfError, } diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 1aea5d31dcd50..a3f97eb406584 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -840,6 +840,99 @@ void main() { }, ); + testWidgets( + 'Does not auto-validate before value changes when autovalidateMode is set to onUserInteractionIfError', + (WidgetTester tester) async { + late FormFieldState formFieldState; + + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: FormField( + initialValue: 'foo', + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + builder: (FormFieldState state) { + formFieldState = state; + return Container(); + }, + validator: errorText, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // The form field has no error. + expect(formFieldState.hasError, isFalse); + // No "Required" error is visible. + expect(find.text('Required'), findsNothing); + }, + ); + + testWidgets( + 'Does not auto-validate before value changes even when initialValue is empty and autovalidateMode is set to onUserInteractionIfError', + (WidgetTester tester) async { + late FormFieldState formFieldState; + + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: FormField( + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + builder: (FormFieldState state) { + formFieldState = state; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(), + if (state.errorText != null) Text(state.errorText!), + ], + ); + }, + validator: errorText, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + expect(formFieldState.hasError, isFalse); + + expect(find.text('Required'), findsNothing); + + expect(formFieldState.errorText, isNull); + + formFieldState.validate(); + await tester.pump(); + + expect(formFieldState.hasError, isTrue); + + expect(find.text('Required'), findsOneWidget); + }, + ); + testWidgets('auto-validate before value changes if autovalidateMode was set to always', ( WidgetTester tester, ) async { @@ -998,6 +1091,74 @@ void main() { }, ); + testWidgets( + 'Form with AutovalidateMode.onUserInteractionIfError only revalidates when user interacts after an error exists', + (WidgetTester tester) async { + final formState = GlobalKey(); + String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; + + Widget builder() { + return MaterialApp( + theme: ThemeData(), + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Form( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteractionIfError, + child: Material( + child: TextFormField(initialValue: 'foo', validator: errorText), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // No error text is visible yet. (Initial valid state). + expect(find.text('Required'), findsNothing); + + // User types valid input 'bar' → autovalidate is disabled → still no error. + await tester.enterText(find.byType(TextFormField), 'bar'); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Clear the input (invalid). + await tester.enterText(find.byType(TextFormField), ''); + await tester.pump(); + + // Manually submit form to show the initial error (AutovalidateMode is now active). + formState.currentState!.validate(); + expect(find.text('Required'), findsNothing); + await tester.pump(); + + // Verify error is shown. + expect(find.text('Required'), findsOneWidget); + + // Now user interacts again with valid text ('baz') → validation auto-runs and clears the error. + await tester.enterText(find.byType(TextFormField), 'baz'); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Check the behavior of a manual validate when the text is already valid. + // This should *confirm* the error is cleared, not re-introduce it. + formState.currentState!.validate(); + await tester.pump(); + expect(find.text('Required'), findsNothing); + + // Resetting should clear form (already cleared, but a safety check). + await tester.enterText(find.byType(TextFormField), ''); + formState.currentState!.reset(); + await tester.pump(); + expect(find.text('Required'), findsNothing); + }, + ); + // Regression test for https://github.com/flutter/flutter/issues/63753. testWidgets('Validate form should return correct validation if the value is composing', ( WidgetTester tester, From 1dfd694d927e408d92f61e1a9d2d7ae32e4fc235 Mon Sep 17 00:00:00 2001 From: yim Date: Thu, 11 Dec 2025 05:11:40 +0800 Subject: [PATCH 13/29] Fix the issue with pinned headers in nested SliverMainAxisGroup. (#179132) Fixes: #178973 ## 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. - [ ] 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. [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 --- .../lib/src/rendering/sliver_group.dart | 16 +++-- .../widgets/sliver_main_axis_group_test.dart | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/flutter/lib/src/rendering/sliver_group.dart b/packages/flutter/lib/src/rendering/sliver_group.dart index 4e99b0e75ea65..feedabb357628 100644 --- a/packages/flutter/lib/src/rendering/sliver_group.dart +++ b/packages/flutter/lib/src/rendering/sliver_group.dart @@ -374,16 +374,22 @@ class RenderSliverMainAxisGroup extends RenderSliver // If the children's paint extent exceeds the remaining scroll extent of the `RenderSliverMainAxisGroup`, // they need to be corrected. if (paintOffset > remainingExtent) { + // Whether the current remaining space can accommodate all pinned children. + final bool pinnedChildrenOverflow = + maxScrollObstructionExtent > remainingExtent - constraints.overlap; final double paintCorrection = paintOffset - remainingExtent; paintOffset = remainingExtent; child = firstChild; while (child != null) { final SliverGeometry childLayoutGeometry = child.geometry!; - final bool childIsTooLarge = childLayoutGeometry.paintExtent > remainingExtent; - final bool pinnedHeadersOverflow = maxScrollObstructionExtent > remainingExtent; - final bool childIsPinnedHeader = childLayoutGeometry.maxScrollObstructionExtent > 0; - if (childIsTooLarge || (pinnedHeadersOverflow && childIsPinnedHeader)) { - final childParentData = child.parentData! as SliverPhysicalParentData; + final childParentData = child.parentData! as SliverPhysicalParentData; + final double childMainAxisPaintOffset = switch (constraints.axis) { + Axis.vertical => childParentData.paintOffset.dy, + Axis.horizontal => childParentData.paintOffset.dx, + }; + final double childPaintEnd = childMainAxisPaintOffset + childLayoutGeometry.paintExtent; + final bool childIsPinned = childLayoutGeometry.maxScrollObstructionExtent > 0; + if (childPaintEnd > remainingExtent || (pinnedChildrenOverflow && childIsPinned)) { childParentData.paintOffset = switch (constraints.axis) { Axis.vertical => Offset(0.0, childParentData.paintOffset.dy - paintCorrection), Axis.horizontal => Offset(childParentData.paintOffset.dx - paintCorrection, 0.0), diff --git a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart index 463050b760dd2..ecdb21ffff049 100644 --- a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart +++ b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart @@ -1518,6 +1518,69 @@ void main() { expect(semantics.nodesWith(label: 'b'), hasLength(1)); semantics.dispose(); }); + + testWidgets( + 'nested SliverMainAxisGroup with multiple PinnedHeaderSlivers positions correctly on scroll', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + final List keys = [GlobalKey(), GlobalKey(), GlobalKey(), GlobalKey()]; + + Future pumpWidget({bool reverse = false}) async { + return tester.pumpWidget( + _buildSliverMainAxisGroup( + controller: controller, + reverse: reverse, + viewportHeight: 300, + precedingSlivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[0])), + ], + otherSlivers: [const SliverToBoxAdapter(child: SizedBox(height: 300))], + slivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[1])), + SliverMainAxisGroup( + slivers: [ + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[2])), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + PinnedHeaderSliver(child: SizedBox(height: 30, key: keys[3])), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 30)), + ], + ), + ); + } + + Future verifyPositions(Map> offsetToPositions) async { + for (final MapEntry> entry in offsetToPositions.entries) { + controller.jumpTo(entry.key); + await tester.pumpAndSettle(); + for (var i = 0; i < keys.length; i++) { + expect(tester.getTopLeft(find.byKey(keys[i])).dy, entry.value[i]); + } + } + } + + // Forward direction + await pumpWidget(); + await verifyPositions({ + 10: [0.0, 30.0, 60.0, 110.0], + 40: [0.0, 30.0, 60.0, 90.0], + 70: [0.0, 30.0, 50.0, 80.0], + 100: [0.0, 30.0, 20.0, 50.0], + }); + + // Reverse direction + await pumpWidget(reverse: true); + await verifyPositions({ + 10: [270.0, 240.0, 210.0, 160.0], + 40: [270.0, 240.0, 210.0, 180.0], + 70: [270.0, 240.0, 220.0, 190.0], + 100: [270.0, 240.0, 250.0, 220.0], + }); + }, + ); } Widget _buildSliverList({ @@ -1559,6 +1622,7 @@ Widget _buildSliverMainAxisGroup({ Axis scrollDirection = Axis.vertical, bool reverse = false, List otherSlivers = const [], + List precedingSlivers = const [], }) { return MaterialApp( home: Directionality( @@ -1573,6 +1637,7 @@ Widget _buildSliverMainAxisGroup({ reverse: reverse, controller: controller, slivers: [ + ...precedingSlivers, SliverMainAxisGroup(slivers: slivers), ...otherSlivers, ], From c1a514e208159ec53c92e2f4f34552c8b779147a Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Wed, 10 Dec 2025 13:39:24 -0800 Subject: [PATCH 14/29] Ensure that the engine converts std::filesystem::path objects to UTF-8 strings on Windows (#179528) Fixes https://github.com/flutter/flutter/issues/178896 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- engine/src/flutter/fml/string_conversion.cc | 5 +++++ engine/src/flutter/fml/string_conversion.h | 4 ++++ engine/src/flutter/fml/string_conversion_unittests.cc | 5 +++++ .../shell/platform/windows/flutter_project_bundle.cc | 3 ++- .../shell/platform/windows/flutter_windows_engine.cc | 4 ++-- engine/src/flutter/tools/templater/templater_main.cc | 7 ++++--- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/engine/src/flutter/fml/string_conversion.cc b/engine/src/flutter/fml/string_conversion.cc index d55577c4e6471..e2f62c0fb117e 100644 --- a/engine/src/flutter/fml/string_conversion.cc +++ b/engine/src/flutter/fml/string_conversion.cc @@ -44,4 +44,9 @@ std::u16string Utf8ToUtf16(const std::string_view string) { return converter.from_bytes(string.data()); } +std::string PathToUtf8(const std::filesystem::path& path) { + const std::u8string path_u8 = path.u8string(); + return std::string(path_u8.begin(), path_u8.end()); +} + } // namespace fml diff --git a/engine/src/flutter/fml/string_conversion.h b/engine/src/flutter/fml/string_conversion.h index eb7d840c3c717..48fb450a577d3 100644 --- a/engine/src/flutter/fml/string_conversion.h +++ b/engine/src/flutter/fml/string_conversion.h @@ -5,6 +5,7 @@ #ifndef FLUTTER_FML_STRING_CONVERSION_H_ #define FLUTTER_FML_STRING_CONVERSION_H_ +#include #include #include @@ -19,6 +20,9 @@ std::string Utf16ToUtf8(const std::u16string_view string); // Returns a UTF-16 encoded equivalent of a UTF-8 encoded input string. std::u16string Utf8ToUtf16(const std::string_view string); +// Returns the pathname encoded in UTF-8. +std::string PathToUtf8(const std::filesystem::path& path); + } // namespace fml #endif // FLUTTER_FML_STRING_CONVERSION_H_ diff --git a/engine/src/flutter/fml/string_conversion_unittests.cc b/engine/src/flutter/fml/string_conversion_unittests.cc index 4d12075709014..2369e4b39e6a9 100644 --- a/engine/src/flutter/fml/string_conversion_unittests.cc +++ b/engine/src/flutter/fml/string_conversion_unittests.cc @@ -33,5 +33,10 @@ TEST(StringConversion, Utf16ToUtf8Unicode) { EXPECT_EQ(Utf16ToUtf8(u"\x2603"), "\xe2\x98\x83"); } +TEST(StringConversion, PathToUtf8) { + EXPECT_EQ(PathToUtf8(std::filesystem::path("abc")), "abc"); + EXPECT_EQ(PathToUtf8(std::filesystem::path(u"\x2603")), "\xe2\x98\x83"); +} + } // namespace testing } // namespace fml diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc index b63dad588a551..f2fc0be8756d0 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc @@ -7,6 +7,7 @@ #include #include "flutter/fml/logging.h" +#include "flutter/fml/string_conversion.h" #include "flutter/shell/platform/common/engine_switches.h" // nogncheck #include "flutter/shell/platform/common/path_utils.h" @@ -73,7 +74,7 @@ UniqueAotDataPtr FlutterProjectBundle::LoadAotData( << "; no such file."; return UniqueAotDataPtr(nullptr, nullptr); } - std::string path_string = aot_library_path_.string(); + std::string path_string = fml::PathToUtf8(aot_library_path_); FlutterEngineAOTDataSource source = {}; source.type = kFlutterEngineAOTDataSourceTypeElfPath; source.elf_path = path_string.c_str(); diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc index d579c818ecfa3..70d7364d56e64 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc @@ -274,8 +274,8 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) { FML_LOG(ERROR) << "Missing or unresolvable paths to assets."; return false; } - std::string assets_path_string = project_->assets_path().string(); - std::string icu_path_string = project_->icu_path().string(); + std::string assets_path_string = fml::PathToUtf8(project_->assets_path()); + std::string icu_path_string = fml::PathToUtf8(project_->icu_path()); if (embedder_api_.RunsAOTCompiledDartCode()) { aot_data_ = project_->LoadAotData(embedder_api_); if (!aot_data_) { diff --git a/engine/src/flutter/tools/templater/templater_main.cc b/engine/src/flutter/tools/templater/templater_main.cc index c803d7290b9af..0bff26e581237 100644 --- a/engine/src/flutter/tools/templater/templater_main.cc +++ b/engine/src/flutter/tools/templater/templater_main.cc @@ -12,6 +12,7 @@ #include "flutter/fml/file.h" #include "flutter/fml/logging.h" #include "flutter/fml/mapping.h" +#include "flutter/fml/string_conversion.h" #include "inja/inja.hpp" namespace flutter { @@ -50,9 +51,9 @@ bool TemplaterMain(const fml::CommandLine& command_line) { reinterpret_cast(rendered_template.data()), rendered_template.size()}; - auto current_dir = - fml::OpenDirectory(std::filesystem::current_path().string().c_str(), - false, fml::FilePermission::kReadWrite); + auto current_dir = fml::OpenDirectory( + fml::PathToUtf8(std::filesystem::current_path()).c_str(), false, + fml::FilePermission::kReadWrite); if (!current_dir.is_valid()) { FML_LOG(ERROR) << "Could not open current directory."; return false; From ae4cb258dcd16f884e08bed5896d688ccab2ebad Mon Sep 17 00:00:00 2001 From: zhongliugo Date: Wed, 10 Dec 2025 14:31:02 -0800 Subject: [PATCH 15/29] Reland: Add framework-side hitTestBehavior support to Semantics (#178817) (#179352) This is a reland of #178817, which was reverted in #179100 due to test failures. The original PR introduced `hitTestBehavior` to the semantics framework but incorrectly applied `opaque` behavior to `ModalRoute`, which blocked platform views from receiving pointer events. Instead of making the entire modal opaque, we: 1. Keep `ModalRoute` without explicit `hitTestBehavior` (defaults to `defer`) 2. Make only the dialog/sheet content opaque (blocks clicks to barrier) 3. Platform views remain clickable because they're outside the opaque content boundary Fixes #149001 Original PR: #177570 Revert: #178744 --- .../lib/src/material/bottom_sheet.dart | 6 +- packages/flutter/lib/src/material/dialog.dart | 4 +- .../lib/src/rendering/custom_paint.dart | 3 + .../flutter/lib/src/rendering/object.dart | 4 + .../lib/src/rendering/platform_view.dart | 11 ++ .../flutter/lib/src/semantics/semantics.dart | 51 ++++++- packages/flutter/lib/src/widgets/basic.dart | 8 +- .../test/material/bottom_sheet_test.dart | 131 +++++++++++++++--- .../flutter/test/material/dialog_test.dart | 89 ++++++++++++ .../flutter/test/material/dropdown_test.dart | 1 + .../test/rendering/platform_view_test.dart | 16 +++ .../test/semantics/semantics_test.dart | 28 ++++ packages/flutter_test/test/matchers_test.dart | 6 + 13 files changed, 330 insertions(+), 28 deletions(-) diff --git a/packages/flutter/lib/src/material/bottom_sheet.dart b/packages/flutter/lib/src/material/bottom_sheet.dart index 00fa0ec6db4f7..77c042db4c06c 100644 --- a/packages/flutter/lib/src/material/bottom_sheet.dart +++ b/packages/flutter/lib/src/material/bottom_sheet.dart @@ -6,6 +6,7 @@ library; import 'dart:math' as math; +import 'dart:ui' show SemanticsHitTestBehavior; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -1117,10 +1118,13 @@ class ModalBottomSheetRoute extends PopupRoute { ), ); - final Widget bottomSheet = useSafeArea + Widget bottomSheet = useSafeArea ? SafeArea(bottom: false, child: content) : MediaQuery.removePadding(context: context, removeTop: true, child: content); + // Prevent clicks inside the bottom sheet from passing through to the barrier + bottomSheet = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: bottomSheet); + return capturedThemes?.wrap(bottomSheet) ?? bottomSheet; } diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index 78d1369e3a5f0..e4c6d2e8017c6 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -10,7 +10,7 @@ /// @docImport 'text_button.dart'; library; -import 'dart:ui' show SemanticsRole, clampDouble, lerpDouble; +import 'dart:ui' show SemanticsHitTestBehavior, SemanticsRole, clampDouble, lerpDouble; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -1676,6 +1676,8 @@ class DialogRoute extends RawDialogRoute { if (useSafeArea) { dialog = SafeArea(child: dialog); } + // Prevent clicks inside the dialog from passing through to the barrier + dialog = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: dialog); return dialog; }, barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 298a34532baf9..d10dfe738a325 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -1043,6 +1043,9 @@ class RenderCustomPaint extends RenderProxyBox { if (config.validationResult != properties.validationResult) { config.validationResult = properties.validationResult; } + if (properties.hitTestBehavior != null) { + config.hitTestBehavior = properties.hitTestBehavior!; + } if (properties.inputType != null) { config.inputType = properties.inputType!; } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index b5c39afa12f9f..15da70fd1af07 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -4938,6 +4938,10 @@ mixin SemanticsAnnotationsMixin on RenderObject { config.validationResult = _properties.validationResult; } + if (_properties.hitTestBehavior != null) { + config.hitTestBehavior = _properties.hitTestBehavior!; + } + if (_properties.inputType != null) { config.inputType = _properties.inputType!; } diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index ca833e3f2a34f..e2830e97f0c33 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -5,6 +5,8 @@ /// @docImport 'package:flutter/widgets.dart'; library; +import 'dart:ui' as ui show SemanticsHitTestBehavior; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; @@ -267,6 +269,9 @@ class RenderAndroidView extends PlatformViewRenderBox { if (_viewController.isCreated) { config.platformViewId = _viewController.viewId; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } } } @@ -372,6 +377,9 @@ abstract class RenderDarwinPlatformView super.describeSemanticsConfiguration(config); config.isSemanticBoundary = true; config.platformViewId = _viewController.id; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } @override @@ -737,6 +745,9 @@ class PlatformViewRenderBox extends RenderBox with _PlatformViewGestureMixin { super.describeSemanticsConfiguration(config); config.isSemanticBoundary = true; config.platformViewId = _controller.viewId; + // Platform views should allow pointer events to pass through to the + // underlying platform view content. + config.hitTestBehavior = ui.SemanticsHitTestBehavior.transparent; } } diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 8bf5f290c9a4e..86f44a09247eb 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -28,6 +28,7 @@ import 'dart:ui' StringAttribute, TextDirection, Tristate; +import 'dart:ui' as ui show SemanticsHitTestBehavior; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -1020,6 +1021,7 @@ class SemanticsData with Diagnosticable { required this.role, required this.controlsNodes, required this.validationResult, + required this.hitTestBehavior, required this.inputType, required this.locale, this.tags, @@ -1288,6 +1290,9 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsProperties.validationResult} final SemanticsValidationResult validationResult; + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + final ui.SemanticsHitTestBehavior hitTestBehavior; + /// {@macro flutter.semantics.SemanticsNode.inputType} final SemanticsInputType inputType; @@ -1415,6 +1420,7 @@ class SemanticsData with Diagnosticable { other.role == role && other.validationResult == validationResult && other.inputType == inputType && + other.hitTestBehavior == hitTestBehavior && _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && setEquals(controlsNodes, other.controlsNodes); } @@ -1451,8 +1457,7 @@ class SemanticsData with Diagnosticable { validationResult, controlsNodes == null ? null : Object.hashAll(controlsNodes!), inputType, - traversalParentIdentifier, - traversalChildIdentifier, + hitTestBehavior, ), ); @@ -1611,6 +1616,7 @@ class SemanticsProperties extends DiagnosticableTree { this.controlsNodes, this.inputType, this.validationResult = SemanticsValidationResult.none, + this.hitTestBehavior, this.onTap, this.onLongPress, this.onScrollLeft, @@ -2555,6 +2561,13 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final SemanticsValidationResult validationResult; + /// {@template flutter.semantics.SemanticsProperties.hitTestBehavior} + /// Describes how the semantic node should behave during hit testing. + /// + /// See [ui.SemanticsHitTestBehavior] for more details. + /// {@endtemplate} + final ui.SemanticsHitTestBehavior? hitTestBehavior; + /// {@template flutter.semantics.SemanticsProperties.inputType} /// The input type for of a editable widget. /// @@ -3235,7 +3248,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _headingLevel != config._headingLevel || _linkUrl != config._linkUrl || _role != config.role || - _validationResult != config.validationResult; + _validationResult != config.validationResult || + _hitTestBehavior != config.hitTestBehavior; } // TAGS, LABELS, ACTIONS @@ -3529,6 +3543,10 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsValidationResult get validationResult => _validationResult; SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult; + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + ui.SemanticsHitTestBehavior get hitTestBehavior => _hitTestBehavior; + ui.SemanticsHitTestBehavior _hitTestBehavior = ui.SemanticsHitTestBehavior.defer; + /// {@template flutter.semantics.SemanticsNode.inputType} /// The input type for of a editable node. /// @@ -3609,6 +3627,7 @@ class SemanticsNode with DiagnosticableTreeMixin { _role = config._role; _controlsNodes = config._controlsNodes; _validationResult = config._validationResult; + _hitTestBehavior = config._hitTestBehavior; _inputType = config._inputType; _locale = config.locale; @@ -3663,6 +3682,7 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsRole role = _role; Set? controlsNodes = _controlsNodes; SemanticsValidationResult validationResult = _validationResult; + ui.SemanticsHitTestBehavior hitTestBehavior = _hitTestBehavior; SemanticsInputType inputType = _inputType; final Locale? locale = _locale; final customSemanticsActionIds = {}; @@ -3727,6 +3747,9 @@ class SemanticsNode with DiagnosticableTreeMixin { if (inputType == SemanticsInputType.none) { inputType = node._inputType; } + if (hitTestBehavior == ui.SemanticsHitTestBehavior.defer) { + hitTestBehavior = node._hitTestBehavior; + } if (tooltip == '') { tooltip = node._tooltip; } @@ -3818,6 +3841,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + hitTestBehavior: hitTestBehavior, inputType: inputType, locale: locale, ); @@ -3989,6 +4013,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: data.role, controlsNodes: data.controlsNodes?.toList(), validationResult: data.validationResult, + hitTestBehavior: data.hitTestBehavior, inputType: data.inputType, locale: data.locale, ); @@ -6412,6 +6437,14 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.hitTestBehavior} + ui.SemanticsHitTestBehavior get hitTestBehavior => _hitTestBehavior; + ui.SemanticsHitTestBehavior _hitTestBehavior = ui.SemanticsHitTestBehavior.defer; + set hitTestBehavior(ui.SemanticsHitTestBehavior value) { + _hitTestBehavior = value; + _hasBeenAnnotated = true; + } + /// {@macro flutter.semantics.SemanticsProperties.inputType} SemanticsInputType get inputType => _inputType; SemanticsInputType _inputType = SemanticsInputType.none; @@ -6523,6 +6556,10 @@ class SemanticsConfiguration { if (_hasExplicitRole && other._hasExplicitRole) { return false; } + if (_hitTestBehavior != ui.SemanticsHitTestBehavior.defer || + other._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { + return false; + } return true; } @@ -6633,6 +6670,11 @@ class SemanticsConfiguration { child._accessiblityFocusBlockType, ); + if (_hitTestBehavior == ui.SemanticsHitTestBehavior.defer && + child._hitTestBehavior != ui.SemanticsHitTestBehavior.defer) { + _hitTestBehavior = child._hitTestBehavior; + } + _hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated; } @@ -6678,7 +6720,8 @@ class SemanticsConfiguration { .._role = _role .._controlsNodes = _controlsNodes .._validationResult = _validationResult - .._inputType = _inputType; + .._inputType = _inputType + .._hitTestBehavior = _hitTestBehavior; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 779882d749525..ed1c909646119 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -9,7 +9,9 @@ library; import 'dart:math' as math; -import 'dart:ui' as ui show Image, ImageFilter, SemanticsInputType, TextHeightBehavior; +import 'dart:ui' + as ui + show Image, ImageFilter, SemanticsHitTestBehavior, SemanticsInputType, TextHeightBehavior; import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; @@ -4047,6 +4049,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required SemanticsRole? role, required Set? controlsNodes, required SemanticsValidationResult validationResult, + required ui.SemanticsHitTestBehavior? hitTestBehavior, required ui.SemanticsInputType? inputType, required Locale? localeForSubtree, }) : this.fromProperties( @@ -4132,6 +4135,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + hitTestBehavior: hitTestBehavior, inputType: inputType, ), ); @@ -4377,6 +4381,7 @@ class SliverSemantics extends _SemanticsBase { super.role, super.controlsNodes, super.validationResult = SemanticsValidationResult.none, + super.hitTestBehavior, super.inputType, super.localeForSubtree, }) : super(child: sliver); @@ -7959,6 +7964,7 @@ class Semantics extends _SemanticsBase { super.role, super.controlsNodes, super.validationResult = SemanticsValidationResult.none, + super.hitTestBehavior, super.inputType, super.localeForSubtree, }); diff --git a/packages/flutter/test/material/bottom_sheet_test.dart b/packages/flutter/test/material/bottom_sheet_test.dart index 9753cca8b98e5..6c2281dc29a9a 100644 --- a/packages/flutter/test/material/bottom_sheet_test.dart +++ b/packages/flutter/test/material/bottom_sheet_test.dart @@ -893,11 +893,18 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: [ + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], + ), ], ), ], @@ -1069,14 +1076,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.hasImplicitScrolling], + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), ], ), ], @@ -1145,14 +1162,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.hasImplicitScrolling], + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], children: [ - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), ], ), ], @@ -1217,17 +1244,24 @@ void main() { TestSemantics( children: [ TestSemantics( - label: 'Dialog', - textDirection: TextDirection.ltr, - flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], children: [ TestSemantics( - flags: [SemanticsFlag.isButton], - actions: [SemanticsAction.tap], - label: 'Dismiss', + label: 'Dialog', textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: [ + TestSemantics( + flags: [SemanticsFlag.isButton], + actions: [SemanticsAction.tap], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], ), - TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), ], ), ], @@ -2958,6 +2992,61 @@ void main() { // Test with theme.platform = iOS on different real platforms. await pumpModalBottomSheetWithTheme(TargetPlatform.iOS); }, variant: TargetPlatformVariant.all()); + + testWidgets('Modal bottom sheet has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + + showModalBottomSheet( + context: savedContext, + builder: (BuildContext context) => Container( + height: 200, + color: Colors.blue, + child: const Center(child: Text('Modal Bottom Sheet')), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Modal Bottom Sheet'), findsOneWidget); + + // Verify the route-level Semantics has opaque hitTestBehavior + // This prevents clicks inside the bottom sheet from passing through to the barrier + final List allSemantics = tester + .widgetList( + find.ancestor(of: find.text('Modal Bottom Sheet'), matching: find.byType(Semantics)), + ) + .toList(); + + final Semantics routeSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.hitTestBehavior == SemanticsHitTestBehavior.opaque, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final Semantics widgetSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.scopesRoute ?? false, + ); + + expect(widgetSemantics.properties.scopesRoute, true); + + semantics.dispose(); + }); } class _TestPage extends StatelessWidget { diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index 85581c6c99009..76f4a98d1218c 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -3311,6 +3311,95 @@ void main() { semantics.dispose(); }, variant: TargetPlatformVariant.all()); }); + + testWidgets('Dialog has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => + const Dialog(child: SizedBox(width: 200, height: 200)), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + + final Semantics routeSemantics = tester.widget( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); + + testWidgets('AlertDialog has hitTestBehavior.opaque via Dialog', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Test Dialog'), + content: const SizedBox(width: 200, height: 100), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + + // Find the route-level Semantics with hitTestBehavior.opaque + // (wraps the entire dialog content, above the Dialog widget) + final Semantics routeSemantics = tester.widget( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); } @pragma('vm:entry-point') diff --git a/packages/flutter/test/material/dropdown_test.dart b/packages/flutter/test/material/dropdown_test.dart index 4b884d503b833..b0917cdb62ca1 100644 --- a/packages/flutter/test/material/dropdown_test.dart +++ b/packages/flutter/test/material/dropdown_test.dart @@ -1460,6 +1460,7 @@ void main() { role: SemanticsRole.menu, flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], label: 'Popup menu', + textDirection: TextDirection.ltr, children: [ TestSemantics( children: [ diff --git a/packages/flutter/test/rendering/platform_view_test.dart b/packages/flutter/test/rendering/platform_view_test.dart index 35780bfabb867..ab28d30b838c7 100644 --- a/packages/flutter/test/rendering/platform_view_test.dart +++ b/packages/flutter/test/rendering/platform_view_test.dart @@ -557,6 +557,22 @@ void main() { }); }); }); + + test('PlatformViewRenderBox has transparent hitTestBehavior in semantics', () { + final controller = FakePlatformViewController(0); + final renderBox = PlatformViewRenderBox( + controller: controller, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: >{}, + ); + + final config = SemanticsConfiguration(); + renderBox.describeSemanticsConfiguration(config); + + expect(config.hitTestBehavior, ui.SemanticsHitTestBehavior.transparent); + expect(config.isSemanticBoundary, true); + expect(config.platformViewId, 0); + }); } ui.PointerData _pointerData( diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart index 9523b53cced89..b34f74baf94c8 100644 --- a/packages/flutter/test/semantics/semantics_test.dart +++ b/packages/flutter/test/semantics/semantics_test.dart @@ -1035,6 +1035,34 @@ void main() { expect(config.customSemanticsActions[customAction], same(onCustomAction)); }); + test('SemanticsConfiguration.copy() preserves hitTestBehavior', () { + final config = SemanticsConfiguration() + ..isSemanticBoundary = true + ..label = 'test' + ..hitTestBehavior = SemanticsHitTestBehavior.opaque; + + expect(config.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final SemanticsConfiguration copy = config.copy(); + + expect(copy.hitTestBehavior, SemanticsHitTestBehavior.opaque); + expect(copy.isSemanticBoundary, isTrue); + expect(copy.label, 'test'); + }); + + test('SemanticsConfiguration.copy() preserves all hitTestBehavior values', () { + final deferConfig = SemanticsConfiguration(); + expect(deferConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.defer); + + final opaqueConfig = SemanticsConfiguration() + ..hitTestBehavior = SemanticsHitTestBehavior.opaque; + expect(opaqueConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final transparentConfig = SemanticsConfiguration() + ..hitTestBehavior = SemanticsHitTestBehavior.transparent; + expect(transparentConfig.copy().hitTestBehavior, SemanticsHitTestBehavior.transparent); + }); + test('SemanticsOwner dispatches memory events', () async { await expectLater( await memoryEvents( diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index bc9f931211fd3..0e6b42b9aa8ee 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -753,6 +753,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1055,6 +1056,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1157,6 +1159,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1264,6 +1267,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1299,6 +1303,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); @@ -1433,6 +1438,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + hitTestBehavior: ui.SemanticsHitTestBehavior.defer, inputType: ui.SemanticsInputType.none, locale: null, ); From 5fa0ba529dc361cde24df7052f0af8401f21961d Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 10 Dec 2025 19:51:37 -0500 Subject: [PATCH 16/29] Roll Skia from e61cc6d073fd to 59c6cad539f7 (22 revisions) (#179714) https://skia.googlesource.com/skia.git/+log/e61cc6d073fd..59c6cad539f7 2025-12-10 bungeman@google.com Modernize SkFontMgr_Mac::onMatchFamilyStyleCharacter 2025-12-10 kylechar@google.com Upgrade gradle to version 9.1.0 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 9f7342ee3761 to 50c5f8b4dd57 (8 revisions) 2025-12-10 bungeman@google.com Revert "Pass first family name to paragraph fallback" 2025-12-10 kjlubick@google.com Revert "[rust bmp] Add initial Rust BMP decoder implementation" 2025-12-10 bungeman@google.com Roll HarfBuzz from 08b52ae2 to 31695252 (971 commits) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2025-12-10 sergiog@microsoft.com [rust bmp] Add initial Rust BMP decoder implementation 2025-12-10 bungeman@google.com Pass first family name to paragraph fallback 2025-12-10 kjlubick@google.com Update CanvasKit to use const version of SerialProcs 2025-12-10 kjlubick@google.com Remove legacy SkParagraphBuilder API 2025-12-10 michaelludwig@google.com [wgsl] Stop forcing all function calls into scratch lets 2025-12-10 michaelludwig@google.com [ganesh] Guard division by zero in AAConvexPathRenderer 2025-12-10 nicolettep@google.com [graphite] Use depth/stencil image aspects for default d/s attachments 2025-12-10 nscobie@google.com Make SkDebugf calls log at Android's WARN level in RenderEngine 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll Dawn from 57fca18a1603 to 9e1721615b33 (7 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll vulkan-deps from 3486579f434a to 9f7342ee3761 (5 revisions) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll ANGLE from 78bb5a33a9e9 to c2a048f9a1db (7 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Skia Infra from ca3eb8741f18 to 492dfbfe3bb2 (10 revisions) 2025-12-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 8de9b80c5bd9 to 57fca18a1603 (17 revisions) 2025-12-10 recipe-mega-autoroller@chops-service-accounts.iam.gserviceaccount.com Roll recipe dependencies (trivial). If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC jimgraham@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 1822c4d24bae1..94d42ef574c1e 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'e61cc6d073fd3dd217b52e7b91d21151aad61478', + 'skia_revision': '59c6cad539f7fe01a42bded543beb927546739d5', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 4714854c897975b3ed8af655c875986c745ee564 Mon Sep 17 00:00:00 2001 From: b-luk <97480502+b-luk@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:14:23 -0800 Subject: [PATCH 17/29] Use kPreventOverdraw for arcs with overlapping stroke caps (#179312) Fixes #178746 Before: image After: image ## 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. [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 --- .../display_list/aiks_dl_basic_unittests.cc | 49 ++++++++ .../impeller/entity/entity_unittests.cc | 111 ++++++++++++++++++ .../impeller/entity/geometry/arc_geometry.cc | 56 ++++++++- .../impeller/entity/geometry/arc_geometry.h | 3 + 4 files changed, 218 insertions(+), 1 deletion(-) diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc index 303257008587b..004d47761b937 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc @@ -852,6 +852,25 @@ void RenderArcFarm(DisplayListBuilder& builder, } builder.Restore(); } + +void RenderArcFarmForOverlappingCapsTest(DisplayListBuilder& builder, + const DlPaint& paint) { + builder.Save(); + builder.Translate(40, 30); + const Rect arc_bounds = Rect::MakeLTRB(0, 0, 40, 40); + for (int stroke_width = 10; stroke_width <= 40; stroke_width += 3) { + DlPaint modified_paint = DlPaint(paint); + modified_paint.setStrokeWidth(stroke_width); + builder.Save(); + for (int sweep = 160; sweep <= 360; sweep += 20) { + builder.DrawArc(arc_bounds, 0, sweep, false, modified_paint); + builder.Translate(84, 0); + } + builder.Restore(); + builder.Translate(0, 44 + stroke_width); + } + builder.Restore(); +} } // namespace TEST_P(AiksTest, FilledArcsRenderCorrectly) { @@ -981,6 +1000,21 @@ TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithSquareEnds) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithTranslucencyAndSquareEnds) { + DisplayListBuilder builder; + builder.Scale(GetContentScale().x, GetContentScale().y); + builder.DrawColor(DlColor::kWhite(), DlBlendMode::kSrc); + + DlPaint paint; + paint.setDrawStyle(DlDrawStyle::kStroke); + paint.setStrokeCap(DlStrokeCap::kSquare); + paint.setColor(DlColor::kBlue().modulateOpacity(0.5)); + + RenderArcFarmForOverlappingCapsTest(builder, paint); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithRoundEnds) { DisplayListBuilder builder; builder.Scale(GetContentScale().x, GetContentScale().y); @@ -1001,6 +1035,21 @@ TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithRoundEnds) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithTranslucencyAndRoundEnds) { + DisplayListBuilder builder; + builder.Scale(GetContentScale().x, GetContentScale().y); + builder.DrawColor(DlColor::kWhite(), DlBlendMode::kSrc); + + DlPaint paint; + paint.setDrawStyle(DlDrawStyle::kStroke); + paint.setStrokeCap(DlStrokeCap::kRound); + paint.setColor(DlColor::kBlue().modulateOpacity(0.5)); + + RenderArcFarmForOverlappingCapsTest(builder, paint); + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + TEST_P(AiksTest, StrokedArcsRenderCorrectlyWithBevelJoinsAndCenter) { DisplayListBuilder builder; builder.Scale(GetContentScale().x, GetContentScale().y); diff --git a/engine/src/flutter/impeller/entity/entity_unittests.cc b/engine/src/flutter/impeller/entity/entity_unittests.cc index f6ccce5387f6b..580f9a753697b 100644 --- a/engine/src/flutter/impeller/entity/entity_unittests.cc +++ b/engine/src/flutter/impeller/entity/entity_unittests.cc @@ -2313,6 +2313,117 @@ TEST_P(EntityTest, FillPathGeometryGetPositionBufferReturnsExpectedMode) { } } +TEST_P(EntityTest, StrokeArcGeometryGetPositionBufferReturnsExpectedMode) { + RenderTarget target; + testing::MockRenderPass mock_pass(GetContext(), target); + Rect oval_bounds = Rect::MakeLTRB(100, 100, 200, 200); + + // Butt caps never overlap + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kButt}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } + } + } + + // Round caps with 10 stroke width overlap starting at 348.6 degrees + { + StrokeParameters stroke = {.width = 10.0f, .cap = Cap::kRound}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 348.6) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Round caps with 50 stroke width overlap starting at 300.1 degrees + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kRound}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 300.0) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Square caps with 10 stroke width overlap starting at 347.4 degrees + { + StrokeParameters stroke = {.width = 10.0f, .cap = Cap::kSquare}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 347.4) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } + + // Square caps with 50 stroke width overlap starting at 270.1 degrees + { + StrokeParameters stroke = {.width = 50.0f, .cap = Cap::kSquare}; + for (auto start = 0; start < 360; start += 60) { + for (auto sweep = 0; sweep < 360; sweep += 12) { + auto geometry = Geometry::MakeStrokedArc(oval_bounds, Degrees(start), + Degrees(sweep), stroke); + + GeometryResult result = + geometry->GetPositionBuffer(*GetContentContext(), {}, mock_pass); + + if (sweep < 270.1) { + EXPECT_EQ(result.mode, GeometryResult::Mode::kNormal) + << "start: " << start << " sweep: " << sweep; + } else { + EXPECT_EQ(result.mode, GeometryResult::Mode::kPreventOverdraw) + << "start: " << start << " sweep: " << sweep; + } + } + } + } +} + TEST_P(EntityTest, FailOnValidationError) { if (GetParam() != PlaygroundBackend::kVulkan) { GTEST_SKIP() << "Validation is only fatal on Vulkan backend."; diff --git a/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc b/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc index 056f7dacb6e11..926dda3035636 100644 --- a/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc +++ b/engine/src/flutter/impeller/entity/geometry/arc_geometry.cc @@ -5,6 +5,7 @@ #include "flutter/impeller/entity/geometry/arc_geometry.h" #include "flutter/impeller/entity/geometry/line_geometry.h" +#include "fml/logging.h" namespace impeller { @@ -47,7 +48,11 @@ GeometryResult ArcGeometry::GetPositionBuffer(const ContentContext& renderer, auto generator = renderer.GetTessellator().StrokedArc(transform, arc_, cap_, half_width); - return ComputePositionGeometry(renderer, generator, entity, pass); + auto result = ComputePositionGeometry(renderer, generator, entity, pass); + if (CapsOverlap()) { + result.mode = GeometryResult::Mode::kPreventOverdraw; + } + return result; } } @@ -80,4 +85,53 @@ bool ArcGeometry::IsAxisAlignedRect() const { return false; } +bool ArcGeometry::CapsOverlap() const { + FML_DCHECK(arc_.GetSweep().degrees >= 0.0f); + FML_DCHECK(arc_.GetSweep().degrees <= 360.0f); + + if (stroke_width_ < 0 || cap_ == Cap::kButt || + arc_.GetSweep().degrees <= 180) { + return false; + } + + switch (cap_) { + case Cap::kSquare: { + // Square caps overlap if the inner corner of the ending cap extends + // inside the inner edge of the start cap. For a visualization of when + // this occurs, see + // https://github.com/flutter/flutter/issues/178746#issuecomment-3554526727 + // Note that testing for overlap is completely independent of the arc's + // start angle. To simplify the overlap test, we treat the arc as if its + // start angle is 0. This allows the test to only require checking the x + // coordinate of the ending cap, rather than needing to calculate overlap + // based on both x and y positions of both caps. + auto radius = arc_.GetOvalSize().width * 0.5f; + auto half_width = stroke_width_ * 0.5f; + auto inner_radius = radius - half_width; + auto inner_arc_end_x = + cos(Radians(arc_.GetSweep()).radians) * inner_radius; + auto inner_square_cap_end_x = + inner_arc_end_x + + cos(Radians(arc_.GetSweep() + Degrees(90)).radians) * half_width; + return inner_square_cap_end_x > inner_radius; + } + case Cap::kRound: { + // Round caps overlap if the distance between the arc's start and end + // points is less than the stroke width. + // https://github.com/flutter/flutter/issues/178746#issuecomment-3554526727 + // Note that testing for overlap is completely independent of the arc's + // start angle. To simplify the overlap test, we treat the arc as if its + // start angle is 0. + auto radius = arc_.GetOvalSize().width / 2.0f; + auto start_point = Point(radius, 0); + auto sweep_radians = Radians(arc_.GetSweep()).radians; + auto end_point = Point(cos(sweep_radians), sin(sweep_radians)) * radius; + return start_point.GetDistanceSquared(end_point) < + stroke_width_ * stroke_width_; + } + case Cap::kButt: + FML_UNREACHABLE() + } +} + } // namespace impeller diff --git a/engine/src/flutter/impeller/entity/geometry/arc_geometry.h b/engine/src/flutter/impeller/entity/geometry/arc_geometry.h index 4657b2ce9fba0..f1b02d6d027f3 100644 --- a/engine/src/flutter/impeller/entity/geometry/arc_geometry.h +++ b/engine/src/flutter/impeller/entity/geometry/arc_geometry.h @@ -40,6 +40,9 @@ class ArcGeometry final : public Geometry { // |Geometry| std::optional GetCoverage(const Matrix& transform) const override; + // Whether the arc has overlapping stroke caps + bool CapsOverlap() const; + Arc arc_; Scalar stroke_width_; Cap cap_; From adf3ac77bd3c93b7de3166405f9d87312d02eb64 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 10 Dec 2025 22:28:26 -0500 Subject: [PATCH 18/29] Roll Dart SDK from 077062c5e515 to dbcb567e2432 (2 revisions) (#179715) https://dart.googlesource.com/sdk.git/+log/077062c5e515..dbcb567e2432 2025-12-11 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.11.0-232.0.dev 2025-12-10 dart-internal-merge@dart-ci-internal.iam.gserviceaccount.com Version 3.11.0-231.0.dev If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/dart-sdk-flutter Please CC dart-vm-team@google.com,jimgraham@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index 94d42ef574c1e..621d6d858921b 100644 --- a/DEPS +++ b/DEPS @@ -59,7 +59,7 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': '077062c5e5154cee4ffb1d72532428eaa79bc0fa', + 'dart_revision': 'dbcb567e2432c7cf8401ca4d9365a3f040aa5deb', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py @@ -77,7 +77,7 @@ vars = { 'dart_protobuf_rev': '9e30258e0aa6a6430ee36c84b75308a9702fde42', 'dart_pub_rev': 'b297f1f5e42f2569f15a98548f44b4b9a48487c1', 'dart_sync_http_rev': '6666fff944221891182e1f80bf56569338164d72', - 'dart_tools_rev': 'd03c394b24829f662098e590a2c812a076b01199', + 'dart_tools_rev': '97014f33a2aad59385df73f25baf9c304ca5c866', 'dart_vector_math_rev': '70a9a2cb610d040b247f3ca2cd70a94c1c6f6f23', 'dart_web_rev': '35fc98dd8f9da175ed0a2dcf246299e922e1e1e2', 'dart_webdev_rev': '234e44c2ba0aa6cee5a36026538ca89457bf0d55', From 74a0f8aa335b251023cc722d68a25119ac90d03c Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Thu, 11 Dec 2025 02:04:27 -0500 Subject: [PATCH 19/29] Roll Skia from 59c6cad539f7 to deb0153719dd (2 revisions) (#179721) https://skia.googlesource.com/skia.git/+log/59c6cad539f7..deb0153719dd 2025-12-11 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 9e1721615b33 to f39150de4906 (8 revisions) 2025-12-11 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Skia Infra from 492dfbfe3bb2 to 6cd15cab2194 (10 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC jimgraham@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 621d6d858921b..130f609d02988 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '59c6cad539f7fe01a42bded543beb927546739d5', + 'skia_revision': 'deb0153719ddf8895c9c1448a9a6959610785947', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 6a5ea6382699eb4487218ffc3ceddbd8a39aeea0 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Thu, 11 Dec 2025 04:53:30 -0500 Subject: [PATCH 20/29] Roll Fuchsia Linux SDK from u5vxWTRT0HlxOP5_r... to QssSL8DkxIbMvf89C... (#179729) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC jimgraham@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 130f609d02988..34b4b850afaed 100644 --- a/DEPS +++ b/DEPS @@ -810,7 +810,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'u5vxWTRT0HlxOP5_rIPOkUJXTun41aPDWjrw0MQdakkC' + 'version': 'QssSL8DkxIbMvf89CsxO9AsjjFWhqAZd7XoYRjQxeP8C' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', From 7429b1f10eaa7e9e8af77565df0b4a110c9568bf Mon Sep 17 00:00:00 2001 From: Mairramer Date: Wed, 17 Dec 2025 15:47:26 -0300 Subject: [PATCH 21/29] small fixes --- packages/flutter/lib/src/material/carousel.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 24f74cee40944..2cc1bfd53dcf3 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -1637,8 +1637,8 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro } // The index of the leading item in the carousel. - // getItemFromPixels may return a fractional value (e.g., 0.6 when mid-scroll). - // We use toInt() to truncate the fractional part, ensuring the leading item + // `getItemFromPixels` may return a fractional value (e.g., 0.6 when mid-scroll). + // Use `toInt()` to truncate the fractional part, ensuring the leading item // only advances after fully crossing the next item's boundary. int get leadingItem => getItemFromPixels(pixels, viewportDimension).toInt(); From 7e2de38a5147df655df4241fb76f0a71ed96d3f4 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Wed, 17 Dec 2025 15:56:07 -0300 Subject: [PATCH 22/29] Clarify documentation for leading item resolution in CarouselView --- packages/flutter/lib/src/material/carousel.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 2cc1bfd53dcf3..217aa23b9e362 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -459,12 +459,8 @@ class CarouselView extends StatefulWidget { /// the current frame according to its layout algorithm. This item can be only /// partially visible while scrolling. /// - /// - In a standard [CarouselView], the leading item is the one positioned at - /// the leading edge of the viewport based on the current scroll offset. - /// - /// - In a [CarouselView.weighted], the leading item is chosen by the weighted - /// layout algorithm (typically the one with the greatest effective weight; - /// ties are resolved using proximity to the leading edge). + /// For both [CarouselView] and [CarouselView.weighted], the leading item is + /// the first item resolved by the layout for the current scroll position. /// /// If `itemSnapping` is enabled, scrolling settles with the resolved leading /// item fully visible when possible. From 34241f18b58904327ee4a5365278d08e8bd46b34 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Thu, 18 Dec 2025 08:38:14 -0300 Subject: [PATCH 23/29] small improves --- .../flutter/lib/src/material/carousel.dart | 11 +-- .../flutter/test/material/carousel_test.dart | 96 +++++++++---------- 2 files changed, 49 insertions(+), 58 deletions(-) diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 217aa23b9e362..52d5402877850 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -455,17 +455,10 @@ class CarouselView extends StatefulWidget { /// {@template flutter.material.CarouselView.onIndexChanged} /// A callback invoked when the leading item changes. /// - /// The “leading” item is the one that the carousel resolves as primary for - /// the current frame according to its layout algorithm. This item can be only - /// partially visible while scrolling. - /// /// For both [CarouselView] and [CarouselView.weighted], the leading item is - /// the first item resolved by the layout for the current scroll position. - /// - /// If `itemSnapping` is enabled, scrolling settles with the resolved leading - /// item fully visible when possible. + /// the first visible item in the carousel view. /// - /// The callback fires only when the resolved leading index actually changes, + /// The callback fires only when the leading index actually changes, /// whether due to user interaction or programmatic scrolling. /// {@endtemplate} /// diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index 2e9fb5199087e..cde7f3c475fc7 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -1990,7 +1990,6 @@ void main() { body: CarouselView( itemExtent: 300, controller: controller, - itemSnapping: true, onIndexChanged: (int index) { leadingIndex = index; }, @@ -2022,62 +2021,62 @@ void main() { expect(leadingIndex, equals(1)); }); - testWidgets('CarouselView shows correct item after animation with symmetric flexWeights', ( - WidgetTester tester, - ) async { - final controller = CarouselController(); - addTearDown(controller.dispose); - var leadingIndex = 0; + testWidgets( + 'CarouselView.weighted shows correct item after animation with symmetric flexWeights', + (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: CarouselView.weighted( - flexWeights: const [2, 5, 2], - controller: controller, - itemSnapping: true, - onIndexChanged: (int index) { - leadingIndex = index; - }, - children: List.generate(6, (int i) => Text('Item $i')), + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [2, 5, 2], + controller: controller, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(6, (int i) => Text('Item $i')), + ), ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - controller.animateToItem( - 4, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - ); - await tester.pumpAndSettle(); + controller.animateToItem( + 4, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(4)); - expect(leadingIndex, equals(4)); + expect(controller.leadingItem, equals(4)); + expect(leadingIndex, equals(4)); - final double visible4 = visiblePortionOf(tester, 'Item 4'); - final double visible3 = visiblePortionOf(tester, 'Item 3'); - final double visible5 = visiblePortionOf(tester, 'Item 5'); - expect(visible4, greaterThan(visible3)); - expect(visible4, greaterThan(visible5)); + final double visible4 = visiblePortionOf(tester, 'Item 4'); + final double visible3 = visiblePortionOf(tester, 'Item 3'); + final double visible5 = visiblePortionOf(tester, 'Item 5'); + expect(visible4, greaterThan(visible3)); + expect(visible4, greaterThan(visible5)); - controller.animateToItem( - 2, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - ); - await tester.pumpAndSettle(); + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); + expect(controller.leadingItem, equals(2)); + expect(leadingIndex, equals(2)); - final double visible2 = visiblePortionOf(tester, 'Item 2'); - final double visible1 = visiblePortionOf(tester, 'Item 1'); - final double visible3After = visiblePortionOf(tester, 'Item 3'); - expect(visible2, greaterThan(visible1)); - expect(visible2, greaterThan(visible3After)); - }); + final double visible2 = visiblePortionOf(tester, 'Item 2'); + final double visible1 = visiblePortionOf(tester, 'Item 1'); + final double visible3After = visiblePortionOf(tester, 'Item 3'); + expect(visible2, greaterThan(visible1)); + expect(visible2, greaterThan(visible3After)); + }, + ); testWidgets('CarouselView shows correct item after animation with asymmetric flexWeights', ( WidgetTester tester, @@ -2092,7 +2091,6 @@ void main() { body: CarouselView.weighted( flexWeights: const [1, 2, 3, 4], controller: controller, - itemSnapping: true, onIndexChanged: (int index) { leadingIndex = index; }, From c373a0dded7852fbb32e379b2cea175e78d21f1f Mon Sep 17 00:00:00 2001 From: Mairramer Date: Thu, 18 Dec 2025 09:00:57 -0300 Subject: [PATCH 24/29] refactor tests --- .../flutter/test/material/carousel_test.dart | 198 ++++++------------ 1 file changed, 61 insertions(+), 137 deletions(-) diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index cde7f3c475fc7..823e7df28cb13 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -2032,12 +2031,10 @@ void main() { MaterialApp( home: Scaffold( body: CarouselView.weighted( - flexWeights: const [2, 5, 2], + flexWeights: const [2, 5, 2], controller: controller, - onIndexChanged: (int index) { - leadingIndex = index; - }, - children: List.generate(6, (int i) => Text('Item $i')), + onIndexChanged: (int index) => leadingIndex = index, + children: List.generate(6, (i) => Text('Item $i')), ), ), ), @@ -2053,12 +2050,36 @@ void main() { expect(controller.leadingItem, equals(4)); expect(leadingIndex, equals(4)); + expect(find.text('Item 4'), findsOneWidget); - final double visible4 = visiblePortionOf(tester, 'Item 4'); - final double visible3 = visiblePortionOf(tester, 'Item 3'); - final double visible5 = visiblePortionOf(tester, 'Item 5'); - expect(visible4, greaterThan(visible3)); - expect(visible4, greaterThan(visible5)); + final Rect viewport = viewportRect(tester); + final Rect rect4 = tester.getRect(getItem(4)); + + expect(rect4.top, greaterThanOrEqualTo(viewport.top)); + expect(rect4.bottom, lessThanOrEqualTo(viewport.bottom)); + }, + ); + + testWidgets( + 'CarouselView.weighted shows correct item after animation with asymmetric flexWeights', + (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [1, 2, 3, 4], + controller: controller, + onIndexChanged: (int index) => leadingIndex = index, + children: List.generate(6, (i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); controller.animateToItem( 2, @@ -2069,73 +2090,16 @@ void main() { expect(controller.leadingItem, equals(2)); expect(leadingIndex, equals(2)); + expect(find.text('Item 2'), findsOneWidget); - final double visible2 = visiblePortionOf(tester, 'Item 2'); - final double visible1 = visiblePortionOf(tester, 'Item 1'); - final double visible3After = visiblePortionOf(tester, 'Item 3'); - expect(visible2, greaterThan(visible1)); - expect(visible2, greaterThan(visible3After)); + final Rect viewport = viewportRect(tester); + final Rect rect2 = tester.getRect(getItem(2)); + + expect(rect2.top, greaterThanOrEqualTo(viewport.top)); + expect(rect2.bottom, lessThanOrEqualTo(viewport.bottom)); }, ); - testWidgets('CarouselView shows correct item after animation with asymmetric flexWeights', ( - WidgetTester tester, - ) async { - final controller = CarouselController(); - addTearDown(controller.dispose); - var leadingIndex = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: CarouselView.weighted( - flexWeights: const [1, 2, 3, 4], - controller: controller, - onIndexChanged: (int index) { - leadingIndex = index; - }, - children: List.generate(6, (int i) => Text('Item $i')), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - controller.animateToItem( - 4, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - ); - await tester.pumpAndSettle(); - - expect(controller.leadingItem, equals(4)); - expect(leadingIndex, equals(4)); - expect(find.text('Item 4'), findsOneWidget); - - final double visible4 = visiblePortionOf(tester, 'Item 4'); - final double visible3 = visiblePortionOf(tester, 'Item 3'); - final double visible5 = visiblePortionOf(tester, 'Item 5'); - expect(visible4, greaterThan(visible3)); - expect(visible4, greaterThan(visible5)); - - controller.animateToItem( - 2, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - ); - await tester.pumpAndSettle(); - - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); - expect(find.text('Item 2'), findsOneWidget); - - final double visible2 = visiblePortionOf(tester, 'Item 2'); - final double visible1 = visiblePortionOf(tester, 'Item 1'); - final double visible3After = visiblePortionOf(tester, 'Item 3'); - expect(visible2, greaterThan(visible1)); - expect(visible2, greaterThan(visible3After)); - }); - testWidgets('CarouselView shows the correct item after dragging', (WidgetTester tester) async { final controller = CarouselController(); addTearDown(controller.dispose); @@ -2158,41 +2122,17 @@ void main() { ); await tester.pumpAndSettle(); - // Drag to the left to move to the next item. await tester.drag(find.byType(CarouselView), const Offset(-300, 0)); await tester.pumpAndSettle(); - // Verify the new index based on controller and callback. expect(controller.leadingItem, equals(2)); expect(leadingIndex, equals(2)); - // Validate that the dragged item is now the most visible in the viewport. - final visibleAreasAfterLeftDrag = List.generate( - 5, - (int i) => visiblePortionOf(tester, 'Item $i'), - ); - final int mostVisibleIndexAfterLeftDrag = visibleAreasAfterLeftDrag.indexWhere( - (double area) => area == visibleAreasAfterLeftDrag.reduce(math.max), - ); - expect(mostVisibleIndexAfterLeftDrag, equals(controller.leadingItem)); - - // Drag to the right to return to the previous item. - await tester.drag(find.byType(CarouselView), const Offset(150, 0)); - await tester.pumpAndSettle(); + final Rect viewport = viewportRect(tester); + final Rect rect2 = tester.getRect(getItem(2)); - // Verify the updated index. - expect(controller.leadingItem, equals(1)); - expect(leadingIndex, equals(1)); - - // Validate again which item is most visible after dragging back. - final visibleAreasAfterRightDrag = List.generate( - 5, - (int i) => visiblePortionOf(tester, 'Item $i'), - ); - final int mostVisibleIndexAfterRightDrag = visibleAreasAfterRightDrag.indexWhere( - (double area) => area == visibleAreasAfterRightDrag.reduce(math.max), - ); - expect(mostVisibleIndexAfterRightDrag, equals(controller.leadingItem)); + expect(rect2.top, greaterThanOrEqualTo(viewport.top)); + expect(rect2.bottom, lessThanOrEqualTo(viewport.bottom)); }); testWidgets( @@ -2206,28 +2146,29 @@ void main() { MaterialApp( home: Scaffold( body: CarouselView.weighted( - flexWeights: const [5, 5, 2, 3], + flexWeights: const [5, 5, 2, 3], controller: controller, itemSnapping: true, - onIndexChanged: (int index) { - leadingIndex = index; - }, - children: List.generate(4, (int i) => Text('Item $i')), + onIndexChanged: (int index) => leadingIndex = index, + children: List.generate(4, (i) => Text('Item $i')), ), ), ), ); await tester.pumpAndSettle(); - // Initially, the first item with max weight should be leading (index 0). + // First max-weight item wins initially expect(controller.leadingItem, equals(0)); expect(leadingIndex, equals(0)); + expect(find.text('Item 0'), findsOneWidget); + + final Rect viewport = tester.getRect(find.byType(Scrollable)); + final Rect rect0 = tester.getRect(find.text('Item 0')); - final double visible0 = visiblePortionOf(tester, 'Item 0'); - final double visible1 = visiblePortionOf(tester, 'Item 1'); - expect(visible0, greaterThanOrEqualTo(visible1)); + expect(rect0.top, greaterThanOrEqualTo(viewport.top)); + expect(rect0.bottom, lessThanOrEqualTo(viewport.bottom)); - // Scroll forward so the second max weight item is more visible. + // Move to the next max-weight item controller.animateToItem( 1, duration: const Duration(milliseconds: 200), @@ -2235,9 +2176,14 @@ void main() { ); await tester.pumpAndSettle(); - // Leading should now be the second item with max weight (index 1). expect(controller.leadingItem, equals(1)); expect(leadingIndex, equals(1)); + expect(find.text('Item 1'), findsOneWidget); + + final Rect rect1 = tester.getRect(find.text('Item 1')); + + expect(rect1.top, greaterThanOrEqualTo(viewport.top)); + expect(rect1.bottom, lessThanOrEqualTo(viewport.bottom)); }, ); @@ -2507,28 +2453,6 @@ Future runCarouselTest({ expect(realOffset(), controller.offset); } -double visiblePortionOf(WidgetTester tester, String label) { - // Locate the CarouselView widget that defines the visible viewport. - final Finder carouselFinder = find.byType(CarouselView); - - // If the item with the given label is not currently in the render tree, - // return 0.0 because it is outside the visible area. - if (find.text(label).evaluate().isEmpty) { - return 0.0; - } - - // Get the RenderBox of the CarouselView to determine its visible boundaries. - final RenderBox carouselBox = tester.renderObject(carouselFinder); - final Rect viewportRect = carouselBox.paintBounds; - - // Get the bounding rectangle of the target item. - final Rect itemRect = tester.getRect(find.text(label)); - - // Calculate the intersection between the item’s rectangle and the viewport. - // This represents the visible portion of the item currently on screen. - final Rect intersection = itemRect.intersect(viewportRect); - - // Return the visible area (width * height) of the intersected region. - // This helps determine which item is most visible in the Carousel. - return intersection.width * intersection.height; +Rect viewportRect(WidgetTester tester) { + return tester.getRect(find.byType(Scrollable)); } From 428db29c4084c2425451e21523791ea9193f686a Mon Sep 17 00:00:00 2001 From: Mairramer Date: Thu, 18 Dec 2025 17:05:23 -0300 Subject: [PATCH 25/29] code removal in tests --- .../flutter/test/material/carousel_test.dart | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index 823e7df28cb13..dfccf84a7e677 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2051,12 +2051,6 @@ void main() { expect(controller.leadingItem, equals(4)); expect(leadingIndex, equals(4)); expect(find.text('Item 4'), findsOneWidget); - - final Rect viewport = viewportRect(tester); - final Rect rect4 = tester.getRect(getItem(4)); - - expect(rect4.top, greaterThanOrEqualTo(viewport.top)); - expect(rect4.bottom, lessThanOrEqualTo(viewport.bottom)); }, ); @@ -2091,12 +2085,6 @@ void main() { expect(controller.leadingItem, equals(2)); expect(leadingIndex, equals(2)); expect(find.text('Item 2'), findsOneWidget); - - final Rect viewport = viewportRect(tester); - final Rect rect2 = tester.getRect(getItem(2)); - - expect(rect2.top, greaterThanOrEqualTo(viewport.top)); - expect(rect2.bottom, lessThanOrEqualTo(viewport.bottom)); }, ); @@ -2127,12 +2115,6 @@ void main() { expect(controller.leadingItem, equals(2)); expect(leadingIndex, equals(2)); - - final Rect viewport = viewportRect(tester); - final Rect rect2 = tester.getRect(getItem(2)); - - expect(rect2.top, greaterThanOrEqualTo(viewport.top)); - expect(rect2.bottom, lessThanOrEqualTo(viewport.bottom)); }); testWidgets( @@ -2162,12 +2144,6 @@ void main() { expect(leadingIndex, equals(0)); expect(find.text('Item 0'), findsOneWidget); - final Rect viewport = tester.getRect(find.byType(Scrollable)); - final Rect rect0 = tester.getRect(find.text('Item 0')); - - expect(rect0.top, greaterThanOrEqualTo(viewport.top)); - expect(rect0.bottom, lessThanOrEqualTo(viewport.bottom)); - // Move to the next max-weight item controller.animateToItem( 1, @@ -2179,11 +2155,6 @@ void main() { expect(controller.leadingItem, equals(1)); expect(leadingIndex, equals(1)); expect(find.text('Item 1'), findsOneWidget); - - final Rect rect1 = tester.getRect(find.text('Item 1')); - - expect(rect1.top, greaterThanOrEqualTo(viewport.top)); - expect(rect1.bottom, lessThanOrEqualTo(viewport.bottom)); }, ); @@ -2452,7 +2423,3 @@ Future runCarouselTest({ expect(find.text('Item 0'), findsOneWidget); expect(realOffset(), controller.offset); } - -Rect viewportRect(WidgetTester tester) { - return tester.getRect(find.byType(Scrollable)); -} From 4755505ffc75e2e98d46ca118ebd6ff32381f3f2 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Thu, 18 Dec 2025 17:57:32 -0300 Subject: [PATCH 26/29] corrects the test name --- packages/flutter/test/material/carousel_test.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index dfccf84a7e677..6d4ded9cc9d03 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2158,7 +2158,9 @@ void main() { }, ); - testWidgets('CarouselView starts with the correct initial item', (WidgetTester tester) async { + testWidgets('CarouselView.weighted starts with the provided initialItem', ( + WidgetTester tester, + ) async { final controller = CarouselController(initialItem: 2); addTearDown(controller.dispose); @@ -2174,15 +2176,21 @@ void main() { ), ), ); + await tester.pumpAndSettle(); + // The carousel should start at the provided `initialItem`. expect(controller.leadingItem, equals(2)); expect(find.text('Item 2'), findsOneWidget); // Verify that the initial item is centered. final Rect itemRect = tester.getRect(find.text('Item 2')); - final double centerX = tester.getCenter(find.byType(CarouselView)).dx; - expect(itemRect.center.dx, closeTo(centerX, 1.0)); // Allow a small margin of error. + final double carouselCenterX = tester.getCenter(find.byType(CarouselView)).dx; + + expect( + itemRect.center.dx, + closeTo(carouselCenterX, 1.0), // Allow a small margin of error. + ); }); }); From 2663f4dcc7920d7aa966eff4a8b97f6caa013640 Mon Sep 17 00:00:00 2001 From: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:16:56 -0800 Subject: [PATCH 27/29] Handle issue when consumeMaxWeight is true --- .../flutter/lib/src/material/carousel.dart | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 52d5402877850..def27dcc39e0e 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -553,11 +553,15 @@ class _CarouselViewState extends State { super.dispose(); } + // The initialItem means the index of the item to occupy the first maximum weight + // when flexWeights is not null. So it might be negative when initialItem value + // is small but the first max weight index is large. In that case, the initial + // leading item should be 0. int _getInitialLeadingItem() { if (widget.flexWeights != null) { final int maxWeight = widget.flexWeights!.max; final int firstMaxWeightIndex = widget.flexWeights!.indexOf(maxWeight); - return _controller.initialItem - firstMaxWeightIndex; + return math.max(_controller.initialItem - firstMaxWeightIndex, 0); } return _controller.initialItem; } @@ -1629,7 +1633,22 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro // `getItemFromPixels` may return a fractional value (e.g., 0.6 when mid-scroll). // Use `toInt()` to truncate the fractional part, ensuring the leading item // only advances after fully crossing the next item's boundary. - int get leadingItem => getItemFromPixels(pixels, viewportDimension).toInt(); + int get leadingItem { + final int leadingItem = getItemFromPixels(pixels, viewportDimension).toInt(); + // When `consumeMaxWeight` is true, there is some reserved space before + // item 0 so that item 0 can be expanded to occupy the maximum + // weight while scrolling. The way how consumeMaxWeight works is that we assume + // there are some "invisible" items before the first visible item. Therefore, + // to calculate the correct visible leading item, we need to offset the leading + // item by the index of the maximum weight. + // + // The subtraction may cause negative number for leading item. In this case, + // constrain the leading item to 0. + if (consumeMaxWeight) { + return math.max(leadingItem - flexWeights!.indexOf(flexWeights!.max), 0); + } + return leadingItem; + } double updateLeadingItem(List? newFlexWeights, bool newConsumeMaxWeight) { final double maxItem; From 9649575a7e36a4941e43d46daec0eb3a5b23e6a0 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Fri, 19 Dec 2025 08:27:29 -0300 Subject: [PATCH 28/29] fix tests --- .../flutter/lib/src/material/carousel.dart | 2 +- .../flutter/test/material/carousel_test.dart | 31 ++++++------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index def27dcc39e0e..54f032ccbcdbb 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -1644,7 +1644,7 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro // // The subtraction may cause negative number for leading item. In this case, // constrain the leading item to 0. - if (consumeMaxWeight) { + if (consumeMaxWeight && flexWeights != null) { return math.max(leadingItem - flexWeights!.indexOf(flexWeights!.max), 0); } return leadingItem; diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index 6d4ded9cc9d03..e6c4c6c578406 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2048,9 +2048,9 @@ void main() { ); await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(4)); - expect(leadingIndex, equals(4)); - expect(find.text('Item 4'), findsOneWidget); + expect(controller.leadingItem, equals(3)); + expect(leadingIndex, equals(3)); + expect(find.text('Item 3'), findsOneWidget); }, ); @@ -2082,9 +2082,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); - expect(find.text('Item 2'), findsOneWidget); + expect(controller.leadingItem, equals(0)); + expect(leadingIndex, equals(0)); }, ); @@ -2113,8 +2112,8 @@ void main() { await tester.drag(find.byType(CarouselView), const Offset(-300, 0)); await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); }); testWidgets( @@ -2158,7 +2157,7 @@ void main() { }, ); - testWidgets('CarouselView.weighted starts with the provided initialItem', ( + testWidgets('CarouselView.weighted starts with the correct initial item', ( WidgetTester tester, ) async { final controller = CarouselController(initialItem: 2); @@ -2179,18 +2178,8 @@ void main() { await tester.pumpAndSettle(); - // The carousel should start at the provided `initialItem`. - expect(controller.leadingItem, equals(2)); - expect(find.text('Item 2'), findsOneWidget); - - // Verify that the initial item is centered. - final Rect itemRect = tester.getRect(find.text('Item 2')); - final double carouselCenterX = tester.getCenter(find.byType(CarouselView)).dx; - - expect( - itemRect.center.dx, - closeTo(carouselCenterX, 1.0), // Allow a small margin of error. - ); + expect(controller.leadingItem, equals(1)); + expect(find.text('Item 1'), findsOneWidget); }); }); From 66f9ac94f046bdf1519596cfd6f9bb7a47fe4f8f Mon Sep 17 00:00:00 2001 From: Mairramer Date: Tue, 23 Dec 2025 08:50:00 -0300 Subject: [PATCH 29/29] Add comment to clarify carousel animation behavior in tests --- packages/flutter/test/material/carousel_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index e6c4c6c578406..5bdc3dc4c4307 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2041,6 +2041,8 @@ void main() { ); await tester.pumpAndSettle(); + // Animate the carousel so item 4 is placed in the first position with max weight (5) + // in `flexWeights`, resulting in item 3 as the leading item. controller.animateToItem( 4, duration: const Duration(milliseconds: 200),