Navigation support for upcoming Element Call Picture in Picture mode. (#3174)

This commit is contained in:
Doug
2024-08-16 11:25:36 +01:00
committed by GitHub
parent 4f5b652608
commit ebf7c00eeb
14 changed files with 273 additions and 2 deletions
@@ -288,6 +288,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.timelineItemAuthenticityEnabled, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.timelineItemAuthenticityEnabled, defaultValue: false, storageType: .userDefaults(store))
var timelineItemAuthenticityEnabled var timelineItemAuthenticityEnabled
// Not user configurable as it depends on work in EC too.
let elementCallPictureInPictureEnabled = false
#endif #endif
@@ -115,6 +115,28 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
fullScreenCoverModule?.coordinator 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? { fileprivate var compactLayoutRootModule: NavigationModule? {
if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator { if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator {
if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule { if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule {
@@ -282,6 +304,47 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) 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 // MARK: - CoordinatorProtocol
@@ -385,6 +448,16 @@ private struct NavigationSplitCoordinatorView: View {
module.coordinator?.toPresentable() module.coordinator?.toPresentable()
.id(module.id) .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 // Handle `horizontalSizeClass` changes breaking the navigation bar
// https://github.com/element-hq/element-x-ios/issues/617 // https://github.com/element-hq/element-x-ios/issues/617
.onChange(of: horizontalSizeClass) { value in .onChange(of: horizontalSizeClass) { value in
@@ -14,6 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import AVKit
import Combine import Combine
import SwiftUI import SwiftUI
@@ -557,7 +558,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Calls // MARK: Calls
private var callScreenPictureInPictureController: AVPictureInPictureController?
private func presentCallScreen(roomProxy: RoomProxyProtocol) { 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 colorScheme: ColorScheme = appMediator.windowManager.mainWindow.traitCollection.userInterfaceStyle == .light ? .light : .dark
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService, let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
clientProxy: userSession.clientProxy, clientProxy: userSession.clientProxy,
@@ -565,19 +573,29 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
clientID: InfoPlistReader.main.bundleIdentifier, clientID: InfoPlistReader.main.bundleIdentifier,
elementCallBaseURL: appSettings.elementCallBaseURL, elementCallBaseURL: appSettings.elementCallBaseURL,
elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride, elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride,
elementCallPictureInPictureEnabled: appSettings.elementCallPictureInPictureEnabled,
colorScheme: colorScheme, colorScheme: colorScheme,
appHooks: appHooks)) appHooks: appHooks))
callScreenCoordinator.actions callScreenCoordinator.actions
.sink { [weak self] action in .sink { [weak self] action in
guard let self else { return }
switch action { 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: case .dismiss:
self?.navigationSplitCoordinator.setSheetCoordinator(nil) navigationSplitCoordinator.setOverlayCoordinator(nil)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true) navigationSplitCoordinator.setOverlayCoordinator(callScreenCoordinator, animated: true)
analytics.track(screen: .RoomCall) analytics.track(screen: .RoomCall)
} }
@@ -4894,6 +4894,7 @@ class ElementCallServiceMock: ElementCallServiceProtocol {
set(value) { underlyingActions = value } set(value) { underlyingActions = value }
} }
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>! var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
var ongoingCallRoomID: String?
//MARK: - setClientProxy //MARK: - setClientProxy
@@ -14,6 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import AVKit
import Combine import Combine
import SwiftUI import SwiftUI
@@ -24,11 +25,17 @@ struct CallScreenCoordinatorParameters {
let clientID: String let clientID: String
let elementCallBaseURL: URL let elementCallBaseURL: URL
let elementCallBaseURLOverride: URL? let elementCallBaseURLOverride: URL?
let elementCallPictureInPictureEnabled: Bool
let colorScheme: ColorScheme let colorScheme: ColorScheme
let appHooks: AppHooks let appHooks: AppHooks
} }
enum CallScreenCoordinatorAction { 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 case dismiss
} }
@@ -48,6 +55,7 @@ final class CallScreenCoordinator: CoordinatorProtocol {
clientID: parameters.clientID, clientID: parameters.clientID,
elementCallBaseURL: parameters.elementCallBaseURL, elementCallBaseURL: parameters.elementCallBaseURL,
elementCallBaseURLOverride: parameters.elementCallBaseURLOverride, elementCallBaseURLOverride: parameters.elementCallBaseURLOverride,
elementCallPictureInPictureEnabled: parameters.elementCallPictureInPictureEnabled,
colorScheme: parameters.colorScheme, colorScheme: parameters.colorScheme,
appHooks: parameters.appHooks) appHooks: parameters.appHooks)
} }
@@ -57,6 +65,10 @@ final class CallScreenCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .pictureInPictureStarted(let controller):
actionsSubject.send(.pictureInPictureStarted(controller))
case .pictureInPictureStopped:
actionsSubject.send(.pictureInPictureStopped)
case .dismiss: case .dismiss:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
} }
@@ -14,9 +14,12 @@
// limitations under the License. // limitations under the License.
// //
import AVKit
import Foundation import Foundation
enum CallScreenViewModelAction { enum CallScreenViewModelAction {
case pictureInPictureStarted(AVPictureInPictureController?)
case pictureInPictureStopped
case dismiss case dismiss
} }
@@ -39,4 +42,5 @@ struct Bindings {
enum CallScreenViewAction { enum CallScreenViewAction {
case urlChanged(URL?) case urlChanged(URL?)
case navigateBack
} }
@@ -14,6 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import AVKit
import CallKit import CallKit
import Combine import Combine
import SwiftUI import SwiftUI
@@ -23,6 +24,7 @@ typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, Cal
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol { class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
private let elementCallService: ElementCallServiceProtocol private let elementCallService: ElementCallServiceProtocol
private let roomProxy: RoomProxyProtocol private let roomProxy: RoomProxyProtocol
private let isPictureInPictureEnabled: Bool
private let widgetDriver: ElementCallWidgetDriverProtocol private let widgetDriver: ElementCallWidgetDriverProtocol
@@ -45,12 +47,14 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
clientID: String, clientID: String,
elementCallBaseURL: URL, elementCallBaseURL: URL,
elementCallBaseURLOverride: URL?, elementCallBaseURLOverride: URL?,
elementCallPictureInPictureEnabled: Bool,
colorScheme: ColorScheme, colorScheme: ColorScheme,
appHooks: AppHooks) { appHooks: AppHooks) {
guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") } guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") }
self.elementCallService = elementCallService self.elementCallService = elementCallService
self.roomProxy = roomProxy self.roomProxy = roomProxy
isPictureInPictureEnabled = elementCallPictureInPictureEnabled
widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID) widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID)
@@ -151,6 +155,23 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
syncUpdateCancellable = nil syncUpdateCancellable = nil
} }
}, receiveValue: { _ in }) }, receiveValue: { _ in })
// Use did start otherwise there's a black box left on the screen during the pip controller animation.
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerDidStartNotification"))
.sink { [weak self] notification in
guard let self else { return }
let controller = notification.object as? AVPictureInPictureController
actionsSubject.send(.pictureInPictureStarted(controller))
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerWillStopNotification"))
.sink { [weak self] _ in
guard let self else { return }
actionsSubject.send(.pictureInPictureStopped)
Task { try await self.state.bindings.javaScriptEvaluator?("controls.disableCompatPip()") }
}
.store(in: &cancellables)
} }
override func process(viewAction: CallScreenViewAction) { override func process(viewAction: CallScreenViewAction) {
@@ -158,6 +179,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
case .urlChanged(let url): case .urlChanged(let url):
guard let url else { return } guard let url else { return }
MXLog.info("URL changed to: \(url)") MXLog.info("URL changed to: \(url)")
case .navigateBack:
handleBackwardsNavigation()
} }
} }
@@ -171,6 +194,29 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
// MARK: - Private // MARK: - Private
private func handleBackwardsNavigation() {
#if targetEnvironment(simulator)
if UIDevice.current.isPhone {
MXLog.warning("The iPhone simulator doesn't support PiP.")
actionsSubject.send(.dismiss)
return
}
#endif
guard isPictureInPictureEnabled, state.url != nil else {
actionsSubject.send(.dismiss)
return
}
Task {
try await state.bindings.javaScriptEvaluator?("controls.enableCompatPip()")
// Enable this check when implemented on web.
// if result as? Bool != true {
// actionsSubject.send(.dismiss)
// }
}
}
private func setAudioEnabled(_ enabled: Bool) async { private func setAudioEnabled(_ enabled: Bool) async {
let message = ElementCallWidgetMessage(direction: .toWidget, let message = ElementCallWidgetMessage(direction: .toWidget,
action: .mediaState, action: .mediaState,
@@ -15,6 +15,7 @@
// //
import Combine import Combine
import SFSafeSymbols
import SwiftUI import SwiftUI
import WebKit import WebKit
@@ -22,6 +23,26 @@ struct CallScreen: View {
@ObservedObject var context: CallScreenViewModel.Context @ObservedObject var context: CallScreenViewModel.Context
var body: some View { var body: some View {
NavigationStack {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .navigateBack) } label: {
Image(systemSymbol: .chevronBackward)
.fontWeight(.semibold)
}
.offset(y: -8)
// .padding(.leading, -8) // Fixes the button alignment, but harder to tap.
}
}
}
}
@ViewBuilder
var content: some View {
if context.viewState.url == nil { if context.viewState.url == nil {
ProgressView() ProgressView()
} else { } else {
@@ -187,6 +208,7 @@ struct CallScreen_Previews: PreviewProvider {
static let viewModel = { static let viewModel = {
let clientProxy = ClientProxyMock() let clientProxy = ClientProxyMock()
clientProxy.getElementWellKnownReturnValue = .success(nil) clientProxy.getElementWellKnownReturnValue = .success(nil)
clientProxy.deviceID = "call-device-id"
let roomProxy = RoomProxyMock() let roomProxy = RoomProxyMock()
roomProxy.sendCallNotificationIfNeeededReturnValue = .success(()) roomProxy.sendCallNotificationIfNeeededReturnValue = .success(())
@@ -204,6 +226,7 @@ struct CallScreen_Previews: PreviewProvider {
clientID: "io.element.elementx", clientID: "io.element.elementx",
elementCallBaseURL: "https://call.element.io", elementCallBaseURL: "https://call.element.io",
elementCallBaseURLOverride: nil, elementCallBaseURLOverride: nil,
elementCallPictureInPictureEnabled: false,
colorScheme: .light, colorScheme: .light,
appHooks: AppHooks()) appHooks: AppHooks())
}() }()
@@ -156,6 +156,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomDetails) actionsSubject.send(.presentRoomDetails)
case .displayCall: case .displayCall:
actionsSubject.send(.presentCallScreen) actionsSubject.send(.presentCallScreen)
case .removeComposerFocus:
composerViewModel.process(timelineAction: .removeFocus)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -22,6 +22,7 @@ enum RoomScreenViewModelAction {
case displayPinnedEventsTimeline case displayPinnedEventsTimeline
case displayRoomDetails case displayRoomDetails
case displayCall case displayCall
case removeComposerFocus
} }
enum RoomScreenViewAction { enum RoomScreenViewAction {
@@ -84,6 +84,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.displayRoomDetails) actionsSubject.send(.displayRoomDetails)
case .displayCall: case .displayCall:
actionsSubject.send(.displayCall) actionsSubject.send(.displayCall)
actionsSubject.send(.removeComposerFocus)
analyticsService.trackInteraction(name: .MobileRoomCallButton) analyticsService.trackInteraction(name: .MobileRoomCallButton)
} }
} }
@@ -59,6 +59,8 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
private var ongoingCallID: CallID? private var ongoingCallID: CallID?
var ongoingCallRoomID: String? { ongoingCallID?.roomID }
private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init() private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init()
var actions: AnyPublisher<ElementCallServiceAction, Never> { var actions: AnyPublisher<ElementCallServiceAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
@@ -26,6 +26,8 @@ enum ElementCallServiceAction {
protocol ElementCallServiceProtocol { protocol ElementCallServiceProtocol {
var actions: AnyPublisher<ElementCallServiceAction, Never> { get } var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
var ongoingCallRoomID: String? { get }
func setClientProxy(_ clientProxy: ClientProxyProtocol) func setClientProxy(_ clientProxy: ClientProxyProtocol)
func setupCallSession(roomID: String, roomDisplayName: String) async func setupCallSession(roomID: String, roomDisplayName: String) async
@@ -99,6 +99,52 @@ class NavigationSplitCoordinatorTests: XCTestCase {
assertCoordinatorsEqual(someOtherSheetCoordinator, navigationSplitCoordinator.sheetCoordinator) 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() { func testSidebarReplacementCallbacks() {
let sidebarCoordinator = SomeTestCoordinator() let sidebarCoordinator = SomeTestCoordinator()
@@ -135,6 +181,43 @@ class NavigationSplitCoordinatorTests: XCTestCase {
waitForExpectations(timeout: 1.0) 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() { func testEmbeddedStackPresentsSheetThroughSplit() {
let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator())