Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

[rotation_distortion] Use "delayed swap" solution to reduce rotation distortion #40730

Merged
merged 2 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 67 additions & 15 deletions shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ @interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegat
@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
*/
Expand Down Expand Up @@ -837,6 +842,35 @@ - (void)viewDidDisappear:(BOOL)animated {
[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);
Expand Down Expand Up @@ -1226,7 +1260,10 @@ - (void)invalidateTouchRateCorrectionVSyncClient {

#pragma mark - Handle view resizing

- (void)updateViewportMetrics {
- (void)updateViewportMetricsIfNeeded {
if (_shouldIgnoreViewportMetricsUpdatesDuringRotation) {
return;
}
if ([_engine.get() viewController] == self) {
[_engine.get() updateViewportMetrics:_viewportMetrics];
}
Expand All @@ -1243,11 +1280,9 @@ - (void)viewDidLayoutSubviews {
// First time since creation that the dimensions of its view is known.
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
_viewportMetrics.device_pixel_ratio = scale;
_viewportMetrics.physical_width = viewBounds.size.width * scale;
_viewportMetrics.physical_height = viewBounds.size.height * scale;

[self updateViewportPadding];
[self updateViewportMetrics];
[self setViewportMetricsSize];
[self setViewportMetricsPaddings];
[self updateViewportMetricsIfNeeded];

// There is no guarantee that UIKit will layout subviews when the application is active. Creating
// the surface when inactive will cause GPU accesses from the background. Only wait for the first
Expand Down Expand Up @@ -1276,16 +1311,33 @@ - (void)viewDidLayoutSubviews {
}

- (void)viewSafeAreaInsetsDidChange {
[self updateViewportPadding];
[self updateViewportMetrics];
[self setViewportMetricsPaddings];
[self updateViewportMetricsIfNeeded];
[super viewSafeAreaInsetsDidChange];
}

// Updates _viewportMetrics physical padding.
// Set _viewportMetrics physical size.
- (void)setViewportMetricsSize {
UIScreen* mainScreen = [self mainScreenIfViewLoaded];
if (!mainScreen) {
return;
}

CGFloat scale = mainScreen.scale;
_viewportMetrics.physical_width = self.view.bounds.size.width * scale;
_viewportMetrics.physical_height = self.view.bounds.size.height * scale;
}

// Set _viewportMetrics physical paddings.
//
// Viewport padding represents the iOS safe area insets.
- (void)updateViewportPadding {
CGFloat scale = [UIScreen mainScreen].scale;
// Viewport paddings represent the iOS safe area insets.
- (void)setViewportMetricsPaddings {
UIScreen* mainScreen = [self mainScreenIfViewLoaded];
if (!mainScreen) {
return;
}

CGFloat scale = mainScreen.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;
Expand Down Expand Up @@ -1609,15 +1661,15 @@ - (void)setupKeyboardAnimationVsyncClient {
flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
flutterViewController.get()
.keyboardAnimationView.layer.presentationLayer.frame.origin.y;
[flutterViewController updateViewportMetrics];
[flutterViewController updateViewportMetricsIfNeeded];
}
} else {
fml::TimeDelta timeElapsed = recorder.get()->GetVsyncTargetTime() -
flutterViewController.get().keyboardAnimationStartTime;

flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
[[flutterViewController keyboardSpringAnimation] curveFunction:timeElapsed.ToSecondsF()];
[flutterViewController updateViewportMetrics];
[flutterViewController updateViewportMetricsIfNeeded];
}
};
flutter::Shell& shell = [_engine.get() shell];
Expand Down Expand Up @@ -1646,7 +1698,7 @@ - (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 updateViewportMetrics];
[self updateViewportMetricsIfNeeded];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences;
- (void)handlePressEvent:(FlutterUIPressProxy*)press
nextAction:(void (^)())next API_AVAILABLE(ios(13.4));
- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer;
- (void)updateViewportMetrics;
- (void)updateViewportMetricsIfNeeded;
- (void)onUserSettingsChanged:(NSNotification*)notification;
- (void)applicationWillTerminate:(NSNotification*)notification;
- (void)goToApplicationLifecycle:(nonnull NSString*)state;
Expand Down Expand Up @@ -834,7 +834,7 @@ - (void)testViewDidDisappearDoesPauseEngineWhenIsTheViewController {
OCMReject([lifecycleChannel sendMessage:@"AppLifecycleState.inactive"]);
}

- (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
- (void)testUpdateViewportMetricsIfNeeded_DoesntInvokeEngineWhenNotTheViewController {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewControllerA = [[FlutterViewController alloc] initWithEngine:mockEngine
Expand All @@ -845,12 +845,12 @@ - (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
nibName:nil
bundle:nil];
mockEngine.viewController = viewControllerB;
[viewControllerA updateViewportMetrics];
[viewControllerA updateViewportMetricsIfNeeded];
flutter::ViewportMetrics viewportMetrics;
OCMVerify(never(), [mockEngine updateViewportMetrics:viewportMetrics]);
}

- (void)testUpdateViewportMetricsDoesInvokeEngineWhenIsTheViewController {
- (void)testUpdateViewportMetricsIfNeeded_DoesInvokeEngineWhenIsTheViewController {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
Expand All @@ -859,7 +859,85 @@ - (void)testUpdateViewportMetricsDoesInvokeEngineWhenIsTheViewController {
mockEngine.viewController = viewController;
flutter::ViewportMetrics viewportMetrics;
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();
[viewController updateViewportMetrics];
[viewController updateViewportMetricsIfNeeded];
OCMVerifyAll(mockEngine);
}

- (void)testUpdateViewportMetricsIfNeeded_DoesNotInvokeEngineWhenShouldBeIgnoredDuringRotation {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
mockEngine.viewController = viewController;

id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
OCMStub([mockCoordinator transitionDuration]).andReturn(0.5);

// Mimic the device rotation.
[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
// Should not trigger the engine call when during rotation.
[viewController updateViewportMetricsIfNeeded];

OCMVerify(never(), [mockEngine updateViewportMetrics:flutter::ViewportMetrics()]);
}

- (void)testViewWillTransitionToSize_DoesDelayEngineCallIfNonZeroDuration {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
mockEngine.viewController = viewController;

// Mimic the device rotation with non-zero transition duration.
NSTimeInterval transitionDuration = 0.5;
id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
OCMStub([mockCoordinator transitionDuration]).andReturn(transitionDuration);

flutter::ViewportMetrics viewportMetrics;
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();

[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
// Should not immediately call the engine (this request should be ignored).
[viewController updateViewportMetricsIfNeeded];
OCMVerify(never(), [mockEngine updateViewportMetrics:flutter::ViewportMetrics()]);

// Should delay the engine call for half of the transition duration.
// Wait for additional transitionDuration to allow updateViewportMetrics calls if any.
XCTWaiterResult result = [XCTWaiter
waitForExpectations:@[ [self expectationWithDescription:@"Waiting for rotation duration"] ]
timeout:transitionDuration];
XCTAssertEqual(result, XCTWaiterResultTimedOut);

OCMVerifyAll(mockEngine);
}

- (void)testViewWillTransitionToSize_DoesNotDelayEngineCallIfZeroDuration {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
mockEngine.viewController = viewController;

// Mimic the device rotation with zero transition duration.
id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
OCMStub([mockCoordinator transitionDuration]).andReturn(0);

flutter::ViewportMetrics viewportMetrics;
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();

// Should immediately trigger the engine call, without delay.
[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
[viewController updateViewportMetricsIfNeeded];

OCMVerifyAll(mockEngine);
}

Expand Down