Flutter iOS Embedder
FlutterPlatformPlugin.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 <AudioToolbox/AudioToolbox.h>
8 #import <Foundation/Foundation.h>
9 #import <UIKit/UIApplication.h>
10 #import <UIKit/UIKit.h>
11 
12 #include "flutter/fml/logging.h"
18 
20 
21 namespace {
22 
23 constexpr char kTextPlainFormat[] = "text/plain";
24 const UInt32 kKeyPressClickSoundId = 1306;
25 
26 NSString* const kSearchURLPrefix = @"x-web-search://?";
27 
28 } // namespace
29 
30 namespace flutter {
31 
32 // TODO(abarth): Move these definitions from system_chrome_impl.cc to here.
34  "io.flutter.plugin.platform.SystemChromeOrientationNotificationName";
36  "io.flutter.plugin.platform.SystemChromeOrientationNotificationKey";
38  "io.flutter.plugin.platform.SystemChromeOverlayNotificationName";
40  "io.flutter.plugin.platform.SystemChromeOverlayNotificationKey";
41 
42 } // namespace flutter
43 
44 using namespace flutter;
45 
46 static void SetStatusBarHiddenForSharedApplication(BOOL hidden) {
47  UIApplication* flutterApplication = FlutterSharedApplication.application;
48  if (flutterApplication) {
49  flutterApplication.statusBarHidden = hidden;
50  } else {
51  FML_LOG(WARNING) << "Application based status bar styling is not available in app extension.";
52  }
53 }
54 
55 static void SetStatusBarStyleForSharedApplication(UIStatusBarStyle style) {
56  UIApplication* flutterApplication = FlutterSharedApplication.application;
57  if (flutterApplication) {
58  // Note: -[UIApplication setStatusBarStyle] is deprecated in iOS9
59  // in favor of delegating to the view controller.
60  [flutterApplication setStatusBarStyle:style];
61  } else {
62  FML_LOG(WARNING) << "Application based status bar styling is not available in app extension.";
63  }
64 }
65 
66 @interface FlutterPlatformPlugin ()
67 
68 /**
69  * @brief Whether the status bar appearance is based on the style preferred for this ViewController.
70  *
71  * The default value is YES.
72  * Explicitly add `UIViewControllerBasedStatusBarAppearance` as `false` in
73  * info.plist makes this value to be false.
74  */
75 @property(nonatomic, assign) BOOL enableViewControllerBasedStatusBarAppearance;
76 @property(nonatomic, weak) FlutterEngine* engine;
77 
78 /**
79  * @brief Used to detect whether or not this device supports live text input from the camera.
80  */
81 @property(nonatomic, strong) UITextField* textField;
82 @end
83 
84 @implementation FlutterPlatformPlugin
85 
86 - (instancetype)initWithEngine:(FlutterEngine*)engine {
87  FML_DCHECK(engine) << "engine must be set";
88  self = [super init];
89 
90  if (self) {
91  _engine = engine;
92  NSObject* infoValue = [[NSBundle mainBundle]
93  objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"];
94 #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG
95  if (infoValue != nil && ![infoValue isKindOfClass:[NSNumber class]]) {
96  FML_LOG(ERROR) << "The value of UIViewControllerBasedStatusBarAppearance in info.plist must "
97  "be a Boolean type.";
98  }
99 #endif
100  _enableViewControllerBasedStatusBarAppearance =
101  (infoValue == nil || [(NSNumber*)infoValue boolValue]);
102  }
103 
104  return self;
105 }
106 
107 - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
108  NSString* method = call.method;
109  id args = call.arguments;
110  if ([method isEqualToString:@"SystemSound.play"]) {
111  [self playSystemSound:args];
112  result(nil);
113  } else if ([method isEqualToString:@"HapticFeedback.vibrate"]) {
114  [self vibrateHapticFeedback:args];
115  result(nil);
116  } else if ([method isEqualToString:@"SystemChrome.setPreferredOrientations"]) {
117  [self setSystemChromePreferredOrientations:args];
118  result(nil);
119  } else if ([method isEqualToString:@"SystemChrome.setApplicationSwitcherDescription"]) {
120  [self setSystemChromeApplicationSwitcherDescription:args];
121  result(nil);
122  } else if ([method isEqualToString:@"SystemChrome.setEnabledSystemUIOverlays"]) {
123  [self setSystemChromeEnabledSystemUIOverlays:args];
124  result(nil);
125  } else if ([method isEqualToString:@"SystemChrome.setEnabledSystemUIMode"]) {
126  [self setSystemChromeEnabledSystemUIMode:args];
127  result(nil);
128  } else if ([method isEqualToString:@"SystemChrome.restoreSystemUIOverlays"]) {
129  [self restoreSystemChromeSystemUIOverlays];
130  result(nil);
131  } else if ([method isEqualToString:@"SystemChrome.setSystemUIOverlayStyle"]) {
132  [self setSystemChromeSystemUIOverlayStyle:args];
133  result(nil);
134  } else if ([method isEqualToString:@"SystemNavigator.pop"]) {
135  NSNumber* isAnimated = args;
136  [self popSystemNavigator:isAnimated.boolValue];
137  result(nil);
138  } else if ([method isEqualToString:@"Clipboard.getData"]) {
139  result([self getClipboardData:args]);
140  } else if ([method isEqualToString:@"Clipboard.setData"]) {
141  [self setClipboardData:args];
142  result(nil);
143  } else if ([method isEqualToString:@"Clipboard.hasStrings"]) {
144  result([self clipboardHasStrings]);
145  } else if ([method isEqualToString:@"LiveText.isLiveTextInputAvailable"]) {
146  result(@([self isLiveTextInputAvailable]));
147  } else if ([method isEqualToString:@"SearchWeb.invoke"]) {
148  [self searchWeb:args];
149  result(nil);
150  } else if ([method isEqualToString:@"LookUp.invoke"]) {
151  [self showLookUpViewController:args];
152  result(nil);
153  } else if ([method isEqualToString:@"Share.invoke"]) {
154  [self showShareViewController:args];
155  result(nil);
156  } else if ([method isEqualToString:@"ContextMenu.showSystemContextMenu"]) {
157  [self showSystemContextMenu:args];
158  result(nil);
159  } else if ([method isEqualToString:@"ContextMenu.hideSystemContextMenu"]) {
160  [self hideSystemContextMenu];
161  result(nil);
162  } else {
164  }
165 }
166 
167 - (void)showSystemContextMenu:(NSDictionary*)args {
168  if (@available(iOS 16.0, *)) {
169  FlutterTextInputPlugin* textInputPlugin = [self.engine textInputPlugin];
170  BOOL shownEditMenu = [textInputPlugin showEditMenu:args];
171  if (!shownEditMenu) {
172  FML_LOG(ERROR) << "Only text input supports system context menu for now. Ensure the system "
173  "context menu is shown with an active text input connection. See "
174  "https://github.com/flutter/flutter/issues/143033.";
175  }
176  }
177 }
178 
179 - (void)hideSystemContextMenu {
180  if (@available(iOS 16.0, *)) {
181  FlutterTextInputPlugin* textInputPlugin = [self.engine textInputPlugin];
182  [textInputPlugin hideEditMenu];
183  }
184 }
185 
186 - (void)showShareViewController:(NSString*)content {
187  UIViewController* engineViewController = [self.engine viewController];
188 
189  NSArray* itemsToShare = @[ content ?: [NSNull null] ];
190  UIActivityViewController* activityViewController =
191  [[UIActivityViewController alloc] initWithActivityItems:itemsToShare
192  applicationActivities:nil];
193 
194  if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
195  // On iPad, the share screen is presented in a popover view, and requires a
196  // sourceView and sourceRect
197  FlutterTextInputPlugin* _textInputPlugin = [self.engine textInputPlugin];
198  UITextRange* range = _textInputPlugin.textInputView.selectedTextRange;
199 
200  // firstRectForRange cannot be used here as it's current implementation does
201  // not always return the full rect of the range.
202  CGRect firstRect = [(FlutterTextInputView*)_textInputPlugin.textInputView
203  caretRectForPosition:(FlutterTextPosition*)range.start];
204  CGRect transformedFirstRect = [(FlutterTextInputView*)_textInputPlugin.textInputView
205  localRectFromFrameworkTransform:firstRect];
206  CGRect lastRect = [(FlutterTextInputView*)_textInputPlugin.textInputView
207  caretRectForPosition:(FlutterTextPosition*)range.end];
208  CGRect transformedLastRect = [(FlutterTextInputView*)_textInputPlugin.textInputView
209  localRectFromFrameworkTransform:lastRect];
210 
211  activityViewController.popoverPresentationController.sourceView = engineViewController.view;
212  // In case of RTL Language, get the minimum x coordinate
213  activityViewController.popoverPresentationController.sourceRect =
214  CGRectMake(fmin(transformedFirstRect.origin.x, transformedLastRect.origin.x),
215  transformedFirstRect.origin.y,
216  abs(transformedLastRect.origin.x - transformedFirstRect.origin.x),
217  transformedFirstRect.size.height);
218  }
219 
220  [engineViewController presentViewController:activityViewController animated:YES completion:nil];
221 }
222 
223 - (void)searchWeb:(NSString*)searchTerm {
224  UIApplication* flutterApplication = FlutterSharedApplication.application;
225  if (flutterApplication == nil) {
226  FML_LOG(WARNING) << "SearchWeb.invoke is not availabe in app extension.";
227  return;
228  }
229 
230  NSString* escapedText = [searchTerm
231  stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet
232  URLHostAllowedCharacterSet]];
233  NSString* searchURL = [NSString stringWithFormat:@"%@%@", kSearchURLPrefix, escapedText];
234 
235  [flutterApplication openURL:[NSURL URLWithString:searchURL] options:@{} completionHandler:nil];
236 }
237 
238 - (void)playSystemSound:(NSString*)soundType {
239  if ([soundType isEqualToString:@"SystemSoundType.click"]) {
240  // All feedback types are specific to Android and are treated as equal on
241  // iOS.
242  AudioServicesPlaySystemSound(kKeyPressClickSoundId);
243  }
244 }
245 
246 - (void)vibrateHapticFeedback:(NSString*)feedbackType {
247  if (!feedbackType) {
248  AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
249  return;
250  }
251 
252  if ([@"HapticFeedbackType.lightImpact" isEqualToString:feedbackType]) {
253  [[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight] impactOccurred];
254  } else if ([@"HapticFeedbackType.mediumImpact" isEqualToString:feedbackType]) {
255  [[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium] impactOccurred];
256  } else if ([@"HapticFeedbackType.heavyImpact" isEqualToString:feedbackType]) {
257  [[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleHeavy] impactOccurred];
258  } else if ([@"HapticFeedbackType.selectionClick" isEqualToString:feedbackType]) {
259  [[[UISelectionFeedbackGenerator alloc] init] selectionChanged];
260  }
261 }
262 
263 - (void)setSystemChromePreferredOrientations:(NSArray*)orientations {
264  UIInterfaceOrientationMask mask = 0;
265 
266  if (orientations.count == 0) {
267  mask |= UIInterfaceOrientationMaskAll;
268  } else {
269  for (NSString* orientation in orientations) {
270  if ([orientation isEqualToString:@"DeviceOrientation.portraitUp"]) {
271  mask |= UIInterfaceOrientationMaskPortrait;
272  } else if ([orientation isEqualToString:@"DeviceOrientation.portraitDown"]) {
273  mask |= UIInterfaceOrientationMaskPortraitUpsideDown;
274  } else if ([orientation isEqualToString:@"DeviceOrientation.landscapeLeft"]) {
275  mask |= UIInterfaceOrientationMaskLandscapeLeft;
276  } else if ([orientation isEqualToString:@"DeviceOrientation.landscapeRight"]) {
277  mask |= UIInterfaceOrientationMaskLandscapeRight;
278  }
279  }
280  }
281 
282  if (!mask) {
283  return;
284  }
285  [[NSNotificationCenter defaultCenter]
286  postNotificationName:@(kOrientationUpdateNotificationName)
287  object:nil
288  userInfo:@{@(kOrientationUpdateNotificationKey) : @(mask)}];
289 }
290 
291 - (void)setSystemChromeApplicationSwitcherDescription:(NSDictionary*)object {
292  // No counterpart on iOS but is a benign operation. So no asserts.
293 }
294 
295 - (void)setSystemChromeEnabledSystemUIOverlays:(NSArray*)overlays {
296  BOOL statusBarShouldBeHidden = ![overlays containsObject:@"SystemUiOverlay.top"];
297  if ([overlays containsObject:@"SystemUiOverlay.bottom"]) {
298  [[NSNotificationCenter defaultCenter]
299  postNotificationName:FlutterViewControllerShowHomeIndicator
300  object:nil];
301  } else {
302  [[NSNotificationCenter defaultCenter]
303  postNotificationName:FlutterViewControllerHideHomeIndicator
304  object:nil];
305  }
306  if (self.enableViewControllerBasedStatusBarAppearance) {
307  [self.engine viewController].prefersStatusBarHidden = statusBarShouldBeHidden;
308  } else {
309  // Checks if the top status bar should be visible. This platform ignores all
310  // other overlays
311 
312  // We opt out of view controller based status bar visibility since we want
313  // to be able to modify this on the fly. The key used is
314  // UIViewControllerBasedStatusBarAppearance.
315  SetStatusBarHiddenForSharedApplication(statusBarShouldBeHidden);
316  }
317 }
318 
319 - (void)setSystemChromeEnabledSystemUIMode:(NSString*)mode {
320  BOOL edgeToEdge = [mode isEqualToString:@"SystemUiMode.edgeToEdge"];
321  if (self.enableViewControllerBasedStatusBarAppearance) {
322  [self.engine viewController].prefersStatusBarHidden = !edgeToEdge;
323  } else {
324  // Checks if the top status bar should be visible, reflected by edge to edge setting. This
325  // platform ignores all other system ui modes.
326 
327  // We opt out of view controller based status bar visibility since we want
328  // to be able to modify this on the fly. The key used is
329  // UIViewControllerBasedStatusBarAppearance.
331  }
332  [[NSNotificationCenter defaultCenter]
333  postNotificationName:edgeToEdge ? FlutterViewControllerShowHomeIndicator
334  : FlutterViewControllerHideHomeIndicator
335  object:nil];
336 }
337 
338 - (void)restoreSystemChromeSystemUIOverlays {
339  // Nothing to do on iOS.
340 }
341 
342 - (void)setSystemChromeSystemUIOverlayStyle:(NSDictionary*)message {
343  NSString* brightness = message[@"statusBarBrightness"];
344  if (brightness == (id)[NSNull null]) {
345  return;
346  }
347 
348  UIStatusBarStyle statusBarStyle;
349  if ([brightness isEqualToString:@"Brightness.dark"]) {
350  statusBarStyle = UIStatusBarStyleLightContent;
351  } else if ([brightness isEqualToString:@"Brightness.light"]) {
352  if (@available(iOS 13, *)) {
353  statusBarStyle = UIStatusBarStyleDarkContent;
354  } else {
355  statusBarStyle = UIStatusBarStyleDefault;
356  }
357  } else {
358  return;
359  }
360 
361  if (self.enableViewControllerBasedStatusBarAppearance) {
362  // This notification is respected by the iOS embedder.
363  [[NSNotificationCenter defaultCenter]
364  postNotificationName:@(kOverlayStyleUpdateNotificationName)
365  object:nil
366  userInfo:@{@(kOverlayStyleUpdateNotificationKey) : @(statusBarStyle)}];
367  } else {
369  }
370 }
371 
372 - (void)popSystemNavigator:(BOOL)isAnimated {
373  // Apple's human user guidelines say not to terminate iOS applications. However, if the
374  // root view of the app is a navigation controller, it is instructed to back up a level
375  // in the navigation hierarchy.
376  // It's also possible in an Add2App scenario that the FlutterViewController was presented
377  // outside the context of a UINavigationController, and still wants to be popped.
378 
379  FlutterViewController* engineViewController = [self.engine viewController];
380  UINavigationController* navigationController = [engineViewController navigationController];
381  if (navigationController) {
382  [navigationController popViewControllerAnimated:isAnimated];
383  } else {
384  UIViewController* rootViewController = nil;
385  UIApplication* flutterApplication = FlutterSharedApplication.application;
386  if (flutterApplication) {
387  rootViewController = flutterApplication.keyWindow.rootViewController;
388  } else {
389  if (@available(iOS 15.0, *)) {
390  rootViewController =
391  [engineViewController flutterWindowSceneIfViewLoaded].keyWindow.rootViewController;
392  } else {
393  FML_LOG(WARNING)
394  << "rootViewController is not available in application extension prior to iOS 15.0.";
395  }
396  }
397 
398  if (engineViewController != rootViewController) {
399  [engineViewController dismissViewControllerAnimated:isAnimated completion:nil];
400  }
401  }
402 }
403 
404 - (NSDictionary*)getClipboardData:(NSString*)format {
405  UIPasteboard* pasteboard = [UIPasteboard generalPasteboard];
406  if (!format || [format isEqualToString:@(kTextPlainFormat)]) {
407  NSString* stringInPasteboard = pasteboard.string;
408  // The pasteboard may contain an item but it may not be a string (an image for instance).
409  return stringInPasteboard == nil ? nil : @{@"text" : stringInPasteboard};
410  }
411  return nil;
412 }
413 
414 - (void)setClipboardData:(NSDictionary*)data {
415  UIPasteboard* pasteboard = [UIPasteboard generalPasteboard];
416  id copyText = data[@"text"];
417  if ([copyText isKindOfClass:[NSString class]]) {
418  pasteboard.string = copyText;
419  } else {
420  pasteboard.string = @"null";
421  }
422 }
423 
424 - (NSDictionary*)clipboardHasStrings {
425  return @{@"value" : @([UIPasteboard generalPasteboard].hasStrings)};
426 }
427 
428 - (BOOL)isLiveTextInputAvailable {
429  return [[self textField] canPerformAction:@selector(captureTextFromCamera:) withSender:nil];
430 }
431 
432 - (void)showLookUpViewController:(NSString*)term {
433  UIViewController* engineViewController = [self.engine viewController];
434  UIReferenceLibraryViewController* referenceLibraryViewController =
435  [[UIReferenceLibraryViewController alloc] initWithTerm:term];
436  [engineViewController presentViewController:referenceLibraryViewController
437  animated:YES
438  completion:nil];
439 }
440 
441 - (UITextField*)textField {
442  if (_textField == nil) {
443  _textField = [[UITextField alloc] init];
444  }
445  return _textField;
446 }
447 
448 @end
void(^ FlutterResult)(id _Nullable result)
FLUTTER_DARWIN_EXPORT NSObject const * FlutterMethodNotImplemented
static void SetStatusBarStyleForSharedApplication(UIStatusBarStyle style)
static void SetStatusBarHiddenForSharedApplication(BOOL hidden)
FlutterTextInputPlugin * textInputPlugin
UIView< UITextInput > * textInputView()
BOOL showEditMenu:(ios(16.0) API_AVAILABLE)
constexpr char kTextPlainFormat[]
const UInt32 kKeyPressClickSoundId
NSString *const kSearchURLPrefix
const char *const kOrientationUpdateNotificationKey
const char *const kOverlayStyleUpdateNotificationName
const char *const kOverlayStyleUpdateNotificationKey
const char *const kOrientationUpdateNotificationName