From cddbb530708509f342e1ed099f02b4c5c9b26861 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 10 Jul 2026 15:44:23 +0200 Subject: [PATCH 1/3] Single FrameRecomposer --- .../CMPUIKitUtils.xcodeproj/project.pbxproj | 32 ++- .../CMPUIKitUtils/CMPContainerView.h | 23 ++ .../CMPUIKitUtils/CMPContainerView.m | 29 ++ .../CMPUIKitUtils/CMPUIKitUtils.h | 1 + .../CMPUIKitUtils/CMPUIKitUtils/CMPView.m | 2 - .../CMPUIKitUtils/CMPViewController.m | 43 ++- .../ui/platform/FrameChoreographer.ios.kt | 162 +++++++++++ .../compose/ui/scene/ComposeContainer.ios.kt | 59 ++-- .../scene/ComposeLayersViewController.ios.kt | 22 +- .../ui/scene/ComposeSceneMediator.ios.kt | 103 ++++--- .../ui/scene/UIKitComposeSceneLayer.ios.kt | 15 +- .../ui/window/ComposeContainerView.ios.kt | 40 ++- .../compose/ui/window/MetalRedrawer.ios.kt | 155 ++--------- .../compose/ui/window/MetalView.ios.kt | 2 - .../ui/window/SurfaceMetalRedrawer.ios.kt | 253 +++--------------- .../compose/ui/window/SurfaceMetalView.ios.kt | 5 +- .../ui/scene/BaseComposeScene.skiko.kt | 6 +- .../compose/ui/modifiers/FrameRateTest.kt | 23 +- .../compose/ui/test/UIKitInstrumentedTest.kt | 4 + .../ui/window/FrameChoreographerTest.kt | 163 +++++++++++ .../compose/ui/window/MetalRedrawerTest.kt | 172 ------------ 21 files changed, 650 insertions(+), 664 deletions(-) create mode 100644 compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.h create mode 100644 compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.m create mode 100644 compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/FrameChoreographerTest.kt delete mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/MetalRedrawerTest.kt diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj index ae6f7b272af9d..dd26dfd14579f 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj @@ -13,7 +13,9 @@ 99293FF52F2B8A81001EC2A1 /* CMPDrawable.m in Sources */ = {isa = PBXBuildFile; fileRef = 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */; }; 99293FF82F2B8A81001EC2A1 /* CMPDrawable.m in Sources */ = {isa = PBXBuildFile; fileRef = 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */; }; 992EDDFB2E55EC8400FB44C5 /* CMPKeyValueObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */; }; - AABB11112F5A0000000000A3 /* CMPUIWindowSceneExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */; }; + 9967254830010FBC0013BF47 /* CMPContainerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 9967254730010FBC0013BF47 /* CMPContainerView.m */; }; + 9967254930010FBC0013BF47 /* CMPContainerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 9967254730010FBC0013BF47 /* CMPContainerView.m */; }; + 9967254A30010FBC0013BF47 /* CMPContainerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 9967254730010FBC0013BF47 /* CMPContainerView.m */; }; 9968C35B2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */; }; 9968C3612D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C3602D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m */; }; 9968C38B2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */; }; @@ -32,6 +34,7 @@ 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; A01609822EB42A3300FB9790 /* CMPLayoutRegion.m in Sources */ = {isa = PBXBuildFile; fileRef = A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */; }; + AABB11112F5A0000000000A3 /* CMPUIWindowSceneExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */; }; C4C07E892F57037300A9DC94 /* CMPTextInputView.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E882F57037300A9DC94 /* CMPTextInputView.m */; }; C4C07E8A2F57037300A9DC94 /* CMPEditMenuCustomAction.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */; }; C4C07E8B2F57037300A9DC94 /* CMPTextInputStringTokenizer.m in Sources */ = {isa = PBXBuildFile; fileRef = C4C07E862F57037300A9DC94 /* CMPTextInputStringTokenizer.m */; }; @@ -87,6 +90,8 @@ 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPDrawable.m; sourceTree = ""; }; 992EDDF92E55EC8400FB44C5 /* CMPKeyValueObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPKeyValueObserver.h; sourceTree = ""; }; 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPKeyValueObserver.m; sourceTree = ""; }; + 9967254630010FBC0013BF47 /* CMPContainerView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPContainerView.h; sourceTree = ""; }; + 9967254730010FBC0013BF47 /* CMPContainerView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPContainerView.m; sourceTree = ""; }; 9968C3592D76FE16005E8DE4 /* CMPPanGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPPanGestureRecognizer.h; sourceTree = ""; }; 9968C35A2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPPanGestureRecognizer.m; sourceTree = ""; }; 9968C35F2D7746BD005E8DE4 /* CMPHoverGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPHoverGestureRecognizer.h; sourceTree = ""; }; @@ -95,8 +100,6 @@ 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPScreenEdgePanGestureRecognizer.m; sourceTree = ""; }; 996EFEEA2B02CE5D0000FE0F /* libCMPUIKitUtils.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCMPUIKitUtils.a; sourceTree = BUILT_PRODUCTS_DIR; }; 996EFEF52B02CE8A0000FE0F /* CMPUIKitUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIKitUtils.h; sourceTree = ""; }; - AABB11112F5A0000000000A1 /* CMPUIWindowSceneExtensions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIWindowSceneExtensions.h; sourceTree = ""; }; - AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPUIWindowSceneExtensions.m; sourceTree = ""; }; 997DFCDC2B18D135000B56B5 /* CMPViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPViewController.h; sourceTree = ""; }; 997DFCDD2B18D135000B56B5 /* CMPViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPViewController.m; sourceTree = ""; }; 997DFCE32B18D99E000B56B5 /* CMPUIKitUtilsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CMPUIKitUtilsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -119,6 +122,8 @@ 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPTextLoupeSession.m; sourceTree = ""; }; A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPLayoutRegion.m; sourceTree = ""; }; A0E69B242EB4227A0049B20F /* CMPLayoutRegion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPLayoutRegion.h; sourceTree = ""; }; + AABB11112F5A0000000000A1 /* CMPUIWindowSceneExtensions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPUIWindowSceneExtensions.h; sourceTree = ""; }; + AABB11112F5A0000000000A2 /* CMPUIWindowSceneExtensions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPUIWindowSceneExtensions.m; sourceTree = ""; }; C4C07E832F57037300A9DC94 /* CMPEditMenuCustomAction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuCustomAction.h; sourceTree = ""; }; C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuCustomAction.m; sourceTree = ""; }; C4C07E852F57037300A9DC94 /* CMPTextInputStringTokenizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextInputStringTokenizer.h; sourceTree = ""; }; @@ -174,22 +179,20 @@ 996EFEEB2B02CE5D0000FE0F /* CMPUIKitUtils */ = { isa = PBXGroup; children = ( - C4C07E832F57037300A9DC94 /* CMPEditMenuCustomAction.h */, - C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */, - C4C07E852F57037300A9DC94 /* CMPTextInputStringTokenizer.h */, - C4C07E862F57037300A9DC94 /* CMPTextInputStringTokenizer.m */, - C4C07E872F57037300A9DC94 /* CMPTextInputView.h */, - C4C07E882F57037300A9DC94 /* CMPTextInputView.m */, EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */, EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */, 99CC4B282ECE04AC007C5C44 /* CMPComposeContainerLifecycleDelegate.h */, 99CC4B2B2ECE07EA007C5C44 /* CMPComposeContainerLifecycleState.h */, + 9967254630010FBC0013BF47 /* CMPContainerView.h */, + 9967254730010FBC0013BF47 /* CMPContainerView.m */, EADD028E2C9846D9003F66E8 /* CMPDragInteractionProxy.h */, EADD028F2C9846D9003F66E8 /* CMPDragInteractionProxy.m */, 99293FF22F2B8A81001EC2A1 /* CMPDrawable.h */, 99293FF32F2B8A81001EC2A1 /* CMPDrawable.m */, EADD02912C98484F003F66E8 /* CMPDropInteractionProxy.h */, EADD02922C98484F003F66E8 /* CMPDropInteractionProxy.m */, + C4C07E832F57037300A9DC94 /* CMPEditMenuCustomAction.h */, + C4C07E842F57037300A9DC94 /* CMPEditMenuCustomAction.m */, 99D97A862BF73A9B0035552B /* CMPEditMenuView.h */, 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */, EA4B52942C2EDEF200FBB55C /* CMPGestureRecognizer.h */, @@ -200,6 +203,8 @@ EABD912A2BC02B5F00455279 /* CMPInteropWrappingView.m */, 992EDDF92E55EC8400FB44C5 /* CMPKeyValueObserver.h */, 992EDDFA2E55EC8400FB44C5 /* CMPKeyValueObserver.m */, + A0E69B242EB4227A0049B20F /* CMPLayoutRegion.h */, + A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */, EA70A7E72B27106100300068 /* CMPMacros.h */, EAB33E162C12E746002CFF44 /* CMPMetalDrawablesHandler.h */, EAB33E172C12E746002CFF44 /* CMPMetalDrawablesHandler.m */, @@ -215,6 +220,10 @@ 9968C38A2D7892DF005E8DE4 /* CMPScreenEdgePanGestureRecognizer.m */, 991A97F52E1FB99300B47130 /* CMPScrollView.h */, 991A97F62E1FB99300B47130 /* CMPScrollView.m */, + C4C07E852F57037300A9DC94 /* CMPTextInputStringTokenizer.h */, + C4C07E862F57037300A9DC94 /* CMPTextInputStringTokenizer.m */, + C4C07E872F57037300A9DC94 /* CMPTextInputView.h */, + C4C07E882F57037300A9DC94 /* CMPTextInputView.m */, 99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */, 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */, 996EFEF52B02CE8A0000FE0F /* CMPUIKitUtils.h */, @@ -224,8 +233,6 @@ 99CC4B2D2ECE0838007C5C44 /* CMPView.m */, 997DFCDC2B18D135000B56B5 /* CMPViewController.h */, 997DFCDD2B18D135000B56B5 /* CMPViewController.m */, - A0E69B242EB4227A0049B20F /* CMPLayoutRegion.h */, - A01609812EB42A3300FB9790 /* CMPLayoutRegion.m */, ); path = CMPUIKitUtils; sourceTree = ""; @@ -420,6 +427,7 @@ EA82F4F92B86144E00465418 /* CMPOSLogger.m in Sources */, A01609822EB42A3300FB9790 /* CMPLayoutRegion.m in Sources */, 9968C3612D7746BD005E8DE4 /* CMPHoverGestureRecognizer.m in Sources */, + 9967254830010FBC0013BF47 /* CMPContainerView.m in Sources */, EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */, EA70A7EB2B27106100300068 /* CMPAccessibilityElement.m in Sources */, 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */, @@ -436,6 +444,7 @@ 99A83D5A2F2CC4FB00BB5698 /* CMPMetalLayerTests.swift in Sources */, 99CC4B2F2ECE0838007C5C44 /* CMPView.m in Sources */, 99CC4B322ECE16C8007C5C44 /* CMPViewTests.swift in Sources */, + 9967254930010FBC0013BF47 /* CMPContainerView.m in Sources */, 997DFCF52B18E276000B56B5 /* XCTestCase.swift in Sources */, 997DFCE62B18D99E000B56B5 /* CMPViewControllerTests.swift in Sources */, 997DFCEE2B18DB7B000B56B5 /* CMPViewController.m in Sources */, @@ -450,6 +459,7 @@ EAC703E32B8C826E001ECDA6 /* CMPAccessibilityElement.m in Sources */, EAC703E42B8C826E001ECDA6 /* CMPViewController.m in Sources */, EAC703E52B8C826E001ECDA6 /* CMPOSLogger.m in Sources */, + 9967254A30010FBC0013BF47 /* CMPContainerView.m in Sources */, 997DFCFD2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift in Sources */, EAC703E62B8C826E001ECDA6 /* CMPOSLoggerInterval.m in Sources */, 99009B7A2F322B4700518C1F /* CMPMetalLayer.m in Sources */, diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.h new file mode 100644 index 0000000000000..767fa1efa7539 --- /dev/null +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.h @@ -0,0 +1,23 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import + +@interface CMPContainerView : UIView + +@property (nonatomic, copy, nullable) void (^onDidMoveToWindowBlock)(void); + +@end diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.m new file mode 100644 index 0000000000000..1a45ee9d0cec4 --- /dev/null +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPContainerView.m @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "CMPContainerView.h" + +@implementation CMPContainerView + +- (void)didMoveToWindow { + [super didMoveToWindow]; + + if (self.onDidMoveToWindowBlock != nil) { + self.onDidMoveToWindowBlock(); + } +} + +@end diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h index 7ece2b7487a3c..367260bdcb5b6 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h @@ -43,3 +43,4 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPView.h" #import "CMPUIWindowSceneExtensions.h" #import "CMPViewController.h" +#import "CMPContainerView.h" diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m index 8f45ea54a9246..7f69b1b93aa36 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m @@ -17,8 +17,6 @@ #import "CMPView.h" #import "CMPComposeContainerLifecycleState.h" -#pragma mark - CMPViewController - @implementation CMPView { CMPComposeContainerLifecycleState _lifecycleState; id _lifecycleDelegate; diff --git a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m index 512622f67f0a6..87b6a330a67ab 100644 --- a/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m +++ b/compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m @@ -17,6 +17,7 @@ #import "CMPViewController.h" #import #import "CMPComposeContainerLifecycleState.h" +#import "CMPContainerView.h" #pragma mark - UIViewController + CMPUIKitUtilsPrivate @@ -103,26 +104,42 @@ - (void)addTraitCollectionObserverIfNeeded { } } -- (void)viewWillAppear:(BOOL)animated { - [self transitLifecycleToStarted]; +- (void)loadView { + self.view = [[CMPContainerView alloc] initWithFrame:CGRectZero]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + if (![self.view isKindOfClass:[CMPContainerView class]]) { + [NSException raise:NSInternalInconsistencyException + format:@"CMPViewController's view must be a kind of CMPContainerView, but was %@", [self.view class]]; + } - [super viewWillAppear:animated]; - [_lifecycleDelegate composeContainerWillAppear]; - _isViewAppeared = YES; + __weak typeof(self) weakSelf = self; + CMPContainerView *containerView = (CMPContainerView *)self.view; + containerView.onDidMoveToWindowBlock = ^{ + [weakSelf onDidMoveToWindow]; + }; } -- (void)viewDidAppear:(BOOL)animated { - // In some cases viewWillAppear may not be called for the view controller. - // The code in the viewDidAppear used as a backup scenario for this case. +- (void)onDidMoveToWindow { + if (self.view.window != nil) { + [self transitLifecycleToStarted]; - [self transitLifecycleToStarted]; + if (!_isViewAppeared) { + _isViewAppeared = YES; + [_lifecycleDelegate composeContainerWillAppear]; + } + } +} +- (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; - if (!_isViewAppeared) { - _isViewAppeared = YES; - [_lifecycleDelegate composeContainerWillAppear]; - } + // In some cases viewWillAppear may not be called for the view controller. + // The code in the viewDidAppear used as a backup scenario for this case. + [self onDidMoveToWindow]; } - (void)viewDidDisappear:(BOOL)animated { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt new file mode 100644 index 0000000000000..dfa813863b68e --- /dev/null +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.uikit.toNanoSeconds +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.window.DisplayLinkFrameRate +import androidx.compose.ui.window.MetalOutOfFrameExecutor +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.IntVar +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.alloc +import kotlinx.cinterop.nativeHeap +import kotlinx.cinterop.ptr +import kotlinx.coroutines.MainScope +import platform.Foundation.NSRunLoop +import platform.Foundation.NSRunLoopCommonModes +import platform.Foundation.NSSelectorFromString +import platform.Foundation.NSTimeInterval +import platform.QuartzCore.CADisplayLink +import platform.UIKit.UIWindowScene +import platform.darwin.NSInteger +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import platform.objc.OBJC_ASSOCIATION_RETAIN +import platform.objc.objc_getAssociatedObject +import platform.objc.objc_setAssociatedObject + +internal class FrameChoreographer(scene: UIWindowScene) { + companion object { + fun choreographerForScene(scene: UIWindowScene): FrameChoreographer { + return scene.frameChoreographer ?: FrameChoreographer(scene).also { + scene.frameChoreographer = it + } + } + } + + interface Listener { + fun onDisplayLink() + + fun onOutOfFrame(lastFrameTimestamp: NSTimeInterval, targetTimestamp: NSTimeInterval) + } + + val frameRecomposer = FrameRecomposer( + coroutineContext = MainScope().coroutineContext, + invalidate = ::setNeedsRedraw + ) + + private val displayLink = CADisplayLink.displayLinkWithTarget( + target = SurfaceDisplayLinkProxy(::onDisplayLink), + selector = NSSelectorFromString(SurfaceDisplayLinkProxy::handleDisplayLinkTick.name) + ).also { + it.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) + } + + private val displayLinkFrameRate = DisplayLinkFrameRate(displayLink).also { + val maximumFramesPerSecond = scene.screen.maximumFramesPerSecond + it.maximumFramesPerSecond = maximumFramesPerSecond + it.preferredFramesPerSecond = maximumFramesPerSecond + } + + val outOfFrameExecutor = MetalOutOfFrameExecutor() + + private val listeners = mutableListOf() + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + fun voteFrameRate(frameRate: Float, frameRateCategory: Float) { + displayLinkFrameRate.voteFrameRate(frameRate, frameRateCategory) + } + + var ongoingActivitiesCount: Int = 0 + set(value) { + assert(value >= 0) + field = value + setNeedsRedraw() + } + + private var advancedFramesCount = 2 + fun setNeedsRedraw() { + advancedFramesCount = 2 + displayLink.paused = false + } + + val targetTimestamp get() = displayLink.targetTimestamp + + val preferredFramesPerSecond: NSInteger get() = displayLink.preferredFramesPerSecond + + val currentTargetFrameDuration: NSTimeInterval + get() = displayLink.targetTimestamp - displayLink.timestamp + + private fun onDisplayLink() { + val lastFrameTimestamp = displayLink.timestamp + val targetTimestamp = displayLink.targetTimestamp + + // Drain out-of-frame work scheduled between frames before producing this frame. + outOfFrameExecutor.onFrameStart() + + val outOfFrameListeners = listeners.toList() + dispatch_async(dispatch_get_main_queue()) { + // The next runloop is performed after all draw calls are processed and before the next + // runloop starts, so this is the moment out-of-frame work should run. + outOfFrameExecutor.onFrameEnd() + outOfFrameListeners.fastForEach { it.onOutOfFrame(lastFrameTimestamp, targetTimestamp) } + } + + listeners.toList().fastForEach { it.onDisplayLink() } + + val timestamp = lastFrameTimestamp.toNanoSeconds() + advancedFramesCount-- + + displayLinkFrameRate.updateFrameRateIfNeeded() + frameRecomposer.performFrame(timestamp) + if (advancedFramesCount <= 0 && ongoingActivitiesCount == 0) { + advancedFramesCount = 0 + displayLink.paused = true + } + } +} + +@OptIn(ExperimentalForeignApi::class) +private val frameChoreographerAssociationKey: COpaquePointer = nativeHeap.alloc().ptr + +@OptIn(ExperimentalForeignApi::class) +internal var UIWindowScene.frameChoreographer: FrameChoreographer? + get() = objc_getAssociatedObject(this, frameChoreographerAssociationKey) as? FrameChoreographer + set(value) { + objc_setAssociatedObject(this, frameChoreographerAssociationKey, value, OBJC_ASSOCIATION_RETAIN) + } + +private class SurfaceDisplayLinkProxy( + private val callback: () -> Unit +) : NSObject() { + @OptIn(BetaInteropApi::class) + @ObjCAction + fun handleDisplayLinkTick() { + callback() + } +} diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index eae38b7c0eba1..0d54f5372d064 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.MotionDurationScaleImpl import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext +import androidx.compose.ui.platform.FrameChoreographer import androidx.compose.ui.uikit.ComposeContainerConfiguration import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalUIViewController @@ -64,7 +65,6 @@ import platform.Foundation.removeObserver import platform.UIKit.UIAccessibilityIsReduceMotionEnabled import platform.UIKit.UIApplication import platform.UIKit.UIResponder -import platform.UIKit.UITraitCollection import platform.UIKit.UIUserInterfaceLayoutDirection import platform.UIKit.UIUserInterfaceLayoutDirection.UIUserInterfaceLayoutDirectionLeftToRight import platform.UIKit.UIUserInterfaceLayoutDirection.UIUserInterfaceLayoutDirectionRightToLeft @@ -168,9 +168,11 @@ internal class ComposeContainer( private fun onLayoutSubviews() { windowContext.updateWindowContainerSize() + + mediator?.doMeasureAndLayout() } - private fun onTraitCollectionDidChange(previousTraitCollection: UITraitCollection?) { + private fun onTraitCollectionDidChange() { layoutDirection = view.effectiveUserInterfaceLayoutDirection.asLayoutDirection() } @@ -200,6 +202,7 @@ internal class ComposeContainer( // Because the container view can change during the modal transition animation, // the gesture handlers and layers view are added back when the animation ends. navigationEventInput.onDidMoveToWindow(view.window, view) + view.setNeedsDisplay() } fun sceneWillDisappear() { @@ -213,6 +216,9 @@ internal class ComposeContainer( } fun initializeComposeScene() { + val frameChoreographer = view.window?.windowScene?.let { FrameChoreographer.choreographerForScene(it) } + ?: error("No window scene found") + sceneJob = Job() val sceneCoroutineContext = coroutineContext + motionDurationScale + sceneJob val metalView = MetalView( @@ -244,15 +250,32 @@ internal class ComposeContainer( lifecycleDelegate.onLifecycleStateUpdated = architectureComponentsOwner::setLifecycleState mediator = ComposeSceneMediator( + frameChoreographer = frameChoreographer, onFocusBehavior = configuration.onFocusBehavior, isClearFocusOnMouseDownEnabled = configuration.isClearFocusOnMouseDownEnabled, focusedViewsList = focusedViewsList, windowContext = windowContext, architectureComponentsOwner = architectureComponentsOwner, coroutineContext = sceneCoroutineContext, - redrawer = metalView.redrawer, - composeSceneFactory = { invalidate, context, frameRecomposer -> - createComposeScene(invalidate, context, holder, frameRecomposer) + composeSceneFactory = { context, frameRecomposer -> + PlatformLayersComposeScene( + frameRecomposer = frameRecomposer, + density = view.density, + layoutDirection = layoutDirection, + composeSceneContext = createComposeSceneContext( + frameChoreographer = frameChoreographer, + platformContext = context, + layersHolder = holder, + frameRecomposer = frameRecomposer, + ), + invalidateLayout = { + view.setNeedsLayout() + view.invalidateIntrinsicContentSize() + }, + invalidateDraw = { + view.setNeedsDisplay() + }, + ) }, navigationEventInput = navigationEventInput, interfaceOrientationState = interfaceOrientationState, @@ -310,6 +333,7 @@ internal class ComposeContainer( } private fun createComposeSceneContext( + frameChoreographer: FrameChoreographer, platformContext: PlatformContext, layersHolder: ComposeLayersHolder, frameRecomposer: FrameRecomposer, @@ -324,12 +348,13 @@ internal class ComposeContainer( consumePointerInputOutside: Boolean, ): ComposeSceneLayer { val layer = UIKitComposeSceneLayer( + frameChoreographer = frameChoreographer, onClosed = { layersHolder.getLayersViewController().detach(it) onFocusConditionsChanged() }, createComposeSceneContext = { - createComposeSceneContext(it, layersHolder, frameRecomposer) + createComposeSceneContext(frameChoreographer, it, layersHolder, frameRecomposer) }, hostCompositionLocals = { ProvideContainerCompositionLocals(it) }, layersViewController = layersHolder.getLayersViewController(), @@ -343,6 +368,8 @@ internal class ComposeContainer( parentCoroutineContext = frameRecomposer.compositionContext.effectCoroutineContext, ownerProvider = architectureComponentsOwner, interfaceOrientationState = interfaceOrientationState, + invalidateLayout = { layersHolder.getLayersViewController().setNeedsLayout() }, + invalidateDraw = { layersHolder.getLayersViewController().setNeedsDisplay() }, ) layersHolder.getLayersViewController().attach(layer) @@ -353,26 +380,6 @@ internal class ComposeContainer( } } - private fun createComposeScene( - invalidate: () -> Unit, - platformContext: PlatformContext, - layersHolder: ComposeLayersHolder, - frameRecomposer: FrameRecomposer, - ): ComposeScene = PlatformLayersComposeScene( - frameRecomposer = frameRecomposer, - density = view.density, - layoutDirection = layoutDirection, - composeSceneContext = createComposeSceneContext( - platformContext = platformContext, - layersHolder = layersHolder, - frameRecomposer = frameRecomposer, - ), - // TODO: Split these into UIKit layout vs display invalidation. `invalidateLayout` - // should call into layout scheduling, while `invalidateDraw` should schedule display. - invalidateLayout = invalidate, - invalidateDraw = invalidate, - ) - /** * Enables or disables accessibility for each layer, as well as the root mediator, taking into * account layer order and ability to overlay underlying content. diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt index f0eb37a112bc0..72f1177511b1d 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt @@ -67,15 +67,6 @@ internal class ComposeLayersViewController( canBeOpaque = false } - enum class AppearanceTransitionState { - Appearing, - Appeared, - Disappearing, - Disappeared - } - - private var transitionState: AppearanceTransitionState = AppearanceTransitionState.Disappeared - private val composeContainerView = ComposeContainerView( useOpaqueConfiguration = false, transparentForTouches = true @@ -93,6 +84,15 @@ internal class ComposeLayersViewController( } } + fun setNeedsLayout() { + composeContainerView.setNeedsLayout() + composeContainerView.invalidateIntrinsicContentSize() + } + + fun setNeedsDisplay() { + composeContainerView.setNeedsDisplay() + } + override fun viewDidLayoutSubviews() { super.viewDidLayoutSubviews() @@ -233,6 +233,7 @@ internal class ComposeLayersViewController( if (hasViewAppeared) { layer.sceneDidAppear() } + setNeedsDisplay() } fun detach(layer: UIKitComposeSceneLayer) { @@ -255,7 +256,7 @@ internal class ComposeLayersViewController( removedLayersTransactions.add(transaction) // Redraw content with layer removed - metalView.redrawer.setNeedsRedraw() + setNeedsDisplay() } } @@ -266,6 +267,7 @@ internal class ComposeLayersViewController( this.layers.fastForEach { it.sceneDidAppear() } + setNeedsDisplay() } override fun viewWillDisappear(animated: Boolean) { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 8ca1f27bb7801..c3f92f2f0ae88 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -74,6 +74,7 @@ import androidx.compose.ui.input.pointer.isAltPressed import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.isMetaPressed import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.platform.FrameChoreographer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntRect @@ -96,8 +97,8 @@ import androidx.compose.ui.window.BackgroundInputView import androidx.compose.ui.window.ComposeSceneKeyboardOffsetManager import androidx.compose.ui.window.FocusedViewsList import androidx.compose.ui.window.KeyboardVisibilityListener -import androidx.compose.ui.window.MetalRedrawer import androidx.compose.ui.window.OverlayInputView +import androidx.compose.ui.window.PlatformPrefetchSchedulerImpl import androidx.compose.ui.window.TouchesEventKind import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds @@ -107,10 +108,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.OSVersion -import org.jetbrains.skiko.available import platform.CoreGraphics.CGPoint +import platform.Foundation.NSTimeInterval import platform.QuartzCore.CACurrentMediaTime import platform.QuartzCore.CATransaction import platform.UIKit.UIEvent @@ -190,17 +189,16 @@ private class SemanticsOwnerListenerImpl( } internal class ComposeSceneMediator( + private val frameChoreographer: FrameChoreographer, private val onFocusBehavior: OnFocusBehavior, private val isClearFocusOnMouseDownEnabled: Boolean, focusedViewsList: FocusedViewsList?, private val windowContext: PlatformWindowContext, private val architectureComponentsOwner: PlatformArchitectureComponentsOwner, private val coroutineContext: CoroutineContext, - private val redrawer: MetalRedrawer, private val navigationEventInput: UIKitNavigationEventInput, interfaceOrientationState: State, composeSceneFactory: ( - invalidate: () -> Unit, platformContext: PlatformContext, frameRecomposer: FrameRecomposer, ) -> ComposeScene, @@ -217,6 +215,38 @@ internal class ComposeSceneMediator( private val isActive get() = coroutineContext.isActive + private var isPrefetchVoteActive: Boolean = false + private val prefetchScheduler = PlatformPrefetchSchedulerImpl( + onHasWorkScheduled = { hasWork -> + if (hasWork != isPrefetchVoteActive) { + isPrefetchVoteActive = hasWork + if (hasWork) { + frameChoreographer.ongoingActivitiesCount++ + } else { + frameChoreographer.ongoingActivitiesCount-- + } + } + } + ) + + private var didDrawSinceDisplayLink = false + private val frameChoreographerListener = object : FrameChoreographer.Listener { + override fun onDisplayLink() { + didDrawSinceDisplayLink = false + } + + override fun onOutOfFrame( + lastFrameTimestamp: NSTimeInterval, + targetTimestamp: NSTimeInterval + ) { + prefetchScheduler.execute( + lastFrameTimestamp = lastFrameTimestamp, + targetTimestamp = targetTimestamp, + didDraw = didDrawSinceDisplayLink, + ) + } + } + private val viewConfiguration: ViewConfiguration = object : ViewConfiguration by PlatformContext.DefaultViewConfiguration { override val touchSlop: Float @@ -226,23 +256,10 @@ internal class ComposeSceneMediator( } } - // TODO: It must be shared between Compose instances. - // It's supposed to be stored in platform's root view or window. - val frameRecomposer = FrameRecomposer(coroutineContext, redrawer::setNeedsRedraw) - - // TODO: It cannot be used in case of shared [FrameRecomposer], replace this helper with calling - // - [frameRecomposer.performFrame] once per frame (across all instances) before platform views layout phase - // - [scene.measureAndLayout] during platform views layout phase. Note that it should be triggered - // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) - // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). - // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(redrawer::setNeedsRedraw) - - private val scene: ComposeScene by lazy { + val scene: ComposeScene by lazy { composeSceneFactory( - sceneRenderingScope::onSceneInvalidation, PlatformContextImpl(), - frameRecomposer, + frameChoreographer.frameRecomposer, ) } @@ -343,7 +360,7 @@ internal class ComposeSceneMediator( private val interopContainer = UIKitInteropContainer( overlayContainer = _overlayView, backgroundContainer = _backgroundView, - requestRedraw = redrawer::setNeedsRedraw + requestRedraw = frameChoreographer::setNeedsRedraw ) private val dragAndDropManager = UIKitDragAndDropManager( @@ -409,8 +426,7 @@ internal class ComposeSceneMediator( UIKitTextInputService( updateView = { if (!isPerformingFrame) { - // Fixes issue with reentrant redraws from native text-input edits mid-frame - frameRecomposer.performFrame(lastRenderTime) + frameChoreographer.frameRecomposer.performFrame(lastRenderTime) } scene.measureAndLayout() CATransaction.flush() @@ -434,8 +450,7 @@ internal class ComposeSceneMediator( } val hasInvalidations: Boolean - get() = frameRecomposer.hasPendingWork() || - scene.hasInvalidations() || + get() = scene.hasInvalidations() || keyboardManager.isAnimating || isLayoutTransitionAnimating || semanticsOwnerListener.hasInvalidations || @@ -443,6 +458,7 @@ internal class ComposeSceneMediator( init { coroutineContext.job.invokeOnCompletion { dispose() } + frameChoreographer.addListener(frameChoreographerListener) } private fun hitTestInteropView(point: CValue): UIView? = @@ -463,9 +479,9 @@ internal class ComposeSceneMediator( eventKind: TouchesEventKind ) { when (eventKind) { - TouchesEventKind.BEGAN -> redrawer.ongoingInteractionEventsCount += 1 + TouchesEventKind.BEGAN -> frameChoreographer.ongoingActivitiesCount += 1 TouchesEventKind.MOVED -> {} - TouchesEventKind.ENDED -> redrawer.ongoingInteractionEventsCount -= 1 + TouchesEventKind.ENDED -> frameChoreographer.ongoingActivitiesCount -= 1 } scene.sendPointerEvent( @@ -513,12 +529,12 @@ internal class ComposeSceneMediator( } private fun onCancelScroll() { - redrawer.ongoingInteractionEventsCount -= 1 + frameChoreographer.ongoingActivitiesCount -= 1 scene.cancelPointerInput() } private fun onCancelAllTouches(touches: Set<*>) { - redrawer.ongoingInteractionEventsCount -= touches.count() + frameChoreographer.ongoingActivitiesCount -= touches.count() scene.cancelPointerInput() } @@ -534,8 +550,8 @@ internal class ComposeSceneMediator( eventKind: TouchesEventKind ): PointerEventResult { when (eventKind) { - TouchesEventKind.BEGAN -> redrawer.ongoingInteractionEventsCount += touches.count() - TouchesEventKind.ENDED -> redrawer.ongoingInteractionEventsCount -= touches.count() + TouchesEventKind.BEGAN -> frameChoreographer.ongoingActivitiesCount += touches.count() + TouchesEventKind.ENDED -> frameChoreographer.ongoingActivitiesCount -= touches.count() TouchesEventKind.MOVED -> {} } @@ -640,14 +656,20 @@ internal class ComposeSceneMediator( } private var isPerformingFrame = false + fun doMeasureAndLayout() { + scene.measureAndLayout() + } + private var lastRenderTime = CACurrentMediaTime().toNanoSeconds() fun render(canvas: Canvas, nanoTime: Long) { lastRenderTime = nanoTime + // Mark that a draw happened in the current display-link interval so the prefetch scheduler + // can tell whether the draw loop was idle when [FrameChoreographer.Listener.onOutOfFrame] + // runs. + didDrawSinceDisplayLink = true isPerformingFrame = true try { - with(sceneRenderingScope) { - scene.render(frameRecomposer, canvas, nanoTime) - } + scene.draw(canvas) } finally { isPerformingFrame = false } @@ -692,6 +714,9 @@ internal class ComposeSceneMediator( onPreviewKeyEvent = { false } onKeyEvent = { false } + frameChoreographer.removeListener(frameChoreographerListener) + prefetchScheduler.dispose() + _overlayView.dispose() keyboardManager.dispose() _backgroundView.dispose() @@ -700,7 +725,6 @@ internal class ComposeSceneMediator( _backgroundView.removeFromSuperview() scene.close() - frameRecomposer.close() interopContainer.dispose() semanticsOwnerListener.dispose() } @@ -726,7 +750,6 @@ internal class ComposeSceneMediator( } fun sceneDidAppear() { - redrawer.setNeedsRedraw() keyboardManager.start() } @@ -878,8 +901,8 @@ internal class ComposeSceneMediator( override val semanticsOwnerListener get() = this@ComposeSceneMediator.semanticsOwnerListener override val dragAndDropManager get() = this@ComposeSceneMediator.dragAndDropManager override val windowInsets get() = this@ComposeSceneMediator.windowInsetsManager.windowInsets - override val outOfFrameExecutor get() = this@ComposeSceneMediator.redrawer.outOfFrameExecutor - override val prefetchScheduler get() = this@ComposeSceneMediator.redrawer.prefetchScheduler + override val outOfFrameExecutor get() = this@ComposeSceneMediator.frameChoreographer.outOfFrameExecutor + override val prefetchScheduler get() = this@ComposeSceneMediator.prefetchScheduler override val isClearFocusOnMouseDownEnabled: Boolean get() = this@ComposeSceneMediator.isClearFocusOnMouseDownEnabled @@ -888,7 +911,7 @@ internal class ComposeSceneMediator( set(value) { UIKitIdleTimerManager.setIdleTimerState(this@ComposeSceneMediator, value) } override fun voteFrameRate(frameRate: Float, frameRateCategory: Float) { - redrawer.voteFrameRate(frameRate, frameRateCategory) + frameChoreographer.voteFrameRate(frameRate, frameRateCategory) } override suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt index 5ddf5a9779c41..111c57b698233 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.navigationevent.UIKitNavigationEventInput import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.FrameChoreographer import androidx.compose.ui.uikit.ComposeContainerConfiguration import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalUIViewController @@ -49,6 +50,7 @@ import platform.UIKit.UIView import platform.UIKit.UIWindow internal class UIKitComposeSceneLayer( + frameChoreographer: FrameChoreographer, private val onClosed: (UIKitComposeSceneLayer) -> Unit, private val createComposeSceneContext: (PlatformContext) -> ComposeSceneContext, @@ -64,6 +66,8 @@ internal class UIKitComposeSceneLayer( parentCoroutineContext: CoroutineContext, private val ownerProvider: PlatformArchitectureComponentsOwner, private val interfaceOrientationState: State, + private var invalidateLayout: () -> Unit, + private var invalidateDraw: () -> Unit, ) : ComposeSceneLayer { private val layerJob = Job() private val layerCoroutineContext = parentCoroutineContext + layerJob @@ -104,13 +108,13 @@ internal class UIKitComposeSceneLayer( } private val mediator = ComposeSceneMediator( + frameChoreographer = frameChoreographer, onFocusBehavior = configuration.onFocusBehavior, isClearFocusOnMouseDownEnabled = configuration.isClearFocusOnMouseDownEnabled, focusedViewsList = focusedViewsList, windowContext = layersViewController.windowContext, architectureComponentsOwner = ownerProvider, coroutineContext = layerCoroutineContext, - redrawer = layersViewController.metalView.redrawer, composeSceneFactory = ::createComposeScene, navigationEventInput = navigationEventInput, interfaceOrientationState = interfaceOrientationState @@ -120,7 +124,6 @@ internal class UIKitComposeSceneLayer( } private fun createComposeScene( - invalidate: () -> Unit, platformContext: PlatformContext, frameRecomposer: FrameRecomposer ): ComposeScene = @@ -129,10 +132,8 @@ internal class UIKitComposeSceneLayer( density = mediator.screenDensity, layoutDirection = initialLayoutDirection, composeSceneContext = createComposeSceneContext(platformContext), - // TODO: Split these into UIKit layout vs display invalidation instead of using the - // same invalidation callback for both phases. - invalidateLayout = invalidate, - invalidateDraw = invalidate, + invalidateLayout = invalidateLayout, + invalidateDraw = invalidateDraw, ) val hasInvalidations by mediator::hasInvalidations @@ -206,6 +207,8 @@ internal class UIKitComposeSceneLayer( interactionView.removeFromSuperview() interactionView.dispose() layerJob.cancel() + invalidateLayout = {} + invalidateDraw = {} } @Composable diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt index c1b5e8d616c0e..22f40afef3c1d 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.window +import androidx.compose.ui.platform.FrameChoreographer +import androidx.compose.ui.uikit.utils.CMPContainerView import androidx.compose.ui.unit.toDpSize import kotlin.math.max import kotlinx.cinterop.CValue @@ -27,6 +29,7 @@ import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectEqualToRect import platform.CoreGraphics.CGRectMake +import platform.QuartzCore.CACurrentMediaTime import platform.UIKit.UIColor import platform.UIKit.UIEvent import platform.UIKit.UIGraphicsImageRenderer @@ -42,7 +45,7 @@ import platform.UIKit.UIWindow internal class ComposeContainerView( private val useOpaqueConfiguration: Boolean, private val transparentForTouches: Boolean, -): UIView(frame = UIScreen.mainScreen.bounds) { +): CMPContainerView(frame = UIScreen.mainScreen.bounds) { init { setClipsToBounds(true) setOpaque(useOpaqueConfiguration) @@ -53,8 +56,9 @@ internal class ComposeContainerView( private var onDidMoveToWindow: (UIWindow?) -> Unit = {} private var onWillMoveToWindow: (UIWindow?) -> Unit = {} private var onLayoutSubviews: () -> Unit = {} - private var onTraitCollectionDidChange: (UITraitCollection?) -> Unit = {} + private var onTraitCollectionDidChange: () -> Unit = {} private var foregroundStateListener: SceneForegroundStateListener? = null + private var frameChoreographer: FrameChoreographer? = null val redrawer: MetalRedrawer? get() = metalView?.redrawer @@ -66,7 +70,7 @@ internal class ComposeContainerView( super.traitCollectionDidChange(previousTraitCollection) updateBackgroundColor() - onTraitCollectionDidChange(previousTraitCollection) + onTraitCollectionDidChange() } private fun updateBackgroundColor() { @@ -86,14 +90,14 @@ internal class ComposeContainerView( onWillMoveToWindow: (UIWindow?) -> Unit = {}, onDidMoveToWindow: (UIWindow?) -> Unit = {}, onLayoutSubviews: () -> Unit = {}, - onTraitCollectionDidChange: (UITraitCollection?) -> Unit = {}, + onTraitCollectionDidChange: () -> Unit = {}, ) { this.metalView?.dispose() this.metalView?.view?.removeFromSuperview() this.metalView = metalView - this.onDidMoveToWindow = onDidMoveToWindow this.onWillMoveToWindow = onWillMoveToWindow + this.onDidMoveToWindow = onDidMoveToWindow this.onLayoutSubviews = onLayoutSubviews this.onTraitCollectionDidChange = onTraitCollectionDidChange @@ -104,7 +108,7 @@ internal class ComposeContainerView( window?.let(onWillMoveToWindow) window?.let(onDidMoveToWindow) - onTraitCollectionDidChange(traitCollection) + onTraitCollectionDidChange() if (metalView == null) { foregroundStateListener?.dispose() @@ -113,7 +117,9 @@ internal class ComposeContainerView( foregroundStateListener = SceneForegroundStateListener(getScene = { window?.windowScene }) { isSceneInForeground -> - metalView.redrawer.isActive = isSceneInForeground + if (!isSceneInForeground) { + metalView.redrawer.awaitRenderingCompletion() + } } } updateRedrawerState() @@ -128,6 +134,7 @@ internal class ComposeContainerView( override fun didMoveToWindow() { super.didMoveToWindow() + frameChoreographer = window?.windowScene?.let(FrameChoreographer::choreographerForScene) onDidMoveToWindow(window) updateRedrawerState() @@ -151,16 +158,19 @@ internal class ComposeContainerView( } override fun drawRect(rect: CValue) { - if (needsSynchronousDraw) { - metalView?.redrawer?.draw(waitUntilCompletion = true) + val frameChoreographer = frameChoreographer ?: return + if (needsSynchronousDraw) { + metalView?.redrawer?.draw(waitUntilCompletion = true, targetTimestamp = CACurrentMediaTime()) needsSynchronousDraw = false + } else { + metalView?.redrawer?.draw(waitUntilCompletion = false, targetTimestamp = frameChoreographer.targetTimestamp) } if (needsDisablePresentWithTransactionOnNextDraw) { needsDisablePresentWithTransactionOnNextDraw = false metalView?.redrawer?.isForcedToPresentWithTransactionEveryFrame = false - metalView?.redrawer?.ongoingInteractionEventsCount-- + frameChoreographer.ongoingActivitiesCount-- } } @@ -171,7 +181,9 @@ internal class ComposeContainerView( } private fun updateRedrawerState() { - metalView?.redrawer?.isActive = foregroundStateListener?.isSceneInForeground ?: false + if (foregroundStateListener?.isSceneInForeground == false) { + metalView?.redrawer?.awaitRenderingCompletion() + } } /** @@ -245,11 +257,15 @@ internal class ComposeContainerView( } fun animateSizeTransition(scope: CoroutineScope, animations: suspend () -> Unit) { + val frameChoreographer = window?.windowScene?.let { + FrameChoreographer.choreographerForScene(it) + } ?: return + val metalView = metalView ?: return isAnimating = true updateLayout() metalView.redrawer.isForcedToPresentWithTransactionEveryFrame = true - metalView.redrawer.ongoingInteractionEventsCount++ + frameChoreographer.ongoingActivitiesCount++ scope.launch { try { animations() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalRedrawer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalRedrawer.ios.kt index 7825823006f1e..b6ee4d05c5c35 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalRedrawer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalRedrawer.ios.kt @@ -17,8 +17,6 @@ package androidx.compose.ui.window import androidx.collection.IntIntPair -import androidx.compose.ui.platform.PlatformOutOfFrameExecutor -import androidx.compose.ui.platform.PlatformPrefetchScheduler import androidx.compose.ui.uikit.utils.CMPMetalDrawablesHandler import androidx.compose.ui.util.trace import androidx.compose.ui.viewinterop.UIKitInteropAction @@ -26,26 +24,17 @@ import androidx.compose.ui.viewinterop.UIKitInteropTransaction import kotlin.math.roundToInt import kotlinx.cinterop.* import org.jetbrains.skia.* -import platform.Foundation.NSRunLoop -import platform.Foundation.NSSelectorFromString import platform.Foundation.NSThread import platform.QuartzCore.* import platform.darwin.* -import platform.Foundation.NSRunLoopCommonModes import platform.Foundation.NSTimeInterval import platform.Metal.MTLCommandQueueProtocol import platform.Metal.MTLDeviceProtocol internal sealed interface MetalRedrawer { - var isActive: Boolean - fun draw(waitUntilCompletion: Boolean) - fun setNeedsRedraw() - val outOfFrameExecutor: PlatformOutOfFrameExecutor - val prefetchScheduler: PlatformPrefetchScheduler - var ongoingInteractionEventsCount: Int + fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) var isForcedToPresentWithTransactionEveryFrame: Boolean - val currentTargetFrameDuration: NSTimeInterval? - fun voteFrameRate(frameRate: Float, frameRateCategory: Float) + fun awaitRenderingCompletion() fun dispose() } @@ -73,39 +62,12 @@ internal class LegacyMetalRedrawer( private val context = DirectContext.makeMetal(device.objcPtr(), queue.objcPtr()) private var lastRenderTimestamp: NSTimeInterval = CACurrentMediaTime() private val pictureRecorder = PictureRecorder() - override val outOfFrameExecutor = MetalOutOfFrameExecutor() private val inflightCommandBuffersGroup = dispatch_group_create() - private val drawCanvasSemaphore = dispatch_semaphore_create(1) // A guard flag to have proper assertion when draw() method is called recursively. private var isDrawRecursiveCall = false override var isForcedToPresentWithTransactionEveryFrame = false - override val currentTargetFrameDuration: NSTimeInterval? - get() { - val currentTargetTimestamp = currentTargetTimestamp ?: return null - val lastFrameTimestamp = lastFrameTimestamp ?: return null - return currentTargetTimestamp - lastFrameTimestamp - } - - private val displayLinkConditions = DisplayLinkConditions { paused -> - caDisplayLink?.paused = paused - } - override val prefetchScheduler = PlatformPrefetchSchedulerImpl { hasWork -> - displayLinkConditions.needsToPrefetch = hasWork - } - - /** - * Runs invalidation-independent displayLink for forcing UITouch events to come at the fastest - * possible cadence. Otherwise, touch events can come at rate lower than actual display refresh - * rate. - */ - override var ongoingInteractionEventsCount: Int = 0 - set(value) { - field = value - displayLinkConditions.needsToBeProactive = value > 0 - } - /** * True if Metal layer can be opaque. In this case if no interop views are present, Metal * rendering will be optimized for direct-to-screen rendering. @@ -138,66 +100,25 @@ internal class LegacyMetalRedrawer( metalLayer.setOpaque(!isInteropActive && canBeOpaque) } - /** - * Display link for driving the rendering loop. - * null after [dispose] call - */ - private var caDisplayLink: CADisplayLink? = CADisplayLink.displayLinkWithTarget( - target = LegacyDisplayLinkProxy { - val lastFrameTimestamp = lastFrameTimestamp ?: return@LegacyDisplayLinkProxy - val targetTimestamp = currentTargetTimestamp ?: return@LegacyDisplayLinkProxy - - var didDraw = false - displayLinkConditions.onDisplayLinkTick { - draw(waitUntilCompletion = false, targetTimestamp) - didDraw = true - } - prefetchScheduler.execute(lastFrameTimestamp, targetTimestamp, didDraw) - }, - selector = NSSelectorFromString(LegacyDisplayLinkProxy::handleDisplayLinkTick.name) - ) - - /** - * Indicates when the [CADisplayLink]'s frame is expected to be displayed - */ - private val currentTargetTimestamp: NSTimeInterval? - get() = caDisplayLink?.targetTimestamp - - /** - * Indicates when the last frame displayed. - */ - private val lastFrameTimestamp: NSTimeInterval? - get() = caDisplayLink?.timestamp - init { - val caDisplayLink = caDisplayLink - ?: throw IllegalStateException("caDisplayLink is null during redrawer init") - - caDisplayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) - updateLayerOpacity() } - override var isActive: Boolean = true - set(newValue) { - if (field != newValue) { - field = newValue - setNeedsRedraw() - - displayLinkConditions.isActive = newValue - if (!newValue) { - // If an application enters the background, synchronously wait for inflightCommandBuffersGroup, as per - // https://developer.apple.com/documentation/metal/gpu_devices_and_work_submission/preparing_your_metal_app_to_run_in_the_background?language=objc - // Set the expiration time to 1 second to ensure that the main thread does not get stuck when the app is suspended. - dispatch_group_wait(inflightCommandBuffersGroup, dispatch_time(DISPATCH_TIME_NOW, 1L * NSEC_PER_SEC.toLong())) - } - } - } + private var isDisposed = false + + override fun awaitRenderingCompletion() { + // If an application enters the background, synchronously wait for inflightCommandBuffersGroup, as per + // https://developer.apple.com/documentation/metal/gpu_devices_and_work_submission/preparing_your_metal_app_to_run_in_the_background?language=objc + // Set the expiration time to 1 second to ensure that the main thread does not get stuck when the app is suspended. + dispatch_group_wait( + inflightCommandBuffersGroup, + dispatch_time(DISPATCH_TIME_NOW, 1L * NSEC_PER_SEC.toLong()) + ) + } override fun dispose() { - check(caDisplayLink != null) { "MetalRedrawer.dispose() was called more than once" } - outOfFrameExecutor.dispose() - prefetchScheduler.dispose() + check(!isDisposed) { "MetalRedrawer.dispose() was called more than once" } + isDisposed = true retrieveInteropTransaction = { object : UIKitInteropTransaction { @@ -210,40 +131,10 @@ internal class LegacyMetalRedrawer( releaseCachedCommandQueue(queue) - displayLinkFrameRate = null - - caDisplayLink?.invalidate() - caDisplayLink = null - pictureRecorder.close() context.close() } - /** - * Marks current state as dirty and unpauses display link if needed and enables draw dispatch operation on - * next vsync - */ - override fun setNeedsRedraw() { - displayLinkConditions.setNeedsRedraw() - } - - /** - * Immediately dispatch draw and block the thread until it's finished and presented on the screen. - */ - override fun draw(waitUntilCompletion: Boolean) { - if (caDisplayLink == null) { - return - } - draw(waitUntilCompletion, CACurrentMediaTime()) - } - - var displayLinkFrameRate: DisplayLinkFrameRate? = caDisplayLink?.let { DisplayLinkFrameRate(it) } - private set - - override fun voteFrameRate(frameRate: Float, frameRateCategory: Float) { - displayLinkFrameRate?.voteFrameRate(frameRate, frameRateCategory) - } - /** * Encodes the frame and presents it on the screen. * @@ -252,13 +143,12 @@ internal class LegacyMetalRedrawer( * @param targetTimestamp the target timestamp for the frame to drive vsync-dependant time clock. */ @OptIn(BetaInteropApi::class) - private fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) = trace("MetalRedrawer:draw") { + override fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) = trace("MetalRedrawer:draw") { check(NSThread.isMainThread) check(!isDrawRecursiveCall) { "Attempt to call MetalRedrawer.draw() recursively which may lead to the PictureRecorder corruption." } isDrawRecursiveCall = true - outOfFrameExecutor.onFrameStart() try { lastRenderTimestamp = maxOf(targetTimestamp, lastRenderTimestamp) @@ -286,8 +176,6 @@ internal class LegacyMetalRedrawer( pictureRecorder.finishRecordingAsPicture() } - displayLinkFrameRate?.updateFrameRateIfNeeded() - val metalDrawable = trace("MetalRedrawer:draw:nextDrawable") { metalDrawablesHandler.nextDrawable() } @@ -388,7 +276,6 @@ internal class LegacyMetalRedrawer( } } finally { isDrawRecursiveCall = false - outOfFrameExecutor.onFrameEnd() } } @@ -433,14 +320,4 @@ internal class LegacyMetalRedrawer( } } } -} - -private class LegacyDisplayLinkProxy( - private val callback: () -> Unit -) : NSObject() { - @OptIn(BetaInteropApi::class) - @ObjCAction - fun handleDisplayLinkTick() { - callback() - } } \ No newline at end of file diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalView.ios.kt index 60aab7f793940..9cb515bef55a2 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/MetalView.ios.kt @@ -135,8 +135,6 @@ private class LegacyMetalView( val screen = window?.screen ?: return contentScaleFactor = screen.scale - redrawer.displayLinkFrameRate?.maximumFramesPerSecond = screen.maximumFramesPerSecond - redrawer.displayLinkFrameRate?.preferredFramesPerSecond = screen.maximumFramesPerSecond } override fun layoutSubviews() { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalRedrawer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalRedrawer.ios.kt index 91af28f5cdb47..f5ecf6e2d581f 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalRedrawer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalRedrawer.ios.kt @@ -17,105 +17,50 @@ package androidx.compose.ui.window import androidx.collection.IntIntPair -import androidx.compose.ui.uikit.utils.CMPMetalLayer import androidx.compose.ui.uikit.utils.CMPDrawable +import androidx.compose.ui.uikit.utils.CMPMetalLayer import androidx.compose.ui.util.trace import androidx.compose.ui.viewinterop.UIKitInteropAction import androidx.compose.ui.viewinterop.UIKitInteropTransaction import kotlin.math.roundToInt -import kotlinx.cinterop.* -import org.jetbrains.skia.* +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.autoreleasepool +import kotlinx.cinterop.objcPtr +import kotlinx.cinterop.rawValue +import kotlinx.cinterop.useContents +import org.jetbrains.skia.BackendRenderTarget +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Picture +import org.jetbrains.skia.PictureRecorder +import org.jetbrains.skia.PixelGeometry +import org.jetbrains.skia.Surface +import org.jetbrains.skia.SurfaceColorFormat +import org.jetbrains.skia.SurfaceOrigin +import org.jetbrains.skia.SurfaceProps import platform.Foundation.NSLock -import platform.Foundation.NSRunLoop -import platform.Foundation.NSSelectorFromString import platform.Foundation.NSThread -import platform.QuartzCore.* -import platform.darwin.* -import platform.Foundation.NSRunLoopCommonModes import platform.Foundation.NSTimeInterval import platform.IOSurface.IOSurfaceGetHeight import platform.IOSurface.IOSurfaceGetWidth import platform.Metal.MTLCommandQueueProtocol import platform.Metal.MTLDeviceProtocol +import platform.QuartzCore.CACurrentMediaTime +import platform.darwin.DISPATCH_TIME_NOW +import platform.darwin.NSEC_PER_SEC +import platform.darwin.dispatch_assert_queue +import platform.darwin.dispatch_async +import platform.darwin.dispatch_group_create +import platform.darwin.dispatch_group_enter +import platform.darwin.dispatch_group_leave +import platform.darwin.dispatch_group_wait +import platform.darwin.dispatch_queue_attr_make_with_qos_class +import platform.darwin.dispatch_queue_create +import platform.darwin.dispatch_sync +import platform.darwin.dispatch_time import platform.posix.QOS_CLASS_USER_INTERACTIVE -internal class DisplayLinkConditions( - val setPausedCallback: (Boolean) -> Unit -) { - /** - * see [MetalRedrawer.ongoingInteractionEventsCount] - */ - var needsToBeProactive: Boolean = false - set(value) { - field = value - - update() - } - - /** - * Indicates that application is running foreground now - */ - var isActive: Boolean = true - set(value) { - field = value - - update() - } - - /** - * Indicates that prefetch work is waiting for display-link time. - */ - var needsToPrefetch: Boolean = false - set(value) { - field = value - - update() - } - - /** - * Number of subsequent vsync that will issue a draw - */ - private var scheduledRedrawsCount = 0 - set(value) { - field = value - - update() - } - - /** - * Handle display link callback by updating internal state and dispatching the draw, if needed. - */ - inline fun onDisplayLinkTick(draw: () -> Unit) { - if (scheduledRedrawsCount > 0) { - scheduledRedrawsCount -= 1 - draw() - } - } - - /** - * Mark next [FRAMES_COUNT_TO_SCHEDULE_ON_NEED_REDRAW] frames to issue a draw dispatch and unpause displayLink if needed. - */ - fun setNeedsRedraw() { - scheduledRedrawsCount = FRAMES_COUNT_TO_SCHEDULE_ON_NEED_REDRAW - } - - private fun update() { - val isUnpaused = - isActive && (needsToBeProactive || needsToPrefetch || scheduledRedrawsCount > 0) - setPausedCallback(!isUnpaused) - } - - companion object { - /** - * Right now `needRedraw` doesn't reentry from within `draw` callback during animation which leads to a situation where CADisplayLink is first paused - * and then asynchronously unpaused. This effectively makes Pro Motion display lose a frame before running on highest possible frequency again. - * To avoid this, we need to render at least two frames (instead of just one) after each `needRedraw` assuming that invalidation comes inbetween them and - * displayLink is not paused by the end of RuntimeLoop tick. - */ - const val FRAMES_COUNT_TO_SCHEDULE_ON_NEED_REDRAW = 2 - } -} - // https://youtrack.jetbrains.com/issue/CMP-9722 // Copy of the class LegacyMetalRedrawer with a different layer. // All changes made here must also be implemented in the `LegacyMetalRedrawer`. @@ -131,7 +76,6 @@ internal class SurfaceMetalRedrawer( private var lastRenderTimestamp: NSTimeInterval = CACurrentMediaTime() private val pictureRecorder = PictureRecorder() private val transactionQueue = InteropTransactionQueue() - override val outOfFrameExecutor = MetalOutOfFrameExecutor() private val inflightCommandBuffersGroup = dispatch_group_create() // A guard flag to have proper assertion when draw() method is called recursively. @@ -159,31 +103,6 @@ internal class SurfaceMetalRedrawer( // Left here for compatibility reasons. Does not make any effect and must be removed. override var isForcedToPresentWithTransactionEveryFrame: Boolean = false - override val currentTargetFrameDuration: NSTimeInterval? - get() { - val currentTargetTimestamp = currentTargetTimestamp ?: return null - val lastFrameTimestamp = lastFrameTimestamp ?: return null - return currentTargetTimestamp - lastFrameTimestamp - } - - private val displayLinkConditions = DisplayLinkConditions { paused -> - caDisplayLink?.paused = paused - } - override val prefetchScheduler = PlatformPrefetchSchedulerImpl { hasWork -> - displayLinkConditions.needsToPrefetch = hasWork - } - - /** - * Runs invalidation-independent displayLink for forcing UITouch events to come at the fastest - * possible cadence. Otherwise, touch events can come at rate lower than actual display refresh - * rate. - */ - override var ongoingInteractionEventsCount: Int = 0 - set(value) { - field = value - displayLinkConditions.needsToBeProactive = value > 0 - } - /** * True if Metal layer can be opaque. In this case if no interop views are present, Metal * rendering will be optimized for direct-to-screen rendering. @@ -215,58 +134,11 @@ internal class SurfaceMetalRedrawer( metalLayer.setOpaque(!isInteropActive && canBeOpaque) } - /** - * Display link for driving the rendering loop. - * null after [dispose] call - */ - private var caDisplayLink: CADisplayLink? = CADisplayLink.displayLinkWithTarget( - target = SurfaceDisplayLinkProxy { - val lastFrameTimestamp = lastFrameTimestamp ?: return@SurfaceDisplayLinkProxy - val targetTimestamp = currentTargetTimestamp ?: return@SurfaceDisplayLinkProxy - - var didDraw = false - displayLinkConditions.onDisplayLinkTick { - draw(waitUntilCompletion = false, targetTimestamp) - didDraw = true - } - prefetchScheduler.execute(lastFrameTimestamp, targetTimestamp, didDraw) - }, - selector = NSSelectorFromString(SurfaceDisplayLinkProxy::handleDisplayLinkTick.name) - ) - - private val currentTargetTimestamp: NSTimeInterval? - get() = caDisplayLink?.targetTimestamp - - private val lastFrameTimestamp: NSTimeInterval? - get() = caDisplayLink?.timestamp - init { - val caDisplayLink = caDisplayLink - ?: throw IllegalStateException("caDisplayLink is null during redrawer init") - - caDisplayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) - updateLayerOpacity() } - override var isActive: Boolean - get() = displayLinkConditions.isActive - set(isActive) { - if (displayLinkConditions.isActive != isActive) { - displayLinkConditions.isActive = isActive - - if (isActive) { - setNeedsRedraw() - } else { - awaitRenderingQueueTasksCompletion() - disposeDrawableAssociatedResources(metalLayer.drawablesGeneration.toInt()) - // If an application enters the background, synchronously wait for inflightCommandBuffersGroup, as per - // https://developer.apple.com/documentation/metal/gpu_devices_and_work_submission/preparing_your_metal_app_to_run_in_the_background?language=objc - // Set the expiration time to 1 second to ensure that the main thread does not get stuck when the app is suspended. - dispatch_group_wait(inflightCommandBuffersGroup, dispatch_time(DISPATCH_TIME_NOW, 1L * NSEC_PER_SEC.toLong())) - } - } - } + private var isDisposed = false /** * Closes all Skia surfaces and render targets that are currently associated with drawables. @@ -279,9 +151,8 @@ internal class SurfaceMetalRedrawer( } override fun dispose() { - check(caDisplayLink != null) { "MetalRedrawer.dispose() was called more than once" } - outOfFrameExecutor.dispose() - prefetchScheduler.dispose() + check(!isDisposed) { "MetalRedrawer.dispose() was called more than once" } + isDisposed = true retrieveInteropTransaction = { object : UIKitInteropTransaction { @@ -294,42 +165,12 @@ internal class SurfaceMetalRedrawer( releaseCachedCommandQueue(queue) - displayLinkFrameRate = null - - caDisplayLink?.invalidate() - caDisplayLink = null - awaitRenderingQueueTasksCompletion() disposeDrawableAssociatedResources(null) pictureRecorder.close() context.close() } - /** - * Marks current state as dirty and unpauses display link if needed and enables draw dispatch operation on - * next vsync - */ - override fun setNeedsRedraw() { - displayLinkConditions.setNeedsRedraw() - } - - /** - * Immediately dispatch draw and block the thread until it's finished and presented on the screen. - */ - override fun draw(waitUntilCompletion: Boolean) { - if (caDisplayLink == null) { - return - } - draw(waitUntilCompletion, CACurrentMediaTime()) - } - - var displayLinkFrameRate: DisplayLinkFrameRate? = caDisplayLink?.let { DisplayLinkFrameRate(it) } - private set - - override fun voteFrameRate(frameRate: Float, frameRateCategory: Float) { - displayLinkFrameRate?.voteFrameRate(frameRate, frameRateCategory) - } - private fun awaitRenderingQueueTasksCompletion() { check(NSThread.isMainThread) { "MetalRedrawer.awaitRenderingQueueTasksCompletion() must be called on main thread" } @@ -339,6 +180,15 @@ internal class SurfaceMetalRedrawer( dispatch_sync(renderingQueue) {} } + override fun awaitRenderingCompletion() { + awaitRenderingQueueTasksCompletion() + disposeDrawableAssociatedResources(metalLayer.drawablesGeneration.toInt()) + // If an application enters the background, synchronously wait for inflightCommandBuffersGroup, as per + // https://developer.apple.com/documentation/metal/gpu_devices_and_work_submission/preparing_your_metal_app_to_run_in_the_background?language=objc + // Set the expiration time to 1 second to ensure that the main thread does not get stuck when the app is suspended. + dispatch_group_wait(inflightCommandBuffersGroup, dispatch_time(DISPATCH_TIME_NOW, 1L * NSEC_PER_SEC.toLong())) + } + /** * Encodes the frame and presents it on the screen. * @@ -347,16 +197,14 @@ internal class SurfaceMetalRedrawer( * @param targetTimestamp the target timestamp for the frame to drive vsync-dependant time clock. */ @OptIn(BetaInteropApi::class) - private fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) = - trace("MetalRedrawer:draw") { + override fun draw(waitUntilCompletion: Boolean, targetTimestamp: NSTimeInterval) = + trace("SurfaceMetalRedrawer:draw") { check(NSThread.isMainThread) { "MetalRedrawer.draw() must be called on main thread" } - check(caDisplayLink != null) { "MetalRedrawer.draw() was called after dispose()" } check(!isDrawRecursiveCall) { "Attempt to call MetalRedrawer.draw() recursively which may lead to the PictureRecorder corruption." } isDrawRecursiveCall = true - outOfFrameExecutor.onFrameStart() try { lastRenderTimestamp = maxOf(targetTimestamp, lastRenderTimestamp) @@ -383,8 +231,6 @@ internal class SurfaceMetalRedrawer( pictureRecorder.finishRecordingAsPicture() } - displayLinkFrameRate?.updateFrameRateIfNeeded() - val transaction = retrieveInteropTransaction() isInteropActive = transaction.isInteropActive val index = transactionQueue.scheduleTransaction(transaction) @@ -407,7 +253,6 @@ internal class SurfaceMetalRedrawer( } } finally { isDrawRecursiveCall = false - outOfFrameExecutor.onFrameEnd() } } @@ -656,16 +501,6 @@ internal class SurfaceMetalRedrawer( } } -private class SurfaceDisplayLinkProxy( - private val callback: () -> Unit -) : NSObject() { - @OptIn(BetaInteropApi::class) - @ObjCAction - fun handleDisplayLinkTick() { - callback() - } -} - private inline fun NSLock.doLocked(block: () -> T): T { lock() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalView.ios.kt index 8b6b66bea6793..152068d053b36 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/SurfaceMetalView.ios.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.window import androidx.compose.ui.uikit.toNanoSeconds import androidx.compose.ui.uikit.utils.CMPMetalLayer import androidx.compose.ui.viewinterop.UIKitInteropTransaction +import kotlin.time.Duration.Companion.milliseconds import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ObjCClass @@ -107,7 +108,7 @@ internal class SurfaceMetalView( drainJob?.cancel() drainJob = MainScope().launch { // Await a safe time to ensure the metal view won't be displayed again soon - delay(500) + delay(500.milliseconds) if (window == null) { redrawer.drainSkiaSurfaces() } @@ -129,8 +130,6 @@ internal class SurfaceMetalView( cancelPendingDrawableDrain() val screen = window?.screen ?: return - redrawer.displayLinkFrameRate?.maximumFramesPerSecond = screen.maximumFramesPerSecond - redrawer.displayLinkFrameRate?.preferredFramesPerSecond = screen.maximumFramesPerSecond contentScaleFactor = screen.scale metalLayer.contentsScale = screen.scale diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/BaseComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/BaseComposeScene.skiko.kt index 319de0535a081..795ad161f96c2 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/BaseComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/BaseComposeScene.skiko.kt @@ -50,8 +50,8 @@ import kotlin.concurrent.Volatile @OptIn(InternalComposeUiApi::class) internal abstract class BaseComposeScene( protected val frameRecomposer: FrameRecomposer, - private val invalidateLayout: () -> Unit, - private val invalidateDraw: () -> Unit, + private var invalidateLayout: () -> Unit, + private var invalidateDraw: () -> Unit, ) : ComposeScene { protected val inputHandler: ComposeSceneInputHandler = ComposeSceneInputHandler( @@ -120,6 +120,8 @@ internal abstract class BaseComposeScene( isClosed = true composition?.dispose() + invalidateDraw = {} + invalidateLayout = {} } override fun setContent( diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt index d006cb9ab391f..7b17114f08c8b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt @@ -33,9 +33,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.preferredFrameRate import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest -import androidx.compose.ui.window.LegacyMetalRedrawer -import androidx.compose.ui.window.MetalRedrawer -import androidx.compose.ui.window.SurfaceMetalRedrawer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -54,16 +51,14 @@ internal class FrameRateTest { } } - val redrawer = rootRedrawer - assertNotNull(redrawer, "redrawer is null") + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") for (frameRate in frameRates) { val expectedFrameDuration = 1.0 / frameRate findNodeWithTag("${frameRate}fps").tap() waitUntil { - val frameDuration = redrawer.currentTargetFrameDuration - assertNotNull(frameDuration) - checkEqual(expectedFrameDuration, frameDuration, 1e-5) + checkEqual(expectedFrameDuration, choreographer.currentTargetFrameDuration, 1e-5) } } } @@ -80,24 +75,18 @@ internal class FrameRateTest { } } - val redrawer = rootRedrawer - assertNotNull(redrawer, "redrawer is null") + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") for (frameRate in frameRates) { findNodeWithTag("${frameRate}fps").tap() waitUntil { - redrawer.preferredFramesPerSecond == frameRate.toLong() + choreographer.preferredFramesPerSecond == frameRate.toLong() } } } } -private val MetalRedrawer.preferredFramesPerSecond: Long? - get() = when (this) { - is LegacyMetalRedrawer -> displayLinkFrameRate?.preferredFramesPerSecond - is SurfaceMetalRedrawer -> displayLinkFrameRate?.preferredFramesPerSecond - } - private fun checkEqual(expected: Double, actual: Double, absoluteTolerance: Double): Boolean = try { assertEquals(expected, actual, absoluteTolerance) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index a160f3131a5c2..189214e31d0c2 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.test import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.platform.AccessibilityNotification +import androidx.compose.ui.platform.FrameChoreographer import androidx.compose.ui.platform.InfiniteAnimationPolicy import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.scene.ComposeHostingViewController @@ -262,6 +263,9 @@ internal class UIKitInstrumentedTest( val rootRedrawer: MetalRedrawer? get() = hostingView?.rootRedrawer ?: hostingViewController?.rootRedrawer + val frameChoreographer: FrameChoreographer? get() = + appDelegate.window()?.windowScene?.let { FrameChoreographer.choreographerForScene(it) } + private val infiniteAnimationPolicy = object : InfiniteAnimationPolicy { override suspend fun onInfiniteOperation(block: suspend () -> R): R { throw CancellationException("Infinite animations are disabled on tests") diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/FrameChoreographerTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/FrameChoreographerTest.kt new file mode 100644 index 0000000000000..8511877316434 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/FrameChoreographerTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.FrameChoreographer +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import platform.Foundation.NSTimeInterval + +class FrameChoreographerTest { + + @Test + fun testIsIdleWhenContentIsIdle() = runUIKitInstrumentedTest { + setContent { Box(Modifier.fillMaxSize()) } + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") + + val listener = CountingListener() + choreographer.addListener(listener) + try { + settleChoreographer() + + val ticksBefore = listener.displayLinkCount + delay(100) + assertEquals( + ticksBefore, + listener.displayLinkCount, + "Choreographer should not tick while content is idle" + ) + } finally { + choreographer.removeListener(listener) + } + } + + @Test + fun testOngoingInteractionEventsKeepChoreographerTicking() = runUIKitInstrumentedTest { + setContent { Box(Modifier.fillMaxSize()) } + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") + + val listener = CountingListener() + choreographer.addListener(listener) + try { + settleChoreographer() + + val ticksBefore = listener.displayLinkCount + choreographer.ongoingActivitiesCount = 1 + waitUntil("Choreographer should keep ticking during interaction") { + listener.displayLinkCount > ticksBefore + 3 + } + + choreographer.ongoingActivitiesCount = 0 + settleChoreographer() + + val ticksAfterStop = listener.displayLinkCount + delay(100) + assertEquals( + ticksAfterStop, + listener.displayLinkCount, + "Choreographer should pause after interaction ends" + ) + } finally { + choreographer.ongoingActivitiesCount = 0 + choreographer.removeListener(listener) + } + } + + @Test + fun testDisplayLinkAndOutOfFrameCallbacksArePaired() = runUIKitInstrumentedTest { + setContent { Box(Modifier.fillMaxSize()) } + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") + + val listener = CountingListener() + choreographer.addListener(listener) + try { + choreographer.ongoingActivitiesCount = 1 + waitUntil("Both callbacks should be delivered while ticking") { + listener.displayLinkCount > 3 && listener.outOfFrameCount > 3 + } + choreographer.ongoingActivitiesCount = 0 + settleChoreographer() + + // Each display-link tick schedules exactly one out-of-frame callback, so the counts + // must stay within a single in-flight frame of each other. + assertTrue( + listener.displayLinkCount - listener.outOfFrameCount <= 1, + "out-of-frame callbacks (${listener.outOfFrameCount}) should track display-link " + + "callbacks (${listener.displayLinkCount})" + ) + } finally { + choreographer.ongoingActivitiesCount = 0 + choreographer.removeListener(listener) + } + } + + @Test + fun testRemovedListenerStopsReceivingCallbacks() = runUIKitInstrumentedTest { + setContent { Box(Modifier.fillMaxSize()) } + val choreographer = frameChoreographer + assertNotNull(choreographer, "frameChoreographer is null") + + val listener = CountingListener() + choreographer.addListener(listener) + choreographer.ongoingActivitiesCount = 1 + waitUntil("Listener should receive callbacks while registered") { + listener.displayLinkCount > 3 + } + + choreographer.removeListener(listener) + val ticksAfterRemoval = listener.displayLinkCount + + // Keep the choreographer ticking, but the removed listener must not observe more ticks. + delay(100) + choreographer.ongoingActivitiesCount = 0 + assertEquals( + ticksAfterRemoval, + listener.displayLinkCount, + "Removed listener should not receive further callbacks" + ) + } + + private fun UIKitInstrumentedTest.settleChoreographer() { + waitForIdle() + delay(100) + } +} + +private class CountingListener : FrameChoreographer.Listener { + var displayLinkCount = 0 + private set + var outOfFrameCount = 0 + private set + + override fun onDisplayLink() { + displayLinkCount++ + } + + override fun onOutOfFrame(lastFrameTimestamp: NSTimeInterval, targetTimestamp: NSTimeInterval) { + outOfFrameCount++ + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/MetalRedrawerTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/MetalRedrawerTest.kt deleted file mode 100644 index 80c81dae806cb..0000000000000 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/window/MetalRedrawerTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.ui.window - -import androidx.compose.ui.uikit.utils.CMPMetalLayer -import androidx.compose.ui.viewinterop.UIKitInteropMutableTransaction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.ObjCAction -import platform.CoreGraphics.CGSizeMake -import platform.Foundation.NSDate -import platform.Foundation.NSRunLoop -import platform.Foundation.NSRunLoopCommonModes -import platform.Foundation.runUntilDate -import platform.Foundation.timeIntervalSinceDate -import platform.QuartzCore.CADisplayLink -import platform.darwin.NSObject -import platform.darwin.sel_registerName - -class MetalRedrawerTest { - @Test - fun testShouldNotRenderWhenInitialized() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.isActive = true - displayLinkListener.await(frames = 10) - - assertEquals(0, rendersCount) - } - - @Test - fun testShouldRenderWhenMakeNeedsRedraw() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.isActive = true - metalRedrawer.setNeedsRedraw() - - displayLinkListener.await(frames = 10) - assertEquals(2, rendersCount) - } - - @Test - fun testShouldNotRenderWhenNotActive() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.isActive = false - metalRedrawer.setNeedsRedraw() - - displayLinkListener.await(frames = 10) - assertEquals(0, rendersCount) - } - - @Test - fun testShouldRenderWhenBecomesActive() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.isActive = false - displayLinkListener.await(frames = 2) - - assertEquals(0, rendersCount) - - metalRedrawer.isActive = true - displayLinkListener.await(frames = 10) - - assertEquals(2, rendersCount) - } - - @Test - fun testOngoingInteractionEventsCountShouldNotStartRendering() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.ongoingInteractionEventsCount = 5 - displayLinkListener.await(frames = 10) - assertEquals(0, rendersCount) - } - - @Test - fun testOngoingInteractionEventsCountShouldRenderWhenNeedsRedraw() { - var rendersCount = 0 - val metalRedrawer = makeMetalRedrawer(onRender = { rendersCount++ }) - val displayLinkListener = TestDisplayLinkListener() - - metalRedrawer.isActive = true - repeat(10) { - metalRedrawer.setNeedsRedraw() - displayLinkListener.await(frames = 1) - } - - assertTrue(rendersCount > 5, "Should be significant number of frames rendered, but got $rendersCount") - } - - @OptIn(ExperimentalForeignApi::class) - private fun makeMetalRedrawer(onRender: () -> Unit): MetalRedrawer { - val transaction = UIKitInteropMutableTransaction(isInteropActive = false) - val metalLayer = CMPMetalLayer() - metalLayer.setDrawableSize(CGSizeMake(100.0, 100.0)) - val metalRedrawer = SurfaceMetalRedrawer( - metalLayer = metalLayer, - retrieveInteropTransaction = { transaction }, - render = { _, _ -> onRender() } - ) - return metalRedrawer - } -} - -internal class TestDisplayLinkListener { - private var displayLink: CADisplayLink? = null - - var framesCountSinceStart = 0 - @OptIn(ExperimentalForeignApi::class) - fun start() { - displayLink = CADisplayLink.displayLinkWithTarget( - target = object : NSObject() { - @OptIn(BetaInteropApi::class) - @Suppress("unused") - @ObjCAction - fun onTick() { - framesCountSinceStart++ - } - }, - selector = sel_registerName("onTick") - ) - displayLink?.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) - } - - fun await(frames: Int = 1) { - invalidate() - framesCountSinceStart = 0 - start() - val startDate = NSDate() - while (framesCountSinceStart < frames) { - if (NSDate().timeIntervalSinceDate(startDate) > 5.0) { - invalidate() - error("Timeout reached") - } - NSRunLoop.mainRunLoop.runUntilDate(NSDate(0.001)) - } - invalidate() - } - - fun invalidate() { - displayLink?.invalidate() - displayLink = null - } -} From 9bd3a6063ad1591647b0c0317901014ba30d31c0 Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 13 Jul 2026 12:28:15 +0200 Subject: [PATCH 2/3] Update tests --- .../ui/platform/FrameChoreographer.ios.kt | 15 ++++++++++-- .../compose/ui/scene/ComposeContainer.ios.kt | 13 +++++++---- .../ui/scene/ComposeHostingView.ios.kt | 2 -- .../scene/ComposeHostingViewController.ios.kt | 2 -- .../scene/ComposeLayersViewController.ios.kt | 7 +++++- .../ui/scene/ComposeSceneMediator.ios.kt | 8 ++++--- .../ui/scene/UIKitComposeSceneLayer.ios.kt | 2 ++ .../integrations/ComposeSceneMediatorTest.kt | 23 +++++-------------- .../compose/ui/keyboard/KeyboardInsetsTest.kt | 3 ++- .../androidx/compose/ui/scroll/ScrollTest.kt | 2 -- .../compose/ui/test/UIKitInstrumentedTest.kt | 20 ++++++++-------- 11 files changed, 51 insertions(+), 46 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt index dfa813863b68e..c555b7af50e28 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/FrameChoreographer.ios.kt @@ -16,10 +16,12 @@ package androidx.compose.ui.platform +import androidx.compose.runtime.TestOnly import androidx.compose.ui.uikit.toNanoSeconds import androidx.compose.ui.util.fastForEach import androidx.compose.ui.window.DisplayLinkFrameRate import androidx.compose.ui.window.MetalOutOfFrameExecutor +import kotlin.coroutines.CoroutineContext import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.COpaquePointer import kotlinx.cinterop.ExperimentalForeignApi @@ -28,6 +30,7 @@ import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.alloc import kotlinx.cinterop.nativeHeap import kotlinx.cinterop.ptr +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import platform.Foundation.NSRunLoop import platform.Foundation.NSRunLoopCommonModes @@ -43,13 +46,21 @@ import platform.objc.OBJC_ASSOCIATION_RETAIN import platform.objc.objc_getAssociatedObject import platform.objc.objc_setAssociatedObject -internal class FrameChoreographer(scene: UIWindowScene) { +internal class FrameChoreographer( + scene: UIWindowScene, + val coroutineContext: CoroutineContext = Dispatchers.Main +) { companion object { fun choreographerForScene(scene: UIWindowScene): FrameChoreographer { return scene.frameChoreographer ?: FrameChoreographer(scene).also { scene.frameChoreographer = it } } + + @TestOnly + fun configureForScene(scene: UIWindowScene, coroutineContext: CoroutineContext) { + scene.frameChoreographer = FrameChoreographer(scene, coroutineContext) + } } interface Listener { @@ -59,7 +70,7 @@ internal class FrameChoreographer(scene: UIWindowScene) { } val frameRecomposer = FrameRecomposer( - coroutineContext = MainScope().coroutineContext, + coroutineContext = coroutineContext, invalidate = ::setNeedsRedraw ) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index 0d54f5372d064..f980541d915b8 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -57,6 +57,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlinx.cinterop.CPointed import kotlinx.cinterop.CPointer import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.jetbrains.skiko.SystemTheme import platform.Foundation.NSKeyValueObservingOptionNew @@ -79,15 +80,19 @@ import platform.UIKit.UIWindowScene internal class ComposeContainer( private val configuration: ComposeContainerConfiguration, private val content: @Composable () -> Unit, - private val coroutineContext: CoroutineContext, private val lifecycleDelegate: ComposeContainerLifecycleDelegate ) { - val view = ComposeContainerView( transparentForTouches = false, useOpaqueConfiguration = configuration.opaque, ) + private val frameChoreographer: FrameChoreographer? + get() = view.window?.scene?.let { FrameChoreographer.choreographerForScene(it) } + + private val coroutineContext: CoroutineContext + get() = frameChoreographer?.coroutineContext ?: Dispatchers.Main + private var mediator: ComposeSceneMediator? = null private val windowContext = PlatformWindowContext() private var layersHolder: ComposeLayersHolder? = null @@ -216,10 +221,8 @@ internal class ComposeContainer( } fun initializeComposeScene() { - val frameChoreographer = view.window?.windowScene?.let { FrameChoreographer.choreographerForScene(it) } - ?: error("No window scene found") - sceneJob = Job() + val frameChoreographer = frameChoreographer ?: error("No window scene found") val sceneCoroutineContext = coroutineContext + motionDurationScale + sceneJob val metalView = MetalView( retrieveInteropTransaction = { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index 89025bd705e85..ec133ee29bda5 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -44,13 +44,11 @@ import platform.UIKit.UIView internal class ComposeHostingView( private val configuration: ComposeUIViewConfiguration, private val content: @Composable () -> Unit, - coroutineContext: CoroutineContext = Dispatchers.Main, private val lifecycleDelegate: ComposeContainerLifecycleDelegate = ComposeContainerLifecycleDelegate() ) : CMPView(lifecycleDelegate = lifecycleDelegate) { private val container = ComposeContainer( configuration = configuration, content = content, - coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate ) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt index 65da9a00cfac8..82066b0072666 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt @@ -47,13 +47,11 @@ import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol internal class ComposeHostingViewController( private val configuration: ComposeUIViewControllerConfiguration, private val content: @Composable () -> Unit, - coroutineContext: CoroutineContext = Dispatchers.Main, private val lifecycleDelegate: ComposeContainerLifecycleDelegate = ComposeContainerLifecycleDelegate() ) : CMPViewController(lifecycleDelegate = lifecycleDelegate) { private val container = ComposeContainer( configuration = configuration, content = content, - coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate ) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt index 72f1177511b1d..6d55032546e30 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeLayersViewController.ios.kt @@ -74,7 +74,8 @@ internal class ComposeLayersViewController( updateMetalView( metalView = metalView, onWillMoveToWindow = { beginAppearanceTransition(it != null, animated = false) }, - onDidMoveToWindow = { endAppearanceTransition() } + onDidMoveToWindow = { endAppearanceTransition() }, + onLayoutSubviews = ::measureAndLayoutLayers, ) } @@ -151,6 +152,10 @@ internal class ComposeLayersViewController( } } + private fun measureAndLayoutLayers() = layersCache.withCopy { layers -> + layers.fastForEach { it.doMeasureAndLayout() } + } + fun withLayers(block: (List) -> Unit) = layersCache.withCopy(block) override fun loadView() { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index c3f92f2f0ae88..5890f2c55ef2e 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -256,7 +256,7 @@ internal class ComposeSceneMediator( } } - val scene: ComposeScene by lazy { + private val scene: ComposeScene by lazy { composeSceneFactory( PlatformContextImpl(), frameChoreographer.frameRecomposer, @@ -449,12 +449,14 @@ internal class ComposeSceneMediator( ) } - val hasInvalidations: Boolean - get() = scene.hasInvalidations() || + val hasInvalidations: Boolean get() { + return scene.hasInvalidations() || + frameChoreographer.frameRecomposer.hasPendingWork() || keyboardManager.isAnimating || isLayoutTransitionAnimating || semanticsOwnerListener.hasInvalidations || textInputService.hasInvalidations + } init { coroutineContext.job.invokeOnCompletion { dispose() } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt index 111c57b698233..1cf2f156e74af 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt @@ -176,6 +176,8 @@ internal class UIKitComposeSceneLayer( navigationEventInput.onDidMoveToWindow(window, interactionView) } + fun doMeasureAndLayout() = mediator.doMeasureAndLayout() + fun render(canvas: Canvas, nanoTime: Long) { if (scrimColor != null) { val density = layersViewController.metalView.view.density diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt index c6180df169b0a..4178365503e48 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.integrations import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.navigationevent.UIKitNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner +import androidx.compose.ui.platform.FrameChoreographer import androidx.compose.ui.platform.PlatformWindowContext import androidx.compose.ui.scene.ComposeSceneContext import androidx.compose.ui.scene.ComposeSceneMediator @@ -27,15 +28,11 @@ import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.uikit.EndEdgePanGestureBehavior import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.OnFocusBehavior -import androidx.compose.ui.uikit.utils.CMPMetalLayer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.center -import androidx.compose.ui.viewinterop.UIKitInteropAction -import androidx.compose.ui.viewinterop.UIKitInteropTransaction -import androidx.compose.ui.window.SurfaceMetalRedrawer import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlinx.cinterop.ExperimentalForeignApi @@ -44,6 +41,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import platform.CoreGraphics.CGRectMake +import platform.UIKit.UIWindowScene class ComposeSceneMediatorTest { @Test @@ -89,22 +87,13 @@ class ComposeSceneMediatorTest { @OptIn(ExperimentalForeignApi::class) private fun makeMediator(coroutineContext: CoroutineContext): ComposeSceneMediator { val mediator = ComposeSceneMediator( + frameChoreographer = FrameChoreographer(UIWindowScene()), onFocusBehavior = OnFocusBehavior.DoNothing, isClearFocusOnMouseDownEnabled = false, focusedViewsList = null, windowContext = PlatformWindowContext(), architectureComponentsOwner = DefaultArchitectureComponentsOwner(), coroutineContext = coroutineContext, - redrawer = SurfaceMetalRedrawer( - metalLayer = CMPMetalLayer(), - retrieveInteropTransaction = { - object : UIKitInteropTransaction { - override val actions: List = emptyList() - override val isInteropActive: Boolean = false - } - }, - render = { _, _ -> } - ), navigationEventInput = UIKitNavigationEventInput( density = Density(1f), initialLayoutDirection = LayoutDirection.Ltr, @@ -112,15 +101,15 @@ class ComposeSceneMediatorTest { endEdgePanGestureBehavior = EndEdgePanGestureBehavior.Disabled, ), interfaceOrientationState = mutableStateOf(InterfaceOrientation.Portrait), - composeSceneFactory = { invalidate, platformContext, frameRecomposer -> + composeSceneFactory = { platformContext, frameRecomposer -> PlatformLayersComposeScene( frameRecomposer = frameRecomposer, density = Density(1f), composeSceneContext = object : ComposeSceneContext { override val platformContext = platformContext }, - invalidateLayout = invalidate, - invalidateDraw = invalidate, + invalidateLayout = {}, + invalidateDraw = {}, ) }, ) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt index 49b372c5b91b6..825af3a4de12d 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt @@ -58,10 +58,10 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.height import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.viewinterop.UIKitView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -101,6 +101,7 @@ internal class KeyboardInsetsTest { } .drawWithContent { contentFrames.add(lastContentFrame) + drawContent() } ) { TextField( diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/scroll/ScrollTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/scroll/ScrollTest.kt index a5e384d03d1b7..ccd896e5d5a88 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/scroll/ScrollTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/scroll/ScrollTest.kt @@ -1054,7 +1054,6 @@ internal class ScrollTest { } } }, - coroutineContext = Dispatchers.Main ) var presented = false @@ -1111,7 +1110,6 @@ internal class ScrollTest { } } }, - coroutineContext = Dispatchers.Main ) parent.addChildViewController(composeVC) scrollView.addSubview(composeVC.view) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 189214e31d0c2..a9522c4e5975e 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -266,14 +266,6 @@ internal class UIKitInstrumentedTest( val frameChoreographer: FrameChoreographer? get() = appDelegate.window()?.windowScene?.let { FrameChoreographer.choreographerForScene(it) } - private val infiniteAnimationPolicy = object : InfiniteAnimationPolicy { - override suspend fun onInfiniteOperation(block: suspend () -> R): R { - throw CancellationException("Infinite animations are disabled on tests") - } - } - - private val coroutineContext = Dispatchers.Main + infiniteAnimationPolicy - fun setContent( configure: ComposeContainerConfiguration.() -> Unit = {}, interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, @@ -343,7 +335,6 @@ internal class UIKitInstrumentedTest( return ComposeHostingView( configuration = configuration, content = content, - coroutineContext = coroutineContext ).also { hostingView = it } @@ -364,7 +355,6 @@ internal class UIKitInstrumentedTest( return ComposeHostingViewController( configuration = configuration, content = content, - coroutineContext = coroutineContext ).also { this.hostingViewController = it } @@ -402,7 +392,6 @@ internal class UIKitInstrumentedTest( val containerInvalidations = hostingViewController?.hasInvalidations() ?: hostingView?.hasInvalidations() ?: false - return !hadSnapshotChanges && !isApplyObserverNotificationPending && !containerInvalidations } @@ -746,11 +735,20 @@ internal class MockAppDelegate: NSObject(), UIApplicationDelegateProtocol { private var supportedInterfaceOrientations: UIInterfaceOrientationMask = UIInterfaceOrientationMaskAll + private val infiniteAnimationPolicy = object : InfiniteAnimationPolicy { + override suspend fun onInfiniteOperation(block: suspend () -> R): R { + throw CancellationException("Infinite animations are disabled on tests") + } + } + fun setUpWindow(viewController: UIViewController) { UIApplication.sharedApplication().setDelegate(this) val scene = UIApplication.sharedApplication().connectedScenes.first() as? UIWindowScene ?: error("No window scene found") + + FrameChoreographer.configureForScene(scene, Dispatchers.Main + infiniteAnimationPolicy) + val allWindows = scene.windows - _window _window?.backgroundColor = UIColor.systemBackgroundColor From 22ac89226fbdd3da90fff50ef40e5341ebad31cd Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Mon, 13 Jul 2026 12:30:14 +0200 Subject: [PATCH 3/3] Fix build errors --- .../kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index f980541d915b8..4fe77957fb708 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -88,7 +88,7 @@ internal class ComposeContainer( ) private val frameChoreographer: FrameChoreographer? - get() = view.window?.scene?.let { FrameChoreographer.choreographerForScene(it) } + get() = view.window?.windowScene?.let { FrameChoreographer.choreographerForScene(it) } private val coroutineContext: CoroutineContext get() = frameChoreographer?.coroutineContext ?: Dispatchers.Main