| 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
5 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h"
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/ios/framework/Source/FlutterEngine_Internal.h"
14 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h"
15 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h"
16 #import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h"
17
18 FLUTTER_ASSERT_ARC
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.
34 const char* const kOrientationUpdateNotificationName =
35 "io.flutter.plugin.platform.SystemChromeOrientationNotificationName";
36 const char* const kOrientationUpdateNotificationKey =
37 "io.flutter.plugin.platform.SystemChromeOrientationNotificationKey";
38 const char* const kOverlayStyleUpdateNotificationName =
39 "io.flutter.plugin.platform.SystemChromeOverlayNotificationName";
40 const char* const kOverlayStyleUpdateNotificationKey =
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 {
162 result(FlutterMethodNotImplemented);
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.
329 SetStatusBarHiddenForSharedApplication(!edgeToEdge);
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 {
367 SetStatusBarStyleForSharedApplication(statusBarStyle);
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