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

Commit 09e4dc1

Browse files
committed
[rotation_distortion]delayed swap to reduce rotation distortion
1 parent b2d0738 commit 09e4dc1

File tree

2 files changed

+138
-19
lines changed

2 files changed

+138
-19
lines changed

shell/platform/darwin/ios/framework/Source/FlutterViewController.mm

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ @interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegat
6161
@property(nonatomic, assign) BOOL isHomeIndicatorHidden;
6262
@property(nonatomic, assign) BOOL isPresentingViewControllerAnimating;
6363

64+
/**
65+
* Whether we should ignore viewport metrics updates during rotation transition.
66+
*/
67+
@property(nonatomic, assign) BOOL shouldIgnoreViewportMetricsUpdatesDuringRotation;
68+
6469
/**
6570
* Keyboard animation properties
6671
*/
@@ -837,6 +842,35 @@ - (void)viewDidDisappear:(BOOL)animated {
837842
[super viewDidDisappear:animated];
838843
}
839844

845+
- (void)viewWillTransitionToSize:(CGSize)size
846+
withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
847+
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
848+
849+
// We delay the viewport metrics update for half of rotation transition duration, to address
850+
// a bug with distorted aspect ratio.
851+
// See: https://github.com/flutter/flutter/issues/16322
852+
//
853+
// This approach does not fully resolve all distortion problem. But instead, it reduces the
854+
// rotation distortion roughly from 4x to 2x. The most distorted frames occur in the middle
855+
// of the transition when it is rotating the fastest, making it hard to notice.
856+
857+
NSTimeInterval transitionDuration = coordinator.transitionDuration;
858+
// Do not delay viewport metrics update if zero transition duration.
859+
if (transitionDuration == 0) {
860+
return;
861+
}
862+
863+
_shouldIgnoreViewportMetricsUpdatesDuringRotation = YES;
864+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
865+
static_cast<int64_t>(transitionDuration / 2.0 * NSEC_PER_SEC)),
866+
dispatch_get_main_queue(), ^{
867+
// `viewWillTransitionToSize` is only called after the previous rotation is
868+
// complete. So there won't be race condition for this flag.
869+
_shouldIgnoreViewportMetricsUpdatesDuringRotation = NO;
870+
[self updateViewportMetricsIfNeeded];
871+
});
872+
}
873+
840874
- (void)flushOngoingTouches {
841875
if (_engine && _ongoingTouches.get().count > 0) {
842876
auto packet = std::make_unique<flutter::PointerDataPacket>(_ongoingTouches.get().count);
@@ -1226,7 +1260,10 @@ - (void)invalidateTouchRateCorrectionVSyncClient {
12261260

12271261
#pragma mark - Handle view resizing
12281262

1229-
- (void)updateViewportMetrics {
1263+
- (void)updateViewportMetricsIfNeeded {
1264+
if (_shouldIgnoreViewportMetricsUpdatesDuringRotation) {
1265+
return;
1266+
}
12301267
if ([_engine.get() viewController] == self) {
12311268
[_engine.get() updateViewportMetrics:_viewportMetrics];
12321269
}
@@ -1243,11 +1280,9 @@ - (void)viewDidLayoutSubviews {
12431280
// First time since creation that the dimensions of its view is known.
12441281
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
12451282
_viewportMetrics.device_pixel_ratio = scale;
1246-
_viewportMetrics.physical_width = viewBounds.size.width * scale;
1247-
_viewportMetrics.physical_height = viewBounds.size.height * scale;
1248-
1249-
[self updateViewportPadding];
1250-
[self updateViewportMetrics];
1283+
[self setViewportMetricsSize];
1284+
[self setViewportMetricsPaddings];
1285+
[self updateViewportMetricsIfNeeded];
12511286

12521287
// There is no guarantee that UIKit will layout subviews when the application is active. Creating
12531288
// the surface when inactive will cause GPU accesses from the background. Only wait for the first
@@ -1276,15 +1311,27 @@ - (void)viewDidLayoutSubviews {
12761311
}
12771312

12781313
- (void)viewSafeAreaInsetsDidChange {
1279-
[self updateViewportPadding];
1280-
[self updateViewportMetrics];
1314+
[self setViewportMetricsPaddings];
1315+
[self updateViewportMetricsIfNeeded];
12811316
[super viewSafeAreaInsetsDidChange];
12821317
}
12831318

1284-
// Updates _viewportMetrics physical padding.
1319+
// Set _viewportMetrics physical size.
1320+
- (void)setViewportMetricsSize {
1321+
// TODO(hellohuanlin): Use [self mainScreenIfViewLoaded] instead of [UIScreen mainScreen].
1322+
// This requires adding the view to window during unit tests, which calls multiple engine calls
1323+
// that is hard to mock since they take/return structs. An alternative approach is to partial mock
1324+
// the FlutterViewController to make view controller life cycle methods no-op, and insert
1325+
// this mock into the responder chain.
1326+
CGFloat scale = [UIScreen mainScreen].scale;
1327+
_viewportMetrics.physical_width = self.view.bounds.size.width * scale;
1328+
_viewportMetrics.physical_height = self.view.bounds.size.height * scale;
1329+
}
1330+
1331+
// Set _viewportMetrics physical paddings.
12851332
//
1286-
// Viewport padding represents the iOS safe area insets.
1287-
- (void)updateViewportPadding {
1333+
// Viewport paddings represent the iOS safe area insets.
1334+
- (void)setViewportMetricsPaddings {
12881335
CGFloat scale = [UIScreen mainScreen].scale;
12891336
_viewportMetrics.physical_padding_top = self.view.safeAreaInsets.top * scale;
12901337
_viewportMetrics.physical_padding_left = self.view.safeAreaInsets.left * scale;
@@ -1609,15 +1656,15 @@ - (void)setupKeyboardAnimationVsyncClient {
16091656
flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
16101657
flutterViewController.get()
16111658
.keyboardAnimationView.layer.presentationLayer.frame.origin.y;
1612-
[flutterViewController updateViewportMetrics];
1659+
[flutterViewController updateViewportMetricsIfNeeded];
16131660
}
16141661
} else {
16151662
fml::TimeDelta timeElapsed = recorder.get()->GetVsyncTargetTime() -
16161663
flutterViewController.get().keyboardAnimationStartTime;
16171664

16181665
flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
16191666
[[flutterViewController keyboardSpringAnimation] curveFunction:timeElapsed.ToSecondsF()];
1620-
[flutterViewController updateViewportMetrics];
1667+
[flutterViewController updateViewportMetricsIfNeeded];
16211668
}
16221669
};
16231670
flutter::Shell& shell = [_engine.get() shell];
@@ -1646,7 +1693,7 @@ - (void)ensureViewportMetricsIsCorrect {
16461693
if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) {
16471694
// Make sure the `physical_view_inset_bottom` is the target value.
16481695
_viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom;
1649-
[self updateViewportMetrics];
1696+
[self updateViewportMetricsIfNeeded];
16501697
}
16511698
}
16521699

shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences;
124124
- (void)handlePressEvent:(FlutterUIPressProxy*)press
125125
nextAction:(void (^)())next API_AVAILABLE(ios(13.4));
126126
- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer;
127-
- (void)updateViewportMetrics;
127+
- (void)updateViewportMetricsIfNeeded;
128128
- (void)onUserSettingsChanged:(NSNotification*)notification;
129129
- (void)applicationWillTerminate:(NSNotification*)notification;
130130
- (void)goToApplicationLifecycle:(nonnull NSString*)state;
@@ -834,7 +834,7 @@ - (void)testViewDidDisappearDoesPauseEngineWhenIsTheViewController {
834834
OCMReject([lifecycleChannel sendMessage:@"AppLifecycleState.inactive"]);
835835
}
836836

837-
- (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
837+
- (void)testUpdateViewportMetricsIfNeeded_DoesntInvokeEngineWhenNotTheViewController {
838838
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
839839
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
840840
FlutterViewController* viewControllerA = [[FlutterViewController alloc] initWithEngine:mockEngine
@@ -845,12 +845,12 @@ - (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
845845
nibName:nil
846846
bundle:nil];
847847
mockEngine.viewController = viewControllerB;
848-
[viewControllerA updateViewportMetrics];
848+
[viewControllerA updateViewportMetricsIfNeeded];
849849
flutter::ViewportMetrics viewportMetrics;
850850
OCMVerify(never(), [mockEngine updateViewportMetrics:viewportMetrics]);
851851
}
852852

853-
- (void)testUpdateViewportMetricsDoesInvokeEngineWhenIsTheViewController {
853+
- (void)testUpdateViewportMetricsIfNeeded_DoesInvokeEngineWhenIsTheViewController {
854854
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
855855
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
856856
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
@@ -859,7 +859,79 @@ - (void)testUpdateViewportMetricsDoesInvokeEngineWhenIsTheViewController {
859859
mockEngine.viewController = viewController;
860860
flutter::ViewportMetrics viewportMetrics;
861861
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();
862-
[viewController updateViewportMetrics];
862+
[viewController updateViewportMetricsIfNeeded];
863+
OCMVerifyAll(mockEngine);
864+
}
865+
866+
- (void)testUpdateViewportMetricsIfNeeded_DoesNotInvokeEngineWhenShouldBeIgnoredDuringRotation {
867+
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
868+
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
869+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
870+
nibName:nil
871+
bundle:nil];
872+
mockEngine.viewController = viewController;
873+
874+
id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
875+
OCMStub([mockCoordinator transitionDuration]).andReturn(0.5);
876+
877+
// Mimic the device rotation.
878+
[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
879+
// Should not trigger the engine call when during rotation.
880+
[viewController updateViewportMetricsIfNeeded];
881+
882+
OCMVerify(never(), [mockEngine updateViewportMetrics:flutter::ViewportMetrics()]);
883+
}
884+
885+
- (void)testViewWillTransitionToSize_DoesDelayEngineCallIfNonZeroDuration {
886+
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
887+
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
888+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
889+
nibName:nil
890+
bundle:nil];
891+
mockEngine.viewController = viewController;
892+
893+
// Mimic the device rotation with non-zero transition duration.
894+
NSTimeInterval transitionDuration = 0.5;
895+
id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
896+
OCMStub([mockCoordinator transitionDuration]).andReturn(transitionDuration);
897+
898+
flutter::ViewportMetrics viewportMetrics;
899+
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();
900+
901+
[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
902+
// Should not immediately call the engine (this request should be ignored).
903+
[viewController updateViewportMetricsIfNeeded];
904+
OCMVerify(never(), [mockEngine updateViewportMetrics:flutter::ViewportMetrics()]);
905+
906+
// Should delay the engine call for half of the transition duration.
907+
// Wait for additional transitionDuration to allow updateViewportMetrics calls if any.
908+
XCTWaiterResult result = [XCTWaiter
909+
waitForExpectations:@[ [self expectationWithDescription:@"Waiting for rotation duration"] ]
910+
timeout:transitionDuration];
911+
XCTAssertEqual(result, XCTWaiterResultTimedOut);
912+
913+
OCMVerifyAll(mockEngine);
914+
}
915+
916+
- (void)testViewWillTransitionToSize_DoesNotDelayEngineCallIfZeroDuration {
917+
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
918+
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
919+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
920+
nibName:nil
921+
bundle:nil];
922+
mockEngine.viewController = viewController;
923+
924+
// Mimic the device rotation with zero transition duration.
925+
id mockCoordinator = OCMProtocolMock(@protocol(UIViewControllerTransitionCoordinator));
926+
OCMStub([mockCoordinator transitionDuration]).andReturn(0);
927+
928+
flutter::ViewportMetrics viewportMetrics;
929+
OCMExpect([mockEngine updateViewportMetrics:viewportMetrics]).ignoringNonObjectArgs();
930+
931+
// Should immediately trigger the engine call, without delay.
932+
[viewController viewWillTransitionToSize:CGSizeZero withTransitionCoordinator:mockCoordinator];
933+
[viewController updateViewportMetricsIfNeeded];
934+
863935
OCMVerifyAll(mockEngine);
864936
}
865937

0 commit comments

Comments
 (0)