diff --git a/DEPS b/DEPS index dc297a638b494..09e6e358b99d2 100644 --- a/DEPS +++ b/DEPS @@ -34,7 +34,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/master/DEPS. # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': '6eb17654b6501e2617c67854ed113ab550d2b3c7', + 'dart_revision': 'e940ff7819053ed8a4c04a4dfcda7df12e969331', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm index abf854f15731b..e49e0fa78cdb5 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm @@ -167,7 +167,11 @@ touch_interceptors_[viewId] = fml::scoped_nsobject([touch_interceptor retain]); - root_views_[viewId] = fml::scoped_nsobject([touch_interceptor retain]); + + ChildClippingView* clipping_view = + [[[ChildClippingView alloc] initWithFrame:CGRectZero] autorelease]; + [clipping_view addSubview:touch_interceptor]; + root_views_[viewId] = fml::scoped_nsobject([clipping_view retain]); result(nil); } @@ -317,83 +321,60 @@ return clipCount; } -UIView* FlutterPlatformViewsController::ReconstructClipViewsChain(int number_of_clips, - UIView* platform_view, - UIView* head_clip_view) { - NSInteger indexInFlutterView = -1; - if (head_clip_view.superview) { - // TODO(cyanglaz): potentially cache the index of oldPlatformViewRoot to make this a O(1). - // https://github.com/flutter/flutter/issues/35023 - indexInFlutterView = [flutter_view_.get().subviews indexOfObject:head_clip_view]; - [head_clip_view removeFromSuperview]; - } - UIView* head = platform_view; - int clipIndex = 0; - // Re-use as much existing clip views as needed. - while (head != head_clip_view && clipIndex < number_of_clips) { - head = head.superview; - clipIndex++; - } - // If there were not enough existing clip views, add more. - while (clipIndex < number_of_clips) { - ChildClippingView* clippingView = - [[[ChildClippingView alloc] initWithFrame:flutter_view_.get().bounds] autorelease]; - [clippingView addSubview:head]; - head = clippingView; - clipIndex++; - } - [head removeFromSuperview]; - - if (indexInFlutterView > -1) { - // The chain was previously attached; attach it to the same position. - [flutter_view_.get() insertSubview:head atIndex:indexInFlutterView]; - } - return head; -} - void FlutterPlatformViewsController::ApplyMutators(const MutatorsStack& mutators_stack, UIView* embedded_view) { FML_DCHECK(CATransform3DEqualToTransform(embedded_view.layer.transform, CATransform3DIdentity)); - UIView* head = embedded_view; - ResetAnchor(head.layer); + ResetAnchor(embedded_view.layer); + ChildClippingView* clipView = (ChildClippingView*)embedded_view.superview; - std::vector>::const_reverse_iterator iter = mutators_stack.Bottom(); - while (iter != mutators_stack.Top()) { + // The UIKit frame is set based on the logical resolution instead of physical. + // (https://developer.apple.com/library/archive/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/Displays/Displays.html). + // However, flow is based on the physical resolution. For example, 1000 pixels in flow equals + // 500 points in UIKit. And until this point, we did all the calculation based on the flow + // resolution. So we need to scale down to match UIKit's logical resolution. + CGFloat screenScale = [UIScreen mainScreen].scale; + CATransform3D finalTransform = CATransform3DMakeScale(1 / screenScale, 1 / screenScale, 1); + + // Mask view needs to be full screen because we might draw platform view pixels outside of the + // `ChildClippingView`. Since the mask view's frame will be based on the `clipView`'s coordinate + // system, we need to convert the flutter_view's frame to the clipView's coordinate system. The + // mask view is not displayed on the screen. + CGRect maskViewFrame = [flutter_view_ convertRect:flutter_view_.get().frame toView:clipView]; + FlutterClippingMaskView* maskView = + [[[FlutterClippingMaskView alloc] initWithFrame:maskViewFrame] autorelease]; + auto iter = mutators_stack.Begin(); + while (iter != mutators_stack.End()) { switch ((*iter)->GetType()) { case transform: { CATransform3D transform = GetCATransform3DFromSkMatrix((*iter)->GetMatrix()); - head.layer.transform = CATransform3DConcat(head.layer.transform, transform); + finalTransform = CATransform3DConcat(transform, finalTransform); break; } case clip_rect: + [maskView clipRect:(*iter)->GetRect() matrix:finalTransform]; + break; case clip_rrect: - case clip_path: { - ChildClippingView* clipView = (ChildClippingView*)head.superview; - clipView.layer.transform = CATransform3DIdentity; - [clipView setClip:(*iter)->GetType() - rect:(*iter)->GetRect() - rrect:(*iter)->GetRRect() - path:(*iter)->GetPath()]; - ResetAnchor(clipView.layer); - head = clipView; + [maskView clipRRect:(*iter)->GetRRect() matrix:finalTransform]; + break; + case clip_path: + [maskView clipPath:(*iter)->GetPath() matrix:finalTransform]; break; - } case opacity: embedded_view.alpha = (*iter)->GetAlphaFloat() * embedded_view.alpha; break; } ++iter; } - // Reverse scale based on screen scale. + // Reverse the offset of the clipView. + // The clipView's frame includes the final translate of the final transform matrix. + // So we need to revese this translate so the platform view can layout at the correct offset. // - // The UIKit frame is set based on the logical resolution instead of physical. - // (https://developer.apple.com/library/archive/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/Displays/Displays.html). - // However, flow is based on the physical resolution. For example, 1000 pixels in flow equals - // 500 points in UIKit. And until this point, we did all the calculation based on the flow - // resolution. So we need to scale down to match UIKit's logical resolution. - CGFloat screenScale = [UIScreen mainScreen].scale; - head.layer.transform = CATransform3DConcat( - head.layer.transform, CATransform3DMakeScale(1 / screenScale, 1 / screenScale, 1)); + // Note that we don't apply this transform matrix the clippings because clippings happen on the + // mask view, whose origin is alwasy (0,0) to the flutter_view. + CATransform3D reverseTranslate = + CATransform3DMakeTranslation(-clipView.frame.origin.x, -clipView.frame.origin.y, 0); + embedded_view.layer.transform = CATransform3DConcat(finalTransform, reverseTranslate); + clipView.maskView = maskView; } void FlutterPlatformViewsController::CompositeWithParams(int view_id, @@ -406,17 +387,15 @@ touchInterceptor.alpha = 1; const MutatorsStack& mutatorStack = params.mutatorsStack(); - int currentClippingCount = CountClips(mutatorStack); - int previousClippingCount = clip_count_[view_id]; - if (currentClippingCount != previousClippingCount) { - clip_count_[view_id] = currentClippingCount; - // If we have a different clipping count in this frame, we need to reconstruct the - // ClippingChildView chain to prepare for `ApplyMutators`. - UIView* oldPlatformViewRoot = root_views_[view_id].get(); - UIView* newPlatformViewRoot = - ReconstructClipViewsChain(currentClippingCount, touchInterceptor, oldPlatformViewRoot); - root_views_[view_id] = fml::scoped_nsobject([newPlatformViewRoot retain]); - } + UIView* clippingView = root_views_[view_id].get(); + // The frame of the clipping view should be the final bounding rect. + // Because the translate matrix in the Mutator Stack also includes the offset, + // when we apply the transforms matrix in |ApplyMutators|, we need + // to remember to do a reverse translate. + const SkRect& rect = params.finalBoundingRect(); + CGFloat screenScale = [UIScreen mainScreen].scale; + clippingView.frame = CGRectMake(rect.x() / screenScale, rect.y() / screenScale, + rect.width() / screenScale, rect.height() / screenScale); ApplyMutators(mutatorStack, touchInterceptor); } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index e2a0088dfc96b..0e8397e1146b4 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -14,6 +14,7 @@ FLUTTER_ASSERT_NOT_ARC @class FlutterPlatformViewsTestMockPlatformView; static FlutterPlatformViewsTestMockPlatformView* gMockPlatformView = nil; +const float kFloatCompareEpsilon = 0.001; @interface FlutterPlatformViewsTestMockPlatformView : UIView @end @@ -143,4 +144,385 @@ - (void)testCanCreatePlatformViewWithoutFlutterView { flutterPlatformViewsController->Reset(); } +- (void)testChildClippingViewHitTests { + ChildClippingView* childClippingView = + [[[ChildClippingView alloc] initWithFrame:CGRectMake(0, 0, 500, 500)] autorelease]; + UIView* childView = [[[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)] autorelease]; + [childClippingView addSubview:childView]; + + XCTAssertFalse([childClippingView pointInside:CGPointMake(50, 50) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(99, 100) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(100, 99) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(201, 200) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(200, 201) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(99, 200) withEvent:nil]); + XCTAssertFalse([childClippingView pointInside:CGPointMake(200, 299) withEvent:nil]); + + XCTAssertTrue([childClippingView pointInside:CGPointMake(150, 150) withEvent:nil]); + XCTAssertTrue([childClippingView pointInside:CGPointMake(100, 100) withEvent:nil]); + XCTAssertTrue([childClippingView pointInside:CGPointMake(199, 100) withEvent:nil]); + XCTAssertTrue([childClippingView pointInside:CGPointMake(100, 199) withEvent:nil]); + XCTAssertTrue([childClippingView pointInside:CGPointMake(199, 199) withEvent:nil]); +} + +- (void)testCompositePlatformView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("FlutterPlatformViewsTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*task_runners=*/runners); + + auto flutterPlatformViewsController = std::make_unique(); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory new] autorelease]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + UIView* mockFlutterView = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 500, 500)] autorelease]; + flutterPlatformViewsController->SetFlutterView(mockFlutterView); + // Create embedded view params + flutter::MutatorsStack stack; + // Layer tree always pushes a screen scale factor to the stack + SkMatrix screenScaleMatrix = + SkMatrix::MakeScale([UIScreen mainScreen].scale, [UIScreen mainScreen].scale); + stack.PushTransform(screenScaleMatrix); + // Push a translate matrix + SkMatrix translateMatrix = SkMatrix::MakeTrans(100, 100); + stack.PushTransform(translateMatrix); + SkMatrix finalMatrix; + finalMatrix.setConcat(screenScaleMatrix, translateMatrix); + + auto embeddedViewParams = + std::make_unique(finalMatrix, SkSize::Make(300, 300), stack); + + flutterPlatformViewsController->PrerollCompositeEmbeddedView(2, std::move(embeddedViewParams)); + flutterPlatformViewsController->CompositeEmbeddedView(2); + CGRect platformViewRectInFlutterView = [gMockPlatformView convertRect:gMockPlatformView.bounds + toView:mockFlutterView]; + XCTAssertTrue(CGRectEqualToRect(platformViewRectInFlutterView, CGRectMake(100, 100, 300, 300))); + flutterPlatformViewsController->Reset(); +} + +- (void)testChildClippingViewShouldBeTheBoundingRectOfPlatformView { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("FlutterPlatformViewsTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*task_runners=*/runners); + + auto flutterPlatformViewsController = std::make_unique(); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory new] autorelease]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + UIView* mockFlutterView = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 500, 500)] autorelease]; + flutterPlatformViewsController->SetFlutterView(mockFlutterView); + // Create embedded view params + flutter::MutatorsStack stack; + // Layer tree always pushes a screen scale factor to the stack + SkMatrix screenScaleMatrix = + SkMatrix::MakeScale([UIScreen mainScreen].scale, [UIScreen mainScreen].scale); + stack.PushTransform(screenScaleMatrix); + // Push a rotate matrix + SkMatrix rotateMatrix; + rotateMatrix.setRotate(10); + stack.PushTransform(rotateMatrix); + SkMatrix finalMatrix; + finalMatrix.setConcat(screenScaleMatrix, rotateMatrix); + + auto embeddedViewParams = + std::make_unique(finalMatrix, SkSize::Make(300, 300), stack); + + flutterPlatformViewsController->PrerollCompositeEmbeddedView(2, std::move(embeddedViewParams)); + flutterPlatformViewsController->CompositeEmbeddedView(2); + CGRect platformViewRectInFlutterView = [gMockPlatformView convertRect:gMockPlatformView.bounds + toView:mockFlutterView]; + XCTAssertTrue([gMockPlatformView.superview.superview isKindOfClass:ChildClippingView.class]); + ChildClippingView* childClippingView = (ChildClippingView*)gMockPlatformView.superview.superview; + // The childclippingview's frame is set based on flow, but the platform view's frame is set based + // on quartz. Although they should be the same, but we should tolerate small floating point + // errors. + XCTAssertLessThan(fabs(platformViewRectInFlutterView.origin.x - childClippingView.frame.origin.x), + kFloatCompareEpsilon); + XCTAssertLessThan(fabs(platformViewRectInFlutterView.origin.y - childClippingView.frame.origin.y), + kFloatCompareEpsilon); + XCTAssertLessThan( + fabs(platformViewRectInFlutterView.size.width - childClippingView.frame.size.width), + kFloatCompareEpsilon); + XCTAssertLessThan( + fabs(platformViewRectInFlutterView.size.height - childClippingView.frame.size.height), + kFloatCompareEpsilon); + + flutterPlatformViewsController->Reset(); +} + +- (void)testClipRect { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("FlutterPlatformViewsTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*task_runners=*/runners); + + auto flutterPlatformViewsController = std::make_unique(); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory new] autorelease]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + UIView* mockFlutterView = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)] autorelease]; + flutterPlatformViewsController->SetFlutterView(mockFlutterView); + // Create embedded view params + flutter::MutatorsStack stack; + // Layer tree always pushes a screen scale factor to the stack + SkMatrix screenScaleMatrix = + SkMatrix::MakeScale([UIScreen mainScreen].scale, [UIScreen mainScreen].scale); + stack.PushTransform(screenScaleMatrix); + // Push a clip rect + SkRect rect = SkRect::MakeXYWH(2, 2, 3, 3); + stack.PushClipRect(rect); + + auto embeddedViewParams = + std::make_unique(screenScaleMatrix, SkSize::Make(10, 10), stack); + + flutterPlatformViewsController->PrerollCompositeEmbeddedView(2, std::move(embeddedViewParams)); + flutterPlatformViewsController->CompositeEmbeddedView(2); + gMockPlatformView.backgroundColor = UIColor.redColor; + XCTAssertTrue([gMockPlatformView.superview.superview isKindOfClass:ChildClippingView.class]); + ChildClippingView* childClippingView = (ChildClippingView*)gMockPlatformView.superview.superview; + [mockFlutterView addSubview:childClippingView]; + + [mockFlutterView setNeedsLayout]; + [mockFlutterView layoutIfNeeded]; + + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + CGPoint point = CGPointMake(i, j); + int alpha = [self alphaOfPoint:CGPointMake(i, j) onView:mockFlutterView]; + // Edges of the clipping might have a semi transparent pixel, we only check the pixels that + // are fully inside the clipped area. + CGRect insideClipping = CGRectMake(3, 3, 1, 1); + if (CGRectContainsPoint(insideClipping, point)) { + XCTAssertEqual(alpha, 255); + } else { + XCTAssertLessThan(alpha, 255); + } + } + } + flutterPlatformViewsController->Reset(); +} + +- (void)testClipRRect { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("FlutterPlatformViewsTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*task_runners=*/runners); + + auto flutterPlatformViewsController = std::make_unique(); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory new] autorelease]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + UIView* mockFlutterView = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)] autorelease]; + flutterPlatformViewsController->SetFlutterView(mockFlutterView); + // Create embedded view params + flutter::MutatorsStack stack; + // Layer tree always pushes a screen scale factor to the stack + SkMatrix screenScaleMatrix = + SkMatrix::MakeScale([UIScreen mainScreen].scale, [UIScreen mainScreen].scale); + stack.PushTransform(screenScaleMatrix); + // Push a clip rrect + SkRRect rrect = SkRRect::MakeRectXY(SkRect::MakeXYWH(2, 2, 6, 6), 1, 1); + stack.PushClipRRect(rrect); + + auto embeddedViewParams = + std::make_unique(screenScaleMatrix, SkSize::Make(10, 10), stack); + + flutterPlatformViewsController->PrerollCompositeEmbeddedView(2, std::move(embeddedViewParams)); + flutterPlatformViewsController->CompositeEmbeddedView(2); + gMockPlatformView.backgroundColor = UIColor.redColor; + XCTAssertTrue([gMockPlatformView.superview.superview isKindOfClass:ChildClippingView.class]); + ChildClippingView* childClippingView = (ChildClippingView*)gMockPlatformView.superview.superview; + [mockFlutterView addSubview:childClippingView]; + + [mockFlutterView setNeedsLayout]; + [mockFlutterView layoutIfNeeded]; + + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + CGPoint point = CGPointMake(i, j); + int alpha = [self alphaOfPoint:CGPointMake(i, j) onView:mockFlutterView]; + // Edges of the clipping might have a semi transparent pixel, we only check the pixels that + // are fully inside the clipped area. + CGRect insideClipping = CGRectMake(3, 3, 4, 4); + if (CGRectContainsPoint(insideClipping, point)) { + XCTAssertEqual(alpha, 255); + } else { + XCTAssertLessThan(alpha, 255); + } + } + } + flutterPlatformViewsController->Reset(); +} + +- (void)testClipPath { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + auto thread_task_runner = CreateNewThread("FlutterPlatformViewsTest"); + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/thread_task_runner, + /*raster=*/thread_task_runner, + /*ui=*/thread_task_runner, + /*io=*/thread_task_runner); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kSoftware, + /*task_runners=*/runners); + + auto flutterPlatformViewsController = std::make_unique(); + + FlutterPlatformViewsTestMockFlutterPlatformFactory* factory = + [[FlutterPlatformViewsTestMockFlutterPlatformFactory new] autorelease]; + flutterPlatformViewsController->RegisterViewFactory( + factory, @"MockFlutterPlatformView", + FlutterPlatformViewGestureRecognizersBlockingPolicyEager); + FlutterResult result = ^(id result) { + }; + flutterPlatformViewsController->OnMethodCall( + [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}], + result); + + XCTAssertNotNil(gMockPlatformView); + + UIView* mockFlutterView = [[[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)] autorelease]; + flutterPlatformViewsController->SetFlutterView(mockFlutterView); + // Create embedded view params + flutter::MutatorsStack stack; + // Layer tree always pushes a screen scale factor to the stack + SkMatrix screenScaleMatrix = + SkMatrix::MakeScale([UIScreen mainScreen].scale, [UIScreen mainScreen].scale); + stack.PushTransform(screenScaleMatrix); + // Push a clip path + SkPath path; + path.addRoundRect(SkRect::MakeXYWH(2, 2, 6, 6), 1, 1); + stack.PushClipPath(path); + + auto embeddedViewParams = + std::make_unique(screenScaleMatrix, SkSize::Make(10, 10), stack); + + flutterPlatformViewsController->PrerollCompositeEmbeddedView(2, std::move(embeddedViewParams)); + flutterPlatformViewsController->CompositeEmbeddedView(2); + gMockPlatformView.backgroundColor = UIColor.redColor; + XCTAssertTrue([gMockPlatformView.superview.superview isKindOfClass:ChildClippingView.class]); + ChildClippingView* childClippingView = (ChildClippingView*)gMockPlatformView.superview.superview; + [mockFlutterView addSubview:childClippingView]; + + [mockFlutterView setNeedsLayout]; + [mockFlutterView layoutIfNeeded]; + + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + CGPoint point = CGPointMake(i, j); + int alpha = [self alphaOfPoint:CGPointMake(i, j) onView:mockFlutterView]; + // Edges of the clipping might have a semi transparent pixel, we only check the pixels that + // are fully inside the clipped area. + CGRect insideClipping = CGRectMake(3, 3, 4, 4); + if (CGRectContainsPoint(insideClipping, point)) { + XCTAssertEqual(alpha, 255); + } else { + XCTAssertLessThan(alpha, 255); + } + } + } + flutterPlatformViewsController->Reset(); +} + +- (int)alphaOfPoint:(CGPoint)point onView:(UIView*)view { + unsigned char pixel[4] = {0}; + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + + // Draw the pixel on `point` in the context. + CGContextRef context = CGBitmapContextCreate( + pixel, 1, 1, 8, 4, colorSpace, kCGBitmapAlphaInfoMask & kCGImageAlphaPremultipliedLast); + CGContextTranslateCTM(context, -point.x, -point.y); + [view.layer renderInContext:context]; + + CGContextRelease(context); + CGColorSpaceRelease(colorSpace); + // Get the alpha from the pixel that we just rendered. + return pixel[3]; +} + @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h index 7a4724f5b0c6b..796d1e5d14bcc 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h @@ -16,6 +16,33 @@ #include "flutter/shell/platform/darwin/ios/ios_context.h" #include "third_party/skia/include/core/SkPictureRecorder.h" +// A UIView that acts as a clipping mask for the |ChildClippingView|. +// +// On the [UIView drawRect:] method, this view performs a series of clipping operations and sets the +// alpha channel to the final resulting area to be 1; it also sets the "clipped out" area's alpha +// channel to be 0. +// +// When a UIView sets a |FlutterClippingMaskView| as its `maskView`, the alpha channel of the UIView +// is replaced with the alpha channel of the |FlutterClippingMaskView|. +@interface FlutterClippingMaskView : UIView + +// Adds a clip rect operation to the queue. +// +// The `clipSkRect` is transformed with the `matrix` before adding to the queue. +- (void)clipRect:(const SkRect&)clipSkRect matrix:(const CATransform3D&)matrix; + +// Adds a clip rrect operation to the queue. +// +// The `clipSkRRect` is transformed with the `matrix` before adding to the queue. +- (void)clipRRect:(const SkRRect&)clipSkRRect matrix:(const CATransform3D&)matrix; + +// Adds a clip path operation to the queue. +// +// The `path` is transformed with the `matrix` before adding to the queue. +- (void)clipPath:(const SkPath&)path matrix:(const CATransform3D&)matrix; + +@end + // A UIView that is used as the parent for embedded UIViews. // // This view has 2 roles: @@ -37,14 +64,6 @@ // The parent view handles clipping to its subviews. @interface ChildClippingView : UIView -// Performs the clipping based on the type. -// -// The `type` must be one of the 3: clip_rect, clip_rrect, clip_path. -- (void)setClip:(flutter::MutatorType)type - rect:(const SkRect&)rect - rrect:(const SkRRect&)rrect - path:(const SkPath&)path; - @end namespace flutter { @@ -253,20 +272,6 @@ class FlutterPlatformViewsController { // Traverse the `mutators_stack` and return the number of clip operations. int CountClips(const MutatorsStack& mutators_stack); - // Make sure that platform_view has exactly clip_count ChildClippingView ancestors. - // - // Existing ChildClippingViews are re-used. If there are currently more ChildClippingView - // ancestors than needed, the extra views are detached. If there are less ChildClippingView - // ancestors than needed, new ChildClippingViews will be added. - // - // If head_clip_view was attached as a subview to FlutterView, the head of the newly constructed - // ChildClippingViews chain is attached to FlutterView in the same position. - // - // Returns the new head of the clip views chain. - UIView* ReconstructClipViewsChain(int number_of_clips, - UIView* platform_view, - UIView* head_clip_view); - // Applies the mutators in the mutators_stack to the UIView chain that was constructed by // `ReconstructClipViewsChain` // diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm index 551535a2c7faf..5e9ed80279975 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm @@ -53,32 +53,72 @@ void ResetAnchor(CALayer* layer) { @implementation ChildClippingView -+ (CGRect)getCGRectFromSkRect:(const SkRect&)clipSkRect { - return CGRectMake(clipSkRect.fLeft, clipSkRect.fTop, clipSkRect.fRight - clipSkRect.fLeft, - clipSkRect.fBottom - clipSkRect.fTop); +// The ChildClippingView's frame is the bounding rect of the platform view. we only want touches to +// be hit tested and consumed by this view if they are inside the embedded platform view which could +// be smaller the embedded platform view is rotated. +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { + for (UIView* view in self.subviews) { + if ([view pointInside:[self convertPoint:point toView:view] withEvent:event]) { + return YES; + } + } + return NO; +} + +@end + +@interface FlutterClippingMaskView () + +- (fml::CFRef)getTransformedPath:(CGPathRef)path matrix:(CATransform3D)matrix; +- (CGRect)getCGRectFromSkRect:(const SkRect&)clipSkRect; + +@end + +@implementation FlutterClippingMaskView { + std::vector> paths_; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if ([super initWithFrame:frame]) { + self.backgroundColor = UIColor.clearColor; + } + return self; } -- (void)clipRect:(const SkRect&)clipSkRect { - CGRect clipRect = [ChildClippingView getCGRectFromSkRect:clipSkRect]; - fml::CFRef pathRef(CGPathCreateWithRect(clipRect, nil)); - CAShapeLayer* clip = [[[CAShapeLayer alloc] init] autorelease]; - clip.path = pathRef; - self.layer.mask = clip; +- (void)drawRect:(CGRect)rect { + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSaveGState(context); + + // For mask view, only the alpha channel is used. + CGContextSetAlpha(context, 1); + + for (size_t i = 0; i < paths_.size(); i++) { + CGContextAddPath(context, paths_.at(i)); + CGContextClip(context); + } + CGContextFillRect(context, rect); + CGContextRestoreGState(context); +} + +- (void)clipRect:(const SkRect&)clipSkRect matrix:(const CATransform3D&)matrix { + CGRect clipRect = [self getCGRectFromSkRect:clipSkRect]; + CGPathRef path = CGPathCreateWithRect(clipRect, nil); + paths_.push_back([self getTransformedPath:path matrix:matrix]); } -- (void)clipRRect:(const SkRRect&)clipSkRRect { +- (void)clipRRect:(const SkRRect&)clipSkRRect matrix:(const CATransform3D&)matrix { CGPathRef pathRef = nullptr; switch (clipSkRRect.getType()) { case SkRRect::kEmpty_Type: { break; } case SkRRect::kRect_Type: { - [self clipRect:clipSkRRect.rect()]; + [self clipRect:clipSkRRect.rect() matrix:matrix]; return; } case SkRRect::kOval_Type: case SkRRect::kSimple_Type: { - CGRect clipRect = [ChildClippingView getCGRectFromSkRect:clipSkRRect.rect()]; + CGRect clipRect = [self getCGRectFromSkRect:clipSkRRect.rect()]; pathRef = CGPathCreateWithRoundedRect(clipRect, clipSkRRect.getSimpleRadii().x(), clipSkRRect.getSimpleRadii().y(), nil); break; @@ -129,23 +169,17 @@ - (void)clipRRect:(const SkRRect&)clipSkRRect { // TODO(cyanglaz): iOS does not seem to support hard edge on CAShapeLayer. It clearly stated that // the CAShaperLayer will be drawn antialiased. Need to figure out a way to do the hard edge // clipping on iOS. - CAShapeLayer* clip = [[[CAShapeLayer alloc] init] autorelease]; - clip.path = pathRef; - self.layer.mask = clip; - CGPathRelease(pathRef); + paths_.push_back([self getTransformedPath:pathRef matrix:matrix]); } -- (void)clipPath:(const SkPath&)path { +- (void)clipPath:(const SkPath&)path matrix:(const CATransform3D&)matrix { if (!path.isValid()) { return; } - fml::CFRef pathRef(CGPathCreateMutable()); if (path.isEmpty()) { - CAShapeLayer* clip = [[[CAShapeLayer alloc] init] autorelease]; - clip.path = pathRef; - self.layer.mask = clip; return; } + CGMutablePathRef pathRef = CGPathCreateMutable(); // Loop through all verbs and translate them into CGPath SkPath::Iter iter(path, true); @@ -197,42 +231,20 @@ - (void)clipPath:(const SkPath&)path { } verb = iter.next(pts); } - - CAShapeLayer* clip = [[[CAShapeLayer alloc] init] autorelease]; - clip.path = pathRef; - self.layer.mask = clip; + paths_.push_back([self getTransformedPath:pathRef matrix:matrix]); } -- (void)setClip:(flutter::MutatorType)type - rect:(const SkRect&)rect - rrect:(const SkRRect&)rrect - path:(const SkPath&)path { - FML_CHECK(type == flutter::clip_rect || type == flutter::clip_rrect || - type == flutter::clip_path); - switch (type) { - case flutter::clip_rect: - [self clipRect:rect]; - break; - case flutter::clip_rrect: - [self clipRRect:rrect]; - break; - case flutter::clip_path: - [self clipPath:path]; - break; - default: - break; - } +- (fml::CFRef)getTransformedPath:(CGPathRef)path matrix:(CATransform3D)matrix { + CGAffineTransform affine = + CGAffineTransformMake(matrix.m11, matrix.m12, matrix.m21, matrix.m22, matrix.m41, matrix.m42); + CGPathRef transformedPath = CGPathCreateCopyByTransformingPath(path, &affine); + CGPathRelease(path); + return fml::CFRef(transformedPath); } -// The ChildClippingView is as big as the FlutterView, we only want touches to be hit tested and -// consumed by this view if they are inside the smaller child view. -- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { - for (UIView* view in self.subviews) { - if ([view pointInside:[self convertPoint:point toView:view] withEvent:event]) { - return YES; - } - } - return NO; +- (CGRect)getCGRectFromSkRect:(const SkRect&)clipSkRect { + return CGRectMake(clipSkRect.fLeft, clipSkRect.fTop, clipSkRect.fRight - clipSkRect.fLeft, + clipSkRect.fBottom - clipSkRect.fTop); } @end diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/GoldenImage.m b/testing/scenario_app/ios/Scenarios/ScenariosUITests/GoldenImage.m index e1b27c9bb845a..9961d1a13faf8 100644 --- a/testing/scenario_app/ios/Scenarios/ScenariosUITests/GoldenImage.m +++ b/testing/scenario_app/ios/Scenarios/ScenariosUITests/GoldenImage.m @@ -6,6 +6,8 @@ #import #include +static const double kRmseThreshold = 0.5; + @interface GoldenImage () @end @@ -67,8 +69,24 @@ - (BOOL)compareGoldenToImage:(UIImage*)image { CGContextDrawImage(contextB, CGRectMake(0, 0, widthA, heightA), imageRefB); CGContextRelease(contextB); - BOOL isSame = memcmp(rawA.mutableBytes, rawB.mutableBytes, size) == 0; - return isSame; + const char* apos = rawA.mutableBytes; + const char* bpos = rawB.mutableBytes; + double sum = 0.0; + for (size_t i = 0; i < size; ++i, ++apos, ++bpos) { + // Skip transparent pixels. + if (*apos == 0 && *bpos == 0 && i % 4 == 0) { + i += 3; + apos += 3; + bpos += 3; + } else { + double aval = *apos; + double bval = *bpos; + double diff = aval - bval; + sum += diff * diff; + } + } + double rmse = sqrt(sum / size); + return rmse <= kRmseThreshold; } NS_INLINE NSString* _platformName() { diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/UnobstructedPlatformViewTests.m b/testing/scenario_app/ios/Scenarios/ScenariosUITests/UnobstructedPlatformViewTests.m index 375220361a8c6..2d3db20b4e7a9 100644 --- a/testing/scenario_app/ios/Scenarios/ScenariosUITests/UnobstructedPlatformViewTests.m +++ b/testing/scenario_app/ios/Scenarios/ScenariosUITests/UnobstructedPlatformViewTests.m @@ -243,12 +243,8 @@ - (void)testPlatformViewsMaxOverlays { XCUIElement* overlay = app.otherElements[@"platform_view[0].overlay[0]"]; XCTAssertTrue(overlay.exists); - XCTAssertEqual(overlay.frame.origin.x, 75); - XCTAssertEqual(overlay.frame.origin.y, 85); - XCTAssertEqual(overlay.frame.size.width, 150); - XCTAssertEqual(overlay.frame.size.height, 190); - XCTAssertFalse(app.otherElements[@"platform_view[0].overlay[1]"].exists); + XCTAssertTrue(CGRectContainsRect(platform_view.frame, overlay.frame)); } @end diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_clippath_iPhone 8_simulator.png b/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_clippath_iPhone 8_simulator.png index 9ec19ab474f03..30072dc621646 100644 Binary files a/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_clippath_iPhone 8_simulator.png and b/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_clippath_iPhone 8_simulator.png differ diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_iPhone 8_simulator.png b/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_iPhone 8_simulator.png index b193419929e7b..69ba03a131136 100644 Binary files a/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_iPhone 8_simulator.png and b/testing/scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_iPhone 8_simulator.png differ