diff --git a/DEPS b/DEPS index 1822c4d24bae1..34b4b850afaed 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': 'deb0153719ddf8895c9c1448a9a6959610785947', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. @@ -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', @@ -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', 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/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_; 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; 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); + }, + ); } 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/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 24f74cee40944..54f032ccbcdbb 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -455,21 +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 visible item in the carousel view. /// - /// - 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). - /// - /// If `itemSnapping` is enabled, scrolling settles with the resolved leading - /// item fully visible when possible. - /// - /// 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} /// @@ -564,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; } @@ -1637,10 +1630,25 @@ 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(); + 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 && flexWeights != null) { + return math.max(leadingItem - flexWeights!.indexOf(flexWeights!.max), 0); + } + return leadingItem; + } double updateLeadingItem(List? newFlexWeights, bool newConsumeMaxWeight) { final double maxItem; 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/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/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/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/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/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index 2e9fb5199087e..5bdc3dc4c4307 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'; @@ -1990,7 +1989,6 @@ void main() { body: CarouselView( itemExtent: 300, controller: controller, - itemSnapping: true, onIndexChanged: (int index) { leadingIndex = index; }, @@ -2022,121 +2020,74 @@ 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, (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)); - - 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(); + ); + await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); + // 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), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); - 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)); - }); + expect(controller.leadingItem, equals(3)); + expect(leadingIndex, equals(3)); + expect(find.text('Item 3'), findsOneWidget); + }, + ); - testWidgets('CarouselView shows correct item after animation with asymmetric flexWeights', ( - WidgetTester tester, - ) async { - final controller = CarouselController(); - addTearDown(controller.dispose); - var leadingIndex = 0; + 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, - 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 [1, 2, 3, 4], + controller: controller, + onIndexChanged: (int index) => leadingIndex = index, + children: List.generate(6, (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(); + ); + await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(leadingIndex, equals(2)); - expect(find.text('Item 2'), findsOneWidget); + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); - 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)); - }); + expect(controller.leadingItem, equals(0)); + expect(leadingIndex, equals(0)); + }, + ); testWidgets('CarouselView shows the correct item after dragging', (WidgetTester tester) async { final controller = CarouselController(); @@ -2160,41 +2111,11 @@ 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(); - - // 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)); }); testWidgets( @@ -2208,28 +2129,23 @@ 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 double visible0 = visiblePortionOf(tester, 'Item 0'); - final double visible1 = visiblePortionOf(tester, 'Item 1'); - expect(visible0, greaterThanOrEqualTo(visible1)); - - // 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), @@ -2237,13 +2153,15 @@ 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); }, ); - testWidgets('CarouselView starts with the correct initial item', (WidgetTester tester) async { + testWidgets('CarouselView.weighted starts with the correct initial item', ( + WidgetTester tester, + ) async { final controller = CarouselController(initialItem: 2); addTearDown(controller.dispose); @@ -2259,15 +2177,11 @@ void main() { ), ), ); - await tester.pumpAndSettle(); - expect(controller.leadingItem, equals(2)); - expect(find.text('Item 2'), findsOneWidget); + await tester.pumpAndSettle(); - // 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. + expect(controller.leadingItem, equals(1)); + expect(find.text('Item 1'), findsOneWidget); }); }); @@ -2508,29 +2422,3 @@ Future runCarouselTest({ expect(find.text('Item 0'), findsOneWidget); 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; -} 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/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, 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, ], 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, );