From ebf7c00eeb1a0e3fdf72c94eef1e75d92ccac0d8 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Fri, 16 Aug 2024 11:25:36 +0100 Subject: [PATCH] Navigation support for upcoming Element Call Picture in Picture mode. (#3174) --- .../Sources/Application/AppSettings.swift | 3 + .../Navigation/NavigationCoordinators.swift | 73 ++++++++++++++++ .../UserSessionFlowCoordinator.swift | 22 ++++- .../Mocks/Generated/GeneratedMocks.swift | 1 + .../CallScreen/CallScreenCoordinator.swift | 12 +++ .../Screens/CallScreen/CallScreenModels.swift | 4 + .../CallScreen/CallScreenViewModel.swift | 46 ++++++++++ .../Screens/CallScreen/View/CallScreen.swift | 23 +++++ .../RoomScreen/RoomScreenCoordinator.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 1 + .../ElementCall/ElementCallService.swift | 2 + .../ElementCallServiceProtocol.swift | 2 + .../NavigationSplitCoordinatorTests.swift | 83 +++++++++++++++++++ 14 files changed, 273 insertions(+), 2 deletions(-) diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 8e49a42c93..255960cc1e 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -288,6 +288,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.timelineItemAuthenticityEnabled, defaultValue: false, storageType: .userDefaults(store)) var timelineItemAuthenticityEnabled + + // Not user configurable as it depends on work in EC too. + let elementCallPictureInPictureEnabled = false #endif diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift index 3ba5cf885c..e1ede6e032 100644 --- a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -115,6 +115,28 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS fullScreenCoverModule?.coordinator } + @Published fileprivate var overlayModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove overlay", oldValue) + oldValue.tearDown() + } + + if let overlayModule { + logPresentationChange("Set overlay", overlayModule) + overlayModule.coordinator?.start() + } + } + } + + /// The currently displayed overlay coordinator + var overlayCoordinator: (any CoordinatorProtocol)? { + overlayModule?.coordinator + } + + enum OverlayPresentationMode { case fullScreen, minimized } + @Published fileprivate var overlayPresentationMode: OverlayPresentationMode = .minimized + fileprivate var compactLayoutRootModule: NavigationModule? { if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator { if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule { @@ -282,6 +304,47 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) } } + + /// Present an overlay on top of the split view + /// - Parameters: + /// - coordinator: the coordinator to display + /// - presentationMode: how the coordinator should be presented + /// - animated: whether the transition should be animated + /// - dismissalCallback: called when the overlay has been dismissed, programatically or otherwise + func setOverlayCoordinator(_ coordinator: (any CoordinatorProtocol)?, + presentationMode: OverlayPresentationMode = .fullScreen, + animated: Bool = true, + dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + overlayModule = nil + return + } + + if overlayModule?.coordinator === coordinator { + fatalError("Cannot use the same coordinator more than once") + } + + var transaction = Transaction() + transaction.disablesAnimations = !animated + + withTransaction(transaction) { + overlayPresentationMode = presentationMode + overlayModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + } + + /// Updates the presentation of the overlay coordinator. + /// - Parameters: + /// - mode: The type of presentation to use. + /// - animated: whether the transition should be animated + func setOverlayPresentationMode(_ mode: OverlayPresentationMode, animated: Bool = true) { + var transaction = Transaction() + transaction.disablesAnimations = !animated + + withTransaction(transaction) { + overlayPresentationMode = mode + } + } // MARK: - CoordinatorProtocol @@ -385,6 +448,16 @@ private struct NavigationSplitCoordinatorView: View { module.coordinator?.toPresentable() .id(module.id) } + .overlay { + Group { + if let coordinator = navigationSplitCoordinator.overlayModule?.coordinator { + coordinator.toPresentable() + .opacity(navigationSplitCoordinator.overlayPresentationMode == .minimized ? 0 : 1) + .transition(.opacity) + } + } + .animation(.elementDefault, value: navigationSplitCoordinator.overlayPresentationMode) + } // Handle `horizontalSizeClass` changes breaking the navigation bar // https://github.com/element-hq/element-x-ios/issues/617 .onChange(of: horizontalSizeClass) { value in diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 9d98247dee..5c8da5b603 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import AVKit import Combine import SwiftUI @@ -557,7 +558,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: Calls + private var callScreenPictureInPictureController: AVPictureInPictureController? private func presentCallScreen(roomProxy: RoomProxyProtocol) { + guard elementCallService.ongoingCallRoomID != roomProxy.id else { + MXLog.info("Returning to existing call.") + callScreenPictureInPictureController?.stopPictureInPicture() + return + } + let colorScheme: ColorScheme = appMediator.windowManager.mainWindow.traitCollection.userInterfaceStyle == .light ? .light : .dark let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService, clientProxy: userSession.clientProxy, @@ -565,19 +573,29 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { clientID: InfoPlistReader.main.bundleIdentifier, elementCallBaseURL: appSettings.elementCallBaseURL, elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride, + elementCallPictureInPictureEnabled: appSettings.elementCallPictureInPictureEnabled, colorScheme: colorScheme, appHooks: appHooks)) callScreenCoordinator.actions .sink { [weak self] action in + guard let self else { return } switch action { + case .pictureInPictureStarted(let controller): + MXLog.info("Hiding call for PiP presentation.") + callScreenPictureInPictureController = controller + navigationSplitCoordinator.setOverlayPresentationMode(.minimized) + case .pictureInPictureStopped: + MXLog.info("Restoring call after PiP presentation.") + navigationSplitCoordinator.setOverlayPresentationMode(.fullScreen) + callScreenPictureInPictureController = nil case .dismiss: - self?.navigationSplitCoordinator.setSheetCoordinator(nil) + navigationSplitCoordinator.setOverlayCoordinator(nil) } } .store(in: &cancellables) - navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true) + navigationSplitCoordinator.setOverlayCoordinator(callScreenCoordinator, animated: true) analytics.track(screen: .RoomCall) } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 08780c2e11..1cf8b8ea70 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4894,6 +4894,7 @@ class ElementCallServiceMock: ElementCallServiceProtocol { set(value) { underlyingActions = value } } var underlyingActions: AnyPublisher! + var ongoingCallRoomID: String? //MARK: - setClientProxy diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift index bfdc7299d8..13b13b0e34 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import AVKit import Combine import SwiftUI @@ -24,11 +25,17 @@ struct CallScreenCoordinatorParameters { let clientID: String let elementCallBaseURL: URL let elementCallBaseURLOverride: URL? + let elementCallPictureInPictureEnabled: Bool let colorScheme: ColorScheme let appHooks: AppHooks } enum CallScreenCoordinatorAction { + /// The call is still ongoing but the user wishes to navigate around the app. + case pictureInPictureStarted(AVPictureInPictureController?) + /// The call is hidden and the user wishes to return to it. + case pictureInPictureStopped + /// The call is finished and the screen is done with. case dismiss } @@ -48,6 +55,7 @@ final class CallScreenCoordinator: CoordinatorProtocol { clientID: parameters.clientID, elementCallBaseURL: parameters.elementCallBaseURL, elementCallBaseURLOverride: parameters.elementCallBaseURLOverride, + elementCallPictureInPictureEnabled: parameters.elementCallPictureInPictureEnabled, colorScheme: parameters.colorScheme, appHooks: parameters.appHooks) } @@ -57,6 +65,10 @@ final class CallScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { + case .pictureInPictureStarted(let controller): + actionsSubject.send(.pictureInPictureStarted(controller)) + case .pictureInPictureStopped: + actionsSubject.send(.pictureInPictureStopped) case .dismiss: actionsSubject.send(.dismiss) } diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift index c9098b11cd..2007ee04ec 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift @@ -14,9 +14,12 @@ // limitations under the License. // +import AVKit import Foundation enum CallScreenViewModelAction { + case pictureInPictureStarted(AVPictureInPictureController?) + case pictureInPictureStopped case dismiss } @@ -39,4 +42,5 @@ struct Bindings { enum CallScreenViewAction { case urlChanged(URL?) + case navigateBack } diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index a33536e721..d6fc6abe09 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import AVKit import CallKit import Combine import SwiftUI @@ -23,6 +24,7 @@ typealias CallScreenViewModelType = StateStoreViewModel = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index 1c47b23f0b..c58a683945 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -26,6 +26,8 @@ enum ElementCallServiceAction { protocol ElementCallServiceProtocol { var actions: AnyPublisher { get } + var ongoingCallRoomID: String? { get } + func setClientProxy(_ clientProxy: ClientProxyProtocol) func setupCallSession(roomID: String, roomDisplayName: String) async diff --git a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift index cc5b7181e0..cc12ea20b6 100644 --- a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift +++ b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift @@ -99,6 +99,52 @@ class NavigationSplitCoordinatorTests: XCTestCase { assertCoordinatorsEqual(someOtherSheetCoordinator, navigationSplitCoordinator.sheetCoordinator) } + func testFullScreenCover() { + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let fullScreenCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + assertCoordinatorsEqual(fullScreenCoordinator, navigationSplitCoordinator.fullScreenCoverCoordinator) + + navigationSplitCoordinator.setFullScreenCoverCoordinator(nil) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + XCTAssertNil(navigationSplitCoordinator.fullScreenCoverCoordinator) + } + + func testOverlay() { + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let overlayCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setOverlayCoordinator(overlayCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + assertCoordinatorsEqual(overlayCoordinator, navigationSplitCoordinator.overlayCoordinator) + + // The coordinator should still be retained when changing the presentation mode. + navigationSplitCoordinator.setOverlayPresentationMode(.minimized) + assertCoordinatorsEqual(overlayCoordinator, navigationSplitCoordinator.overlayCoordinator) + navigationSplitCoordinator.setOverlayPresentationMode(.fullScreen) + assertCoordinatorsEqual(overlayCoordinator, navigationSplitCoordinator.overlayCoordinator) + + navigationSplitCoordinator.setOverlayCoordinator(nil) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + XCTAssertNil(navigationSplitCoordinator.overlayCoordinator) + } + func testSidebarReplacementCallbacks() { let sidebarCoordinator = SomeTestCoordinator() @@ -135,6 +181,43 @@ class NavigationSplitCoordinatorTests: XCTestCase { waitForExpectations(timeout: 1.0) } + func testFullScreenCoverDismissalCallback() { + let fullScreenCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationSplitCoordinator.setFullScreenCoverCoordinator(fullScreenCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setFullScreenCoverCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testOverlayDismissalCallback() { + let overlayCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationSplitCoordinator.setOverlayCoordinator(overlayCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setOverlayCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testOverlayDismissalCallbackWhenChangingMode() { + let overlayCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + expectation.isInverted = true + navigationSplitCoordinator.setOverlayCoordinator(overlayCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setOverlayPresentationMode(.minimized) + waitForExpectations(timeout: 1.0) + } + func testEmbeddedStackPresentsSheetThroughSplit() { let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())