From 130147587329be6088f88c3cb607cf0a132f3d35 Mon Sep 17 00:00:00 2001 From: Gray Mackall <34871572+gmackall@users.noreply.github.com> Date: Tue, 4 Mar 2025 22:45:21 -0800 Subject: [PATCH] Implement `clipPath` Mutator for hcpp (#164525) Implements `clipPath` mutator for hcpp. Fixes https://github.com/flutter/flutter/issues/164219 https://github.com/user-attachments/assets/2c98e621-c73e-40ca-bc76-77de1a3826a0 ## 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]. [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 --- .../lib/hcpp/platform_view_clippath_main.dart | 174 ++++++++++++++++++ .../platform_view_clippath_main_test.dart | 77 ++++++++ .../mutatorsstack/FlutterMutatorsStack.java | 12 ++ .../android/platform_view_android_jni_impl.cc | 127 ++++++++++++- 4 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart create mode 100644 dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart 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 new file mode 100644 index 0000000000..cd66e1adee --- /dev/null +++ b/dev/integration_tests/android_engine_test/lib/hcpp/platform_view_clippath_main.dart @@ -0,0 +1,174 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:android_driver_extensions/extension.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_driver/driver_extension.dart'; + +import '../src/allow_list_devices.dart'; + +void main() async { + ensureAndroidDevice(); + enableFlutterDriverExtension( + handler: (String? command) async { + return json.encode({ + 'supported': await HybridAndroidViewController.checkIfSupported(), + }); + }, + commands: [nativeDriverCommands], + ); + + // Run on full screen. + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + runApp(const _ComplicatedClipPathWrappedMainApp()); +} + +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; + } + }); + } + + @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', + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// Clips to show the top half of the screen, with a cubic wave as the dividing +// line. +class CubicWaveClipper extends CustomClipper { + @override + Path getClip(Size size) { + final Path path = Path(); + // Closer to 1 moves the wave lower, closer to 0 moves it higher. + final double waveHeight = size.height * 0.65; + + path.lineTo(0, waveHeight); + + path.cubicTo( + size.width * 0.25, + waveHeight * 0.8, + size.width * 0.75, + waveHeight * 1.2, + size.width, + waveHeight, + ); + + path.lineTo(size.width, 0); + path.lineTo(0, 0); + + path.close(); + return path; + } + + @override + bool shouldReclip(CustomClipper oldClipper) { + return false; + } +} + +// Clips a triangle off the top left of the screen. +class TriangleClipper extends CustomClipper { + @override + Path getClip(Size size) { + final Path path = Path(); + path.lineTo(0, size.height); + path.lineTo(size.width, size.height); + path.lineTo(size.width, 0); + path.lineTo(size.width / 2, 0); + path.lineTo(0, size.height / 2); + path.lineTo(0, size.height); + path.close(); + return path; + } + + @override + bool shouldReclip(CustomClipper oldClipper) { + return false; + } +} + +final class _HybridCompositionAndroidPlatformView extends StatelessWidget { + const _HybridCompositionAndroidPlatformView({required this.viewType}); + + final String viewType; + + @override + Widget build(BuildContext context) { + return PlatformViewLink( + viewType: viewType, + surfaceFactory: (BuildContext context, PlatformViewController controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + return PlatformViewsService.initHybridAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + }, + ); + } +} 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 new file mode 100644 index 0000000000..84b90a61d8 --- /dev/null +++ b/dev/integration_tests/android_engine_test/test_driver/hcpp/platform_view_clippath_main_test.dart @@ -0,0 +1,77 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:android_driver_extensions/native_driver.dart'; +import 'package:android_driver_extensions/skia_gold.dart'; +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +import '../_luci_skia_gold_prelude.dart'; + +/// For local debugging, a (local) golden-file is required as a baseline: +/// +/// ```sh +/// # Checkout HEAD, i.e. *before* changes you want to test. +/// UPDATE_GOLDENS=1 flutter drive lib/hcpp/platform_view_clippath_main.dart +/// +/// # Make your changes. +/// +/// # Run the test against baseline. +/// flutter drive lib/hcpp/platform_view_clippath_main.dart +/// ``` +/// +/// For a convenient way to deflake a test, see `tool/deflake.dart`. +void main() async { + const String goldenPrefix = 'hybrid_composition_pp_platform_view'; + + late final FlutterDriver flutterDriver; + late final NativeDriver nativeDriver; + + setUpAll(() async { + if (isLuci) { + await enableSkiaGoldComparator(namePrefix: 'android_engine_test$goldenVariant'); + } + flutterDriver = await FlutterDriver.connect(); + nativeDriver = await AndroidNativeDriver.connect(flutterDriver); + await nativeDriver.configureForScreenshotTesting(); + await flutterDriver.waitUntilFirstFrameRasterized(); + }); + + tearDownAll(() async { + await nativeDriver.close(); + await flutterDriver.close(); + }); + + test('verify that HCPP is supported and enabled', () async { + final Map response = + json.decode(await flutterDriver.requestData('')) as Map; + + expect(response['supported'], true); + }, timeout: Timeout.none); + + test('should screenshot a platform view with specified clippath', () async { + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.complex_clippath.png'), + ); + }, timeout: Timeout.none); + + test( + 'should start with triangle cutoff on left, and toggle to no triangle cutoff on left', + () async { + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.complex_clippath.png'), + ); + await flutterDriver.tap(find.byValueKey('ToggleTriangleClipping')); + await expectLater( + nativeDriver.screenshot(), + matchesGoldenFile('$goldenPrefix.complex_clippath_no_triangle.png'), + ); + }, + timeout: Timeout.none, + ); +} diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorsStack.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorsStack.java index 8013b78634..419a4ea89a 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorsStack.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorsStack.java @@ -220,6 +220,18 @@ public class FlutterMutatorsStack { finalOpacity *= opacity; } + /** + * Push a clipPath {@link FlutterMutatorsStack.FlutterMutator} to the stack. + * + * @param path the path to be clipped. + */ + public void pushClipPath(Path path) { + FlutterMutator mutator = new FlutterMutator(path); + mutators.add(mutator); + path.transform(finalMatrix); + finalClippingPaths.add(path); + } + /** * Get a list of all the raw mutators. The 0 index of the returned list is the top of the stack. */ 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 9926fb00d7..db785e3fe1 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 @@ -14,6 +14,7 @@ #include "unicode/uchar.h" #include "flutter/common/constants.h" +#include "flutter/flow/embedded_views.h" #include "flutter/fml/mapping.h" #include "flutter/fml/native_library.h" #include "flutter/fml/platform/android/jni_util.h" @@ -159,6 +160,17 @@ static jmethodID g_mutators_stack_push_transform_method = nullptr; static jmethodID g_mutators_stack_push_cliprect_method = nullptr; 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 +static fml::jni::ScopedJavaGlobalRef* path_class = nullptr; +static jmethodID path_constructor = nullptr; +static jmethodID path_move_to_method = nullptr; +static jmethodID path_line_to_method = nullptr; +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; // Called By Java static jlong AttachJNI(JNIEnv* env, jclass clazz, jobject flutterJNI) { @@ -1047,6 +1059,15 @@ bool PlatformViewAndroid::Register(JNIEnv* env) { return false; } + g_mutators_stack_push_clippath_method = + env->GetMethodID(g_mutators_stack_class->obj(), "pushClipPath", + "(Landroid/graphics/Path;)V"); + if (g_mutators_stack_push_clippath_method == nullptr) { + FML_LOG(ERROR) + << "Could not locate FlutterMutatorsStack.pushClipPath method"; + return false; + } + g_java_weak_reference_class = new fml::jni::ScopedJavaGlobalRef( env, env->FindClass("java/lang/ref/WeakReference")); if (g_java_weak_reference_class->is_null()) { @@ -1193,6 +1214,57 @@ bool PlatformViewAndroid::Register(JNIEnv* env) { return false; } + // Android path class and methods. + path_class = new fml::jni::ScopedJavaGlobalRef( + env, env->FindClass("android/graphics/Path")); + if (path_class->is_null()) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path class"; + return false; + } + + path_constructor = env->GetMethodID(path_class->obj(), "", "()V"); + if (path_constructor == nullptr) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path constructor"; + 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"; + return false; + } + path_line_to_method = env->GetMethodID(path_class->obj(), "lineTo", "(FF)V"); + if (path_line_to_method == nullptr) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path.lineTo method"; + return false; + } + path_quad_to_method = + env->GetMethodID(path_class->obj(), "quadTo", "(FFFF)V"); + if (path_quad_to_method == nullptr) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path.quadTo method"; + return false; + } + path_cubic_to_method = + env->GetMethodID(path_class->obj(), "cubicTo", "(FFFFFF)V"); + if (path_cubic_to_method == nullptr) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path.cubicTo method"; + return false; + } + // Ensure we don't have any pending exceptions. + FML_CHECK(fml::jni::CheckException(env)); + + path_conic_to_method = + env->GetMethodID(path_class->obj(), "conicTo", "(FFFFF)V"); + if (path_conic_to_method == nullptr) { + // Continue on as this method may not exist at API <= 34. + fml::jni::ClearException(env, true); + } + path_close_method = env->GetMethodID(path_class->obj(), "close", "()V"); + if (path_close_method == nullptr) { + FML_LOG(ERROR) << "Could not locate android.graphics.Path.close method"; + return false; + } + return RegisterApi(env); } @@ -1995,7 +2067,60 @@ void PlatformViewAndroidJNIImpl::onDisplayPlatformView2( } // TODO(cyanglaz): Implement other mutators. // https://github.com/flutter/flutter/issues/58426 - case kClipPath: + case kClipPath: { + const SkPath& path = (*iter)->GetPath(); + + // Define and populate an Android Path with data from the Skia SkPath + jobject androidPath = + env->NewObject(path_class->obj(), path_constructor); + + SkPath::Iter pathIter(path, false); + SkPoint points[4]; + SkPath::Verb verb; + + while ((verb = pathIter.next(points)) != SkPath::kDone_Verb) { + switch (verb) { + case SkPath::kMove_Verb: { + env->CallVoidMethod(androidPath, path_move_to_method, + points[0].fX, points[0].fY); + break; + } + case SkPath::kLine_Verb: { + env->CallVoidMethod(androidPath, path_line_to_method, + points[1].fX, points[1].fY); + break; + } + case SkPath::kQuad_Verb: { + env->CallVoidMethod(androidPath, path_quad_to_method, + points[1].fX, points[1].fY, points[2].fX, + points[2].fY); + break; + } + case SkPath::kCubic_Verb: { + env->CallVoidMethod(androidPath, path_cubic_to_method, + points[1].fX, points[1].fY, points[2].fX, + points[2].fY, points[3].fX, points[3].fY); + break; + } + case SkPath::kConic_Verb: { + FML_DCHECK(path_conic_to_method != nullptr); + env->CallVoidMethod(androidPath, path_conic_to_method, + points[1].fX, points[1].fY, points[2].fX, + points[2].fY, pathIter.conicWeight()); + break; + } + + case SkPath::kClose_Verb: { + env->CallVoidMethod(androidPath, path_close_method); + break; + } + default: + break; + } + } + env->CallVoidMethod(mutatorsStack, + g_mutators_stack_push_clippath_method, androidPath); + } case kBackdropFilter: break; }