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

Commit c25de0b

Browse files
committed
[rotation_distortion]delayed swap to reduce rotation distortion
1 parent 78f9c68 commit c25de0b

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
@@ -63,6 +63,11 @@ @interface FlutterViewController () <FlutterBinaryMessenger,
6363
@property(nonatomic, assign) BOOL isHomeIndicatorHidden;
6464
@property(nonatomic, assign) BOOL isPresentingViewControllerAnimating;
6565

66+
/**
67+
* Whether we should ignore viewport metrics updates during rotation transition.
68+
*/
69+
@property(nonatomic, assign) BOOL shouldIgnoreViewportMetricsUpdatesDuringRotation;
70+
6671
/**
6772
* Keyboard animation properties
6873
*/
@@ -843,6 +848,35 @@ - (void)viewDidDisappear:(BOOL)animated {
843848
[super viewDidDisappear:animated];
844849
}
845850

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

12791313
#pragma mark - Handle view resizing
12801314

1281-
- (void)updateViewportMetrics {
1315+
- (void)updateViewportMetricsIfNeeded {
1316+
if (_shouldIgnoreViewportMetricsUpdatesDuringRotation) {
1317+
return;
1318+
}
12821319
if ([_engine.get() viewController] == self) {
12831320
[_engine.get() updateViewportMetrics:_viewportMetrics];
12841321
}
@@ -1295,11 +1332,9 @@ - (void)viewDidLayoutSubviews {
12951332
// First time since creation that the dimensions of its view is known.
12961333
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
12971334
_viewportMetrics.device_pixel_ratio = scale;
1298-
_viewportMetrics.physical_width = viewBounds.size.width * scale;
1299-
_viewportMetrics.physical_height = viewBounds.size.height * scale;
1300-
1301-
[self updateViewportPadding];
1302-
[self updateViewportMetrics];
1335+
[self setViewportMetricsSize];
1336+
[self setViewportMetricsPaddings];
1337+
[self updateViewportMetricsIfNeeded];
13031338

13041339
// There is no guarantee that UIKit will layout subviews when the application is active. Creating
13051340
// the surface when inactive will cause GPU accesses from the background. Only wait for the first
@@ -1328,15 +1363,27 @@ - (void)viewDidLayoutSubviews {
13281363
}
13291364

13301365
- (void)viewSafeAreaInsetsDidChange {
1331-
[self updateViewportPadding];
1332-
[self updateViewportMetrics];
1366+
[self setViewportMetricsPaddings];
1367+
[self updateViewportMetricsIfNeeded];
13331368
[super viewSafeAreaInsetsDidChange];
13341369
}
13351370

1336-
// Updates _viewportMetrics physical padding.
1371+
// Set _viewportMetrics physical size.
1372+
- (void)setViewportMetricsSize {
1373+
// TODO(hellohuanlin): Use [self mainScreenIfViewLoaded] instead of [UIScreen mainScreen].
1374+
// This requires adding the view to window during unit tests, which calls multiple engine calls
1375+
// that is hard to mock since they take/return structs. An alternative approach is to partial mock
1376+
// the FlutterViewController to make view controller life cycle methods no-op, and insert
1377+
// this mock into the responder chain.
1378+
CGFloat scale = [UIScreen mainScreen].scale;
1379+
_viewportMetrics.physical_width = self.view.bounds.size.width * scale;
1380+
_viewportMetrics.physical_height = self.view.bounds.size.height * scale;
1381+
}
1382+
1383+
// Set _viewportMetrics physical paddings.
13371384
//
1338-
// Viewport padding represents the iOS safe area insets.
1339-
- (void)updateViewportPadding {
1385+
// Viewport paddings represent the iOS safe area insets.
1386+
- (void)setViewportMetricsPaddings {
13401387
CGFloat scale = [UIScreen mainScreen].scale;
13411388
_viewportMetrics.physical_padding_top = self.view.safeAreaInsets.top * scale;
13421389
_viewportMetrics.physical_padding_left = self.view.safeAreaInsets.left * scale;
@@ -1661,15 +1708,15 @@ - (void)setupKeyboardAnimationVsyncClient {
16611708
flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
16621709
flutterViewController.get()
16631710
.keyboardAnimationView.layer.presentationLayer.frame.origin.y;
1664-
[flutterViewController updateViewportMetrics];
1711+
[flutterViewController updateViewportMetricsIfNeeded];
16651712
}
16661713
} else {
16671714
fml::TimeDelta timeElapsed = recorder.get()->GetVsyncTargetTime() -
16681715
flutterViewController.get().keyboardAnimationStartTime;
16691716

16701717
flutterViewController.get()->_viewportMetrics.physical_view_inset_bottom =
16711718
[[flutterViewController keyboardSpringAnimation] curveFunction:timeElapsed.ToSecondsF()];
1672-
[flutterViewController updateViewportMetrics];
1719+
[flutterViewController updateViewportMetricsIfNeeded];
16731720
}
16741721
};
16751722
flutter::Shell& shell = [_engine.get() shell];
@@ -1698,7 +1745,7 @@ - (void)ensureViewportMetricsIsCorrect {
16981745
if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) {
16991746
// Make sure the `physical_view_inset_bottom` is the target value.
17001747
_viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom;
1701-
[self updateViewportMetrics];
1748+
[self updateViewportMetricsIfNeeded];
17021749
}
17031750
}
17041751

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

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ - (void)handlePressEvent:(FlutterUIPressProxy*)press
126126
nextAction:(void (^)())next API_AVAILABLE(ios(13.4));
127127
- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer;
128128
- (flutter::PointerData)createAuxillaryStylusActionData;
129-
- (void)updateViewportMetrics;
129+
- (void)updateViewportMetricsIfNeeded;
130130
- (void)onUserSettingsChanged:(NSNotification*)notification;
131131
- (void)applicationWillTerminate:(NSNotification*)notification;
132132
- (void)goToApplicationLifecycle:(nonnull NSString*)state;
@@ -836,7 +836,7 @@ - (void)testViewDidDisappearDoesPauseEngineWhenIsTheViewController {
836836
OCMReject([lifecycleChannel sendMessage:@"AppLifecycleState.inactive"]);
837837
}
838838

839-
- (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
839+
- (void)testUpdateViewportMetricsIfNeeded_DoesntInvokeEngineWhenNotTheViewController {
840840
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
841841
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
842842
FlutterViewController* viewControllerA = [[FlutterViewController alloc] initWithEngine:mockEngine
@@ -847,12 +847,12 @@ - (void)testUpdateViewportMetricsDoesntInvokeEngineWhenNotTheViewController {
847847
nibName:nil
848848
bundle:nil];
849849
mockEngine.viewController = viewControllerB;
850-
[viewControllerA updateViewportMetrics];
850+
[viewControllerA updateViewportMetricsIfNeeded];
851851
flutter::ViewportMetrics viewportMetrics;
852852
OCMVerify(never(), [mockEngine updateViewportMetrics:viewportMetrics]);
853853
}
854854

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

0 commit comments

Comments
 (0)