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