diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj index 3702be81a4b5d..a8608fa8d34e6 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj @@ -19,6 +19,10 @@ 997DFCF32B18DE59000B56B5 /* MockAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCF22B18DE59000B56B5 /* MockAppDelegate.swift */; }; 997DFCF52B18E276000B56B5 /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCF42B18E276000B56B5 /* XCTestCase.swift */; }; 997DFCFD2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DFCFC2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift */; }; + 99CC4B2E2ECE0838007C5C44 /* CMPView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99CC4B2D2ECE0838007C5C44 /* CMPView.m */; }; + 99CC4B2F2ECE0838007C5C44 /* CMPView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99CC4B2D2ECE0838007C5C44 /* CMPView.m */; }; + 99CC4B302ECE0838007C5C44 /* CMPView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99CC4B2D2ECE0838007C5C44 /* CMPView.m */; }; + 99CC4B322ECE16C8007C5C44 /* CMPViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99CC4B312ECE16C8007C5C44 /* CMPViewTests.swift */; }; 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */; }; 99DCAB0E2BD00F5C002E6AC7 /* CMPTextLoupeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DCAB0D2BD00F5C002E6AC7 /* CMPTextLoupeSession.m */; }; EA4B52962C2EDEF200FBB55C /* CMPGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = EA4B52952C2EDEF200FBB55C /* CMPGestureRecognizer.m */; }; @@ -87,6 +91,11 @@ 997DFCFA2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CMPUIKitUtilsTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 997DFCFC2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMPUIKitUtilsTestApp.swift; sourceTree = ""; }; 99BE84D22C3467B100E43826 /* CMPUIKitUtilsTestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CMPUIKitUtilsTestApp.xctestplan; sourceTree = ""; }; + 99CC4B282ECE04AC007C5C44 /* CMPComposeContainerLifecycleDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPComposeContainerLifecycleDelegate.h; sourceTree = ""; }; + 99CC4B2B2ECE07EA007C5C44 /* CMPComposeContainerLifecycleState.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPComposeContainerLifecycleState.h; sourceTree = ""; }; + 99CC4B2C2ECE0838007C5C44 /* CMPView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPView.h; sourceTree = ""; }; + 99CC4B2D2ECE0838007C5C44 /* CMPView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPView.m; sourceTree = ""; }; + 99CC4B312ECE16C8007C5C44 /* CMPViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMPViewTests.swift; sourceTree = ""; }; 99D97A862BF73A9B0035552B /* CMPEditMenuView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPEditMenuView.h; sourceTree = ""; }; 99D97A872BF73A9B0035552B /* CMPEditMenuView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CMPEditMenuView.m; sourceTree = ""; }; 99DCAB0C2BD00F5C002E6AC7 /* CMPTextLoupeSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CMPTextLoupeSession.h; sourceTree = ""; }; @@ -140,6 +149,8 @@ 996EFEEB2B02CE5D0000FE0F /* CMPUIKitUtils */ = { isa = PBXGroup; children = ( + 99CC4B282ECE04AC007C5C44 /* CMPComposeContainerLifecycleDelegate.h */, + 99CC4B2B2ECE07EA007C5C44 /* CMPComposeContainerLifecycleState.h */, EA70A7E62B27106100300068 /* CMPAccessibilityElement.h */, EA70A7E82B27106100300068 /* CMPAccessibilityElement.m */, EADD028E2C9846D9003F66E8 /* CMPDragInteractionProxy.h */, @@ -174,6 +185,8 @@ 996EFEF52B02CE8A0000FE0F /* CMPUIKitUtils.h */, 997DFCDC2B18D135000B56B5 /* CMPViewController.h */, 997DFCDD2B18D135000B56B5 /* CMPViewController.m */, + 99CC4B2C2ECE0838007C5C44 /* CMPView.h */, + 99CC4B2D2ECE0838007C5C44 /* CMPView.m */, ); path = CMPUIKitUtils; sourceTree = ""; @@ -204,6 +217,7 @@ children = ( 997DFCF02B18DBF2000B56B5 /* CMPUIKitUtilsTests-Bridging-Header.h */, 997DFCE52B18D99E000B56B5 /* CMPViewControllerTests.swift */, + 99CC4B312ECE16C8007C5C44 /* CMPViewTests.swift */, 997DFCF12B18DE47000B56B5 /* Utils */, ); path = CMPUIKitUtilsTests; @@ -354,6 +368,7 @@ 9968C35B2D76FE16005E8DE4 /* CMPPanGestureRecognizer.m in Sources */, 991A97F72E1FB99300B47130 /* CMPScrollView.m in Sources */, 99D97A882BF73A9B0035552B /* CMPEditMenuView.m in Sources */, + 99CC4B2E2ECE0838007C5C44 /* CMPView.m in Sources */, EABD912B2BC02B5F00455279 /* CMPInteropWrappingView.m in Sources */, EADD02902C9846D9003F66E8 /* CMPDragInteractionProxy.m in Sources */, 992EDDFB2E55EC8400FB44C5 /* CMPKeyValueObserver.m in Sources */, @@ -371,6 +386,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 99CC4B2F2ECE0838007C5C44 /* CMPView.m in Sources */, + 99CC4B322ECE16C8007C5C44 /* CMPViewTests.swift in Sources */, 997DFCF52B18E276000B56B5 /* XCTestCase.swift in Sources */, 997DFCE62B18D99E000B56B5 /* CMPViewControllerTests.swift in Sources */, 997DFCEE2B18DB7B000B56B5 /* CMPViewController.m in Sources */, @@ -387,6 +404,7 @@ EAC703E52B8C826E001ECDA6 /* CMPOSLogger.m in Sources */, 997DFCFD2B18E5D3000B56B5 /* CMPUIKitUtilsTestApp.swift in Sources */, EAC703E62B8C826E001ECDA6 /* CMPOSLoggerInterval.m in Sources */, + 99CC4B302ECE0838007C5C44 /* CMPView.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleDelegate.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleDelegate.h new file mode 100644 index 0000000000000..ca3834d18f982 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleDelegate.h @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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 + +@protocol CMPComposeContainerLifecycleDelegate + +- (void)composeContainerWillAppear; +- (void)composeContainerDidDisappear; +- (void)composeContainerWillDealloc; + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleState.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleState.h new file mode 100644 index 0000000000000..689dc9fb79afb --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPComposeContainerLifecycleState.h @@ -0,0 +1,23 @@ +/* + * Copyright 2025 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 + +typedef NS_ENUM(NSInteger, CMPComposeContainerLifecycleState) { + CMPComposeContainerLifecycleStateInitialized, + CMPComposeContainerLifecycleStateStarted, + CMPComposeContainerLifecycleStateStopped +}; diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h index c5b0bbfa12178..9dd2494e0ab93 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPUIKitUtils.h @@ -23,6 +23,7 @@ FOUNDATION_EXPORT double CMPUIKitUtilsVersionNumber; FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPViewController.h" +#import "CMPView.h" #import "CMPAccessibilityElement.h" #import "CMPOSLogger.h" #import "CMPTextLoupeSession.h" @@ -34,3 +35,4 @@ FOUNDATION_EXPORT const unsigned char CMPUIKitUtilsVersionString[]; #import "CMPHoverGestureHandler.h" #import "CMPScreenEdgePanGestureRecognizer.h" #import "CMPScrollView.h" +#import "CMPComposeContainerLifecycleDelegate.h" diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.h new file mode 100644 index 0000000000000..50aed953ecc1a --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.h @@ -0,0 +1,43 @@ +/* + * Copyright 2025 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 +#import "CMPComposeContainerLifecycleDelegate.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface CMPView : UIView + +- (id)initWithLifecycleDelegate:(id _Nullable)delegate; + +/// Notifies the view is added to a view hierarchy +- (void)viewDidAppear; + +/// Notifies the view was removed from a view hierarchy +- (void)viewDidDisappear; + +/// Indicates that view is considered alive in terms of structural containment +- (void)viewDidEnterWindowHierarchy; + +/// Indicates that view is considered as closed in terms of structural containment +- (void)viewDidLeaveWindowHierarchy; + +/// Indicates that trait interface style trait changed +- (void)userInterfaceStyleDidChange; + +@end + +NS_ASSUME_NONNULL_END diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m new file mode 100644 index 0000000000000..c6583d3ffaa7f --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPView.m @@ -0,0 +1,143 @@ +/* + * Copyright 2025 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 "CMPView.h" +#import "CMPComposeContainerLifecycleState.h" + +#pragma mark - CMPViewController + +@implementation CMPView { + CMPComposeContainerLifecycleState _lifecycleState; + id _lifecycleDelegate; + BOOL _isViewInWindowHierarchy; +} + +- (id)initWithLifecycleDelegate:(id)delegate { + self = [super initWithFrame:CGRectZero]; + + if (self) { + _lifecycleDelegate = delegate; + _lifecycleState = CMPComposeContainerLifecycleStateInitialized; + _isViewInWindowHierarchy = NO; + + __weak typeof(self) weakSelf = self; + if (@available(iOS 17, *)) { + [self registerForTraitChanges:@[[UITraitUserInterfaceStyle class]] + withHandler:^(__kindof id _Nonnull traitEnvironment, + UITraitCollection * _Nonnull previousCollection) { + [weakSelf userInterfaceStyleDidChange]; + }]; + } + } + + return self; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + + [self updateViewAppearanceState]; +} + +- (void)didMoveToSuperview { + [super didMoveToSuperview]; + + [self updateViewAppearanceState]; +} + +- (void)updateViewAppearanceState { + BOOL isViewInWindowHierarchy = self.superview != nil && self.window != nil; + if (_isViewInWindowHierarchy != isViewInWindowHierarchy) { + _isViewInWindowHierarchy = isViewInWindowHierarchy; + if (isViewInWindowHierarchy) { + [_lifecycleDelegate composeContainerWillAppear]; + [self transitViewLifecycleToStarted]; + [self viewDidAppear]; + } else { + [self viewDidDisappear]; + [self scheduleViewHierarchyContainmentCheck]; + [_lifecycleDelegate composeContainerDidDisappear]; + } + } +} + +- (void)transitViewLifecycleToStarted { + switch (_lifecycleState) { + case CMPComposeContainerLifecycleStateInitialized: + case CMPComposeContainerLifecycleStateStopped: + _lifecycleState = CMPComposeContainerLifecycleStateStarted; + [self viewDidEnterWindowHierarchy]; + break; + case CMPComposeContainerLifecycleStateStarted: + break; + } +} + +- (void)scheduleViewHierarchyContainmentCheck { + double delayInSeconds = 0.5; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + switch (self->_lifecycleState) { + case CMPComposeContainerLifecycleStateInitialized: + NSAssert(false, @"Attempt to schedule hierarchy check without starting the container"); + break; + case CMPComposeContainerLifecycleStateStopped: + break; + case CMPComposeContainerLifecycleStateStarted: + // perform check + if (!self->_isViewInWindowHierarchy) { + self->_lifecycleState = CMPComposeContainerLifecycleStateStopped; + [self viewDidLeaveWindowHierarchy]; + } + break; + } + }); +} + +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + + if (@available(iOS 17, *)) { + // Do nothing + } else if (self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle) { + [self userInterfaceStyleDidChange]; + } +} + +- (void)viewDidAppear { +} + +- (void)viewDidDisappear { +} + +- (void)viewDidEnterWindowHierarchy { +} + +- (void)viewDidLeaveWindowHierarchy { +} + +- (void)userInterfaceStyleDidChange { +} + +- (void)dealloc { + if (_lifecycleState == CMPComposeContainerLifecycleStateStarted) { + [self viewDidLeaveWindowHierarchy]; + } + + [_lifecycleDelegate composeContainerWillDealloc]; +} + +@end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h index a9f50159c8132..26d6eff451032 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.h @@ -15,35 +15,22 @@ */ #import +#import "CMPComposeContainerLifecycleDelegate.h" NS_ASSUME_NONNULL_BEGIN -@protocol CMPViewControllerLifecycleDelegate - -- (void)viewControllerWillAppear; -- (void)viewControllerDidDisappear; -- (void)viewControllerWillDealloc; - -@end - @interface CMPViewController : UIViewController -- (id)initWithLifecycleDelegate:(id _Nullable)delegate; +- (id)initWithLifecycleDelegate:(id _Nullable)delegate; -/// Indicates that view controller is considered alive in terms of structural containment. -/// Overriding classes should call super. +/// Indicates that view controller is considered alive in terms of structural containment - (void)viewControllerDidEnterWindowHierarchy; -/// Indicates that view controller is considered alive in terms of structural containment -/// Overriding classes should call super. +/// Indicates that view controller is considered closed in terms of structural containment - (void)viewControllerDidLeaveWindowHierarchy; - -// MARK: Unexported methods redeclaration block -// Redeclared to make it visible to Kotlin for override purposes, workaround for the following issue: -// https://youtrack.jetbrains.com/issue/KT-56001/Kotlin-Native-import-Objective-C-category-members-as-class-members-if-the-category-is-located-in-the-same-file - -- (void)viewSafeAreaInsetsDidChange; +/// Indicates that trait interface style trait changed +- (void)userInterfaceStyleDidChange; @end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m index d715e3d3ca236..9e9716cedc764 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPViewController.m @@ -16,6 +16,7 @@ #import "CMPViewController.h" #import +#import "CMPComposeContainerLifecycleState.h" #pragma mark - UIViewController + CMPUIKitUtilsPrivate @@ -65,27 +66,21 @@ - (BOOL)cmp_isInWindowHierarchy { @end -#pragma mark - CMPViewControllerLifecycleState - -typedef NS_ENUM(NSInteger, CMPViewControllerLifecycleState) { - CMPViewControllerLifecycleStateInitialized, - CMPViewControllerLifecycleStateStarted, - CMPViewControllerLifecycleStateStopped -}; - #pragma mark - CMPViewController @implementation CMPViewController { - CMPViewControllerLifecycleState _lifecycleState; - id _lifecycleDelegate; + CMPComposeContainerLifecycleState _lifecycleState; + id _lifecycleDelegate; } -- (instancetype)initWithLifecycleDelegate:(id)delegate { +- (id)initWithLifecycleDelegate:(id)delegate { self = [super initWithNibName:nil bundle:nil]; if (self) { _lifecycleDelegate = delegate; - _lifecycleState = CMPViewControllerLifecycleStateInitialized; + _lifecycleState = CMPComposeContainerLifecycleStateInitialized; + + [self addTraitCollectionObserverIfNeeded]; } return self; @@ -96,38 +91,47 @@ - (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibB if (self) { _lifecycleDelegate = nil; - _lifecycleState = CMPViewControllerLifecycleStateInitialized; + _lifecycleState = CMPComposeContainerLifecycleStateInitialized; + + [self addTraitCollectionObserverIfNeeded]; } return self; } +- (void)addTraitCollectionObserverIfNeeded { + __weak typeof(self) weakSelf = self; + if (@available(iOS 17, *)) { + [self registerForTraitChanges:@[[UITraitUserInterfaceStyle class]] + withHandler:^(__kindof id _Nonnull traitEnvironment, + UITraitCollection * _Nonnull previousCollection) { + [weakSelf userInterfaceStyleDidChange]; + }]; + } +} + - (void)viewWillAppear:(BOOL)animated { [self transitLifecycleToStarted]; [super viewWillAppear:animated]; - [_lifecycleDelegate viewControllerWillAppear]; + [_lifecycleDelegate composeContainerWillAppear]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; - [_lifecycleDelegate viewControllerDidDisappear]; -} - -- (void)viewSafeAreaInsetsDidChange { - [super viewSafeAreaInsetsDidChange]; + [_lifecycleDelegate composeContainerDidDisappear]; } - (void)transitLifecycleToStarted { switch (_lifecycleState) { - case CMPViewControllerLifecycleStateInitialized: - case CMPViewControllerLifecycleStateStopped: - _lifecycleState = CMPViewControllerLifecycleStateStarted; + case CMPComposeContainerLifecycleStateInitialized: + case CMPComposeContainerLifecycleStateStopped: + _lifecycleState = CMPComposeContainerLifecycleStateStarted; [self viewControllerDidEnterWindowHierarchy]; [self scheduleHierarchyContainmentCheck]; break; - case CMPViewControllerLifecycleStateStarted: + case CMPComposeContainerLifecycleStateStarted: break; } } @@ -137,17 +141,18 @@ - (void)scheduleHierarchyContainmentCheck { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ switch (self->_lifecycleState) { - case CMPViewControllerLifecycleStateInitialized: - case CMPViewControllerLifecycleStateStopped: - assert(false); + case CMPComposeContainerLifecycleStateInitialized: + NSAssert(false, @"Attempt to schedule hierarchy check without starting the container"); break; - case CMPViewControllerLifecycleStateStarted: + case CMPComposeContainerLifecycleStateStopped: + break; + case CMPComposeContainerLifecycleStateStarted: // perform check if ([self cmp_isInWindowHierarchy]) { // everything is fine, schedule next one [self scheduleHierarchyContainmentCheck]; } else { - self->_lifecycleState = CMPViewControllerLifecycleStateStopped; + self->_lifecycleState = CMPComposeContainerLifecycleStateStopped; [self viewControllerDidLeaveWindowHierarchy]; } break; @@ -155,18 +160,31 @@ - (void)scheduleHierarchyContainmentCheck { }); } +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + + if (@available(iOS 17, *)) { + // Do nothing + } else if (self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle) { + [self userInterfaceStyleDidChange]; + } +} + - (void)viewControllerDidEnterWindowHierarchy { } - (void)viewControllerDidLeaveWindowHierarchy { } +- (void)userInterfaceStyleDidChange { +} + - (void)dealloc { - if (_lifecycleState == CMPViewControllerLifecycleStateStarted) { + if (_lifecycleState == CMPComposeContainerLifecycleStateStarted) { [self viewControllerDidLeaveWindowHierarchy]; } - [_lifecycleDelegate viewControllerWillDealloc]; + [_lifecycleDelegate composeContainerWillDealloc]; } @end diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewControllerTests.swift b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewControllerTests.swift index f2b640d6feecc..2513229a16e3c 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewControllerTests.swift +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewControllerTests.swift @@ -311,30 +311,30 @@ final class CMPViewControllerTests: XCTestCase { rootViewController = viewController } - await expect { delegate.viewControllerWillAppearCallsCount == 1 } + await expect { delegate.containerWillAppearCallsCount == 1 } rootViewController = UIViewController() - await expect { delegate.viewControllerWillAppearCallsCount == 1 } - await expect { delegate.viewControllerDidDisappearCallsCount == 1 } - await expect { delegate.viewControllerWillDeallocCallsCount == 1 } + await expect { delegate.containerWillAppearCallsCount == 1 } + await expect { delegate.containerDidDisappearCallsCount == 1 } + await expect { delegate.containerWillDeallocCallsCount == 1 } } } -private class LifecycleDelegate: CMPViewControllerLifecycleDelegate { - var viewControllerWillAppearCallsCount = 0 - func viewControllerWillAppear() { - viewControllerWillAppearCallsCount += 1 +class LifecycleDelegate: CMPComposeContainerLifecycleDelegate { + var containerWillAppearCallsCount = 0 + func composeContainerWillAppear() { + containerWillAppearCallsCount += 1 } - var viewControllerDidDisappearCallsCount = 0 - func viewControllerDidDisappear() { - viewControllerDidDisappearCallsCount += 1 + var containerDidDisappearCallsCount = 0 + func composeContainerDidDisappear() { + containerDidDisappearCallsCount += 1 } - var viewControllerWillDeallocCallsCount = 0 - func viewControllerWillDealloc() { - viewControllerWillDeallocCallsCount += 1 + var containerWillDeallocCallsCount = 0 + func composeContainerWillDealloc() { + containerWillDeallocCallsCount += 1 } } @@ -345,7 +345,7 @@ private class TestViewController: CMPViewController { public var viewIsInWindowHierarchy: Bool = false - init(delegate: CMPViewControllerLifecycleDelegate? = nil) { + init(delegate: CMPComposeContainerLifecycleDelegate? = nil) { id = TestViewController.counter TestViewController.counter += 1 super.init(lifecycleDelegate: delegate) diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewTests.swift b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewTests.swift new file mode 100644 index 0000000000000..263f7c347df69 --- /dev/null +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtilsTests/CMPViewTests.swift @@ -0,0 +1,174 @@ +/* + * Copyright 2025 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 AVKit +import XCTest + +final class CMPViewTests: XCTestCase { + var appDelegate: MockAppDelegate! + var rootView: UIView? { + get { + appDelegate.window!.rootViewController?.view.subviews.first + } + set { + appDelegate.window!.rootViewController?.view.subviews.forEach { + $0.removeFromSuperview() + } + if let newValue { + appDelegate.window!.rootViewController?.view.addSubview(newValue) + newValue.frame = appDelegate.window!.rootViewController?.view.bounds ?? .zero + } + } + } + + override func setUpWithError() throws { + super.setUp() + + appDelegate = MockAppDelegate() + UIApplication.shared.delegate = appDelegate + appDelegate.setUpClearWindow() + TestView.counter = 1 + } + + override func tearDownWithError() throws { + super.tearDown() + + appDelegate?.cleanUp() + appDelegate = nil + } + + @MainActor + private func expect( + view: TestView, + toBeInHierarchy inHierarchy: Bool, + line: Int = #line + ) async { + await expect(timeout: 5.0, line: line) { + view.viewIsInWindowHierarchy == inHierarchy + } + } + + @MainActor + private func expect( + view: TestView, + toBeAppeared isAppeared: Bool, + line: Int = #line + ) async { + await expect(timeout: 5.0, line: line) { + view.isViewAppeared == isAppeared + } + } + + @MainActor + public func testNotAttached() async { + let view = TestView() + await expect(view: view, toBeInHierarchy: false) + } + + @MainActor + public func testViewAttach() async { + let view = TestView() + rootView = view + await expect(view: view, toBeInHierarchy: true) + + rootView = nil + await expect(view: view, toBeInHierarchy: false) + } + + @MainActor + public func testViewThroughSuperviewAttach() async { + let view = TestView() + view.frame = .init(x: 0, y: 0, width: 100, height: 100) + let superview = UIView() + superview.addSubview(view) + + await expect(view: view, toBeInHierarchy: false) + + rootView = superview + await expect(view: view, toBeInHierarchy: true) + + rootView = nil + await expect(view: view, toBeInHierarchy: false) + } + + @MainActor + public func testLifecycleDelegate() async { + let delegate = LifecycleDelegate() + + autoreleasepool { + let view = TestView(delegate: delegate) + rootView = view + } + + await expect { delegate.containerWillAppearCallsCount == 1 } + + rootView = nil + + await expect { delegate.containerWillAppearCallsCount == 1 } + await expect { delegate.containerDidDisappearCallsCount == 1 } + await expect { delegate.containerWillDeallocCallsCount == 1 } + } + + @MainActor + public func testViewAppeared() async { + let view = TestView() + rootView = view + await expect(view: view, toBeAppeared: true) + + rootView = nil + await expect(view: view, toBeAppeared: false) + } +} + +private class TestView: CMPView { + public static var counter: Int = 1 + + private let id: Int + + public var viewIsInWindowHierarchy: Bool = false + public var isViewAppeared: Bool = false + + init(delegate: CMPComposeContainerLifecycleDelegate? = nil) { + id = TestView.counter + TestView.counter += 1 + super.init(lifecycleDelegate: delegate) + } + + required init?(coder: NSCoder) { + nil + } + + override func viewDidAppear() { + isViewAppeared = true + } + + override func viewDidDisappear() { + isViewAppeared = false + } + + override func viewDidEnterWindowHierarchy() { + print("TestView_\(id) didEnterWindowHierarchy") + XCTAssertFalse(viewIsInWindowHierarchy) + viewIsInWindowHierarchy = true + } + + override func viewDidLeaveWindowHierarchy() { + super.viewDidLeaveWindowHierarchy() + print("TestView_\(id) didLeaveWindowHierarchy") + XCTAssertTrue(viewIsInWindowHierarchy) + viewIsInWindowHierarchy = false + } +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt index 60c417d1d32cb..21310be29e64a 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.navigationevent.UIKitNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.MotionDurationScaleImpl -import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration @@ -54,7 +53,7 @@ import androidx.compose.ui.window.FocusedViewsList import androidx.compose.ui.window.MetalRedrawer import androidx.compose.ui.window.MetalView import androidx.compose.ui.window.SceneActiveStateListener -import androidx.compose.ui.window.ViewControllerLifecycleDelegate +import androidx.compose.ui.window.ComposeContainerLifecycleDelegate import androidx.lifecycle.enableSavedStateHandles import androidx.savedstate.SavedState import kotlin.coroutines.CoroutineContext @@ -82,7 +81,6 @@ import platform.UIKit.UIAccessibilityIsReduceMotionEnabled import platform.UIKit.UIApplication import platform.UIKit.UIStatusBarAnimation import platform.UIKit.UIStatusBarStyle -import platform.UIKit.UITraitCollection import platform.UIKit.UIUserInterfaceLayoutDirection import platform.UIKit.UIUserInterfaceStyle import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol @@ -97,7 +95,7 @@ internal class ComposeHostingViewController( private val configuration: ComposeUIViewControllerConfiguration, private val content: @Composable () -> Unit, coroutineContext: CoroutineContext = Dispatchers.Main, - private val lifecycleDelegate: ViewControllerLifecycleDelegate = ViewControllerLifecycleDelegate() + private val lifecycleDelegate: ComposeContainerLifecycleDelegate = ComposeContainerLifecycleDelegate() ) : CMPViewController(lifecycleDelegate = lifecycleDelegate) { private val hapticFeedback = CupertinoHapticFeedback() @@ -202,9 +200,7 @@ internal class ComposeHostingViewController( windowContext.updateWindowContainerSize() } - override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - + override fun userInterfaceStyleDidChange() { systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ViewControllerLifecycleDelegate.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainerLifecycleDelegate.uikit.kt similarity index 89% rename from compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ViewControllerLifecycleDelegate.uikit.kt rename to compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainerLifecycleDelegate.uikit.kt index 3f22b45274ab8..1b29786b49149 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ViewControllerLifecycleDelegate.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainerLifecycleDelegate.uikit.kt @@ -16,15 +16,15 @@ package androidx.compose.ui.window -import androidx.compose.ui.uikit.utils.CMPViewControllerLifecycleDelegateProtocol +import androidx.compose.ui.uikit.utils.CMPComposeContainerLifecycleDelegateProtocol import androidx.lifecycle.Lifecycle import platform.Foundation.NSNotificationCenter import platform.UIKit.UIWindowScene import platform.darwin.NSObject -internal class ViewControllerLifecycleDelegate( +internal class ComposeContainerLifecycleDelegate( private val notificationCenter: NSNotificationCenter = NSNotificationCenter.defaultCenter -): NSObject(), CMPViewControllerLifecycleDelegateProtocol { +): NSObject(), CMPComposeContainerLifecycleDelegateProtocol { private var isViewAppeared = false set(value) { field = value @@ -73,18 +73,18 @@ internal class ViewControllerLifecycleDelegate( this.isSceneActive = activeStateListener.isSceneActive } - override fun viewControllerWillDealloc() { + override fun composeContainerWillDealloc() { this.isDisposed = true activeStateListener.dispose() foregroundStateListener.dispose() windowScene = null } - override fun viewControllerWillAppear() { + override fun composeContainerWillAppear() { this.isViewAppeared = true } - override fun viewControllerDidDisappear() { + override fun composeContainerDidDisappear() { this.isViewAppeared = false } diff --git a/compose/ui/ui/src/uikitTest/kotlin/androidx/compose/ui/window/ViewControllerBasedLifecycleOwnerTest.kt b/compose/ui/ui/src/uikitTest/kotlin/androidx/compose/ui/window/ViewControllerBasedLifecycleOwnerTest.kt index 4810efd24fa7f..fb5dd8fc24a9b 100644 --- a/compose/ui/ui/src/uikitTest/kotlin/androidx/compose/ui/window/ViewControllerBasedLifecycleOwnerTest.kt +++ b/compose/ui/ui/src/uikitTest/kotlin/androidx/compose/ui/window/ViewControllerBasedLifecycleOwnerTest.kt @@ -32,7 +32,7 @@ class ViewControllerBasedLifecycleOwnerTest { fun allEvents() { val notificationCenter = NSNotificationCenter() val lifecycleOwner = DefaultArchitectureComponentsOwner() - val lifecycleDelegate = ViewControllerLifecycleDelegate(notificationCenter) + val lifecycleDelegate = ComposeContainerLifecycleDelegate(notificationCenter) lifecycleDelegate.onLifecycleStateUpdated = lifecycleOwner::setLifecycleState val scene = UIWindowScene() lifecycleDelegate.windowScene = scene @@ -41,7 +41,7 @@ class ViewControllerBasedLifecycleOwnerTest { notificationCenter.postNotificationName(UISceneWillEnterForegroundNotification, scene) assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState) - lifecycleDelegate.viewControllerWillAppear() + lifecycleDelegate.composeContainerWillAppear() assertEquals(Lifecycle.State.STARTED, lifecycleOwner.lifecycle.currentState) notificationCenter.postNotificationName(UISceneDidActivateNotification, scene) @@ -61,10 +61,10 @@ class ViewControllerBasedLifecycleOwnerTest { notificationCenter.postNotificationName(UISceneWillEnterForegroundNotification, scene) assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState) - lifecycleDelegate.viewControllerDidDisappear() + lifecycleDelegate.composeContainerDidDisappear() assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState) - lifecycleDelegate.viewControllerWillDealloc() + lifecycleDelegate.composeContainerWillDealloc() assertEquals(Lifecycle.State.DESTROYED, lifecycleOwner.lifecycle.currentState) } @@ -72,7 +72,7 @@ class ViewControllerBasedLifecycleOwnerTest { fun foregroundThenViewWillAppear() { val notificationCenter = NSNotificationCenter() val lifecycleOwner = DefaultArchitectureComponentsOwner() - val lifecycleDelegate = ViewControllerLifecycleDelegate(notificationCenter) + val lifecycleDelegate = ComposeContainerLifecycleDelegate(notificationCenter) lifecycleDelegate.onLifecycleStateUpdated = lifecycleOwner::setLifecycleState val scene = UIWindowScene() lifecycleDelegate.windowScene = scene @@ -81,7 +81,7 @@ class ViewControllerBasedLifecycleOwnerTest { notificationCenter.postNotificationName(UISceneWillEnterForegroundNotification, scene) assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState) - lifecycleDelegate.viewControllerWillAppear() + lifecycleDelegate.composeContainerWillAppear() assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState) } @@ -89,17 +89,17 @@ class ViewControllerBasedLifecycleOwnerTest { fun viewDidDisappearThenBackground() { val notificationCenter = NSNotificationCenter() val lifecycleOwner = DefaultArchitectureComponentsOwner() - val lifecycleDelegate = ViewControllerLifecycleDelegate(notificationCenter) + val lifecycleDelegate = ComposeContainerLifecycleDelegate(notificationCenter) lifecycleDelegate.onLifecycleStateUpdated = lifecycleOwner::setLifecycleState val scene = UIWindowScene() lifecycleDelegate.windowScene = scene - lifecycleDelegate.viewControllerWillAppear() + lifecycleDelegate.composeContainerWillAppear() notificationCenter.postNotificationName(UISceneWillEnterForegroundNotification, scene) notificationCenter.postNotificationName(UISceneDidActivateNotification, scene) assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState) - lifecycleDelegate.viewControllerDidDisappear() + lifecycleDelegate.composeContainerDidDisappear() assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState) // this should not happen, but let's protect against it anyway