[HCPP] Add filltype support for clipPath mutator (#167571)

Fixes https://github.com/flutter/flutter/issues/164808.

Also overhauls the test app so that we can apply clippers independently
of each other.

Looks like (with a weirdly small box)

<img
src="https://github.com/user-attachments/assets/b290ea2e-79b8-46cd-93c2-5fe6024ba5d2"
width="200" />

## 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].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Gray Mackall <mackall@google.com>
This commit is contained in:
Gray Mackall 2025-04-24 13:16:04 -07:00 committed by GitHub
parent 09d4dabd6d
commit ce51065f81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 274 additions and 58 deletions

View File

@ -14,6 +14,10 @@ import 'package:flutter_driver/driver_extension.dart';
import '../src/allow_list_devices.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 { void main() async {
ensureAndroidDevice(); ensureAndroidDevice();
enableFlutterDriverExtension( enableFlutterDriverExtension(
@ -27,53 +31,86 @@ void main() async {
// Run on full screen. // Run on full screen.
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
runApp(const _ComplicatedClipPathWrappedMainApp()); runApp(const ClipperToggleApp());
} }
final class _ComplicatedClipPathWrappedMainApp extends StatefulWidget { class ClipperToggleApp extends StatelessWidget {
const _ComplicatedClipPathWrappedMainApp(); const ClipperToggleApp({super.key});
@override
State<_ComplicatedClipPathWrappedMainApp> createState() {
return _ComplicatedClipPathWrappedMainAppState();
}
}
class _ComplicatedClipPathWrappedMainAppState extends State<_ComplicatedClipPathWrappedMainApp> {
final CustomClipper<Path> _triangleClipper = TriangleClipper();
CustomClipper<Path>? _triangleOrEmpty = TriangleClipper();
void _toggleTriangleClipper() {
setState(() {
if (_triangleOrEmpty == null) {
_triangleOrEmpty = _triangleClipper;
} else {
_triangleOrEmpty = null;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
home: ClipPath( theme: ThemeData(elevatedButtonTheme: const ElevatedButtonThemeData()),
clipper: _triangleOrEmpty, home: const ClipperHomePage(),
child: ClipPath( );
clipper: CubicWaveClipper(), }
child: ClipOval( }
class ClipperHomePage extends StatefulWidget {
const ClipperHomePage({super.key});
@override
State<ClipperHomePage> createState() => _ClipperHomePageState();
}
class _ClipperHomePageState extends State<ClipperHomePage> {
// State map to track which clippers are active
// Initialize all clippers to off (false)
final Map<ClipperType, bool> _clipperStates = <ClipperType, bool>{
for (ClipperType type in ClipperType.values) type: false,
};
// Instantiate clippers (keep these readily available)
final Map<ClipperType, CustomClipper<Path>> _clippers = <ClipperType, CustomClipper<Path>>{
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<ClipperType> _clipperNestingOrder = <ClipperType>[
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( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: <Widget>[ children: <Widget>[
TextButton( // Background
key: const ValueKey<String>('ToggleTriangleClipping'), SizedBox(width: 500, height: 500, child: ColoredBox(color: Colors.green)),
onPressed: _toggleTriangleClipper, SizedBox(
child: const SizedBox(
width: 500,
height: 500,
child: ColoredBox(color: Colors.green),
),
),
const SizedBox(
width: 400, width: 400,
height: 400, height: 400,
child: _HybridCompositionAndroidPlatformView( child: _HybridCompositionAndroidPlatformView(
@ -82,8 +119,34 @@ class _ComplicatedClipPathWrappedMainAppState extends State<_ComplicatedClipPath
), ),
], ],
), ),
);
return Scaffold(
body: Column(
children: <Widget>[
// 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<String>('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<Path> {
} }
} }
// Clips based on two overlapping rectangles.
class OverlappingRectClipper extends CustomClipper<Path> {
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 { final class _HybridCompositionAndroidPlatformView extends StatelessWidget {
const _HybridCompositionAndroidPlatformView({required this.viewType}); const _HybridCompositionAndroidPlatformView({required this.viewType});
@ -160,6 +261,7 @@ final class _HybridCompositionAndroidPlatformView extends StatelessWidget {
); );
}, },
onCreatePlatformView: (PlatformViewCreationParams params) { onCreatePlatformView: (PlatformViewCreationParams params) {
// Use initHybridAndroidView for Hybrid Composition
return PlatformViewsService.initHybridAndroidView( return PlatformViewsService.initHybridAndroidView(
id: params.id, id: params.id,
viewType: viewType, viewType: viewType,

View File

@ -52,26 +52,80 @@ void main() async {
expect(response['supported'], true); expect(response['supported'], true);
}, timeout: Timeout.none); }, 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( await expectLater(
nativeDriver.screenshot(), nativeDriver.screenshot(),
matchesGoldenFile('$goldenPrefix.complex_clippath.png'), matchesGoldenFile('$goldenPrefix.no_path_clipping.png'),
); );
}, timeout: Timeout.none); }, timeout: Timeout.none);
test( test(
'should start with triangle cutoff on left, and toggle to no triangle cutoff on left', 'should start with triangle cutoff on left, and toggle to no triangle cutoff on left',
() async { () async {
await flutterDriver.tap(find.byValueKey('clipper_button_triangle'));
await expectLater( await expectLater(
nativeDriver.screenshot(), 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( await expectLater(
nativeDriver.screenshot(), nativeDriver.screenshot(),
matchesGoldenFile('$goldenPrefix.complex_clippath_no_triangle.png'), matchesGoldenFile('$goldenPrefix.no_path_clipping.png'),
); );
}, },
timeout: Timeout.none, 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);
} }

View File

@ -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_opacity_method = nullptr;
static jmethodID g_mutators_stack_push_clippath_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<jclass>* path_class = nullptr; static fml::jni::ScopedJavaGlobalRef<jclass>* path_class = nullptr;
static jmethodID path_constructor = nullptr; static jmethodID path_constructor = nullptr;
static jmethodID path_move_to_method = 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_cubic_to_method = nullptr;
static jmethodID path_conic_to_method = nullptr; static jmethodID path_conic_to_method = nullptr;
static jmethodID path_close_method = nullptr; static jmethodID path_close_method = nullptr;
static jmethodID path_set_fill_type_method = nullptr;
static fml::jni::ScopedJavaGlobalRef<jclass>* 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 // Called By Java
static jlong AttachJNI(JNIEnv* env, jclass clazz, jobject flutterJNI) { static jlong AttachJNI(JNIEnv* env, jclass clazz, jobject flutterJNI) {
@ -1234,6 +1239,14 @@ bool PlatformViewAndroid::Register(JNIEnv* env) {
return false; 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"); path_move_to_method = env->GetMethodID(path_class->obj(), "moveTo", "(FF)V");
if (path_move_to_method == nullptr) { if (path_move_to_method == nullptr) {
FML_LOG(ERROR) << "Could not locate android.graphics.Path.moveTo method"; FML_LOG(ERROR) << "Could not locate android.graphics.Path.moveTo method";
@ -1271,6 +1284,29 @@ bool PlatformViewAndroid::Register(JNIEnv* env) {
return false; return false;
} }
g_path_fill_type_class = new fml::jni::ScopedJavaGlobalRef<jclass>(
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); return RegisterApi(env);
} }
@ -2037,9 +2073,33 @@ class AndroidPathReceiver final : public DlPathReceiver {
android_path_(env->NewObject(path_class->obj(), path_constructor)) {} android_path_(env->NewObject(path_class->obj(), path_constructor)) {}
void SetPathInfo(DlPathFillType type, bool is_convex) override { void SetPathInfo(DlPathFillType type, bool is_convex) override {
// Need to convert the fill type to the Android enum and jfieldID fill_type_field_id;
// call setFillType on the path... switch (type) {
// see https://github.com/flutter/flutter/issues/164808 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<jobject> fill_type_enum =
fml::jni::ScopedJavaLocalRef<jobject>(
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 { void MoveTo(const DlPoint& p2) override {
env_->CallVoidMethod(android_path_, path_move_to_method, p2.x, p2.y); env_->CallVoidMethod(android_path_, path_move_to_method, p2.x, p2.y);