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