Flutter iOS Embedder
FlutterPlatformViews.mm
Go to the documentation of this file.
1 // Copyright 2013 The Flutter Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
6 
7 #import <WebKit/WebKit.h>
8 
9 #include "flutter/display_list/effects/dl_image_filter.h"
10 #include "flutter/fml/platform/darwin/cf_utils.h"
12 
14 
15 namespace {
16 static CGRect GetCGRectFromDlRect(const flutter::DlRect& clipDlRect) {
17  return CGRectMake(clipDlRect.GetX(), //
18  clipDlRect.GetY(), //
19  clipDlRect.GetWidth(), //
20  clipDlRect.GetHeight());
21 }
22 
23 CATransform3D GetCATransform3DFromDlMatrix(const flutter::DlMatrix& matrix) {
24  CATransform3D transform = CATransform3DIdentity;
25  transform.m11 = matrix.m[0];
26  transform.m12 = matrix.m[1];
27  transform.m13 = matrix.m[2];
28  transform.m14 = matrix.m[3];
29 
30  transform.m21 = matrix.m[4];
31  transform.m22 = matrix.m[5];
32  transform.m23 = matrix.m[6];
33  transform.m24 = matrix.m[7];
34 
35  transform.m31 = matrix.m[8];
36  transform.m32 = matrix.m[9];
37  transform.m33 = matrix.m[10];
38  transform.m34 = matrix.m[11];
39 
40  transform.m41 = matrix.m[12];
41  transform.m42 = matrix.m[13];
42  transform.m43 = matrix.m[14];
43  transform.m44 = matrix.m[15];
44  return transform;
45 }
46 
47 class CGPathReceiver final : public flutter::DlPathReceiver {
48  public:
49  void MoveTo(const flutter::DlPoint& p2, bool will_be_closed) override { //
50  CGPathMoveToPoint(path_ref_, nil, p2.x, p2.y);
51  }
52  void LineTo(const flutter::DlPoint& p2) override {
53  CGPathAddLineToPoint(path_ref_, nil, p2.x, p2.y);
54  }
55  void QuadTo(const flutter::DlPoint& cp, const flutter::DlPoint& p2) override {
56  CGPathAddQuadCurveToPoint(path_ref_, nil, cp.x, cp.y, p2.x, p2.y);
57  }
58  // bool conic_to(...) { CGPath has no equivalent to the conic curve type }
59  void CubicTo(const flutter::DlPoint& cp1,
60  const flutter::DlPoint& cp2,
61  const flutter::DlPoint& p2) override {
62  CGPathAddCurveToPoint(path_ref_, nil, //
63  cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y);
64  }
65  void Close() override { CGPathCloseSubpath(path_ref_); }
66 
67  CGMutablePathRef TakePath() const { return path_ref_; }
68 
69  private:
70  CGMutablePathRef path_ref_ = CGPathCreateMutable();
71 };
72 } // namespace
73 
74 @interface PlatformViewFilter ()
75 
76 // `YES` if the backdropFilterView has been configured at least once.
77 @property(nonatomic) BOOL backdropFilterViewConfigured;
78 @property(nonatomic) UIVisualEffectView* backdropFilterView;
79 
80 // Updates the `visualEffectView` with the current filter parameters.
81 // Also sets `self.backdropFilterView` to the updated visualEffectView.
82 - (void)updateVisualEffectView:(UIVisualEffectView*)visualEffectView;
83 
84 @end
85 
86 @implementation PlatformViewFilter
87 
88 static NSObject* _gaussianBlurFilter = nil;
89 // The index of "_UIVisualEffectBackdropView" in UIVisualEffectView's subViews.
90 static NSInteger _indexOfBackdropView = -1;
91 // The index of "_UIVisualEffectSubview" in UIVisualEffectView's subViews.
92 static NSInteger _indexOfVisualEffectSubview = -1;
93 static BOOL _preparedOnce = NO;
94 
95 - (instancetype)initWithFrame:(CGRect)frame
96  blurRadius:(CGFloat)blurRadius
97  cornerRadius:(CGFloat)cornerRadius
98  isRoundedSuperellipse:(BOOL)isRoundedSuperellipse
99  visualEffectView:(UIVisualEffectView*)visualEffectView {
100  if (self = [super init]) {
101  _frame = frame;
102  _blurRadius = blurRadius;
103  _cornerRadius = cornerRadius;
104  _isRoundedSuperellipse = isRoundedSuperellipse;
105  [PlatformViewFilter prepareOnce:visualEffectView];
106  if (![PlatformViewFilter isUIVisualEffectViewImplementationValid]) {
107  FML_DLOG(ERROR) << "Apple's API for UIVisualEffectView changed. Update the implementation to "
108  "access the gaussianBlur CAFilter.";
109  return nil;
110  }
111  _backdropFilterView = visualEffectView;
112  _backdropFilterViewConfigured = NO;
113  }
114  return self;
115 }
116 
117 + (void)resetPreparation {
118  _preparedOnce = NO;
119  _gaussianBlurFilter = nil;
122 }
123 
124 + (void)prepareOnce:(UIVisualEffectView*)visualEffectView {
125  if (_preparedOnce) {
126  return;
127  }
128  for (NSUInteger i = 0; i < visualEffectView.subviews.count; i++) {
129  UIView* view = visualEffectView.subviews[i];
130  if ([NSStringFromClass([view class]) hasSuffix:@"BackdropView"]) {
132  for (NSObject* filter in view.layer.filters) {
133  if ([[filter valueForKey:@"name"] isEqual:@"gaussianBlur"] &&
134  [[filter valueForKey:@"inputRadius"] isKindOfClass:[NSNumber class]]) {
135  _gaussianBlurFilter = filter;
136  break;
137  }
138  }
139  } else if ([NSStringFromClass([view class]) hasSuffix:@"VisualEffectSubview"]) {
141  }
142  }
143  _preparedOnce = YES;
144 }
145 
146 + (BOOL)isUIVisualEffectViewImplementationValid {
148 }
149 
150 - (UIVisualEffectView*)backdropFilterView {
151  FML_DCHECK(_backdropFilterView);
152  if (!self.backdropFilterViewConfigured) {
153  [self updateVisualEffectView:_backdropFilterView];
154  self.backdropFilterViewConfigured = YES;
155  }
156  return _backdropFilterView;
157 }
158 
159 - (void)updateVisualEffectView:(UIVisualEffectView*)visualEffectView {
160  NSObject* gaussianBlurFilter = [_gaussianBlurFilter copy];
161  FML_DCHECK(gaussianBlurFilter);
162  UIView* backdropView = visualEffectView.subviews[_indexOfBackdropView];
163  [gaussianBlurFilter setValue:@(_blurRadius) forKey:@"inputRadius"];
164  backdropView.layer.filters = @[ gaussianBlurFilter ];
165 
166  UIView* visualEffectSubview = visualEffectView.subviews[_indexOfVisualEffectSubview];
167  visualEffectSubview.layer.backgroundColor = UIColor.clearColor.CGColor;
168  visualEffectView.frame = _frame;
169 
170  visualEffectView.layer.cornerRadius = _cornerRadius;
171  visualEffectView.layer.cornerCurve =
172  _isRoundedSuperellipse ? kCACornerCurveContinuous : kCACornerCurveCircular;
173  visualEffectView.clipsToBounds = YES;
174 
175  self.backdropFilterView = visualEffectView;
176 }
177 
178 @end
179 
180 @interface ChildClippingView ()
181 
182 @property(nonatomic, copy) NSArray<PlatformViewFilter*>* filters;
183 @property(nonatomic) NSMutableArray<UIVisualEffectView*>* backdropFilterSubviews;
184 
185 @end
186 
187 @implementation ChildClippingView
188 
189 // The ChildClippingView's frame is the bounding rect of the platform view. we only want touches to
190 // be hit tested and consumed by this view if they are inside the embedded platform view which could
191 // be smaller the embedded platform view is rotated.
192 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event {
193  for (UIView* view in self.subviews) {
194  if ([view pointInside:[self convertPoint:point toView:view] withEvent:event]) {
195  return YES;
196  }
197  }
198  return NO;
199 }
200 
201 - (void)applyBlurBackdropFilters:(NSArray<PlatformViewFilter*>*)filters {
202  FML_DCHECK(self.filters.count == self.backdropFilterSubviews.count);
203  if (self.filters.count == 0 && filters.count == 0) {
204  return;
205  }
206  self.filters = filters;
207  NSUInteger index = 0;
208  for (index = 0; index < self.filters.count; index++) {
209  UIVisualEffectView* backdropFilterView;
210  PlatformViewFilter* filter = self.filters[index];
211  if (self.backdropFilterSubviews.count <= index) {
212  backdropFilterView = filter.backdropFilterView;
213  [self addSubview:backdropFilterView];
214  [self.backdropFilterSubviews addObject:backdropFilterView];
215  } else {
216  [filter updateVisualEffectView:self.backdropFilterSubviews[index]];
217  }
218  }
219  for (NSUInteger i = self.backdropFilterSubviews.count; i > index; i--) {
220  [self.backdropFilterSubviews[i - 1] removeFromSuperview];
221  [self.backdropFilterSubviews removeLastObject];
222  }
223 }
224 
225 - (NSMutableArray*)backdropFilterSubviews {
226  if (!_backdropFilterSubviews) {
227  _backdropFilterSubviews = [[NSMutableArray alloc] init];
228  }
229  return _backdropFilterSubviews;
230 }
231 
232 @end
233 
235 
236 // A `CATransform3D` matrix represnts a scale transform that revese UIScreen.scale.
237 //
238 // The transform matrix passed in clipRect/clipRRect/clipPath methods are in device coordinate
239 // space. The transfrom matrix concats `reverseScreenScale` to create a transform matrix in the iOS
240 // logical coordinates (points).
241 //
242 // See https://developer.apple.com/documentation/uikit/uiscreen/1617836-scale?language=objc for
243 // information about screen scale.
244 @property(nonatomic) CATransform3D reverseScreenScale;
245 
246 - (fml::CFRef<CGPathRef>)getTransformedPath:(CGPathRef)path matrix:(CATransform3D)matrix;
247 
248 @end
249 
250 @implementation FlutterClippingMaskView {
251  std::vector<fml::CFRef<CGPathRef>> paths_;
253  CGRect rectSoFar_;
254 }
255 
256 - (instancetype)initWithFrame:(CGRect)frame {
257  return [self initWithFrame:frame screenScale:[UIScreen mainScreen].scale];
258 }
259 
260 - (instancetype)initWithFrame:(CGRect)frame screenScale:(CGFloat)screenScale {
261  if (self = [super initWithFrame:frame]) {
262  self.backgroundColor = UIColor.clearColor;
263  _reverseScreenScale = CATransform3DMakeScale(1 / screenScale, 1 / screenScale, 1);
264  rectSoFar_ = self.bounds;
266  }
267  return self;
268 }
269 
270 + (Class)layerClass {
271  return [CAShapeLayer class];
272 }
273 
274 - (CAShapeLayer*)shapeLayer {
275  return (CAShapeLayer*)self.layer;
276 }
277 
278 - (void)reset {
279  paths_.clear();
280  rectSoFar_ = self.bounds;
282  [self shapeLayer].path = nil;
283  [self setNeedsDisplay];
284 }
285 
286 // In some scenarios, when we add this view as a maskView of the ChildClippingView, iOS added
287 // this view as a subview of the ChildClippingView.
288 // This results this view blocking touch events on the ChildClippingView.
289 // So we should always ignore any touch events sent to this view.
290 // See https://github.com/flutter/flutter/issues/66044
291 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event {
292  return NO;
293 }
294 
295 - (void)drawRect:(CGRect)rect {
296  // It's hard to compute intersection of arbitrary non-rect paths.
297  // So we fallback to software rendering.
298  if (containsNonRectPath_ && paths_.size() > 1) {
299  CGContextRef context = UIGraphicsGetCurrentContext();
300  CGContextSaveGState(context);
301 
302  // For mask view, only the alpha channel is used.
303  CGContextSetAlpha(context, 1);
304 
305  for (size_t i = 0; i < paths_.size(); i++) {
306  CGContextAddPath(context, paths_.at(i));
307  CGContextClip(context);
308  }
309  CGContextFillRect(context, rect);
310  CGContextRestoreGState(context);
311  } else {
312  // Either a single path, or multiple rect paths.
313  // Use hardware rendering with CAShapeLayer.
314  [super drawRect:rect];
315  if (![self shapeLayer].path) {
316  if (paths_.size() == 1) {
317  // A single path, either rect or non-rect.
318  [self shapeLayer].path = paths_.at(0);
319  } else {
320  // Multiple paths, all paths must be rects.
321  CGPathRef pathSoFar = CGPathCreateWithRect(rectSoFar_, nil);
322  [self shapeLayer].path = pathSoFar;
323  CGPathRelease(pathSoFar);
324  }
325  }
326  }
327 }
328 
329 - (void)clipRect:(const flutter::DlRect&)clipDlRect matrix:(const flutter::DlMatrix&)matrix {
330  CGRect clipRect = GetCGRectFromDlRect(clipDlRect);
331  CGPathRef path = CGPathCreateWithRect(clipRect, nil);
332  // The `matrix` is based on the physical pixels, convert it to UIKit points.
333  CATransform3D matrixInPoints =
334  CATransform3DConcat(GetCATransform3DFromDlMatrix(matrix), _reverseScreenScale);
335  paths_.push_back([self getTransformedPath:path matrix:matrixInPoints]);
336  CGAffineTransform affine = [self affineWithMatrix:matrixInPoints];
337  // Make sure the rect is not rotated (only translated or scaled).
338  if (affine.b == 0 && affine.c == 0) {
339  rectSoFar_ = CGRectIntersection(rectSoFar_, CGRectApplyAffineTransform(clipRect, affine));
340  } else {
341  containsNonRectPath_ = YES;
342  }
343 }
344 
345 - (void)clipRRect:(const flutter::DlRoundRect&)clipDlRRect matrix:(const flutter::DlMatrix&)matrix {
346  if (clipDlRRect.IsEmpty()) {
347  return;
348  } else if (clipDlRRect.IsRect()) {
349  [self clipRect:clipDlRRect.GetBounds() matrix:matrix];
350  return;
351  } else {
352  CGPathRef pathRef = nullptr;
353  containsNonRectPath_ = YES;
354 
355  if (clipDlRRect.GetRadii().AreAllCornersSame()) {
356  CGRect clipRect = GetCGRectFromDlRect(clipDlRRect.GetBounds());
357  auto radii = clipDlRRect.GetRadii();
358  pathRef =
359  CGPathCreateWithRoundedRect(clipRect, radii.top_left.width, radii.top_left.height, nil);
360  } else {
361  CGMutablePathRef mutablePathRef = CGPathCreateMutable();
362  // Complex types, we manually add each corner.
363  flutter::DlRect clipDlRect = clipDlRRect.GetBounds();
364  auto left = clipDlRect.GetLeft();
365  auto top = clipDlRect.GetTop();
366  auto right = clipDlRect.GetRight();
367  auto bottom = clipDlRect.GetBottom();
368  flutter::DlRoundingRadii radii = clipDlRRect.GetRadii();
369  auto& top_left = radii.top_left;
370  auto& top_right = radii.top_right;
371  auto& bottom_left = radii.bottom_left;
372  auto& bottom_right = radii.bottom_right;
373 
374  // Start drawing RRect
375  // These calculations are off, the AddCurve methods add a Bezier curve
376  // which, for round rects should be a "magic distance" from the end
377  // point of the horizontal/vertical section to the corner.
378  // Move point to the top left corner adding the top left radii's x.
379  CGPathMoveToPoint(mutablePathRef, nil, //
380  left + top_left.width, top);
381  // Move point horizontally right to the top right corner and add the top right curve.
382  CGPathAddLineToPoint(mutablePathRef, nil, //
383  right - top_right.width, top);
384  CGPathAddCurveToPoint(mutablePathRef, nil, //
385  right, top, //
386  right, top + top_right.height, //
387  right, top + top_right.height);
388  // Move point vertically down to the bottom right corner and add the bottom right curve.
389  CGPathAddLineToPoint(mutablePathRef, nil, //
390  right, bottom - bottom_right.height);
391  CGPathAddCurveToPoint(mutablePathRef, nil, //
392  right, bottom, //
393  right - bottom_right.width, bottom, //
394  right - bottom_right.width, bottom);
395  // Move point horizontally left to the bottom left corner and add the bottom left curve.
396  CGPathAddLineToPoint(mutablePathRef, nil, //
397  left + bottom_left.width, bottom);
398  CGPathAddCurveToPoint(mutablePathRef, nil, //
399  left, bottom, //
400  left, bottom - bottom_left.height, //
401  left, bottom - bottom_left.height);
402  // Move point vertically up to the top left corner and add the top left curve.
403  CGPathAddLineToPoint(mutablePathRef, nil, //
404  left, top + top_left.height);
405  CGPathAddCurveToPoint(mutablePathRef, nil, //
406  left, top, //
407  left + top_left.width, top, //
408  left + top_left.width, top);
409  CGPathCloseSubpath(mutablePathRef);
410  pathRef = mutablePathRef;
411  }
412  // The `matrix` is based on the physical pixels, convert it to UIKit points.
413  CATransform3D matrixInPoints =
414  CATransform3DConcat(GetCATransform3DFromDlMatrix(matrix), _reverseScreenScale);
415  // TODO(cyanglaz): iOS does not seem to support hard edge on CAShapeLayer. It clearly stated
416  // that the CAShaperLayer will be drawn antialiased. Need to figure out a way to do the hard
417  // edge clipping on iOS.
418  paths_.push_back([self getTransformedPath:pathRef matrix:matrixInPoints]);
419  }
420 }
421 
422 - (void)clipPath:(const flutter::DlPath&)dlPath matrix:(const flutter::DlMatrix&)matrix {
423  containsNonRectPath_ = YES;
424 
425  CGPathReceiver receiver;
426 
427  // TODO(flar): https://github.com/flutter/flutter/issues/164826
428  // CGPaths do not have an inherit fill type, we would need to remember
429  // the fill type and employ it when we use the path.
430  dlPath.Dispatch(receiver);
431 
432  // The `matrix` is based on the physical pixels, convert it to UIKit points.
433  CATransform3D matrixInPoints =
434  CATransform3DConcat(GetCATransform3DFromDlMatrix(matrix), _reverseScreenScale);
435  paths_.push_back([self getTransformedPath:receiver.TakePath() matrix:matrixInPoints]);
436 }
437 
438 - (CGAffineTransform)affineWithMatrix:(CATransform3D)matrix {
439  return CGAffineTransformMake(matrix.m11, matrix.m12, matrix.m21, matrix.m22, matrix.m41,
440  matrix.m42);
441 }
442 
443 - (fml::CFRef<CGPathRef>)getTransformedPath:(CGPathRef)path matrix:(CATransform3D)matrix {
444  CGAffineTransform affine = [self affineWithMatrix:matrix];
445  CGPathRef transformedPath = CGPathCreateCopyByTransformingPath(path, &affine);
446 
447  CGPathRelease(path);
448  return fml::CFRef<CGPathRef>(transformedPath);
449 }
450 
451 @end
452 
454 
455 // The maximum number of `FlutterClippingMaskView` the pool can contain.
456 // This prevents the pool to grow infinately and limits the maximum memory a pool can use.
457 @property(nonatomic) NSUInteger capacity;
458 
459 // The pool contains the views that are available to use.
460 // The number of items in the pool must not excceds `capacity`.
461 @property(nonatomic) NSMutableSet<FlutterClippingMaskView*>* pool;
462 
463 @end
464 
465 @implementation FlutterClippingMaskViewPool : NSObject
466 
467 - (instancetype)initWithCapacity:(NSInteger)capacity {
468  if (self = [super init]) {
469  // Most of cases, there are only one PlatformView in the scene.
470  // Thus init with the capacity of 1.
471  _pool = [[NSMutableSet alloc] initWithCapacity:1];
472  _capacity = capacity;
473  }
474  return self;
475 }
476 
477 - (FlutterClippingMaskView*)getMaskViewWithFrame:(CGRect)frame {
478  FML_DCHECK(self.pool.count <= self.capacity);
479  if (self.pool.count == 0) {
480  // The pool is empty, alloc a new one.
481  return [[FlutterClippingMaskView alloc] initWithFrame:frame
482  screenScale:UIScreen.mainScreen.scale];
483  }
484  FlutterClippingMaskView* maskView = [self.pool anyObject];
485  maskView.frame = frame;
486  [maskView reset];
487  [self.pool removeObject:maskView];
488  return maskView;
489 }
490 
491 - (void)insertViewToPoolIfNeeded:(FlutterClippingMaskView*)maskView {
492  FML_DCHECK(![self.pool containsObject:maskView]);
493  FML_DCHECK(self.pool.count <= self.capacity);
494  if (self.pool.count == self.capacity) {
495  return;
496  }
497  [self.pool addObject:maskView];
498 }
499 
500 @end
501 
502 @implementation UIView (FirstResponder)
504  if (self.isFirstResponder) {
505  return YES;
506  }
507  for (UIView* subview in self.subviews) {
508  if (subview.flt_hasFirstResponderInViewHierarchySubtree) {
509  return YES;
510  }
511  }
512  return NO;
513 }
514 @end
515 
517 @property(nonatomic, weak, readonly) UIView* embeddedView;
518 @property(nonatomic, weak, readonly) UIViewController<FlutterViewResponder>* flutterViewController;
519 @property(nonatomic, weak, readonly) FlutterPlatformViewsController* platformViewsController;
520 @property(nonatomic, readonly) FlutterDelayingGestureRecognizer* delayingRecognizer;
521 @end
522 
524 - (instancetype)initWithEmbeddedView:(UIView*)embeddedView
525  platformViewsController:(FlutterPlatformViewsController*)platformViewsController
526  gestureRecognizersBlockingPolicy:
528  self = [super initWithFrame:embeddedView.frame];
529  if (self) {
530  self.multipleTouchEnabled = YES;
531  _embeddedView = embeddedView;
532  _platformViewsController = platformViewsController;
533  _flutterViewController = platformViewsController.flutterViewController;
534  embeddedView.autoresizingMask =
535  (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight);
536 
537  [self addSubview:embeddedView];
538 
539  ForwardingGestureRecognizer* forwardingRecognizer =
540  [[ForwardingGestureRecognizer alloc] initWithTarget:self
541  platformViewsController:platformViewsController];
542 
543  _delayingRecognizer =
544  [[FlutterDelayingGestureRecognizer alloc] initWithTarget:self
545  action:nil
546  forwardingRecognizer:forwardingRecognizer];
547  _blockingPolicy = blockingPolicy;
548 
549  // For hit test, don't block gestures using delaying recognizer. However, we still
550  // forward touches so Flutter can process it in its gesture arena (e.g. dismiss a
551  // drop-down menu when tapping outside of the menu but inside the platform view).
553  [self addGestureRecognizer:_delayingRecognizer];
554  }
555  [self addGestureRecognizer:forwardingRecognizer];
556  }
557  return self;
558 }
559 
560 - (void)forceResetForwardingGestureRecognizerState {
561  // When iPad pencil is involved in a finger touch gesture, the gesture is not reset to "possible"
562  // state and is stuck on "failed" state, which causes subsequent touches to be blocked. As a
563  // workaround, we force reset the state by recreating the forwarding gesture recognizer. See:
564  // https://github.com/flutter/flutter/issues/136244
565  ForwardingGestureRecognizer* oldForwardingRecognizer =
566  (ForwardingGestureRecognizer*)self.delayingRecognizer.forwardingRecognizer;
567  ForwardingGestureRecognizer* newForwardingRecognizer =
568  [oldForwardingRecognizer recreateRecognizerWithTarget:self];
569  self.delayingRecognizer.forwardingRecognizer = newForwardingRecognizer;
570  [self removeGestureRecognizer:oldForwardingRecognizer];
571  [self addGestureRecognizer:newForwardingRecognizer];
572 }
573 
574 - (void)releaseGesture {
575  self.delayingRecognizer.state = UIGestureRecognizerStateFailed;
576 }
577 
578 - (BOOL)containsWebView:(UIView*)view {
579  if ([view isKindOfClass:[WKWebView class]]) {
580  return YES;
581  }
582  for (UIView* subview in view.subviews) {
583  if ([self containsWebView:subview]) {
584  return YES;
585  }
586  }
587  return NO;
588 }
589 
590 - (void)searchAndFixWebView:(UIView*)view {
591  if ([view isKindOfClass:[WKWebView class]]) {
592  return [self searchAndFixWebViewGestureRecognzier:view];
593  } else {
594  for (UIView* subview in view.subviews) {
595  [self searchAndFixWebView:subview];
596  }
597  }
598 }
599 
600 - (void)searchAndFixWebViewGestureRecognzier:(UIView*)view {
601  for (UIGestureRecognizer* recognizer in view.gestureRecognizers) {
602  // This is to fix a bug on iOS 26 where web view link is not tappable.
603  // We reset the web view's WKTouchEventsGestureRecognizer in a bad state
604  // by disabling and re-enabling it.
605  // See: https://github.com/flutter/flutter/issues/175099.
606  // See also: https://github.com/flutter/engine/pull/56804 for an explanation of the
607  // bug on iOS 18.2, which is still valid on iOS 26.
608  // Warning: This is just a quick fix that patches the bug. For example,
609  // touches on a drawing website is still not completely blocked. A proper solution
610  // should rely on overriding the hitTest behavior.
611  // See: https://github.com/flutter/flutter/issues/179916.
612  if (recognizer.enabled &&
613  [NSStringFromClass([recognizer class]) hasSuffix:@"TouchEventsGestureRecognizer"]) {
614  recognizer.enabled = NO;
615  recognizer.enabled = YES;
616  }
617  }
618  for (UIView* subview in view.subviews) {
619  [self searchAndFixWebViewGestureRecognzier:subview];
620  }
621 }
622 
623 - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {
624  // In release mode, FlutterTouchInterceptingView's init is called before flutterViewController
625  // is set on platformViewsController.
626  if (self.flutterViewController == nil) {
627  _flutterViewController = self.platformViewsController.flutterViewController;
628  }
629  CGPoint pointInFlutterView = [self convertPoint:point toView:self.flutterViewController.view];
630  // Consult the framework on if the touch should be handled by the platform view.
631  // If NO, the touch is handled by a Flutter widget and should be blocked (by returning self).
632  // If YES, the touch should continue to the standard hit-testing (through super), allowing the
633  // touch to be delivered to the underlying native platform view or one of its subviews.
634  if (![self.flutterViewController
635  platformViewShouldAcceptTouchAtTouchBeganLocation:pointInFlutterView]) {
636  return self;
637  }
638 
639  return [super hitTest:point withEvent:event];
640 }
641 
642 - (void)blockGesture {
643  switch (_blockingPolicy) {
645  // No-op. Handled by hit test.
646  break;
648  // We block all other gesture recognizers immediately in this policy.
649  self.delayingRecognizer.state = UIGestureRecognizerStateEnded;
650 
651  // On iOS 18.2, WKWebView's internal recognizer likely caches the old state of its blocking
652  // recognizers (i.e. delaying recognizer), resulting in non-tappable links. See
653  // https://github.com/flutter/flutter/issues/158961. Removing and adding back the delaying
654  // recognizer solves the problem, possibly because UIKit notifies all the recognizers related
655  // to (blocking or blocked by) this recognizer. It is not possible to inject this workaround
656  // from the web view plugin level. Right now we only observe this issue for
657  // FlutterPlatformViewGestureRecognizersBlockingPolicyEager, but we should try it if a similar
658  // issue arises for the other policy.
659  if (@available(iOS 26.4, *)) {
660  // Skip workaround as this non-tappable web view bug has been fixed on iOS 26.4.
661  // See: https://github.com/WebKit/WebKit/pull/57358.
662  } else if (@available(iOS 26.0, *)) {
663  // This performs a nested DFS, with the outer one searching for any web view, and the inner
664  // one searching for a TouchEventsGestureRecognizer inside the web view. Once found, disable
665  // and immediately reenable it to reset its state.
666  // TODO(hellohuanlin): remove this flag after it is battle tested.
667  NSNumber* isWorkaroundDisabled =
668  [[NSBundle mainBundle] objectForInfoDictionaryKey:@"FLTDisableWebViewGestureReset"];
669  if (!isWorkaroundDisabled.boolValue) {
670  [self searchAndFixWebView:self.embeddedView];
671  }
672  } else if (@available(iOS 18.2, *)) {
673  // The 1P web view plugin provides a WKWebView itself as the platform view. However, some 3P
674  // plugins provide wrappers of WKWebView instead, and AdMob banner has a WKWebView at
675  // depth 7. So we perform DFS to search the view hierarchy.
676  if ([self containsWebView:self.embeddedView]) {
677  [self removeGestureRecognizer:self.delayingRecognizer];
678  [self addGestureRecognizer:self.delayingRecognizer];
679  }
680  }
681 
682  break;
684  if (self.delayingRecognizer.touchedEndedWithoutBlocking) {
685  // If touchesEnded of the `DelayingGesureRecognizer` has been already invoked,
686  // we want to set the state of the `DelayingGesureRecognizer` to
687  // `UIGestureRecognizerStateEnded` as soon as possible.
688  self.delayingRecognizer.state = UIGestureRecognizerStateEnded;
689  } else {
690  // If touchesEnded of the `DelayingGesureRecognizer` has not been invoked,
691  // We will set a flag to notify the `DelayingGesureRecognizer` to set the state to
692  // `UIGestureRecognizerStateEnded` when touchesEnded is called.
693  self.delayingRecognizer.shouldEndInNextTouchesEnded = YES;
694  }
695  break;
696  default:
697  break;
698  }
699 }
700 
701 // We want the intercepting view to consume the touches and not pass the touches up to the parent
702 // view. Make the touch event method not call super will not pass the touches up to the parent view.
703 // Hence we overide the touch event methods and do nothing.
704 - (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
705 }
706 
707 - (void)touchesMoved:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
708 }
709 
710 - (void)touchesCancelled:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
711 }
712 
713 - (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event {
714 }
715 
717  return self.flutterAccessibilityContainer;
718 }
719 
720 @end
721 
723 
724 - (instancetype)initWithTarget:(id)target
725  action:(SEL)action
726  forwardingRecognizer:(UIGestureRecognizer*)forwardingRecognizer {
727  self = [super initWithTarget:target action:action];
728  if (self) {
729  self.delaysTouchesBegan = YES;
730  self.delaysTouchesEnded = YES;
731  self.delegate = self;
732  _shouldEndInNextTouchesEnded = NO;
733  _touchedEndedWithoutBlocking = NO;
734  _forwardingRecognizer = forwardingRecognizer;
735  }
736  return self;
737 }
738 
739 - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
740  shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer {
741  // The forwarding gesture recognizer should always get all touch events, so it should not be
742  // required to fail by any other gesture recognizer.
743  return otherGestureRecognizer != _forwardingRecognizer && otherGestureRecognizer != self;
744 }
745 
746 - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
747  shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer {
748  return otherGestureRecognizer == self;
749 }
750 
751 - (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
752  self.touchedEndedWithoutBlocking = NO;
753  [super touchesBegan:touches withEvent:event];
754 }
755 
756 - (void)touchesEnded:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
757  if (self.shouldEndInNextTouchesEnded) {
758  self.state = UIGestureRecognizerStateEnded;
759  self.shouldEndInNextTouchesEnded = NO;
760  } else {
761  self.touchedEndedWithoutBlocking = YES;
762  }
763  [super touchesEnded:touches withEvent:event];
764 }
765 
766 - (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event {
767  self.state = UIGestureRecognizerStateFailed;
768 }
769 @end
770 
772  // Weak reference to PlatformViewsController. The PlatformViewsController has
773  // a reference to the FlutterViewController, where we can dispatch pointer events to.
774  //
775  // The lifecycle of PlatformViewsController is bind to FlutterEngine, which should always
776  // outlives the FlutterViewController. And ForwardingGestureRecognizer is owned by a subview of
777  // FlutterView, so the ForwardingGestureRecognizer never out lives FlutterViewController.
778  // Therefore, `_platformViewsController` should never be nullptr.
779  __weak FlutterPlatformViewsController* _platformViewsController;
780  // Counting the pointers that has started in one touch sequence.
782  // We can't dispatch events to the framework without this back pointer.
783  // This gesture recognizer retains the `FlutterViewController` until the
784  // end of a gesture sequence, that is all the touches in touchesBegan are concluded
785  // with |touchesCancelled| or |touchesEnded|.
786  UIViewController<FlutterViewResponder>* _flutterViewController;
787 }
788 
789 - (instancetype)initWithTarget:(id)target
790  platformViewsController:(FlutterPlatformViewsController*)platformViewsController {
791  self = [super initWithTarget:target action:nil];
792  if (self) {
793  self.delegate = self;
794  FML_DCHECK(platformViewsController);
795  _platformViewsController = platformViewsController;
797  }
798  return self;
799 }
800 
801 - (ForwardingGestureRecognizer*)recreateRecognizerWithTarget:(id)target {
802  return [[ForwardingGestureRecognizer alloc] initWithTarget:target
803  platformViewsController:_platformViewsController];
804 }
805 
806 - (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event {
807  FML_DCHECK(_currentTouchPointersCount >= 0);
808  if (_currentTouchPointersCount == 0) {
809  // TODO(hellohuanlin): the following comment is likely incorrect and very misleading.
810  // The actual reason is a race condition when platform view is created before
811  // flutterViewController is set in platformViewsController in debug mode. We should clean up the
812  // code, either fix the race condition, or make flutterViewController a computed property rather
813  // than a stored property.
814  // See: https://github.com/flutter/flutter/issues/184354.
815  //
816  // At the start of each gesture sequence, we reset the `_flutterViewController`,
817  // so that all the touch events in the same sequence are forwarded to the same
818  // `_flutterViewController`.
819  _flutterViewController = _platformViewsController.flutterViewController;
820  }
821  [_flutterViewController touchesBegan:touches withEvent:event];
822  _currentTouchPointersCount += touches.count;
823 }
824 
825 - (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event {
826  [_flutterViewController touchesMoved:touches withEvent:event];
827 }
828 
829 - (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event {
830  [_flutterViewController touchesEnded:touches withEvent:event];
831  _currentTouchPointersCount -= touches.count;
832  // Touches in one touch sequence are sent to the touchesEnded method separately if different
833  // fingers stop touching the screen at different time. So one touchesEnded method triggering does
834  // not necessarially mean the touch sequence has ended. We Only set the state to
835  // UIGestureRecognizerStateFailed when all the touches in the current touch sequence is ended.
836  if (_currentTouchPointersCount == 0) {
837  self.state = UIGestureRecognizerStateFailed;
839  [self forceResetStateIfNeeded];
840  }
841 }
842 
843 - (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event {
844  // In the event of platform view is removed, iOS generates a "stationary" change type instead of
845  // "cancelled" change type.
846  // Flutter needs all the cancelled touches to be "cancelled" change types in order to correctly
847  // handle gesture sequence.
848  // We always override the change type to "cancelled".
849  [_flutterViewController forceTouchesCancelled:touches];
850  _currentTouchPointersCount -= touches.count;
851  if (_currentTouchPointersCount == 0) {
852  self.state = UIGestureRecognizerStateFailed;
854  [self forceResetStateIfNeeded];
855  }
856 }
857 
858 - (void)forceResetStateIfNeeded {
859  // Apple fixed the bug where the gesture recognizer gets stuck at "failed" state in iOS 26.
860  // The workaround is no longer needed on iOS 26+.
861  // See: https://github.com/flutter/flutter/issues/179907
862  if (@available(iOS 26.0, *)) {
863  return;
864  }
865  __weak ForwardingGestureRecognizer* weakSelf = self;
866  dispatch_async(dispatch_get_main_queue(), ^{
867  ForwardingGestureRecognizer* strongSelf = weakSelf;
868  if (!strongSelf) {
869  return;
870  }
871  if (strongSelf.state != UIGestureRecognizerStatePossible) {
872  [(FlutterTouchInterceptingView*)strongSelf.view forceResetForwardingGestureRecognizerState];
873  }
874  });
875 }
876 
877 - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
878  shouldRecognizeSimultaneouslyWithGestureRecognizer:
879  (UIGestureRecognizer*)otherGestureRecognizer {
880  return YES;
881 }
882 @end
883 
884 @implementation PendingRRectClip
885 @end
BOOL containsNonRectPath_
static NSInteger _indexOfVisualEffectSubview
static NSInteger _indexOfBackdropView
static BOOL _preparedOnce
UIViewController< FlutterViewResponder > * _flutterViewController
NSInteger _currentTouchPointersCount
CGRect rectSoFar_
static NSObject * _gaussianBlurFilter
static CATransform3D GetCATransform3DFromDlMatrix(const DlMatrix &matrix)
static CGRect GetCGRectFromDlRect(const DlRect &clipDlRect)
FlutterPlatformViewGestureRecognizersBlockingPolicy
@ FlutterPlatformViewGestureRecognizersBlockingPolicyEager
@ FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded
@ FlutterPlatformViewGestureRecognizersBlockingPolicyDoNotBlockGesture
instancetype initWithFrame
NSMutableArray * backdropFilterSubviews()
void CubicTo(const flutter::DlPoint &cp1, const flutter::DlPoint &cp2, const flutter::DlPoint &p2) override
void MoveTo(const flutter::DlPoint &p2, bool will_be_closed) override
void LineTo(const flutter::DlPoint &p2) override
void QuadTo(const flutter::DlPoint &cp, const flutter::DlPoint &p2) override
FlutterPlatformViewGestureRecognizersBlockingPolicy blockingPolicy
UIVisualEffectView * backdropFilterView
UIViewController< FlutterViewResponder > *_Nullable flutterViewController
The flutter view controller.