diff --git a/dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart b/dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart index cd66e1adeeb..fbe6b0bb5bd 100644 --- a/dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart +++ b/dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart @@ -14,6 +14,10 @@ import 'package:flutter_driver/driver_extension.dart'; import '../src/allow_list_devices.dart'; +// Enum to represent the different clipper types that can be toggled in this +// test app. See their definitions below. +enum ClipperType { triangle, cubicWave, overlappingNonZero, overlappingEvenOdd } + void main() async { ensureAndroidDevice(); enableFlutterDriverExtension( @@ -27,63 +31,122 @@ void main() async { // Run on full screen. await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - runApp(const _ComplicatedClipPathWrappedMainApp()); + runApp(const ClipperToggleApp()); } -final class _ComplicatedClipPathWrappedMainApp extends StatefulWidget { - const _ComplicatedClipPathWrappedMainApp(); - - @override - State<_ComplicatedClipPathWrappedMainApp> createState() { - return _ComplicatedClipPathWrappedMainAppState(); - } -} - -class _ComplicatedClipPathWrappedMainAppState extends State<_ComplicatedClipPathWrappedMainApp> { - final CustomClipper _triangleClipper = TriangleClipper(); - CustomClipper? _triangleOrEmpty = TriangleClipper(); - - void _toggleTriangleClipper() { - setState(() { - if (_triangleOrEmpty == null) { - _triangleOrEmpty = _triangleClipper; - } else { - _triangleOrEmpty = null; - } - }); - } +class ClipperToggleApp extends StatelessWidget { + const ClipperToggleApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - home: ClipPath( - clipper: _triangleOrEmpty, - child: ClipPath( - clipper: CubicWaveClipper(), - child: ClipOval( - child: Stack( - alignment: Alignment.center, - children: [ - TextButton( - key: const ValueKey('ToggleTriangleClipping'), - onPressed: _toggleTriangleClipper, - child: const SizedBox( - width: 500, - height: 500, - child: ColoredBox(color: Colors.green), - ), - ), - const SizedBox( - width: 400, - height: 400, - child: _HybridCompositionAndroidPlatformView( - viewType: 'changing_color_button_platform_view', - ), - ), - ], + theme: ThemeData(elevatedButtonTheme: const ElevatedButtonThemeData()), + home: const ClipperHomePage(), + ); + } +} + +class ClipperHomePage extends StatefulWidget { + const ClipperHomePage({super.key}); + + @override + State createState() => _ClipperHomePageState(); +} + +class _ClipperHomePageState extends State { + // State map to track which clippers are active + // Initialize all clippers to off (false) + final Map _clipperStates = { + for (ClipperType type in ClipperType.values) type: false, + }; + + // Instantiate clippers (keep these readily available) + final Map> _clippers = >{ + ClipperType.triangle: TriangleClipper(), + ClipperType.cubicWave: CubicWaveClipper(), + ClipperType.overlappingNonZero: OverlappingRectClipper(fillType: PathFillType.nonZero), + ClipperType.overlappingEvenOdd: OverlappingRectClipper(fillType: PathFillType.evenOdd), + }; + + // Define the order in which clippers will be nested (outermost to innermost) + // The build logic will apply them in this sequence. + final List _clipperNestingOrder = [ + ClipperType.triangle, + ClipperType.cubicWave, + ClipperType.overlappingNonZero, + ClipperType.overlappingEvenOdd, + ]; + + // Method to toggle the state of a specific clipper + void _toggleClipper(ClipperType type) { + setState(() { + _clipperStates[type] = !(_clipperStates[type] ?? false); + }); + } + + // Helper function to build the potentially nested ClipPath structure + Widget _buildClippedContent(Widget child) { + Widget currentChild = child; + // Iterate through the defined nesting order + for (final ClipperType clipperType in _clipperNestingOrder) { + // If the clipper is active in the state map, wrap the current widget + if (_clipperStates[clipperType] ?? false) { + currentChild = ClipPath( + clipper: _clippers[clipperType], // Get the clipper instance + child: currentChild, // Wrap the previously built widget + ); + } + } + return currentChild; // Return the final potentially nested structure + } + + @override + Widget build(BuildContext context) { + // Content that will be clipped + const Widget contentToClip = ClipOval( + // Inner ClipOval remains + child: Stack( + alignment: Alignment.center, + children: [ + // Background + SizedBox(width: 500, height: 500, child: ColoredBox(color: Colors.green)), + SizedBox( + width: 400, + height: 400, + child: _HybridCompositionAndroidPlatformView( + viewType: 'changing_color_button_platform_view', ), ), - ), + ], + ), + ); + + return Scaffold( + body: Column( + children: [ + // Row of buttons to toggle each clipper + Padding( + padding: const EdgeInsets.all(8.0), + child: Wrap( + spacing: 8.0, + runSpacing: 4.0, + alignment: WrapAlignment.center, + children: + ClipperType.values.map((ClipperType type) { + return ElevatedButton( + key: ValueKey('clipper_button_${type.name}'), // Use enum name in key + onPressed: () => _toggleClipper(type), + child: Text(type.name), // Display clipper name on button + ); + }).toList(), + ), + ), + // Expanded takes remaining space for the clipped content + Expanded( + // Dynamically build the clipped structure + child: _buildClippedContent(contentToClip), + ), + ], ), ); } @@ -143,6 +206,44 @@ class TriangleClipper extends CustomClipper { } } +// Clips based on two overlapping rectangles. +class OverlappingRectClipper extends CustomClipper { + OverlappingRectClipper({required this.fillType}); + final PathFillType fillType; + + @override + Path getClip(Size size) { + // Note: Path coordinates are relative to the widget being clipped. + final Path path = Path(); + + // Define the two rectangles relative to the widget's size + final double rectWidth = size.width * 0.4; + final double rectHeight = size.height * 0.4; + final double offsetX1 = size.width * 0.1; + final double offsetY1 = size.height * 0.1; + final double offsetX2 = size.width * 0.25; + final double offsetY2 = size.height * 0.25; + + final Rect rect1 = Rect.fromLTWH(offsetX1, offsetY1, rectWidth, rectHeight); + final Rect rect2 = Rect.fromLTWH(offsetX2, offsetY2, rectWidth, rectHeight); // Overlaps rect1 + + // Add the rectangles to the path + path.addRect(rect1); + path.addRect(rect2); + + path.fillType = fillType; + + return path; + } + + @override + bool shouldReclip(covariant OverlappingRectClipper oldClipper) { + // Reclip only if the fillType changes. + return oldClipper.fillType != fillType; + } +} + +// --- Platform View Definition --- final class _HybridCompositionAndroidPlatformView extends StatelessWidget { const _HybridCompositionAndroidPlatformView({required this.viewType}); @@ -160,6 +261,7 @@ final class _HybridCompositionAndroidPlatformView extends StatelessWidget { ); }, onCreatePlatformView: (PlatformViewCreationParams params) { + // Use initHybridAndroidView for Hybrid Composition return PlatformViewsService.initHybridAndroidView( id: params.id, viewType: viewType, diff --git a/dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart b/dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart index 84b90a61d8f..875dbe8842c 100644 --- a/dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart +++ b/dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart @@ -52,26 +52,80 @@ void main() async { expect(response['supported'], true); }, timeout: Timeout.none); - test('should screenshot a platform view with specified clippath', () async { + test('should screenshot a platform view with no path clipping', () async { await expectLater( nativeDriver.screenshot(), - matchesGoldenFile('$goldenPrefix.complex_clippath.png'), + matchesGoldenFile('$goldenPrefix.no_path_clipping.png'), ); }, timeout: Timeout.none); test( 'should start with triangle cutoff on left, and toggle to no triangle cutoff on left', () async { + await flutterDriver.tap(find.byValueKey('clipper_button_triangle')); await expectLater( nativeDriver.screenshot(), - matchesGoldenFile('$goldenPrefix.complex_clippath.png'), + matchesGoldenFile('$goldenPrefix.only_triangle.png'), ); - await flutterDriver.tap(find.byValueKey('ToggleTriangleClipping')); + await flutterDriver.tap(find.byValueKey('clipper_button_triangle')); await expectLater( nativeDriver.screenshot(), - matchesGoldenFile('$goldenPrefix.complex_clippath_no_triangle.png'), + matchesGoldenFile('$goldenPrefix.no_path_clipping.png'), ); }, timeout: Timeout.none, ); + + test( + 'should start with wave cutoff on bottom, and toggle to no wave cutoff on bottom', + () async { + await flutterDriver.tap(find.byValueKey('clipper_button_cubicWave')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.only_cubicWave.png'), + ); + await flutterDriver.tap(find.byValueKey('clipper_button_cubicWave')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.no_path_clipping.png'), + ); + }, + timeout: Timeout.none, + ); + + test('should start with box cutout (nonZero), and toggle to no box cutout', () async { + await flutterDriver.tap(find.byValueKey('clipper_button_overlappingNonZero')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.only_overlappingNonZero.png'), + ); + await flutterDriver.tap(find.byValueKey('clipper_button_overlappingNonZero')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.no_path_clipping.png'), + ); + }, timeout: Timeout.none); + + test('should start with box cutout (evenOdd), and toggle to no box cutout', () async { + await flutterDriver.tap(find.byValueKey('clipper_button_overlappingEvenOdd')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.only_overlappingEvenOdd.png'), + ); + await flutterDriver.tap(find.byValueKey('clipper_button_overlappingEvenOdd')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.no_path_clipping.png'), + ); + }, timeout: Timeout.none); + + test('should apply all except evenOdd box clipper', () async { + await flutterDriver.tap(find.byValueKey('clipper_button_overlappingNonZero')); + await flutterDriver.tap(find.byValueKey('clipper_button_cubicWave')); + await flutterDriver.tap(find.byValueKey('clipper_button_triangle')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.complex_clippath.png'), + ); + }, timeout: Timeout.none); } diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc index 29ed47dbcb0..cadec5fad79 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc @@ -162,7 +162,7 @@ static jmethodID g_mutators_stack_push_cliprrect_method = nullptr; static jmethodID g_mutators_stack_push_opacity_method = nullptr; static jmethodID g_mutators_stack_push_clippath_method = nullptr; -// android.graphics.Path class and methods +// android.graphics.Path class, methods, and nested classes. static fml::jni::ScopedJavaGlobalRef* path_class = nullptr; static jmethodID path_constructor = nullptr; static jmethodID path_move_to_method = nullptr; @@ -171,6 +171,11 @@ static jmethodID path_quad_to_method = nullptr; static jmethodID path_cubic_to_method = nullptr; static jmethodID path_conic_to_method = nullptr; static jmethodID path_close_method = nullptr; +static jmethodID path_set_fill_type_method = nullptr; + +static fml::jni::ScopedJavaGlobalRef* g_path_fill_type_class = nullptr; +static jfieldID g_path_fill_type_winding_field = nullptr; +static jfieldID g_path_fill_type_even_odd_field = nullptr; // Called By Java static jlong AttachJNI(JNIEnv* env, jclass clazz, jobject flutterJNI) { @@ -1234,6 +1239,14 @@ bool PlatformViewAndroid::Register(JNIEnv* env) { return false; } + path_set_fill_type_method = env->GetMethodID( + path_class->obj(), "setFillType", "(Landroid/graphics/Path$FillType;)V"); + if (path_set_fill_type_method == nullptr) { + FML_LOG(ERROR) + << "Could not locate android.graphics.Path.setFillType method"; + return false; + } + path_move_to_method = env->GetMethodID(path_class->obj(), "moveTo", "(FF)V"); if (path_move_to_method == nullptr) { FML_LOG(ERROR) << "Could not locate android.graphics.Path.moveTo method"; @@ -1271,6 +1284,29 @@ bool PlatformViewAndroid::Register(JNIEnv* env) { return false; } + g_path_fill_type_class = new fml::jni::ScopedJavaGlobalRef( + env, env->FindClass("android/graphics/Path$FillType")); + if (g_path_fill_type_class->is_null()) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path$FillType class"; + return false; + } + + g_path_fill_type_winding_field = + env->GetStaticFieldID(g_path_fill_type_class->obj(), "WINDING", + "Landroid/graphics/Path$FillType;"); + if (g_path_fill_type_winding_field == nullptr) { + FML_LOG(ERROR) << "Could not locate Path.FillType.WINDING field"; + return false; + } + + g_path_fill_type_even_odd_field = + env->GetStaticFieldID(g_path_fill_type_class->obj(), "EVEN_ODD", + "Landroid/graphics/Path$FillType;"); + if (g_path_fill_type_even_odd_field == nullptr) { + FML_LOG(ERROR) << "Could not locate Path.FillType.EVEN_ODD field"; + return false; + } + return RegisterApi(env); } @@ -2037,9 +2073,33 @@ class AndroidPathReceiver final : public DlPathReceiver { android_path_(env->NewObject(path_class->obj(), path_constructor)) {} void SetPathInfo(DlPathFillType type, bool is_convex) override { - // Need to convert the fill type to the Android enum and - // call setFillType on the path... - // see https://github.com/flutter/flutter/issues/164808 + jfieldID fill_type_field_id; + switch (type) { + case DlPathFillType::kOdd: + fill_type_field_id = g_path_fill_type_even_odd_field; + break; + case DlPathFillType::kNonZero: + fill_type_field_id = g_path_fill_type_winding_field; + break; + default: + // DlPathFillType does not have corresponding kInverseEvenOdd or + // kInverseWinding fill types. + return; + } + + // Get the static enum field value (Path.FillType.WINDING or + // Path.FillType.EVEN_ODD) + fml::jni::ScopedJavaLocalRef fill_type_enum = + fml::jni::ScopedJavaLocalRef( + env_, env_->GetStaticObjectField(g_path_fill_type_class->obj(), + fill_type_field_id)); + FML_CHECK(fml::jni::CheckException(env_)); + FML_CHECK(!fill_type_enum.is_null()); + + // Call Path.setFillType(Path.FillType) + env_->CallVoidMethod(android_path_, path_set_fill_type_method, + fill_type_enum.obj()); + FML_CHECK(fml::jni::CheckException(env_)); } void MoveTo(const DlPoint& p2) override { env_->CallVoidMethod(android_path_, path_move_to_method, p2.x, p2.y);