diff --git a/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h b/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h index 90588ac9f926f..fc2687c4456b3 100644 --- a/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h +++ b/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h @@ -130,6 +130,14 @@ FLUTTER_EXPORT */ - (id)pluginRegistry; +/** + * True if at least one frame has rendered and the ViewController has appeared. + * + * This property is reset to false when the ViewController disappears. It is + * guaranteed to only alternate between true and false for observers. + */ +@property(nonatomic, readonly, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI; + /** * Specifies the view to use as a splash screen. Flutter's rendering is asynchronous, so the first * frame rendered by the Flutter application might not immediately appear when theFlutter view is diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index f94593913b190..74f604d69fa1b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -29,6 +29,7 @@ // change. Unfortunately unless you have Werror turned on, incompatible pointers as arguments are // just a warning. @interface FlutterViewController () +@property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI; @end @implementation FlutterViewController { @@ -49,6 +50,8 @@ @implementation FlutterViewController { NSMutableSet* _ongoingTouches; } +@synthesize displayingFlutterUI = _displayingFlutterUI; + #pragma mark - Manage and override all designated initializers - (instancetype)initWithEngine:(FlutterEngine*)engine @@ -263,7 +266,25 @@ - (void)installSplashScreenViewIfNecessary { [self.view addSubview:splashScreenView]; } ++ (BOOL)automaticallyNotifiesObserversOfDisplayingFlutterUI { + return NO; +} + +- (void)setDisplayingFlutterUI:(BOOL)displayingFlutterUI { + if (_displayingFlutterUI != displayingFlutterUI) { + if (displayingFlutterUI == YES) { + if (!self.isViewLoaded || !self.view.window) { + return; + } + } + [self willChangeValueForKey:@"displayingFlutterUI"]; + _displayingFlutterUI = displayingFlutterUI; + [self didChangeValueForKey:@"displayingFlutterUI"]; + } +} + - (void)callViewRenderedCallback { + self.displayingFlutterUI = YES; if (_flutterViewRenderedCallback != nil) { _flutterViewRenderedCallback.get()(); _flutterViewRenderedCallback.reset(); @@ -395,6 +416,7 @@ - (void)surfaceUpdated:(BOOL)appeared { [_engine.get() platformViewsController] -> SetFlutterViewController(self); [_engine.get() platformView] -> NotifyCreated(); } else { + self.displayingFlutterUI = NO; [_engine.get() platformView] -> NotifyDestroyed(); [_engine.get() platformViewsController] -> SetFlutterView(nullptr); [_engine.get() platformViewsController] -> SetFlutterViewController(nullptr); diff --git a/testing/scenario_app/ios/Scenarios/ScenariosTests/FlutterViewControllerTest.m b/testing/scenario_app/ios/Scenarios/ScenariosTests/FlutterViewControllerTest.m index 833f8a6afcbd8..15acf3fcd7939 100644 --- a/testing/scenario_app/ios/Scenarios/ScenariosTests/FlutterViewControllerTest.m +++ b/testing/scenario_app/ios/Scenarios/ScenariosTests/FlutterViewControllerTest.m @@ -25,25 +25,31 @@ - (void)tearDown { } - (void)testFirstFrameCallback { + XCTestExpectation* firstFrameRendered = [self expectationWithDescription:@"firstFrameRendered"]; + FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil]; [engine runWithEntrypoint:nil]; self.flutterViewController = [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; - __block BOOL shouldKeepRunning = YES; + + XCTAssertFalse(self.flutterViewController.isDisplayingFlutterUI); + + XCTestExpectation* displayingFlutterUIExpectation = + [self keyValueObservingExpectationForObject:self.flutterViewController + keyPath:@"displayingFlutterUI" + expectedValue:@YES]; + displayingFlutterUIExpectation.assertForOverFulfill = YES; + [self.flutterViewController setFlutterViewDidRenderCallback:^{ - shouldKeepRunning = NO; + [firstFrameRendered fulfill]; }]; + AppDelegate* appDelegate = (AppDelegate*)UIApplication.sharedApplication.delegate; UIViewController* rootVC = appDelegate.window.rootViewController; [rootVC presentViewController:self.flutterViewController animated:NO completion:nil]; - NSRunLoop* runLoop = [NSRunLoop currentRunLoop]; - int countDownMs = 2000; - while (shouldKeepRunning && countDownMs > 0) { - [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - countDownMs -= 100; - } - XCTAssertGreaterThan(countDownMs, 0); + + [self waitForExpectationsWithTimeout:30.0 handler:nil]; } @end