| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #define FML_USED_ON_EMBEDDER |
| |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h" |
| |
| #import <os/log.h> |
| #include <memory> |
| |
| #include "flutter/common/constants.h" |
| #include "flutter/fml/memory/weak_ptr.h" |
| #include "flutter/fml/message_loop.h" |
| #include "flutter/fml/platform/darwin/platform_version.h" |
| #include "flutter/fml/platform/darwin/scoped_nsobject.h" |
| #include "flutter/runtime/ptrace_check.h" |
| #include "flutter/shell/common/thread_host.h" |
| #import "flutter/shell/platform/darwin/common/framework/Source/FlutterBinaryMessengerRelay.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterChannelKeyResponder.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEmbedderKeyResponder.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyPrimaryResponder.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardManager.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/platform_message_response_darwin.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h" |
| #import "flutter/shell/platform/darwin/ios/platform_view_ios.h" |
| #import "flutter/shell/platform/embedder/embedder.h" |
| #import "flutter/third_party/spring_animation/spring_animation.h" |
| |
| static constexpr int kMicrosecondsPerSecond = 1000 * 1000; |
| static constexpr CGFloat kScrollViewContentSize = 2.0; |
| |
| static NSString* const kFlutterRestorationStateAppData = @"FlutterRestorationStateAppData"; |
| |
| NSNotificationName const FlutterSemanticsUpdateNotification = @"FlutterSemanticsUpdate"; |
| NSNotificationName const FlutterViewControllerWillDealloc = @"FlutterViewControllerWillDealloc"; |
| NSNotificationName const FlutterViewControllerHideHomeIndicator = |
| @"FlutterViewControllerHideHomeIndicator"; |
| NSNotificationName const FlutterViewControllerShowHomeIndicator = |
| @"FlutterViewControllerShowHomeIndicator"; |
| |
| // Struct holding data to help adapt system mouse/trackpad events to embedder events. |
| typedef struct MouseState { |
| // Current coordinate of the mouse cursor in physical device pixels. |
| CGPoint location = CGPointZero; |
| |
| // Last reported translation for an in-flight pan gesture in physical device pixels. |
| CGPoint last_translation = CGPointZero; |
| } MouseState; |
| |
| // This is left a FlutterBinaryMessenger privately for now to give people a chance to notice the |
| // change. Unfortunately unless you have Werror turned on, incompatible pointers as arguments are |
| // just a warning. |
| @interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegate> |
| // TODO(dkwingsmt): Make the view ID property public once the iOS shell |
| // supports multiple views. |
| // https://github.com/flutter/flutter/issues/138168 |
| @property(nonatomic, readonly) int64_t viewIdentifier; |
| |
| @property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI; |
| @property(nonatomic, assign) BOOL isHomeIndicatorHidden; |
| @property(nonatomic, assign) BOOL isPresentingViewControllerAnimating; |
| |
| /** |
| * Whether we should ignore viewport metrics updates during rotation transition. |
| */ |
| @property(nonatomic, assign) BOOL shouldIgnoreViewportMetricsUpdatesDuringRotation; |
| |
| /** |
| * Keyboard animation properties |
| */ |
| @property(nonatomic, assign) CGFloat targetViewInsetBottom; |
| @property(nonatomic, assign) CGFloat originalViewInsetBottom; |
| @property(nonatomic, retain) VSyncClient* keyboardAnimationVSyncClient; |
| @property(nonatomic, assign) BOOL keyboardAnimationIsShowing; |
| @property(nonatomic, assign) fml::TimePoint keyboardAnimationStartTime; |
| @property(nonatomic, assign) BOOL isKeyboardInOrTransitioningFromBackground; |
| |
| /// VSyncClient for touch events delivery frame rate correction. |
| /// |
| /// On promotion devices(eg: iPhone13 Pro), the delivery frame rate of touch events is 60HZ |
| /// but the frame rate of rendering is 120HZ, which is different and will leads jitter and laggy. |
| /// With this VSyncClient, it can correct the delivery frame rate of touch events to let it keep |
| /// the same with frame rate of rendering. |
| @property(nonatomic, retain) VSyncClient* touchRateCorrectionVSyncClient; |
| |
| /* |
| * Mouse and trackpad gesture recognizers |
| */ |
| // Mouse and trackpad hover |
| @property(nonatomic, retain) |
| UIHoverGestureRecognizer* hoverGestureRecognizer API_AVAILABLE(ios(13.4)); |
| // Mouse wheel scrolling |
| @property(nonatomic, retain) |
| UIPanGestureRecognizer* discreteScrollingPanGestureRecognizer API_AVAILABLE(ios(13.4)); |
| // Trackpad and Magic Mouse scrolling |
| @property(nonatomic, retain) |
| UIPanGestureRecognizer* continuousScrollingPanGestureRecognizer API_AVAILABLE(ios(13.4)); |
| // Trackpad pinching |
| @property(nonatomic, retain) |
| UIPinchGestureRecognizer* pinchGestureRecognizer API_AVAILABLE(ios(13.4)); |
| // Trackpad rotating |
| @property(nonatomic, retain) |
| UIRotationGestureRecognizer* rotationGestureRecognizer API_AVAILABLE(ios(13.4)); |
| |
| /** |
| * Creates and registers plugins used by this view controller. |
| */ |
| - (void)addInternalPlugins; |
| - (void)deregisterNotifications; |
| @end |
| |
| @implementation FlutterViewController { |
| std::unique_ptr<fml::WeakNSObjectFactory<FlutterViewController>> _weakFactory; |
| fml::scoped_nsobject<FlutterEngine> _engine; |
| |
| // We keep a separate reference to this and create it ahead of time because we want to be able to |
| // set up a shell along with its platform view before the view has to appear. |
| fml::scoped_nsobject<FlutterView> _flutterView; |
| fml::scoped_nsobject<UIView> _splashScreenView; |
| fml::ScopedBlock<void (^)(void)> _flutterViewRenderedCallback; |
| UIInterfaceOrientationMask _orientationPreferences; |
| UIStatusBarStyle _statusBarStyle; |
| flutter::ViewportMetrics _viewportMetrics; |
| BOOL _initialized; |
| BOOL _viewOpaque; |
| BOOL _engineNeedsLaunch; |
| fml::scoped_nsobject<NSMutableSet<NSNumber*>> _ongoingTouches; |
| // This scroll view is a workaround to accommodate iOS 13 and higher. There isn't a way to get |
| // touches on the status bar to trigger scrolling to the top of a scroll view. We place a |
| // UIScrollView with height zero and a content offset so we can get those events. See also: |
| // https://github.com/flutter/flutter/issues/35050 |
| fml::scoped_nsobject<UIScrollView> _scrollView; |
| fml::scoped_nsobject<UIView> _keyboardAnimationView; |
| fml::scoped_nsobject<SpringAnimation> _keyboardSpringAnimation; |
| MouseState _mouseState; |
| // Timestamp after which a scroll inertia cancel event should be inferred. |
| NSTimeInterval _scrollInertiaEventStartline; |
| // When an iOS app is running in emulation on an Apple Silicon Mac, trackpad input goes through |
| // a translation layer, and events are not received with precise deltas. Due to this, we can't |
| // rely on checking for a stationary trackpad event. Fortunately, AppKit will send an event of |
| // type UIEventTypeScroll following a scroll when inertia should stop. This field is needed to |
| // estimate if such an event represents the natural end of scrolling inertia or a user-initiated |
| // cancellation. |
| NSTimeInterval _scrollInertiaEventAppKitDeadline; |
| } |
| |
| @synthesize displayingFlutterUI = _displayingFlutterUI; |
| @synthesize prefersStatusBarHidden = _flutterPrefersStatusBarHidden; |
| @dynamic viewIdentifier; |
| |
| #pragma mark - Manage and override all designated initializers |
| |
| - (instancetype)initWithEngine:(FlutterEngine*)engine |
| nibName:(nullable NSString*)nibName |
| bundle:(nullable NSBundle*)nibBundle { |
| NSAssert(engine != nil, @"Engine is required"); |
| self = [super initWithNibName:nibName bundle:nibBundle]; |
| if (self) { |
| _viewOpaque = YES; |
| if (engine.viewController) { |
| FML_LOG(ERROR) << "The supplied FlutterEngine " << [[engine description] UTF8String] |
| << " is already used with FlutterViewController instance " |
| << [[engine.viewController description] UTF8String] |
| << ". One instance of the FlutterEngine can only be attached to one " |
| "FlutterViewController at a time. Set FlutterEngine.viewController " |
| "to nil before attaching it to another FlutterViewController."; |
| } |
| _engine.reset([engine retain]); |
| _engineNeedsLaunch = NO; |
| _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine |
| opaque:self.isViewOpaque |
| enableWideGamut:engine.project.isWideGamutEnabled]); |
| _weakFactory = std::make_unique<fml::WeakNSObjectFactory<FlutterViewController>>(self); |
| _ongoingTouches.reset([[NSMutableSet alloc] init]); |
| |
| [self performCommonViewControllerInitialization]; |
| [engine setViewController:self]; |
| } |
| |
| return self; |
| } |
| |
| - (instancetype)initWithProject:(FlutterDartProject*)project |
| nibName:(NSString*)nibName |
| bundle:(NSBundle*)nibBundle { |
| self = [super initWithNibName:nibName bundle:nibBundle]; |
| if (self) { |
| [self sharedSetupWithProject:project initialRoute:nil]; |
| } |
| |
| return self; |
| } |
| |
| - (instancetype)initWithProject:(FlutterDartProject*)project |
| initialRoute:(NSString*)initialRoute |
| nibName:(NSString*)nibName |
| bundle:(NSBundle*)nibBundle { |
| self = [super initWithNibName:nibName bundle:nibBundle]; |
| if (self) { |
| [self sharedSetupWithProject:project initialRoute:initialRoute]; |
| } |
| |
| return self; |
| } |
| |
| - (instancetype)initWithNibName:(NSString*)nibNameOrNil bundle:(NSBundle*)nibBundleOrNil { |
| return [self initWithProject:nil nibName:nil bundle:nil]; |
| } |
| |
| - (instancetype)initWithCoder:(NSCoder*)aDecoder { |
| self = [super initWithCoder:aDecoder]; |
| return self; |
| } |
| |
| - (void)awakeFromNib { |
| [super awakeFromNib]; |
| if (!_engine) { |
| [self sharedSetupWithProject:nil initialRoute:nil]; |
| } |
| } |
| |
| - (instancetype)init { |
| return [self initWithProject:nil nibName:nil bundle:nil]; |
| } |
| |
| - (void)sharedSetupWithProject:(nullable FlutterDartProject*)project |
| initialRoute:(nullable NSString*)initialRoute { |
| // Need the project to get settings for the view. Initializing it here means |
| // the Engine class won't initialize it later. |
| if (!project) { |
| project = [[[FlutterDartProject alloc] init] autorelease]; |
| } |
| FlutterView.forceSoftwareRendering = project.settings.enable_software_rendering; |
| _weakFactory = std::make_unique<fml::WeakNSObjectFactory<FlutterViewController>>(self); |
| auto engine = fml::scoped_nsobject<FlutterEngine>{[[FlutterEngine alloc] |
| initWithName:@"io.flutter" |
| project:project |
| allowHeadlessExecution:self.engineAllowHeadlessExecution |
| restorationEnabled:[self restorationIdentifier] != nil]}; |
| |
| if (!engine) { |
| return; |
| } |
| |
| _viewOpaque = YES; |
| _engine = engine; |
| _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine |
| opaque:self.isViewOpaque |
| enableWideGamut:project.isWideGamutEnabled]); |
| [_engine.get() createShell:nil libraryURI:nil initialRoute:initialRoute]; |
| _engineNeedsLaunch = YES; |
| _ongoingTouches.reset([[NSMutableSet alloc] init]); |
| [self loadDefaultSplashScreenView]; |
| [self performCommonViewControllerInitialization]; |
| } |
| |
| - (BOOL)isViewOpaque { |
| return _viewOpaque; |
| } |
| |
| - (void)setViewOpaque:(BOOL)value { |
| _viewOpaque = value; |
| if (_flutterView.get().layer.opaque != value) { |
| _flutterView.get().layer.opaque = value; |
| [_flutterView.get().layer setNeedsLayout]; |
| } |
| } |
| |
| #pragma mark - Common view controller initialization tasks |
| |
| - (void)performCommonViewControllerInitialization { |
| if (_initialized) { |
| return; |
| } |
| |
| _initialized = YES; |
| |
| _orientationPreferences = UIInterfaceOrientationMaskAll; |
| _statusBarStyle = UIStatusBarStyleDefault; |
| |
| [self setUpNotificationCenterObservers]; |
| } |
| |
| - (FlutterEngine*)engine { |
| return _engine.get(); |
| } |
| |
| - (fml::WeakNSObject<FlutterViewController>)getWeakNSObject { |
| return _weakFactory->GetWeakNSObject(); |
| } |
| |
| - (void)setUpNotificationCenterObservers { |
| NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; |
| [center addObserver:self |
| selector:@selector(onOrientationPreferencesUpdated:) |
| name:@(flutter::kOrientationUpdateNotificationName) |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onPreferredStatusBarStyleUpdated:) |
| name:@(flutter::kOverlayStyleUpdateNotificationName) |
| object:nil]; |
| |
| #if APPLICATION_EXTENSION_API_ONLY |
| if (@available(iOS 13.0, *)) { |
| [self setUpSceneLifecycleNotifications:center]; |
| } else { |
| [self setUpApplicationLifecycleNotifications:center]; |
| } |
| #else |
| [self setUpApplicationLifecycleNotifications:center]; |
| #endif |
| |
| [center addObserver:self |
| selector:@selector(keyboardWillChangeFrame:) |
| name:UIKeyboardWillChangeFrameNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(keyboardWillShowNotification:) |
| name:UIKeyboardWillShowNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(keyboardWillBeHidden:) |
| name:UIKeyboardWillHideNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityVoiceOverStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilitySwitchControlStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilitySpeakScreenStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityInvertColorsStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityReduceMotionStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityBoldTextStatusDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityDarkerSystemColorsStatusDidChangeNotification |
| object:nil]; |
| |
| if (@available(iOS 13.0, *)) { |
| [center addObserver:self |
| selector:@selector(onAccessibilityStatusChanged:) |
| name:UIAccessibilityOnOffSwitchLabelsDidChangeNotification |
| object:nil]; |
| } |
| |
| [center addObserver:self |
| selector:@selector(onUserSettingsChanged:) |
| name:UIContentSizeCategoryDidChangeNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onHideHomeIndicatorNotification:) |
| name:FlutterViewControllerHideHomeIndicator |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(onShowHomeIndicatorNotification:) |
| name:FlutterViewControllerShowHomeIndicator |
| object:nil]; |
| } |
| |
| - (void)setUpSceneLifecycleNotifications:(NSNotificationCenter*)center API_AVAILABLE(ios(13.0)) { |
| [center addObserver:self |
| selector:@selector(sceneBecameActive:) |
| name:UISceneDidActivateNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(sceneWillResignActive:) |
| name:UISceneWillDeactivateNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(sceneWillDisconnect:) |
| name:UISceneDidDisconnectNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(sceneDidEnterBackground:) |
| name:UISceneDidEnterBackgroundNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(sceneWillEnterForeground:) |
| name:UISceneWillEnterForegroundNotification |
| object:nil]; |
| } |
| |
| - (void)setUpApplicationLifecycleNotifications:(NSNotificationCenter*)center { |
| [center addObserver:self |
| selector:@selector(applicationBecameActive:) |
| name:UIApplicationDidBecomeActiveNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(applicationWillResignActive:) |
| name:UIApplicationWillResignActiveNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(applicationWillTerminate:) |
| name:UIApplicationWillTerminateNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(applicationDidEnterBackground:) |
| name:UIApplicationDidEnterBackgroundNotification |
| object:nil]; |
| |
| [center addObserver:self |
| selector:@selector(applicationWillEnterForeground:) |
| name:UIApplicationWillEnterForegroundNotification |
| object:nil]; |
| } |
| |
| - (void)setInitialRoute:(NSString*)route { |
| [[_engine.get() navigationChannel] invokeMethod:@"setInitialRoute" arguments:route]; |
| } |
| |
| - (void)popRoute { |
| [[_engine.get() navigationChannel] invokeMethod:@"popRoute" arguments:nil]; |
| } |
| |
| - (void)pushRoute:(NSString*)route { |
| [[_engine.get() navigationChannel] invokeMethod:@"pushRoute" arguments:route]; |
| } |
| |
| #pragma mark - Loading the view |
| |
| static UIView* GetViewOrPlaceholder(UIView* existing_view) { |
| if (existing_view) { |
| return existing_view; |
| } |
| |
| auto placeholder = [[[UIView alloc] init] autorelease]; |
| |
| placeholder.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; |
| if (@available(iOS 13.0, *)) { |
| placeholder.backgroundColor = UIColor.systemBackgroundColor; |
| } else { |
| placeholder.backgroundColor = UIColor.whiteColor; |
| } |
| placeholder.autoresizesSubviews = YES; |
| |
| // Only add the label when we know we have failed to enable tracing (and it was necessary). |
| // Otherwise, a spurious warning will be shown in cases where an engine cannot be initialized for |
| // other reasons. |
| if (flutter::GetTracingResult() == flutter::TracingResult::kDisabled) { |
| auto messageLabel = [[[UILabel alloc] init] autorelease]; |
| messageLabel.numberOfLines = 0u; |
| messageLabel.textAlignment = NSTextAlignmentCenter; |
| messageLabel.autoresizingMask = |
| UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; |
| messageLabel.text = |
| @"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling, " |
| @"IDEs with Flutter plugins or from Xcode.\n\nAlternatively, build in profile or release " |
| @"modes to enable launching from the home screen."; |
| [placeholder addSubview:messageLabel]; |
| } |
| |
| return placeholder; |
| } |
| |
| - (void)loadView { |
| self.view = GetViewOrPlaceholder(_flutterView.get()); |
| self.view.multipleTouchEnabled = YES; |
| self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; |
| |
| [self installSplashScreenViewIfNecessary]; |
| UIScrollView* scrollView = [[UIScrollView alloc] init]; |
| scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth; |
| // The color shouldn't matter since it is offscreen. |
| scrollView.backgroundColor = UIColor.whiteColor; |
| scrollView.delegate = self; |
| // This is an arbitrary small size. |
| scrollView.contentSize = CGSizeMake(kScrollViewContentSize, kScrollViewContentSize); |
| // This is an arbitrary offset that is not CGPointZero. |
| scrollView.contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize); |
| [self.view addSubview:scrollView]; |
| _scrollView.reset(scrollView); |
| } |
| |
| - (flutter::PointerData)generatePointerDataForFake { |
| flutter::PointerData pointer_data; |
| pointer_data.Clear(); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kTouch; |
| // `UITouch.timestamp` is defined as seconds since system startup. Synthesized events can get this |
| // time with `NSProcessInfo.systemUptime`. See |
| // https://developer.apple.com/documentation/uikit/uitouch/1618144-timestamp?language=objc |
| pointer_data.time_stamp = [[NSProcessInfo processInfo] systemUptime] * kMicrosecondsPerSecond; |
| return pointer_data; |
| } |
| |
| static void SendFakeTouchEvent(UIScreen* screen, |
| FlutterEngine* engine, |
| CGPoint location, |
| flutter::PointerData::Change change) { |
| const CGFloat scale = screen.scale; |
| flutter::PointerData pointer_data = [[engine viewController] generatePointerDataForFake]; |
| pointer_data.physical_x = location.x * scale; |
| pointer_data.physical_y = location.y * scale; |
| auto packet = std::make_unique<flutter::PointerDataPacket>(/*count=*/1); |
| pointer_data.change = change; |
| packet->SetPointerData(0, pointer_data); |
| [engine dispatchPointerDataPacket:std::move(packet)]; |
| } |
| |
| - (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView { |
| if (!_engine) { |
| return NO; |
| } |
| CGPoint statusBarPoint = CGPointZero; |
| UIScreen* screen = [self flutterScreenIfViewLoaded]; |
| if (screen) { |
| SendFakeTouchEvent(screen, _engine.get(), statusBarPoint, flutter::PointerData::Change::kDown); |
| SendFakeTouchEvent(screen, _engine.get(), statusBarPoint, flutter::PointerData::Change::kUp); |
| } |
| return NO; |
| } |
| |
| #pragma mark - Managing launch views |
| |
| - (void)installSplashScreenViewIfNecessary { |
| // Show the launch screen view again on top of the FlutterView if available. |
| // This launch screen view will be removed once the first Flutter frame is rendered. |
| if (_splashScreenView && (self.isBeingPresented || self.isMovingToParentViewController)) { |
| [_splashScreenView.get() removeFromSuperview]; |
| _splashScreenView.reset(); |
| return; |
| } |
| |
| // Use the property getter to initialize the default value. |
| UIView* splashScreenView = self.splashScreenView; |
| if (splashScreenView == nil) { |
| return; |
| } |
| splashScreenView.frame = self.view.bounds; |
| [self.view addSubview:splashScreenView]; |
| } |
| |
| + (BOOL)automaticallyNotifiesObserversOfDisplayingFlutterUI { |
| return NO; |
| } |
| |
| - (void)setDisplayingFlutterUI:(BOOL)displayingFlutterUI { |
| if (_displayingFlutterUI != displayingFlutterUI) { |
| if (displayingFlutterUI == YES) { |
| if (!self.viewIfLoaded.window) { |
| return; |
| } |
| } |
| [self willChangeValueForKey:@"displayingFlutterUI"]; |
| _displayingFlutterUI = displayingFlutterUI; |
| [self didChangeValueForKey:@"displayingFlutterUI"]; |
| } |
| } |
| |
| - (void)callViewRenderedCallback { |
| self.displayingFlutterUI = YES; |
| if (_flutterViewRenderedCallback != nil) { |
| _flutterViewRenderedCallback.get()(); |
| _flutterViewRenderedCallback.reset(); |
| } |
| } |
| |
| - (void)removeSplashScreenView:(dispatch_block_t _Nullable)onComplete { |
| NSAssert(_splashScreenView, @"The splash screen view must not be null"); |
| UIView* splashScreen = [_splashScreenView.get() retain]; |
| _splashScreenView.reset(); |
| [UIView animateWithDuration:0.2 |
| animations:^{ |
| splashScreen.alpha = 0; |
| } |
| completion:^(BOOL finished) { |
| [splashScreen removeFromSuperview]; |
| [splashScreen release]; |
| if (onComplete) { |
| onComplete(); |
| } |
| }]; |
| } |
| |
| - (void)installFirstFrameCallback { |
| if (!_engine) { |
| return; |
| } |
| |
| fml::WeakPtr<flutter::PlatformViewIOS> weakPlatformView = [_engine.get() platformView]; |
| if (!weakPlatformView) { |
| return; |
| } |
| |
| // Start on the platform thread. |
| weakPlatformView->SetNextFrameCallback([weakSelf = [self getWeakNSObject], |
| platformTaskRunner = [_engine.get() platformTaskRunner], |
| rasterTaskRunner = [_engine.get() rasterTaskRunner]]() { |
| FML_DCHECK(rasterTaskRunner->RunsTasksOnCurrentThread()); |
| // Get callback on raster thread and jump back to platform thread. |
| platformTaskRunner->PostTask([weakSelf]() { |
| if (weakSelf) { |
| fml::scoped_nsobject<FlutterViewController> flutterViewController( |
| [(FlutterViewController*)weakSelf.get() retain]); |
| if (flutterViewController) { |
| if (flutterViewController.get()->_splashScreenView) { |
| [flutterViewController removeSplashScreenView:^{ |
| [flutterViewController callViewRenderedCallback]; |
| }]; |
| } else { |
| [flutterViewController callViewRenderedCallback]; |
| } |
| } |
| } |
| }); |
| }); |
| } |
| |
| #pragma mark - Properties |
| |
| - (int64_t)viewIdentifier { |
| // TODO(dkwingsmt): Fill the view ID property with the correct value once the |
| // iOS shell supports multiple views. |
| return flutter::kFlutterImplicitViewId; |
| } |
| |
| - (UIView*)splashScreenView { |
| if (!_splashScreenView) { |
| return nil; |
| } |
| return _splashScreenView.get(); |
| } |
| |
| - (UIView*)keyboardAnimationView { |
| return _keyboardAnimationView.get(); |
| } |
| |
| - (SpringAnimation*)keyboardSpringAnimation { |
| return _keyboardSpringAnimation.get(); |
| } |
| |
| - (BOOL)loadDefaultSplashScreenView { |
| NSString* launchscreenName = |
| [[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"]; |
| if (launchscreenName == nil) { |
| return NO; |
| } |
| UIView* splashView = [self splashScreenFromStoryboard:launchscreenName]; |
| if (!splashView) { |
| splashView = [self splashScreenFromXib:launchscreenName]; |
| } |
| if (!splashView) { |
| return NO; |
| } |
| self.splashScreenView = splashView; |
| return YES; |
| } |
| |
| - (UIView*)splashScreenFromStoryboard:(NSString*)name { |
| UIStoryboard* storyboard = nil; |
| @try { |
| storyboard = [UIStoryboard storyboardWithName:name bundle:nil]; |
| } @catch (NSException* exception) { |
| return nil; |
| } |
| if (storyboard) { |
| UIViewController* splashScreenViewController = [storyboard instantiateInitialViewController]; |
| return splashScreenViewController.view; |
| } |
| return nil; |
| } |
| |
| - (UIView*)splashScreenFromXib:(NSString*)name { |
| NSArray* objects = nil; |
| @try { |
| objects = [[NSBundle mainBundle] loadNibNamed:name owner:self options:nil]; |
| } @catch (NSException* exception) { |
| return nil; |
| } |
| if ([objects count] != 0) { |
| UIView* view = [objects objectAtIndex:0]; |
| return view; |
| } |
| return nil; |
| } |
| |
| - (void)setSplashScreenView:(UIView*)view { |
| if (!view) { |
| // Special case: user wants to remove the splash screen view. |
| if (_splashScreenView) { |
| [self removeSplashScreenView:nil]; |
| } |
| return; |
| } |
| |
| _splashScreenView.reset([view retain]); |
| _splashScreenView.get().autoresizingMask = |
| UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; |
| } |
| |
| - (void)setFlutterViewDidRenderCallback:(void (^)(void))callback { |
| _flutterViewRenderedCallback.reset(callback, fml::scoped_policy::OwnershipPolicy::kRetain); |
| } |
| |
| #pragma mark - Surface creation and teardown updates |
| |
| - (void)surfaceUpdated:(BOOL)appeared { |
| if (!_engine) { |
| return; |
| } |
| |
| // NotifyCreated/NotifyDestroyed are synchronous and require hops between the UI and raster |
| // thread. |
| if (appeared) { |
| [self installFirstFrameCallback]; |
| [_engine.get() platformViewsController]->SetFlutterView(_flutterView.get()); |
| [_engine.get() platformViewsController]->SetFlutterViewController(self); |
| [_engine.get() iosPlatformView]->NotifyCreated(); |
| } else { |
| self.displayingFlutterUI = NO; |
| [_engine.get() iosPlatformView]->NotifyDestroyed(); |
| [_engine.get() platformViewsController]->SetFlutterView(nullptr); |
| [_engine.get() platformViewsController]->SetFlutterViewController(nullptr); |
| } |
| } |
| |
| #pragma mark - UIViewController lifecycle notifications |
| |
| - (void)viewDidLoad { |
| TRACE_EVENT0("flutter", "viewDidLoad"); |
| |
| if (_engine && _engineNeedsLaunch) { |
| [_engine.get() launchEngine:nil libraryURI:nil entrypointArgs:nil]; |
| [_engine.get() setViewController:self]; |
| _engineNeedsLaunch = NO; |
| } else if ([_engine.get() viewController] == self) { |
| [_engine.get() attachView]; |
| } |
| |
| // Register internal plugins. |
| [self addInternalPlugins]; |
| |
| // Create a vsync client to correct delivery frame rate of touch events if needed. |
| [self createTouchRateCorrectionVSyncClientIfNeeded]; |
| |
| if (@available(iOS 13.4, *)) { |
| _hoverGestureRecognizer = |
| [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(hoverEvent:)]; |
| _hoverGestureRecognizer.delegate = self; |
| [_flutterView.get() addGestureRecognizer:_hoverGestureRecognizer]; |
| |
| _discreteScrollingPanGestureRecognizer = |
| [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(discreteScrollEvent:)]; |
| _discreteScrollingPanGestureRecognizer.allowedScrollTypesMask = UIScrollTypeMaskDiscrete; |
| // Disallowing all touch types. If touch events are allowed here, touches to the screen will be |
| // consumed by the UIGestureRecognizer instead of being passed through to flutter via |
| // touchesBegan. Trackpad and mouse scrolls are sent by the platform as scroll events rather |
| // than touch events, so they will still be received. |
| _discreteScrollingPanGestureRecognizer.allowedTouchTypes = @[]; |
| _discreteScrollingPanGestureRecognizer.delegate = self; |
| [_flutterView.get() addGestureRecognizer:_discreteScrollingPanGestureRecognizer]; |
| _continuousScrollingPanGestureRecognizer = |
| [[UIPanGestureRecognizer alloc] initWithTarget:self |
| action:@selector(continuousScrollEvent:)]; |
| _continuousScrollingPanGestureRecognizer.allowedScrollTypesMask = UIScrollTypeMaskContinuous; |
| _continuousScrollingPanGestureRecognizer.allowedTouchTypes = @[]; |
| _continuousScrollingPanGestureRecognizer.delegate = self; |
| [_flutterView.get() addGestureRecognizer:_continuousScrollingPanGestureRecognizer]; |
| _pinchGestureRecognizer = |
| [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinchEvent:)]; |
| _pinchGestureRecognizer.allowedTouchTypes = @[]; |
| _pinchGestureRecognizer.delegate = self; |
| [_flutterView.get() addGestureRecognizer:_pinchGestureRecognizer]; |
| _rotationGestureRecognizer = [[UIRotationGestureRecognizer alloc] init]; |
| _rotationGestureRecognizer.allowedTouchTypes = @[]; |
| _rotationGestureRecognizer.delegate = self; |
| [_flutterView.get() addGestureRecognizer:_rotationGestureRecognizer]; |
| } |
| |
| [super viewDidLoad]; |
| } |
| |
| - (void)addInternalPlugins { |
| self.keyboardManager = [[[FlutterKeyboardManager alloc] init] autorelease]; |
| fml::WeakNSObject<FlutterViewController> weakSelf = [self getWeakNSObject]; |
| FlutterSendKeyEvent sendEvent = |
| ^(const FlutterKeyEvent& event, FlutterKeyEventCallback callback, void* userData) { |
| if (weakSelf) { |
| [weakSelf.get()->_engine.get() sendKeyEvent:event callback:callback userData:userData]; |
| } |
| }; |
| [self.keyboardManager addPrimaryResponder:[[[FlutterEmbedderKeyResponder alloc] |
| initWithSendEvent:sendEvent] autorelease]]; |
| FlutterChannelKeyResponder* responder = [[[FlutterChannelKeyResponder alloc] |
| initWithChannel:self.engine.keyEventChannel] autorelease]; |
| [self.keyboardManager addPrimaryResponder:responder]; |
| FlutterTextInputPlugin* textInputPlugin = self.engine.textInputPlugin; |
| if (textInputPlugin != nil) { |
| [self.keyboardManager addSecondaryResponder:textInputPlugin]; |
| } |
| if ([_engine.get() viewController] == self) { |
| [textInputPlugin setUpIndirectScribbleInteraction:self]; |
| } |
| } |
| |
| - (void)removeInternalPlugins { |
| self.keyboardManager = nil; |
| } |
| |
| - (void)viewWillAppear:(BOOL)animated { |
| TRACE_EVENT0("flutter", "viewWillAppear"); |
| if ([_engine.get() viewController] == self) { |
| // Send platform settings to Flutter, e.g., platform brightness. |
| [self onUserSettingsChanged:nil]; |
| |
| // Only recreate surface on subsequent appearances when viewport metrics are known. |
| // First time surface creation is done on viewDidLayoutSubviews. |
| if (_viewportMetrics.physical_width) { |
| [self surfaceUpdated:YES]; |
| } |
| [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"]; |
| [[_engine.get() restorationPlugin] markRestorationComplete]; |
| } |
| |
| [super viewWillAppear:animated]; |
| } |
| |
| - (void)viewDidAppear:(BOOL)animated { |
| TRACE_EVENT0("flutter", "viewDidAppear"); |
| if ([_engine.get() viewController] == self) { |
| [self onUserSettingsChanged:nil]; |
| [self onAccessibilityStatusChanged:nil]; |
| BOOL stateIsActive = YES; |
| #if APPLICATION_EXTENSION_API_ONLY |
| if (@available(iOS 13.0, *)) { |
| stateIsActive = self.flutterWindowSceneIfViewLoaded.activationState == |
| UISceneActivationStateForegroundActive; |
| } |
| #else |
| stateIsActive = UIApplication.sharedApplication.applicationState == UIApplicationStateActive; |
| #endif |
| if (stateIsActive) { |
| [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.resumed"]; |
| } |
| } |
| [super viewDidAppear:animated]; |
| } |
| |
| - (void)viewWillDisappear:(BOOL)animated { |
| TRACE_EVENT0("flutter", "viewWillDisappear"); |
| if ([_engine.get() viewController] == self) { |
| [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"]; |
| } |
| [super viewWillDisappear:animated]; |
| } |
| |
| - (void)viewDidDisappear:(BOOL)animated { |
| TRACE_EVENT0("flutter", "viewDidDisappear"); |
| if ([_engine.get() viewController] == self) { |
| [self invalidateKeyboardAnimationVSyncClient]; |
| [self ensureViewportMetricsIsCorrect]; |
| [self surfaceUpdated:NO]; |
| [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"]; |
| [self flushOngoingTouches]; |
| [_engine.get() notifyLowMemory]; |
| } |
| |
| [super viewDidDisappear:animated]; |
| } |
| |
| - (void)viewWillTransitionToSize:(CGSize)size |
| withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator { |
| [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; |
| |
| // We delay the viewport metrics update for half of rotation transition duration, to address |
| // a bug with distorted aspect ratio. |
| // See: https://github.com/flutter/flutter/issues/16322 |
| // |
| // This approach does not fully resolve all distortion problem. But instead, it reduces the |
| // rotation distortion roughly from 4x to 2x. The most distorted frames occur in the middle |
| // of the transition when it is rotating the fastest, making it hard to notice. |
| |
| NSTimeInterval transitionDuration = coordinator.transitionDuration; |
| // Do not delay viewport metrics update if zero transition duration. |
| if (transitionDuration == 0) { |
| return; |
| } |
| |
| _shouldIgnoreViewportMetricsUpdatesDuringRotation = YES; |
| dispatch_after(dispatch_time(DISPATCH_TIME_NOW, |
| static_cast<int64_t>(transitionDuration / 2.0 * NSEC_PER_SEC)), |
| dispatch_get_main_queue(), ^{ |
| // `viewWillTransitionToSize` is only called after the previous rotation is |
| // complete. So there won't be race condition for this flag. |
| _shouldIgnoreViewportMetricsUpdatesDuringRotation = NO; |
| [self updateViewportMetricsIfNeeded]; |
| }); |
| } |
| |
| - (void)flushOngoingTouches { |
| if (_engine && _ongoingTouches.get().count > 0) { |
| auto packet = std::make_unique<flutter::PointerDataPacket>(_ongoingTouches.get().count); |
| size_t pointer_index = 0; |
| // If the view controller is going away, we want to flush cancel all the ongoing |
| // touches to the framework so nothing gets orphaned. |
| for (NSNumber* device in _ongoingTouches.get()) { |
| // Create fake PointerData to balance out each previously started one for the framework. |
| flutter::PointerData pointer_data = [self generatePointerDataForFake]; |
| |
| pointer_data.change = flutter::PointerData::Change::kCancel; |
| pointer_data.device = device.longLongValue; |
| pointer_data.pointer_identifier = 0; |
| pointer_data.view_id = self.viewIdentifier; |
| |
| // Anything we put here will be arbitrary since there are no touches. |
| pointer_data.physical_x = 0; |
| pointer_data.physical_y = 0; |
| pointer_data.physical_delta_x = 0.0; |
| pointer_data.physical_delta_y = 0.0; |
| pointer_data.pressure = 1.0; |
| pointer_data.pressure_max = 1.0; |
| |
| packet->SetPointerData(pointer_index++, pointer_data); |
| } |
| |
| [_ongoingTouches removeAllObjects]; |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| } |
| |
| - (void)deregisterNotifications { |
| [[NSNotificationCenter defaultCenter] postNotificationName:FlutterViewControllerWillDealloc |
| object:self |
| userInfo:nil]; |
| [[NSNotificationCenter defaultCenter] removeObserver:self]; |
| } |
| |
| - (void)dealloc { |
| // It will be destroyed and invalidate its weak pointers |
| // before any other members are destroyed. |
| _weakFactory.reset(); |
| |
| [self removeInternalPlugins]; |
| [self deregisterNotifications]; |
| |
| [self invalidateKeyboardAnimationVSyncClient]; |
| [self invalidateTouchRateCorrectionVSyncClient]; |
| _scrollView.get().delegate = nil; |
| _hoverGestureRecognizer.delegate = nil; |
| [_hoverGestureRecognizer release]; |
| _discreteScrollingPanGestureRecognizer.delegate = nil; |
| [_discreteScrollingPanGestureRecognizer release]; |
| _continuousScrollingPanGestureRecognizer.delegate = nil; |
| [_continuousScrollingPanGestureRecognizer release]; |
| _pinchGestureRecognizer.delegate = nil; |
| [_pinchGestureRecognizer release]; |
| _rotationGestureRecognizer.delegate = nil; |
| [_rotationGestureRecognizer release]; |
| [super dealloc]; |
| } |
| |
| #pragma mark - Application lifecycle notifications |
| |
| - (void)applicationBecameActive:(NSNotification*)notification { |
| TRACE_EVENT0("flutter", "applicationBecameActive"); |
| [self appOrSceneBecameActive]; |
| } |
| |
| - (void)applicationWillResignActive:(NSNotification*)notification { |
| TRACE_EVENT0("flutter", "applicationWillResignActive"); |
| [self appOrSceneWillResignActive]; |
| } |
| |
| - (void)applicationWillTerminate:(NSNotification*)notification { |
| [self appOrSceneWillTerminate]; |
| } |
| |
| - (void)applicationDidEnterBackground:(NSNotification*)notification { |
| TRACE_EVENT0("flutter", "applicationDidEnterBackground"); |
| [self appOrSceneDidEnterBackground]; |
| } |
| |
| - (void)applicationWillEnterForeground:(NSNotification*)notification { |
| TRACE_EVENT0("flutter", "applicationWillEnterForeground"); |
| [self appOrSceneWillEnterForeground]; |
| } |
| |
| #pragma mark - Scene lifecycle notifications |
| |
| - (void)sceneBecameActive:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { |
| TRACE_EVENT0("flutter", "sceneBecameActive"); |
| [self appOrSceneBecameActive]; |
| } |
| |
| - (void)sceneWillResignActive:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { |
| TRACE_EVENT0("flutter", "sceneWillResignActive"); |
| [self appOrSceneWillResignActive]; |
| } |
| |
| - (void)sceneWillDisconnect:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { |
| [self appOrSceneWillTerminate]; |
| } |
| |
| - (void)sceneDidEnterBackground:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { |
| TRACE_EVENT0("flutter", "sceneDidEnterBackground"); |
| [self appOrSceneDidEnterBackground]; |
| } |
| |
| - (void)sceneWillEnterForeground:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { |
| TRACE_EVENT0("flutter", "sceneWillEnterForeground"); |
| [self appOrSceneWillEnterForeground]; |
| } |
| |
| #pragma mark - Lifecycle shared |
| |
| - (void)appOrSceneBecameActive { |
| self.isKeyboardInOrTransitioningFromBackground = NO; |
| if (_viewportMetrics.physical_width) { |
| [self surfaceUpdated:YES]; |
| } |
| [self performSelector:@selector(goToApplicationLifecycle:) |
| withObject:@"AppLifecycleState.resumed" |
| afterDelay:0.0f]; |
| } |
| |
| - (void)appOrSceneWillResignActive { |
| [NSObject cancelPreviousPerformRequestsWithTarget:self |
| selector:@selector(goToApplicationLifecycle:) |
| object:@"AppLifecycleState.resumed"]; |
| [self goToApplicationLifecycle:@"AppLifecycleState.inactive"]; |
| } |
| |
| - (void)appOrSceneWillTerminate { |
| [self goToApplicationLifecycle:@"AppLifecycleState.detached"]; |
| [self.engine destroyContext]; |
| } |
| |
| - (void)appOrSceneDidEnterBackground { |
| self.isKeyboardInOrTransitioningFromBackground = YES; |
| [self surfaceUpdated:NO]; |
| [self goToApplicationLifecycle:@"AppLifecycleState.paused"]; |
| } |
| |
| - (void)appOrSceneWillEnterForeground { |
| [self goToApplicationLifecycle:@"AppLifecycleState.inactive"]; |
| } |
| |
| // Make this transition only while this current view controller is visible. |
| - (void)goToApplicationLifecycle:(nonnull NSString*)state { |
| // Accessing self.view will create the view. Instead use viewIfLoaded |
| // to check whether the view is attached to window. |
| if (self.viewIfLoaded.window) { |
| [[_engine.get() lifecycleChannel] sendMessage:state]; |
| } |
| } |
| |
| #pragma mark - Touch event handling |
| |
| static flutter::PointerData::Change PointerDataChangeFromUITouchPhase(UITouchPhase phase) { |
| switch (phase) { |
| case UITouchPhaseBegan: |
| return flutter::PointerData::Change::kDown; |
| case UITouchPhaseMoved: |
| case UITouchPhaseStationary: |
| // There is no EVENT_TYPE_POINTER_STATIONARY. So we just pass a move type |
| // with the same coordinates |
| return flutter::PointerData::Change::kMove; |
| case UITouchPhaseEnded: |
| return flutter::PointerData::Change::kUp; |
| case UITouchPhaseCancelled: |
| return flutter::PointerData::Change::kCancel; |
| default: |
| // TODO(53695): Handle the `UITouchPhaseRegion`... enum values. |
| FML_DLOG(INFO) << "Unhandled touch phase: " << phase; |
| break; |
| } |
| |
| return flutter::PointerData::Change::kCancel; |
| } |
| |
| static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) { |
| switch (touch.type) { |
| case UITouchTypeDirect: |
| case UITouchTypeIndirect: |
| return flutter::PointerData::DeviceKind::kTouch; |
| case UITouchTypeStylus: |
| return flutter::PointerData::DeviceKind::kStylus; |
| case UITouchTypeIndirectPointer: |
| return flutter::PointerData::DeviceKind::kMouse; |
| default: |
| FML_DLOG(INFO) << "Unhandled touch type: " << touch.type; |
| break; |
| } |
| |
| return flutter::PointerData::DeviceKind::kTouch; |
| } |
| |
| // Dispatches the UITouches to the engine. Usually, the type of change of the touch is determined |
| // from the UITouch's phase. However, FlutterAppDelegate fakes touches to ensure that touch events |
| // in the status bar area are available to framework code. The change type (optional) of the faked |
| // touch is specified in the second argument. |
| - (void)dispatchTouches:(NSSet*)touches |
| pointerDataChangeOverride:(flutter::PointerData::Change*)overridden_change |
| event:(UIEvent*)event { |
| if (!_engine) { |
| return; |
| } |
| |
| // If the UIApplicationSupportsIndirectInputEvents in Info.plist returns YES, then the platform |
| // dispatches indirect pointer touches (trackpad clicks) as UITouch with a type of |
| // UITouchTypeIndirectPointer and different identifiers for each click. They are translated into |
| // Flutter pointer events with type of kMouse and different device IDs. These devices must be |
| // terminated with kRemove events when the touches end, otherwise they will keep triggering hover |
| // events. |
| // |
| // If the UIApplicationSupportsIndirectInputEvents in Info.plist returns NO, then the platform |
| // dispatches indirect pointer touches (trackpad clicks) as UITouch with a type of |
| // UITouchTypeIndirectPointer and different identifiers for each click. They are translated into |
| // Flutter pointer events with type of kTouch and different device IDs. Removing these devices is |
| // neither necessary nor harmful. |
| // |
| // Therefore Flutter always removes these devices. The touches_to_remove_count tracks how many |
| // remove events are needed in this group of touches to properly allocate space for the packet. |
| // The remove event of a touch is synthesized immediately after its normal event. |
| // |
| // See also: |
| // https://developer.apple.com/documentation/uikit/pointer_interactions?language=objc |
| // https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationsupportsindirectinputevents?language=objc |
| NSUInteger touches_to_remove_count = 0; |
| for (UITouch* touch in touches) { |
| if (touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled) { |
| touches_to_remove_count++; |
| } |
| } |
| |
| // Activate or pause the correction of delivery frame rate of touch events. |
| [self triggerTouchRateCorrectionIfNeeded:touches]; |
| |
| const CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| auto packet = |
| std::make_unique<flutter::PointerDataPacket>(touches.count + touches_to_remove_count); |
| |
| size_t pointer_index = 0; |
| |
| for (UITouch* touch in touches) { |
| CGPoint windowCoordinates = [touch locationInView:self.view]; |
| |
| flutter::PointerData pointer_data; |
| pointer_data.Clear(); |
| |
| constexpr int kMicrosecondsPerSecond = 1000 * 1000; |
| pointer_data.time_stamp = touch.timestamp * kMicrosecondsPerSecond; |
| |
| pointer_data.change = overridden_change != nullptr |
| ? *overridden_change |
| : PointerDataChangeFromUITouchPhase(touch.phase); |
| |
| pointer_data.kind = DeviceKindFromTouchType(touch); |
| |
| pointer_data.device = reinterpret_cast<int64_t>(touch); |
| |
| pointer_data.view_id = self.viewIdentifier; |
| |
| // Pointer will be generated in pointer_data_packet_converter.cc. |
| pointer_data.pointer_identifier = 0; |
| |
| pointer_data.physical_x = windowCoordinates.x * scale; |
| pointer_data.physical_y = windowCoordinates.y * scale; |
| |
| // Delta will be generated in pointer_data_packet_converter.cc. |
| pointer_data.physical_delta_x = 0.0; |
| pointer_data.physical_delta_y = 0.0; |
| |
| NSNumber* deviceKey = [NSNumber numberWithLongLong:pointer_data.device]; |
| // Track touches that began and not yet stopped so we can flush them |
| // if the view controller goes away. |
| switch (pointer_data.change) { |
| case flutter::PointerData::Change::kDown: |
| [_ongoingTouches addObject:deviceKey]; |
| break; |
| case flutter::PointerData::Change::kCancel: |
| case flutter::PointerData::Change::kUp: |
| [_ongoingTouches removeObject:deviceKey]; |
| break; |
| case flutter::PointerData::Change::kHover: |
| case flutter::PointerData::Change::kMove: |
| // We're only tracking starts and stops. |
| break; |
| case flutter::PointerData::Change::kAdd: |
| case flutter::PointerData::Change::kRemove: |
| // We don't use kAdd/kRemove. |
| break; |
| case flutter::PointerData::Change::kPanZoomStart: |
| case flutter::PointerData::Change::kPanZoomUpdate: |
| case flutter::PointerData::Change::kPanZoomEnd: |
| // We don't send pan/zoom events here |
| break; |
| } |
| |
| // pressure_min is always 0.0 |
| pointer_data.pressure = touch.force; |
| pointer_data.pressure_max = touch.maximumPossibleForce; |
| pointer_data.radius_major = touch.majorRadius; |
| pointer_data.radius_min = touch.majorRadius - touch.majorRadiusTolerance; |
| pointer_data.radius_max = touch.majorRadius + touch.majorRadiusTolerance; |
| |
| // iOS Documentation: altitudeAngle |
| // A value of 0 radians indicates that the stylus is parallel to the surface. The value of |
| // this property is Pi/2 when the stylus is perpendicular to the surface. |
| // |
| // PointerData Documentation: tilt |
| // The angle of the stylus, in radians in the range: |
| // 0 <= tilt <= pi/2 |
| // giving the angle of the axis of the stylus, relative to the axis perpendicular to the input |
| // surface (thus 0.0 indicates the stylus is orthogonal to the plane of the input surface, |
| // while pi/2 indicates that the stylus is flat on that surface). |
| // |
| // Discussion: |
| // The ranges are the same. Origins are swapped. |
| pointer_data.tilt = M_PI_2 - touch.altitudeAngle; |
| |
| // iOS Documentation: azimuthAngleInView: |
| // With the tip of the stylus touching the screen, the value of this property is 0 radians |
| // when the cap end of the stylus (that is, the end opposite of the tip) points along the |
| // positive x axis of the device's screen. The azimuth angle increases as the user swings the |
| // cap end of the stylus in a clockwise direction around the tip. |
| // |
| // PointerData Documentation: orientation |
| // The angle of the stylus, in radians in the range: |
| // -pi < orientation <= pi |
| // giving the angle of the axis of the stylus projected onto the input surface, relative to |
| // the positive y-axis of that surface (thus 0.0 indicates the stylus, if projected onto that |
| // surface, would go from the contact point vertically up in the positive y-axis direction, pi |
| // would indicate that the stylus would go down in the negative y-axis direction; pi/4 would |
| // indicate that the stylus goes up and to the right, -pi/2 would indicate that the stylus |
| // goes to the left, etc). |
| // |
| // Discussion: |
| // Sweep direction is the same. Phase of M_PI_2. |
| pointer_data.orientation = [touch azimuthAngleInView:nil] - M_PI_2; |
| |
| if (@available(iOS 13.4, *)) { |
| if (event != nullptr) { |
| pointer_data.buttons = (((event.buttonMask & UIEventButtonMaskPrimary) > 0) |
| ? flutter::PointerButtonMouse::kPointerButtonMousePrimary |
| : 0) | |
| (((event.buttonMask & UIEventButtonMaskSecondary) > 0) |
| ? flutter::PointerButtonMouse::kPointerButtonMouseSecondary |
| : 0); |
| } |
| } |
| |
| packet->SetPointerData(pointer_index++, pointer_data); |
| |
| if (touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled) { |
| flutter::PointerData remove_pointer_data = pointer_data; |
| remove_pointer_data.change = flutter::PointerData::Change::kRemove; |
| packet->SetPointerData(pointer_index++, remove_pointer_data); |
| } |
| } |
| |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| |
| - (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { |
| [self dispatchTouches:touches pointerDataChangeOverride:nullptr event:event]; |
| } |
| |
| - (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event { |
| [self dispatchTouches:touches pointerDataChangeOverride:nullptr event:event]; |
| } |
| |
| - (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event { |
| [self dispatchTouches:touches pointerDataChangeOverride:nullptr event:event]; |
| } |
| |
| - (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event { |
| [self dispatchTouches:touches pointerDataChangeOverride:nullptr event:event]; |
| } |
| |
| - (void)forceTouchesCancelled:(NSSet*)touches { |
| flutter::PointerData::Change cancel = flutter::PointerData::Change::kCancel; |
| [self dispatchTouches:touches pointerDataChangeOverride:&cancel event:nullptr]; |
| } |
| |
| #pragma mark - Touch events rate correction |
| |
| - (void)createTouchRateCorrectionVSyncClientIfNeeded { |
| if (_touchRateCorrectionVSyncClient != nil) { |
| return; |
| } |
| |
| double displayRefreshRate = [DisplayLinkManager displayRefreshRate]; |
| const double epsilon = 0.1; |
| if (displayRefreshRate < 60.0 + epsilon) { // displayRefreshRate <= 60.0 |
| |
| // If current device's max frame rate is not larger than 60HZ, the delivery rate of touch events |
| // is the same with render vsync rate. So it is unnecessary to create |
| // _touchRateCorrectionVSyncClient to correct touch callback's rate. |
| return; |
| } |
| |
| flutter::Shell& shell = [_engine.get() shell]; |
| auto callback = [](std::unique_ptr<flutter::FrameTimingsRecorder> recorder) { |
| // Do nothing in this block. Just trigger system to callback touch events with correct rate. |
| }; |
| _touchRateCorrectionVSyncClient = |
| [[VSyncClient alloc] initWithTaskRunner:shell.GetTaskRunners().GetPlatformTaskRunner() |
| callback:callback]; |
| _touchRateCorrectionVSyncClient.allowPauseAfterVsync = NO; |
| } |
| |
| - (void)triggerTouchRateCorrectionIfNeeded:(NSSet*)touches { |
| if (_touchRateCorrectionVSyncClient == nil) { |
| // If the _touchRateCorrectionVSyncClient is not created, means current devices doesn't |
| // need to correct the touch rate. So just return. |
| return; |
| } |
| |
| // As long as there is a touch's phase is UITouchPhaseBegan or UITouchPhaseMoved, |
| // activate the correction. Otherwise pause the correction. |
| BOOL isUserInteracting = NO; |
| for (UITouch* touch in touches) { |
| if (touch.phase == UITouchPhaseBegan || touch.phase == UITouchPhaseMoved) { |
| isUserInteracting = YES; |
| break; |
| } |
| } |
| |
| if (isUserInteracting && [_engine.get() viewController] == self) { |
| [_touchRateCorrectionVSyncClient await]; |
| } else { |
| [_touchRateCorrectionVSyncClient pause]; |
| } |
| } |
| |
| - (void)invalidateTouchRateCorrectionVSyncClient { |
| [_touchRateCorrectionVSyncClient invalidate]; |
| [_touchRateCorrectionVSyncClient release]; |
| _touchRateCorrectionVSyncClient = nil; |
| } |
| |
| #pragma mark - Handle view resizing |
| |
| - (void)updateViewportMetricsIfNeeded { |
| if (_shouldIgnoreViewportMetricsUpdatesDuringRotation) { |
| return; |
| } |
| if ([_engine.get() viewController] == self) { |
| [_engine.get() updateViewportMetrics:_viewportMetrics]; |
| } |
| } |
| |
| - (void)viewDidLayoutSubviews { |
| CGRect viewBounds = self.view.bounds; |
| CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| |
| // Purposefully place this not visible. |
| _scrollView.get().frame = CGRectMake(0.0, 0.0, viewBounds.size.width, 0.0); |
| _scrollView.get().contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize); |
| |
| // First time since creation that the dimensions of its view is known. |
| bool firstViewBoundsUpdate = !_viewportMetrics.physical_width; |
| _viewportMetrics.device_pixel_ratio = scale; |
| [self setViewportMetricsSize]; |
| [self setViewportMetricsPaddings]; |
| [self updateViewportMetricsIfNeeded]; |
| |
| // There is no guarantee that UIKit will layout subviews when the application/scene is active. |
| // Creating the surface when inactive will cause GPU accesses from the background. Only wait for |
| // the first frame to render when the application/scene is actually active. |
| bool applicationOrSceneIsActive = YES; |
| #if APPLICATION_EXTENSION_API_ONLY |
| if (@available(iOS 13.0, *)) { |
| applicationOrSceneIsActive = self.flutterWindowSceneIfViewLoaded.activationState == |
| UISceneActivationStateForegroundActive; |
| } |
| #else |
| applicationOrSceneIsActive = |
| [UIApplication sharedApplication].applicationState == UIApplicationStateActive; |
| #endif |
| |
| // This must run after updateViewportMetrics so that the surface creation tasks are queued after |
| // the viewport metrics update tasks. |
| if (firstViewBoundsUpdate && applicationOrSceneIsActive && _engine) { |
| [self surfaceUpdated:YES]; |
| |
| flutter::Shell& shell = [_engine.get() shell]; |
| fml::TimeDelta waitTime = |
| #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG |
| fml::TimeDelta::FromMilliseconds(200); |
| #else |
| fml::TimeDelta::FromMilliseconds(100); |
| #endif |
| if (shell.WaitForFirstFrame(waitTime).code() == fml::StatusCode::kDeadlineExceeded) { |
| FML_LOG(INFO) << "Timeout waiting for the first frame to render. This may happen in " |
| << "unoptimized builds. If this is a release build, you should load a less " |
| << "complex frame to avoid the timeout."; |
| } |
| } |
| } |
| |
| - (void)viewSafeAreaInsetsDidChange { |
| [self setViewportMetricsPaddings]; |
| [self updateViewportMetricsIfNeeded]; |
| [super viewSafeAreaInsetsDidChange]; |
| } |
| |
| // Set _viewportMetrics physical size. |
| - (void)setViewportMetricsSize { |
| UIScreen* screen = [self flutterScreenIfViewLoaded]; |
| if (!screen) { |
| return; |
| } |
| |
| CGFloat scale = screen.scale; |
| _viewportMetrics.physical_width = self.view.bounds.size.width * scale; |
| _viewportMetrics.physical_height = self.view.bounds.size.height * scale; |
| } |
| |
| // Set _viewportMetrics physical paddings. |
| // |
| // Viewport paddings represent the iOS safe area insets. |
| - (void)setViewportMetricsPaddings { |
| UIScreen* screen = [self flutterScreenIfViewLoaded]; |
| if (!screen) { |
| return; |
| } |
| |
| CGFloat scale = screen.scale; |
| _viewportMetrics.physical_padding_top = self.view.safeAreaInsets.top * scale; |
| _viewportMetrics.physical_padding_left = self.view.safeAreaInsets.left * scale; |
| _viewportMetrics.physical_padding_right = self.view.safeAreaInsets.right * scale; |
| _viewportMetrics.physical_padding_bottom = self.view.safeAreaInsets.bottom * scale; |
| } |
| |
| #pragma mark - Keyboard events |
| |
| - (void)keyboardWillShowNotification:(NSNotification*)notification { |
| // Immediately prior to a docked keyboard being shown or when a keyboard goes from |
| // undocked/floating to docked, this notification is triggered. This notification also happens |
| // when Minimized/Expanded Shortcuts bar is dropped after dragging (the keyboard's end frame will |
| // be CGRectZero). |
| [self handleKeyboardNotification:notification]; |
| } |
| |
| - (void)keyboardWillChangeFrame:(NSNotification*)notification { |
| // Immediately prior to a change in keyboard frame, this notification is triggered. |
| // Sometimes when the keyboard is being hidden or undocked, this notification's keyboard's end |
| // frame is not yet entirely out of screen, which is why we also use |
| // UIKeyboardWillHideNotification. |
| [self handleKeyboardNotification:notification]; |
| } |
| |
| - (void)keyboardWillBeHidden:(NSNotification*)notification { |
| // When keyboard is hidden or undocked, this notification will be triggered. |
| // This notification might not occur when the keyboard is changed from docked to floating, which |
| // is why we also use UIKeyboardWillChangeFrameNotification. |
| [self handleKeyboardNotification:notification]; |
| } |
| |
| - (void)handleKeyboardNotification:(NSNotification*)notification { |
| // See https://flutter.dev/go/ios-keyboard-calculating-inset for more details |
| // on why notifications are used and how things are calculated. |
| if ([self shouldIgnoreKeyboardNotification:notification]) { |
| return; |
| } |
| |
| NSDictionary* info = notification.userInfo; |
| CGRect beginKeyboardFrame = [info[UIKeyboardFrameBeginUserInfoKey] CGRectValue]; |
| CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; |
| FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:notification]; |
| CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode]; |
| |
| // Avoid double triggering startKeyBoardAnimation. |
| if (self.targetViewInsetBottom == calculatedInset) { |
| return; |
| } |
| |
| self.targetViewInsetBottom = calculatedInset; |
| NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; |
| |
| // Flag for simultaneous compounding animation calls. |
| // This captures animation calls made while the keyboard animation is currently animating. If the |
| // new animation is in the same direction as the current animation, this flag lets the current |
| // animation continue with an updated targetViewInsetBottom instead of starting a new keyboard |
| // animation. This allows for smoother keyboard animation interpolation. |
| BOOL keyboardWillShow = beginKeyboardFrame.origin.y > keyboardFrame.origin.y; |
| BOOL keyboardAnimationIsCompounding = |
| self.keyboardAnimationIsShowing == keyboardWillShow && _keyboardAnimationVSyncClient != nil; |
| |
| // Mark keyboard as showing or hiding. |
| self.keyboardAnimationIsShowing = keyboardWillShow; |
| |
| if (!keyboardAnimationIsCompounding) { |
| [self startKeyBoardAnimation:duration]; |
| } else if ([self keyboardSpringAnimation]) { |
| [self keyboardSpringAnimation].toValue = self.targetViewInsetBottom; |
| } |
| } |
| |
| - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { |
| // Don't ignore UIKeyboardWillHideNotification notifications. |
| // Even if the notification is triggered in the background or by a different app/view controller, |
| // we want to always handle this notification to avoid inaccurate inset when in a mulitasking mode |
| // or when switching between apps. |
| if (notification.name == UIKeyboardWillHideNotification) { |
| return NO; |
| } |
| |
| // Ignore notification when keyboard's dimensions and position are all zeroes for |
| // UIKeyboardWillChangeFrameNotification. This happens when keyboard is dragged. Do not ignore if |
| // the notification is UIKeyboardWillShowNotification, as CGRectZero for that notfication only |
| // occurs when Minimized/Expanded Shortcuts Bar is dropped after dragging, which we later use to |
| // categorize it as floating. |
| NSDictionary* info = notification.userInfo; |
| CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; |
| if (notification.name == UIKeyboardWillChangeFrameNotification && |
| CGRectEqualToRect(keyboardFrame, CGRectZero)) { |
| return YES; |
| } |
| |
| // When keyboard's height or width is set to 0, don't ignore. This does not happen |
| // often but can happen sometimes when switching between multitasking modes. |
| if (CGRectIsEmpty(keyboardFrame)) { |
| return NO; |
| } |
| |
| // Ignore keyboard notifications related to other apps or view controllers. |
| if ([self isKeyboardNotificationForDifferentView:notification]) { |
| return YES; |
| } |
| |
| if (@available(iOS 13.0, *)) { |
| // noop |
| } else { |
| // If OS version is less than 13, ignore notification if the app is in the background |
| // or is transitioning from the background. In older versions, when switching between |
| // apps with the keyboard open in the secondary app, notifications are sent when |
| // the app is in the background/transitioning from background as if they belong |
| // to the app and as if the keyboard is showing even though it is not. |
| if (self.isKeyboardInOrTransitioningFromBackground) { |
| return YES; |
| } |
| } |
| |
| return NO; |
| } |
| |
| - (BOOL)isKeyboardNotificationForDifferentView:(NSNotification*)notification { |
| NSDictionary* info = notification.userInfo; |
| // Keyboard notifications related to other apps. |
| // If the UIKeyboardIsLocalUserInfoKey key doesn't exist (this should not happen after iOS 8), |
| // proceed as if it was local so that the notification is not ignored. |
| id isLocal = info[UIKeyboardIsLocalUserInfoKey]; |
| if (isLocal && ![isLocal boolValue]) { |
| return YES; |
| } |
| // Engine’s viewController is not current viewController. |
| if ([_engine.get() viewController] != self) { |
| return YES; |
| } |
| return NO; |
| } |
| |
| - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification { |
| // There are multiple types of keyboard: docked, undocked, split, split docked, |
| // floating, expanded shortcuts bar, minimized shortcuts bar. This function will categorize |
| // the keyboard as one of the following modes: docked, floating, or hidden. |
| // Docked mode includes docked, split docked, expanded shortcuts bar (when opening via click), |
| // and minimized shortcuts bar (when opened via click). |
| // Floating includes undocked, split, floating, expanded shortcuts bar (when dragged and dropped), |
| // and minimized shortcuts bar (when dragged and dropped). |
| NSDictionary* info = notification.userInfo; |
| CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; |
| |
| if (notification.name == UIKeyboardWillHideNotification) { |
| return FlutterKeyboardModeHidden; |
| } |
| |
| // If keyboard's dimensions and position are all zeroes, that means it's a Minimized/Expanded |
| // Shortcuts Bar that has been dropped after dragging, which we categorize as floating. |
| if (CGRectEqualToRect(keyboardFrame, CGRectZero)) { |
| return FlutterKeyboardModeFloating; |
| } |
| // If keyboard's width or height are 0, it's hidden. |
| if (CGRectIsEmpty(keyboardFrame)) { |
| return FlutterKeyboardModeHidden; |
| } |
| |
| CGRect screenRect = [self flutterScreenIfViewLoaded].bounds; |
| CGRect adjustedKeyboardFrame = keyboardFrame; |
| adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect |
| keyboardFrame:keyboardFrame]; |
| |
| // If the keyboard is partially or fully showing within the screen, it's either docked or |
| // floating. Sometimes with custom keyboard extensions, the keyboard's position may be off by a |
| // small decimal amount (which is why CGRectIntersectRect can't be used). Round to compare. |
| CGRect intersection = CGRectIntersection(adjustedKeyboardFrame, screenRect); |
| CGFloat intersectionHeight = CGRectGetHeight(intersection); |
| CGFloat intersectionWidth = CGRectGetWidth(intersection); |
| if (round(intersectionHeight) > 0 && intersectionWidth > 0) { |
| // If the keyboard is above the bottom of the screen, it's floating. |
| CGFloat screenHeight = CGRectGetHeight(screenRect); |
| CGFloat adjustedKeyboardBottom = CGRectGetMaxY(adjustedKeyboardFrame); |
| if (round(adjustedKeyboardBottom) < screenHeight) { |
| return FlutterKeyboardModeFloating; |
| } |
| return FlutterKeyboardModeDocked; |
| } |
| return FlutterKeyboardModeHidden; |
| } |
| |
| - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame { |
| // In Slide Over mode, the keyboard's frame does not include the space |
| // below the app, even though the keyboard may be at the bottom of the screen. |
| // To handle, shift the Y origin by the amount of space below the app. |
| if (self.viewIfLoaded.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad && |
| self.viewIfLoaded.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact && |
| self.viewIfLoaded.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular) { |
| CGFloat screenHeight = CGRectGetHeight(screenRect); |
| CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); |
| |
| // Stage Manager mode will also meet the above parameters, but it does not handle |
| // the keyboard positioning the same way, so skip if keyboard is at bottom of page. |
| if (screenHeight == keyboardBottom) { |
| return 0; |
| } |
| CGRect viewRectRelativeToScreen = |
| [self.viewIfLoaded convertRect:self.viewIfLoaded.frame |
| toCoordinateSpace:[self flutterScreenIfViewLoaded].coordinateSpace]; |
| CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen); |
| CGFloat offset = screenHeight - viewBottom; |
| if (offset > 0) { |
| return offset; |
| } |
| } |
| return 0; |
| } |
| |
| - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(NSInteger)keyboardMode { |
| // Only docked keyboards will have an inset. |
| if (keyboardMode == FlutterKeyboardModeDocked) { |
| // Calculate how much of the keyboard intersects with the view. |
| CGRect viewRectRelativeToScreen = |
| [self.viewIfLoaded convertRect:self.viewIfLoaded.frame |
| toCoordinateSpace:[self flutterScreenIfViewLoaded].coordinateSpace]; |
| CGRect intersection = CGRectIntersection(keyboardFrame, viewRectRelativeToScreen); |
| CGFloat portionOfKeyboardInView = CGRectGetHeight(intersection); |
| |
| // The keyboard is treated as an inset since we want to effectively reduce the window size by |
| // the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming |
| // bottom padding. |
| CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| return portionOfKeyboardInView * scale; |
| } |
| return 0; |
| } |
| |
| - (void)startKeyBoardAnimation:(NSTimeInterval)duration { |
| // If current physical_view_inset_bottom == targetViewInsetBottom, do nothing. |
| if (_viewportMetrics.physical_view_inset_bottom == self.targetViewInsetBottom) { |
| return; |
| } |
| |
| // When this method is called for the first time, |
| // initialize the keyboardAnimationView to get animation interpolation during animation. |
| if ([self keyboardAnimationView] == nil) { |
| UIView* keyboardAnimationView = [[UIView alloc] init]; |
| [keyboardAnimationView setHidden:YES]; |
| _keyboardAnimationView.reset(keyboardAnimationView); |
| } |
| |
| if ([self keyboardAnimationView].superview == nil) { |
| [self.view addSubview:[self keyboardAnimationView]]; |
| } |
| |
| // Remove running animation when start another animation. |
| [[self keyboardAnimationView].layer removeAllAnimations]; |
| |
| // Set animation begin value and DisplayLink tracking values. |
| [self keyboardAnimationView].frame = |
| CGRectMake(0, _viewportMetrics.physical_view_inset_bottom, 0, 0); |
| self.keyboardAnimationStartTime = fml::TimePoint().Now(); |
| self.originalViewInsetBottom = _viewportMetrics.physical_view_inset_bottom; |
| |
| // Invalidate old vsync client if old animation is not completed. |
| [self invalidateKeyboardAnimationVSyncClient]; |
| |
| fml::WeakNSObject<FlutterViewController> weakSelf = [self getWeakNSObject]; |
| FlutterKeyboardAnimationCallback keyboardAnimationCallback = ^( |
| fml::TimePoint keyboardAnimationTargetTime) { |
| if (!weakSelf) { |
| return; |
| } |
| fml::scoped_nsobject<FlutterViewController> flutterViewController( |
| [(FlutterViewController*)weakSelf.get() retain]); |
| if (!flutterViewController) { |
| return; |
| } |
| |
| // If the view controller's view is not loaded, bail out. |
| if (!flutterViewController.get().isViewLoaded) { |
| return; |
| } |
| // If the view for tracking keyboard animation is nil, means it is not |
| // created, bail out. |
| if ([flutterViewController keyboardAnimationView] == nil) { |
| return; |
| } |
| // If keyboardAnimationVSyncClient is nil, means the animation ends. |
| // And should bail out. |
| if (flutterViewController.get().keyboardAnimationVSyncClient == nil) { |
| return; |
| } |
| |
| if ([flutterViewController keyboardAnimationView].superview == nil) { |
| // Ensure the keyboardAnimationView is in view hierarchy when animation running. |
| [flutterViewController.get().view addSubview:[flutterViewController keyboardAnimationView]]; |
| } |
| |
| if ([flutterViewController keyboardSpringAnimation] == nil) { |
| if (flutterViewController.get().keyboardAnimationView.layer.presentationLayer) { |
| flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom = |
| flutterViewController.get() |
| .keyboardAnimationView.layer.presentationLayer.frame.origin.y; |
| [flutterViewController updateViewportMetricsIfNeeded]; |
| } |
| } else { |
| fml::TimeDelta timeElapsed = |
| keyboardAnimationTargetTime - flutterViewController.get().keyboardAnimationStartTime; |
| flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom = |
| [[flutterViewController keyboardSpringAnimation] curveFunction:timeElapsed.ToSecondsF()]; |
| [flutterViewController updateViewportMetricsIfNeeded]; |
| } |
| }; |
| [self setUpKeyboardAnimationVsyncClient:keyboardAnimationCallback]; |
| VSyncClient* currentVsyncClient = _keyboardAnimationVSyncClient; |
| |
| [UIView animateWithDuration:duration |
| animations:^{ |
| // Set end value. |
| [self keyboardAnimationView].frame = CGRectMake(0, self.targetViewInsetBottom, 0, 0); |
| |
| // Setup keyboard animation interpolation. |
| CAAnimation* keyboardAnimation = |
| [[self keyboardAnimationView].layer animationForKey:@"position"]; |
| [self setUpKeyboardSpringAnimationIfNeeded:keyboardAnimation]; |
| } |
| completion:^(BOOL finished) { |
| if (_keyboardAnimationVSyncClient == currentVsyncClient) { |
| // Indicates the vsync client captured by this block is the original one, which also |
| // indicates the animation has not been interrupted from its beginning. Moreover, |
| // indicates the animation is over and there is no more to execute. |
| [self invalidateKeyboardAnimationVSyncClient]; |
| [self removeKeyboardAnimationView]; |
| [self ensureViewportMetricsIsCorrect]; |
| } |
| }]; |
| } |
| |
| - (void)setUpKeyboardSpringAnimationIfNeeded:(CAAnimation*)keyboardAnimation { |
| // If keyboard animation is null or not a spring animation, fallback to DisplayLink tracking. |
| if (keyboardAnimation == nil || ![keyboardAnimation isKindOfClass:[CASpringAnimation class]]) { |
| _keyboardSpringAnimation.reset(); |
| return; |
| } |
| |
| // Setup keyboard spring animation details for spring curve animation calculation. |
| CASpringAnimation* keyboardCASpringAnimation = (CASpringAnimation*)keyboardAnimation; |
| _keyboardSpringAnimation.reset([[SpringAnimation alloc] |
| initWithStiffness:keyboardCASpringAnimation.stiffness |
| damping:keyboardCASpringAnimation.damping |
| mass:keyboardCASpringAnimation.mass |
| initialVelocity:keyboardCASpringAnimation.initialVelocity |
| fromValue:self.originalViewInsetBottom |
| toValue:self.targetViewInsetBottom]); |
| } |
| |
| - (void)setUpKeyboardAnimationVsyncClient: |
| (FlutterKeyboardAnimationCallback)keyboardAnimationCallback { |
| if (!keyboardAnimationCallback) { |
| return; |
| } |
| NSAssert(_keyboardAnimationVSyncClient == nil, |
| @"_keyboardAnimationVSyncClient must be nil when setting up."); |
| |
| // Make sure the new viewport metrics get sent after the begin frame event has processed. |
| fml::scoped_nsprotocol<FlutterKeyboardAnimationCallback> animationCallback( |
| [keyboardAnimationCallback copy]); |
| auto uiCallback = [animationCallback](std::unique_ptr<flutter::FrameTimingsRecorder> recorder) { |
| fml::TimeDelta frameInterval = recorder->GetVsyncTargetTime() - recorder->GetVsyncStartTime(); |
| fml::TimePoint keyboardAnimationTargetTime = recorder->GetVsyncTargetTime() + frameInterval; |
| dispatch_async(dispatch_get_main_queue(), ^(void) { |
| animationCallback.get()(keyboardAnimationTargetTime); |
| }); |
| }; |
| |
| _keyboardAnimationVSyncClient = [[VSyncClient alloc] initWithTaskRunner:[_engine uiTaskRunner] |
| callback:uiCallback]; |
| _keyboardAnimationVSyncClient.allowPauseAfterVsync = NO; |
| [_keyboardAnimationVSyncClient await]; |
| } |
| |
| - (void)invalidateKeyboardAnimationVSyncClient { |
| [_keyboardAnimationVSyncClient invalidate]; |
| [_keyboardAnimationVSyncClient release]; |
| _keyboardAnimationVSyncClient = nil; |
| } |
| |
| - (void)removeKeyboardAnimationView { |
| if ([self keyboardAnimationView].superview != nil) { |
| [[self keyboardAnimationView] removeFromSuperview]; |
| } |
| } |
| |
| - (void)ensureViewportMetricsIsCorrect { |
| if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) { |
| // Make sure the `physical_view_inset_bottom` is the target value. |
| _viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom; |
| [self updateViewportMetricsIfNeeded]; |
| } |
| } |
| |
| - (void)handlePressEvent:(FlutterUIPressProxy*)press |
| nextAction:(void (^)())next API_AVAILABLE(ios(13.4)) { |
| if (@available(iOS 13.4, *)) { |
| } else { |
| next(); |
| return; |
| } |
| [self.keyboardManager handlePress:press nextAction:next]; |
| } |
| |
| // The documentation for presses* handlers (implemented below) is entirely |
| // unclear about how to handle the case where some, but not all, of the presses |
| // are handled here. I've elected to call super separately for each of the |
| // presses that aren't handled, but it's not clear if this is correct. It may be |
| // that iOS intends for us to either handle all or none of the presses, and pass |
| // the original set to super. I have not yet seen multiple presses in the set in |
| // the wild, however, so I suspect that the API is built for a tvOS remote or |
| // something, and perhaps only one ever appears in the set on iOS from a |
| // keyboard. |
| |
| // If you substantially change these presses overrides, consider also changing |
| // the similar ones in FlutterTextInputPlugin. They need to be overridden in |
| // both places to capture keys both inside and outside of a text field, but have |
| // slightly different implmentations. |
| |
| - (void)pressesBegan:(NSSet<UIPress*>*)presses |
| withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { |
| if (@available(iOS 13.4, *)) { |
| for (UIPress* press in presses) { |
| [self handlePressEvent:[[[FlutterUIPressProxy alloc] initWithPress:press |
| withEvent:event] autorelease] |
| nextAction:^() { |
| [super pressesBegan:[NSSet setWithObject:press] withEvent:event]; |
| }]; |
| } |
| } else { |
| [super pressesBegan:presses withEvent:event]; |
| } |
| } |
| |
| - (void)pressesChanged:(NSSet<UIPress*>*)presses |
| withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { |
| if (@available(iOS 13.4, *)) { |
| for (UIPress* press in presses) { |
| [self handlePressEvent:[[[FlutterUIPressProxy alloc] initWithPress:press |
| withEvent:event] autorelease] |
| nextAction:^() { |
| [super pressesChanged:[NSSet setWithObject:press] withEvent:event]; |
| }]; |
| } |
| } else { |
| [super pressesChanged:presses withEvent:event]; |
| } |
| } |
| |
| - (void)pressesEnded:(NSSet<UIPress*>*)presses |
| withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { |
| if (@available(iOS 13.4, *)) { |
| for (UIPress* press in presses) { |
| [self handlePressEvent:[[[FlutterUIPressProxy alloc] initWithPress:press |
| withEvent:event] autorelease] |
| nextAction:^() { |
| [super pressesEnded:[NSSet setWithObject:press] withEvent:event]; |
| }]; |
| } |
| } else { |
| [super pressesEnded:presses withEvent:event]; |
| } |
| } |
| |
| - (void)pressesCancelled:(NSSet<UIPress*>*)presses |
| withEvent:(UIPressesEvent*)event API_AVAILABLE(ios(9.0)) { |
| if (@available(iOS 13.4, *)) { |
| for (UIPress* press in presses) { |
| [self handlePressEvent:[[[FlutterUIPressProxy alloc] initWithPress:press |
| withEvent:event] autorelease] |
| nextAction:^() { |
| [super pressesCancelled:[NSSet setWithObject:press] withEvent:event]; |
| }]; |
| } |
| } else { |
| [super pressesCancelled:presses withEvent:event]; |
| } |
| } |
| |
| #pragma mark - Orientation updates |
| |
| - (void)onOrientationPreferencesUpdated:(NSNotification*)notification { |
| // Notifications may not be on the iOS UI thread |
| dispatch_async(dispatch_get_main_queue(), ^{ |
| NSDictionary* info = notification.userInfo; |
| |
| NSNumber* update = info[@(flutter::kOrientationUpdateNotificationKey)]; |
| |
| if (update == nil) { |
| return; |
| } |
| [self performOrientationUpdate:update.unsignedIntegerValue]; |
| }); |
| } |
| |
| - (void)requestGeometryUpdateForWindowScenes:(NSSet<UIScene*>*)windowScenes |
| API_AVAILABLE(ios(16.0)) { |
| for (UIScene* windowScene in windowScenes) { |
| FML_DCHECK([windowScene isKindOfClass:[UIWindowScene class]]); |
| UIWindowSceneGeometryPreferencesIOS* preference = [[[UIWindowSceneGeometryPreferencesIOS alloc] |
| initWithInterfaceOrientations:_orientationPreferences] autorelease]; |
| [(UIWindowScene*)windowScene |
| requestGeometryUpdateWithPreferences:preference |
| errorHandler:^(NSError* error) { |
| os_log_error(OS_LOG_DEFAULT, |
| "Failed to change device orientation: %@", error); |
| }]; |
| [self setNeedsUpdateOfSupportedInterfaceOrientations]; |
| } |
| } |
| |
| - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences { |
| if (new_preferences != _orientationPreferences) { |
| _orientationPreferences = new_preferences; |
| |
| if (@available(iOS 16.0, *)) { |
| NSSet<UIScene*>* scenes = |
| #if APPLICATION_EXTENSION_API_ONLY |
| self.flutterWindowSceneIfViewLoaded |
| ? [NSSet setWithObject:self.flutterWindowSceneIfViewLoaded] |
| : [NSSet set]; |
| #else |
| [UIApplication.sharedApplication.connectedScenes |
| filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( |
| id scene, NSDictionary* bindings) { |
| return [scene isKindOfClass:[UIWindowScene class]]; |
| }]]; |
| #endif |
| [self requestGeometryUpdateForWindowScenes:scenes]; |
| } else { |
| UIInterfaceOrientationMask currentInterfaceOrientation = 0; |
| if (@available(iOS 13.0, *)) { |
| UIWindowScene* windowScene = [self flutterWindowSceneIfViewLoaded]; |
| if (!windowScene) { |
| FML_LOG(WARNING) |
| << "Accessing the interface orientation when the window scene is unavailable."; |
| return; |
| } |
| currentInterfaceOrientation = 1 << windowScene.interfaceOrientation; |
| } else { |
| #if APPLICATION_EXTENSION_API_ONLY |
| FML_LOG(ERROR) << "Application based status bar orentiation update is not supported in " |
| "app extension. Orientation: " |
| << currentInterfaceOrientation; |
| #else |
| currentInterfaceOrientation = 1 << [[UIApplication sharedApplication] statusBarOrientation]; |
| #endif |
| } |
| if (!(_orientationPreferences & currentInterfaceOrientation)) { |
| [UIViewController attemptRotationToDeviceOrientation]; |
| // Force orientation switch if the current orientation is not allowed |
| if (_orientationPreferences & UIInterfaceOrientationMaskPortrait) { |
| // This is no official API but more like a workaround / hack (using |
| // key-value coding on a read-only property). This might break in |
| // the future, but currently it´s the only way to force an orientation change |
| [[UIDevice currentDevice] setValue:@(UIInterfaceOrientationPortrait) |
| forKey:@"orientation"]; |
| } else if (_orientationPreferences & UIInterfaceOrientationMaskPortraitUpsideDown) { |
| [[UIDevice currentDevice] setValue:@(UIInterfaceOrientationPortraitUpsideDown) |
| forKey:@"orientation"]; |
| } else if (_orientationPreferences & UIInterfaceOrientationMaskLandscapeLeft) { |
| [[UIDevice currentDevice] setValue:@(UIInterfaceOrientationLandscapeLeft) |
| forKey:@"orientation"]; |
| } else if (_orientationPreferences & UIInterfaceOrientationMaskLandscapeRight) { |
| [[UIDevice currentDevice] setValue:@(UIInterfaceOrientationLandscapeRight) |
| forKey:@"orientation"]; |
| } |
| } |
| } |
| } |
| } |
| |
| - (void)onHideHomeIndicatorNotification:(NSNotification*)notification { |
| self.isHomeIndicatorHidden = YES; |
| } |
| |
| - (void)onShowHomeIndicatorNotification:(NSNotification*)notification { |
| self.isHomeIndicatorHidden = NO; |
| } |
| |
| - (void)setIsHomeIndicatorHidden:(BOOL)hideHomeIndicator { |
| if (hideHomeIndicator != _isHomeIndicatorHidden) { |
| _isHomeIndicatorHidden = hideHomeIndicator; |
| [self setNeedsUpdateOfHomeIndicatorAutoHidden]; |
| } |
| } |
| |
| - (BOOL)prefersHomeIndicatorAutoHidden { |
| return self.isHomeIndicatorHidden; |
| } |
| |
| - (BOOL)shouldAutorotate { |
| return YES; |
| } |
| |
| - (NSUInteger)supportedInterfaceOrientations { |
| return _orientationPreferences; |
| } |
| |
| #pragma mark - Accessibility |
| |
| - (void)onAccessibilityStatusChanged:(NSNotification*)notification { |
| if (!_engine) { |
| return; |
| } |
| auto platformView = [_engine.get() platformView]; |
| int32_t flags = [self accessibilityFlags]; |
| #if TARGET_OS_SIMULATOR |
| // There doesn't appear to be any way to determine whether the accessibility |
| // inspector is enabled on the simulator. We conservatively always turn on the |
| // accessibility bridge in the simulator, but never assistive technology. |
| platformView->SetSemanticsEnabled(true); |
| platformView->SetAccessibilityFeatures(flags); |
| #else |
| _isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning(); |
| bool enabled = _isVoiceOverRunning || UIAccessibilityIsSwitchControlRunning(); |
| if (enabled) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kAccessibleNavigation); |
| } |
| platformView->SetSemanticsEnabled(enabled || UIAccessibilityIsSpeakScreenEnabled()); |
| platformView->SetAccessibilityFeatures(flags); |
| #endif |
| } |
| |
| - (int32_t)accessibilityFlags { |
| int32_t flags = 0; |
| if (UIAccessibilityIsInvertColorsEnabled()) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kInvertColors); |
| } |
| if (UIAccessibilityIsReduceMotionEnabled()) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kReduceMotion); |
| } |
| if (UIAccessibilityIsBoldTextEnabled()) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kBoldText); |
| } |
| if (UIAccessibilityDarkerSystemColorsEnabled()) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kHighContrast); |
| } |
| if ([FlutterViewController accessibilityIsOnOffSwitchLabelsEnabled]) { |
| flags |= static_cast<int32_t>(flutter::AccessibilityFeatureFlag::kOnOffSwitchLabels); |
| } |
| |
| return flags; |
| } |
| |
| + (BOOL)accessibilityIsOnOffSwitchLabelsEnabled { |
| if (@available(iOS 13, *)) { |
| return UIAccessibilityIsOnOffSwitchLabelsEnabled(); |
| } else { |
| return NO; |
| } |
| } |
| |
| #pragma mark - Set user settings |
| |
| - (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { |
| [super traitCollectionDidChange:previousTraitCollection]; |
| [self onUserSettingsChanged:nil]; |
| } |
| |
| - (void)onUserSettingsChanged:(NSNotification*)notification { |
| [[_engine.get() settingsChannel] sendMessage:@{ |
| @"textScaleFactor" : @([self textScaleFactor]), |
| @"alwaysUse24HourFormat" : @([self isAlwaysUse24HourFormat]), |
| @"platformBrightness" : [self brightnessMode], |
| @"platformContrast" : [self contrastMode], |
| @"nativeSpellCheckServiceDefined" : @true |
| }]; |
| } |
| |
| - (CGFloat)textScaleFactor { |
| #if APPLICATION_EXTENSION_API_ONLY |
| FML_LOG(WARNING) << "Dynamic content size update is not supported in app extension."; |
| return 1.0; |
| #else |
| UIContentSizeCategory category = [UIApplication sharedApplication].preferredContentSizeCategory; |
| // The delta is computed by approximating Apple's typography guidelines: |
| // https://developer.apple.com/ios/human-interface-guidelines/visual-design/typography/ |
| // |
| // Specifically: |
| // Non-accessibility sizes for "body" text are: |
| const CGFloat xs = 14; |
| const CGFloat s = 15; |
| const CGFloat m = 16; |
| const CGFloat l = 17; |
| const CGFloat xl = 19; |
| const CGFloat xxl = 21; |
| const CGFloat xxxl = 23; |
| |
| // Accessibility sizes for "body" text are: |
| const CGFloat ax1 = 28; |
| const CGFloat ax2 = 33; |
| const CGFloat ax3 = 40; |
| const CGFloat ax4 = 47; |
| const CGFloat ax5 = 53; |
| |
| // We compute the scale as relative difference from size L (large, the default size), where |
| // L is assumed to have scale 1.0. |
| if ([category isEqualToString:UIContentSizeCategoryExtraSmall]) { |
| return xs / l; |
| } else if ([category isEqualToString:UIContentSizeCategorySmall]) { |
| return s / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryMedium]) { |
| return m / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryLarge]) { |
| return 1.0; |
| } else if ([category isEqualToString:UIContentSizeCategoryExtraLarge]) { |
| return xl / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryExtraExtraLarge]) { |
| return xxl / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryExtraExtraExtraLarge]) { |
| return xxxl / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryAccessibilityMedium]) { |
| return ax1 / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryAccessibilityLarge]) { |
| return ax2 / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryAccessibilityExtraLarge]) { |
| return ax3 / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryAccessibilityExtraExtraLarge]) { |
| return ax4 / l; |
| } else if ([category isEqualToString:UIContentSizeCategoryAccessibilityExtraExtraExtraLarge]) { |
| return ax5 / l; |
| } else { |
| return 1.0; |
| } |
| #endif |
| } |
| |
| - (BOOL)isAlwaysUse24HourFormat { |
| // iOS does not report its "24-Hour Time" user setting in the API. Instead, it applies |
| // it automatically to NSDateFormatter when used with [NSLocale currentLocale]. It is |
| // essential that [NSLocale currentLocale] is used. Any custom locale, even the one |
| // that's the same as [NSLocale currentLocale] will ignore the 24-hour option (there |
| // must be some internal field that's not exposed to developers). |
| // |
| // Therefore this option behaves differently across Android and iOS. On Android this |
| // setting is exposed standalone, and can therefore be applied to all locales, whether |
| // the "current system locale" or a custom one. On iOS it only applies to the current |
| // system locale. Widget implementors must take this into account in order to provide |
| // platform-idiomatic behavior in their widgets. |
| NSString* dateFormat = [NSDateFormatter dateFormatFromTemplate:@"j" |
| options:0 |
| locale:[NSLocale currentLocale]]; |
| return [dateFormat rangeOfString:@"a"].location == NSNotFound; |
| } |
| |
| // The brightness mode of the platform, e.g., light or dark, expressed as a string that |
| // is understood by the Flutter framework. See the settings |
| // system channel for more information. |
| - (NSString*)brightnessMode { |
| if (@available(iOS 13, *)) { |
| UIUserInterfaceStyle style = self.traitCollection.userInterfaceStyle; |
| |
| if (style == UIUserInterfaceStyleDark) { |
| return @"dark"; |
| } else { |
| return @"light"; |
| } |
| } else { |
| return @"light"; |
| } |
| } |
| |
| // The contrast mode of the platform, e.g., normal or high, expressed as a string that is |
| // understood by the Flutter framework. See the settings system channel for more |
| // information. |
| - (NSString*)contrastMode { |
| if (@available(iOS 13, *)) { |
| UIAccessibilityContrast contrast = self.traitCollection.accessibilityContrast; |
| |
| if (contrast == UIAccessibilityContrastHigh) { |
| return @"high"; |
| } else { |
| return @"normal"; |
| } |
| } else { |
| return @"normal"; |
| } |
| } |
| |
| #pragma mark - Status bar style |
| |
| - (UIStatusBarStyle)preferredStatusBarStyle { |
| return _statusBarStyle; |
| } |
| |
| - (void)onPreferredStatusBarStyleUpdated:(NSNotification*)notification { |
| // Notifications may not be on the iOS UI thread |
| dispatch_async(dispatch_get_main_queue(), ^{ |
| NSDictionary* info = notification.userInfo; |
| |
| NSNumber* update = info[@(flutter::kOverlayStyleUpdateNotificationKey)]; |
| |
| if (update == nil) { |
| return; |
| } |
| |
| NSInteger style = update.integerValue; |
| |
| if (style != _statusBarStyle) { |
| _statusBarStyle = static_cast<UIStatusBarStyle>(style); |
| [self setNeedsStatusBarAppearanceUpdate]; |
| } |
| }); |
| } |
| |
| - (void)setPrefersStatusBarHidden:(BOOL)hidden { |
| if (hidden != _flutterPrefersStatusBarHidden) { |
| _flutterPrefersStatusBarHidden = hidden; |
| [self setNeedsStatusBarAppearanceUpdate]; |
| } |
| } |
| |
| - (BOOL)prefersStatusBarHidden { |
| return _flutterPrefersStatusBarHidden; |
| } |
| |
| #pragma mark - Platform views |
| |
| - (std::shared_ptr<flutter::FlutterPlatformViewsController>&)platformViewsController { |
| return [_engine.get() platformViewsController]; |
| } |
| |
| - (NSObject<FlutterBinaryMessenger>*)binaryMessenger { |
| return _engine.get().binaryMessenger; |
| } |
| |
| #pragma mark - FlutterBinaryMessenger |
| |
| - (void)sendOnChannel:(NSString*)channel message:(NSData*)message { |
| [_engine.get().binaryMessenger sendOnChannel:channel message:message]; |
| } |
| |
| - (void)sendOnChannel:(NSString*)channel |
| message:(NSData*)message |
| binaryReply:(FlutterBinaryReply)callback { |
| NSAssert(channel, @"The channel must not be null"); |
| [_engine.get().binaryMessenger sendOnChannel:channel message:message binaryReply:callback]; |
| } |
| |
| - (NSObject<FlutterTaskQueue>*)makeBackgroundTaskQueue { |
| return [_engine.get().binaryMessenger makeBackgroundTaskQueue]; |
| } |
| |
| - (FlutterBinaryMessengerConnection)setMessageHandlerOnChannel:(NSString*)channel |
| binaryMessageHandler: |
| (FlutterBinaryMessageHandler)handler { |
| return [self setMessageHandlerOnChannel:channel binaryMessageHandler:handler taskQueue:nil]; |
| } |
| |
| - (FlutterBinaryMessengerConnection) |
| setMessageHandlerOnChannel:(NSString*)channel |
| binaryMessageHandler:(FlutterBinaryMessageHandler _Nullable)handler |
| taskQueue:(NSObject<FlutterTaskQueue>* _Nullable)taskQueue { |
| NSAssert(channel, @"The channel must not be null"); |
| return [_engine.get().binaryMessenger setMessageHandlerOnChannel:channel |
| binaryMessageHandler:handler |
| taskQueue:taskQueue]; |
| } |
| |
| - (void)cleanUpConnection:(FlutterBinaryMessengerConnection)connection { |
| [_engine.get().binaryMessenger cleanUpConnection:connection]; |
| } |
| |
| #pragma mark - FlutterTextureRegistry |
| |
| - (int64_t)registerTexture:(NSObject<FlutterTexture>*)texture { |
| return [_engine.get().textureRegistry registerTexture:texture]; |
| } |
| |
| - (void)unregisterTexture:(int64_t)textureId { |
| [_engine.get().textureRegistry unregisterTexture:textureId]; |
| } |
| |
| - (void)textureFrameAvailable:(int64_t)textureId { |
| [_engine.get().textureRegistry textureFrameAvailable:textureId]; |
| } |
| |
| - (NSString*)lookupKeyForAsset:(NSString*)asset { |
| return [FlutterDartProject lookupKeyForAsset:asset]; |
| } |
| |
| - (NSString*)lookupKeyForAsset:(NSString*)asset fromPackage:(NSString*)package { |
| return [FlutterDartProject lookupKeyForAsset:asset fromPackage:package]; |
| } |
| |
| - (id<FlutterPluginRegistry>)pluginRegistry { |
| return _engine; |
| } |
| |
| + (BOOL)isUIAccessibilityIsVoiceOverRunning { |
| return UIAccessibilityIsVoiceOverRunning(); |
| } |
| |
| #pragma mark - FlutterPluginRegistry |
| |
| - (NSObject<FlutterPluginRegistrar>*)registrarForPlugin:(NSString*)pluginKey { |
| return [_engine.get() registrarForPlugin:pluginKey]; |
| } |
| |
| - (BOOL)hasPlugin:(NSString*)pluginKey { |
| return [_engine.get() hasPlugin:pluginKey]; |
| } |
| |
| - (NSObject*)valuePublishedByPlugin:(NSString*)pluginKey { |
| return [_engine.get() valuePublishedByPlugin:pluginKey]; |
| } |
| |
| - (void)presentViewController:(UIViewController*)viewControllerToPresent |
| animated:(BOOL)flag |
| completion:(void (^)(void))completion { |
| self.isPresentingViewControllerAnimating = YES; |
| [super presentViewController:viewControllerToPresent |
| animated:flag |
| completion:^{ |
| self.isPresentingViewControllerAnimating = NO; |
| if (completion) { |
| completion(); |
| } |
| }]; |
| } |
| |
| - (BOOL)isPresentingViewController { |
| return self.presentedViewController != nil || self.isPresentingViewControllerAnimating; |
| } |
| |
| - (flutter::PointerData)generatePointerDataAtLastMouseLocation API_AVAILABLE(ios(13.4)) { |
| flutter::PointerData pointer_data; |
| pointer_data.Clear(); |
| pointer_data.time_stamp = [[NSProcessInfo processInfo] systemUptime] * kMicrosecondsPerSecond; |
| pointer_data.physical_x = _mouseState.location.x; |
| pointer_data.physical_y = _mouseState.location.y; |
| return pointer_data; |
| } |
| |
| - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer |
| shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer |
| API_AVAILABLE(ios(13.4)) { |
| return YES; |
| } |
| |
| - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer |
| shouldReceiveEvent:(UIEvent*)event API_AVAILABLE(ios(13.4)) { |
| if (gestureRecognizer == _continuousScrollingPanGestureRecognizer && |
| event.type == UIEventTypeScroll) { |
| // Events with type UIEventTypeScroll are only received when running on macOS under emulation. |
| flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; |
| pointer_data.device = reinterpret_cast<int64_t>(_continuousScrollingPanGestureRecognizer); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; |
| pointer_data.signal_kind = flutter::PointerData::SignalKind::kScrollInertiaCancel; |
| pointer_data.view_id = self.viewIdentifier; |
| |
| if (event.timestamp < _scrollInertiaEventAppKitDeadline) { |
| // Only send the event if it occured before the expected natural end of gesture momentum. |
| // If received after the deadline, it's not likely the event is from a user-initiated cancel. |
| auto packet = std::make_unique<flutter::PointerDataPacket>(1); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| _scrollInertiaEventAppKitDeadline = 0; |
| } |
| } |
| // This method is also called for UITouches, should return YES to process all touches. |
| return YES; |
| } |
| |
| - (void)hoverEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { |
| CGPoint location = [recognizer locationInView:self.view]; |
| CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| CGPoint oldLocation = _mouseState.location; |
| _mouseState.location = {location.x * scale, location.y * scale}; |
| |
| flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; |
| pointer_data.device = reinterpret_cast<int64_t>(recognizer); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kMouse; |
| pointer_data.view_id = self.viewIdentifier; |
| |
| switch (_hoverGestureRecognizer.state) { |
| case UIGestureRecognizerStateBegan: |
| pointer_data.change = flutter::PointerData::Change::kAdd; |
| break; |
| case UIGestureRecognizerStateChanged: |
| pointer_data.change = flutter::PointerData::Change::kHover; |
| break; |
| case UIGestureRecognizerStateEnded: |
| case UIGestureRecognizerStateCancelled: |
| pointer_data.change = flutter::PointerData::Change::kRemove; |
| break; |
| default: |
| // Sending kHover is the least harmful thing to do here |
| // But this state is not expected to ever be reached. |
| pointer_data.change = flutter::PointerData::Change::kHover; |
| break; |
| } |
| |
| NSTimeInterval time = [NSProcessInfo processInfo].systemUptime; |
| BOOL isRunningOnMac = NO; |
| if (@available(iOS 14.0, *)) { |
| // This "stationary pointer" heuristic is not reliable when running within macOS. |
| // We instead receive a scroll cancel event directly from AppKit. |
| // See gestureRecognizer:shouldReceiveEvent: |
| isRunningOnMac = [NSProcessInfo processInfo].iOSAppOnMac; |
| } |
| if (!isRunningOnMac && CGPointEqualToPoint(oldLocation, _mouseState.location) && |
| time > _scrollInertiaEventStartline) { |
| // iPadOS reports trackpad movements events with high (sub-pixel) precision. When an event |
| // is received with the same position as the previous one, it can only be from a finger |
| // making or breaking contact with the trackpad surface. |
| auto packet = std::make_unique<flutter::PointerDataPacket>(2); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| flutter::PointerData inertia_cancel = pointer_data; |
| inertia_cancel.device = reinterpret_cast<int64_t>(_continuousScrollingPanGestureRecognizer); |
| inertia_cancel.kind = flutter::PointerData::DeviceKind::kTrackpad; |
| inertia_cancel.signal_kind = flutter::PointerData::SignalKind::kScrollInertiaCancel; |
| inertia_cancel.view_id = self.viewIdentifier; |
| packet->SetPointerData(/*i=*/1, inertia_cancel); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| _scrollInertiaEventStartline = DBL_MAX; |
| } else { |
| auto packet = std::make_unique<flutter::PointerDataPacket>(1); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| } |
| |
| - (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { |
| CGPoint translation = [recognizer translationInView:self.view]; |
| const CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| |
| translation.x *= scale; |
| translation.y *= scale; |
| |
| flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; |
| pointer_data.device = reinterpret_cast<int64_t>(recognizer); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kMouse; |
| pointer_data.signal_kind = flutter::PointerData::SignalKind::kScroll; |
| pointer_data.scroll_delta_x = (translation.x - _mouseState.last_translation.x); |
| pointer_data.scroll_delta_y = -(translation.y - _mouseState.last_translation.y); |
| pointer_data.view_id = self.viewIdentifier; |
| |
| // The translation reported by UIPanGestureRecognizer is the total translation |
| // generated by the pan gesture since the gesture began. We need to be able |
| // to keep track of the last translation value in order to generate the deltaX |
| // and deltaY coordinates for each subsequent scroll event. |
| if (recognizer.state != UIGestureRecognizerStateEnded) { |
| _mouseState.last_translation = translation; |
| } else { |
| _mouseState.last_translation = CGPointZero; |
| } |
| |
| auto packet = std::make_unique<flutter::PointerDataPacket>(1); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| |
| - (void)continuousScrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { |
| CGPoint translation = [recognizer translationInView:self.view]; |
| const CGFloat scale = [self flutterScreenIfViewLoaded].scale; |
| |
| flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; |
| pointer_data.device = reinterpret_cast<int64_t>(recognizer); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; |
| pointer_data.view_id = self.viewIdentifier; |
| switch (recognizer.state) { |
| case UIGestureRecognizerStateBegan: |
| pointer_data.change = flutter::PointerData::Change::kPanZoomStart; |
| break; |
| case UIGestureRecognizerStateChanged: |
| pointer_data.change = flutter::PointerData::Change::kPanZoomUpdate; |
| pointer_data.pan_x = translation.x * scale; |
| pointer_data.pan_y = translation.y * scale; |
| pointer_data.pan_delta_x = 0; // Delta will be generated in pointer_data_packet_converter.cc. |
| pointer_data.pan_delta_y = 0; // Delta will be generated in pointer_data_packet_converter.cc. |
| pointer_data.scale = 1; |
| break; |
| case UIGestureRecognizerStateEnded: |
| case UIGestureRecognizerStateCancelled: |
| _scrollInertiaEventStartline = |
| [[NSProcessInfo processInfo] systemUptime] + |
| 0.1; // Time to lift fingers off trackpad (experimentally determined) |
| // When running an iOS app on an Apple Silicon Mac, AppKit will send an event |
| // of type UIEventTypeScroll when trackpad scroll momentum has ended. This event |
| // is sent whether the momentum ended normally or was cancelled by a trackpad touch. |
| // Since Flutter scrolling inertia will likely not match the system inertia, we should |
| // only send a PointerScrollInertiaCancel event for user-initiated cancellations. |
| // The following (curve-fitted) calculation provides a cutoff point after which any |
| // UIEventTypeScroll event will likely be from the system instead of the user. |
| // See https://github.com/flutter/engine/pull/34929. |
| _scrollInertiaEventAppKitDeadline = |
| [[NSProcessInfo processInfo] systemUptime] + |
| (0.1821 * log(fmax([recognizer velocityInView:self.view].x, |
| [recognizer velocityInView:self.view].y))) - |
| 0.4825; |
| pointer_data.change = flutter::PointerData::Change::kPanZoomEnd; |
| break; |
| default: |
| // continuousScrollEvent: should only ever be triggered with the above phases |
| NSAssert(false, @"Trackpad pan event occured with unexpected phase 0x%lx", |
| (long)recognizer.state); |
| break; |
| } |
| |
| auto packet = std::make_unique<flutter::PointerDataPacket>(1); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| |
| - (void)pinchEvent:(UIPinchGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { |
| flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; |
| pointer_data.device = reinterpret_cast<int64_t>(recognizer); |
| pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; |
| pointer_data.view_id = self.viewIdentifier; |
| switch (recognizer.state) { |
| case UIGestureRecognizerStateBegan: |
| pointer_data.change = flutter::PointerData::Change::kPanZoomStart; |
| break; |
| case UIGestureRecognizerStateChanged: |
| pointer_data.change = flutter::PointerData::Change::kPanZoomUpdate; |
| pointer_data.scale = recognizer.scale; |
| pointer_data.rotation = _rotationGestureRecognizer.rotation; |
| break; |
| case UIGestureRecognizerStateEnded: |
| case UIGestureRecognizerStateCancelled: |
| pointer_data.change = flutter::PointerData::Change::kPanZoomEnd; |
| break; |
| default: |
| // pinchEvent: should only ever be triggered with the above phases |
| NSAssert(false, @"Trackpad pinch event occured with unexpected phase 0x%lx", |
| (long)recognizer.state); |
| break; |
| } |
| |
| auto packet = std::make_unique<flutter::PointerDataPacket>(1); |
| packet->SetPointerData(/*i=*/0, pointer_data); |
| [_engine.get() dispatchPointerDataPacket:std::move(packet)]; |
| } |
| |
| #pragma mark - State Restoration |
| |
| - (void)encodeRestorableStateWithCoder:(NSCoder*)coder { |
| NSData* restorationData = [[_engine.get() restorationPlugin] restorationData]; |
| [coder encodeBytes:(const unsigned char*)restorationData.bytes |
| length:restorationData.length |
| forKey:kFlutterRestorationStateAppData]; |
| [super encodeRestorableStateWithCoder:coder]; |
| } |
| |
| - (void)decodeRestorableStateWithCoder:(NSCoder*)coder { |
| NSUInteger restorationDataLength; |
| const unsigned char* restorationBytes = [coder decodeBytesForKey:kFlutterRestorationStateAppData |
| returnedLength:&restorationDataLength]; |
| NSData* restorationData = [NSData dataWithBytes:restorationBytes length:restorationDataLength]; |
| [[_engine.get() restorationPlugin] setRestorationData:restorationData]; |
| } |
| |
| - (FlutterRestorationPlugin*)restorationPlugin { |
| return [_engine.get() restorationPlugin]; |
| } |
| |
| @end |