From e7cc8070844f5053897eff0b5899312742e533a3 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 18 Dec 2024 19:10:19 +0000 Subject: [PATCH] Add a fullscreen button to TimelineMediaPreviewScreen and hook up swiping through the timeline. (#3638) * Add a fullscreen button to media previews - Not ideal but the gestures conflict with the preview controller. * Don't un-flip the preview thumbnail until the preview has disappeared, and only do it on iOS 18. * Add all of the loaded items for previewing in the preview controller. --- .../MediaEventsTimelineFlowCoordinator.swift | 4 +- .../TimelineMediaPreviewCoordinator.swift | 10 ++- .../TimelineMediaPreviewModels.swift | 23 +++++++ .../TimelineMediaPreviewViewModel.swift | 10 ++- .../TimelineMediaPreviewDetailsView.swift | 5 +- ...neMediaPreviewRedactConfirmationView.swift | 5 +- .../View/TimelineMediaPreviewScreen.swift | 66 ++++++++++++++----- .../MediaEventsTimelineScreenViewModel.swift | 4 +- .../View/MediaEventsTimelineScreen.swift | 8 +-- .../TimelineMediaPreviewViewModelTests.swift | 3 +- 10 files changed, 105 insertions(+), 33 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index a08af5a6a6..bcb14f3e99 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -126,8 +126,6 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) { - previewContext.completion?() - } + navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift index 5f937f1805..4fb04d1bbe 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewCoordinator.swift @@ -16,11 +16,12 @@ struct TimelineMediaPreviewContext { let viewModel: TimelineViewModelProtocol /// The namespace that the navigation transition's `sourceID` should be defined in. let namespace: Namespace.ID - /// A completion to be called immediately *after* the preview has been dismissed. + /// A closure to be called whenever a different preview item is shown. It should also + /// be called *after* the preview has been dismissed, with an ID of `nil`. /// /// This helps work around a bug caused by the flipped scrollview where the zoomed /// thumbnail starts off upside down while loading the preview screen. - var completion: (() -> Void)? + var itemIDHandler: ((TimelineItemIdentifier?) -> Void)? } struct TimelineMediaPreviewCoordinatorParameters { @@ -72,6 +73,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol { } func toPresentable() -> AnyView { - AnyView(TimelineMediaPreviewScreen(context: viewModel.context)) + // Calling the completion onDisappear isn't ideal, but we don't push away from the screen so it should be + // a good enough approximation of didDismiss, given that the only other option is our navigation callbacks + // which are essentially willDismiss callbacks and happen too early for this particular completion handler. + AnyView(TimelineMediaPreviewScreen(context: viewModel.context, itemIDHandler: parameters.context.itemIDHandler)) } } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 0172fddc72..fd088f92cf 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -15,11 +15,19 @@ enum TimelineMediaPreviewViewModelAction: Equatable { } struct TimelineMediaPreviewViewState: BindableState { + /// All of the items in the timeline that can be previewed. var previewItems: [TimelineMediaPreviewItem] + /// The index of the initial item inside of `previewItems` that is to be shown. + let initialItemIndex: Int + + /// The media item that is currently being previewed. var currentItem: TimelineMediaPreviewItem + /// All of the available actions for the current item. var currentItemActions: TimelineItemMenuActions? + /// The namespace used for the zoom transition. let transitionNamespace: Namespace.ID + /// A publisher that the view model uses to signal to the QLPreviewController when the current item has been loaded. let fileLoadedPublisher = PassthroughSubject() var bindings = TimelineMediaPreviewViewStateBindings() @@ -49,6 +57,21 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable { self.timelineItem = timelineItem } + init?(roomTimelineItemViewState: RoomTimelineItemViewState) { + switch roomTimelineItemViewState.type { + case .audio(let audioRoomTimelineItem): + timelineItem = audioRoomTimelineItem + case .file(let fileRoomTimelineItem): + timelineItem = fileRoomTimelineItem + case .image(let imageRoomTimelineItem): + timelineItem = imageRoomTimelineItem + case .video(let videoRoomTimelineItem): + timelineItem = videoRoomTimelineItem + default: + return nil + } + } + // MARK: Identifiable var id: TimelineItemIdentifier { timelineItem.id } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index e3cdc1c704..14c274187e 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -12,6 +12,7 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel Void)? private let mediaProvider: MediaProviderProtocol private let photoLibraryManager: PhotoLibraryManagerProtocol private let userIndicatorController: UserIndicatorControllerProtocol @@ -28,14 +29,18 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { userIndicatorController: UserIndicatorControllerProtocol, appMediator: AppMediatorProtocol) { timelineViewModel = context.viewModel + currentItemIDHandler = context.itemIDHandler self.mediaProvider = mediaProvider self.photoLibraryManager = photoLibraryManager self.userIndicatorController = userIndicatorController self.appMediator = appMediator - let currentItem = TimelineMediaPreviewItem(timelineItem: context.item) + let previewItems = timelineViewModel.context.viewState.timelineState.itemViewStates.compactMap(TimelineMediaPreviewItem.init) + let initialItemIndex = previewItems.firstIndex { $0.id == context.item.id } ?? 0 + let currentItem = previewItems[initialItemIndex] - super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: [currentItem], + super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems, + initialItemIndex: initialItemIndex, currentItem: currentItem, transitionNamespace: context.namespace), mediaProvider: mediaProvider) @@ -76,6 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { state.currentItem = previewItem + currentItemIDHandler?(previewItem.id) rebuildCurrentItemActions() if previewItem.fileHandle == nil, let source = previewItem.mediaSource { diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift index f1e3c27702..9320acd2c1 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift @@ -180,8 +180,11 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie contentType: contentType)) let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen) + let timelineController = MockRoomTimelineController(timelineKind: timelineKind) + timelineController.timelineItems = [item] return TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock(timelineKind: timelineKind), + viewModel: TimelineViewModel.mock(timelineKind: timelineKind, + timelineController: timelineController), namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), photoLibraryManager: PhotoLibraryManagerMock(.init()), diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift index ca62a953bb..0a13fd059b 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewRedactConfirmationView.swift @@ -138,8 +138,11 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes thumbnailInfo: .mockThumbnail, contentType: contentType)) + let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) + timelineController.timelineItems = [item] return TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock, + viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, + timelineController: timelineController), namespace: previewNamespace), mediaProvider: MediaProviderMock(configuration: .init()), photoLibraryManager: PhotoLibraryManagerMock(.init()), diff --git a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift index aaac9d7e36..fe7c563b5b 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewScreen.swift @@ -12,18 +12,16 @@ import SwiftUI struct TimelineMediaPreviewScreen: View { @ObservedObject var context: TimelineMediaPreviewViewModel.Context + var itemIDHandler: ((TimelineItemIdentifier?) -> Void)? + + @State private var isFullScreen = false + private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible } private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem } var body: some View { NavigationStack { - Color.clear - .overlay { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Overlay to stop QL hijacking the toolbar. - .toolbar { toolbar } - .toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷‍♂️ - .toolbarBackground(.visible, for: .bottomBar) - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .bottom, spacing: 0) { caption } + quickLookPreview } .introspect(.navigationStack, on: .supportedVersions) { // Fixes a bug where the QuickLook view overrides the .toolbarBackground(.visible) after it loads the real item. @@ -39,12 +37,41 @@ struct TimelineMediaPreviewScreen: View { } .alert(item: $context.alertInfo) .preferredColorScheme(.dark) + .onDisappear { + itemIDHandler?(nil) + } .zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace) } + var quickLookPreview: some View { + Color.clear // A completely clear view breaks any SwiftUI gestures (such as drag to dismiss). + .background { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Not the root view to stop QL hijacking the toolbar. + .overlay(alignment: .topTrailing) { fullScreenButton } + .toolbar { toolbar } + .toolbar(toolbarVisibility, for: .navigationBar) + .toolbar(toolbarVisibility, for: .bottomBar) + .toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷‍♂️ + .toolbarBackground(.visible, for: .bottomBar) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom, spacing: 0) { caption } + } + + private var fullScreenButton: some View { + Button { + withAnimation { isFullScreen.toggle() } + } label: { + CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG) + .padding(6) + .background(.thinMaterial, in: Circle()) + } + .tint(.compound.textActionPrimary) + .padding(.top, 12) + .padding(.trailing, 14) + } + @ViewBuilder private var caption: some View { - if let caption = currentItem.caption { + if let caption = currentItem.caption, !isFullScreen { Text(caption) .font(.compound.bodyLG) .foregroundStyle(.compound.textPrimary) @@ -55,6 +82,7 @@ struct TimelineMediaPreviewScreen: View { .background { BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath. } + .transition(.move(edge: .bottom).combined(with: .opacity)) } } @@ -114,12 +142,16 @@ struct TimelineMediaPreviewScreen: View { } } +// MARK: - QuickLook + private struct QuickLookView: UIViewControllerRepresentable { let viewModelContext: TimelineMediaPreviewViewModel.Context func makeUIViewController(context: Context) -> PreviewController { - PreviewController(coordinator: context.coordinator, - fileLoadedPublisher: viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()) + let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher() + let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher) + controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex + return controller } func updateUIViewController(_ uiViewController: PreviewController, context: Context) { } @@ -128,6 +160,8 @@ private struct QuickLookView: UIViewControllerRepresentable { Coordinator(viewModelContext: viewModelContext) } + // MARK: Coordinator + class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { private let viewModelContext: TimelineMediaPreviewViewModel.Context @@ -148,14 +182,12 @@ private struct QuickLookView: UIViewControllerRepresentable { } } + // MARK: UIKit + class PreviewController: QLPreviewController { - let coordinator: Coordinator - private var cancellables: Set = [] init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher) { - self.coordinator = coordinator - super.init(nibName: nil, bundle: nil) dataSource = coordinator @@ -208,8 +240,12 @@ struct TimelineMediaPreviewScreen_Previews: PreviewProvider { thumbnailSource: nil, contentType: .pdf)) + let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen)) + timelineController.timelineItems = [item] + return TimelineMediaPreviewViewModel(context: .init(item: item, - viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)), + viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind, + timelineController: timelineController), namespace: namespace), mediaProvider: MediaProviderMock(configuration: .init()), photoLibraryManager: PhotoLibraryManagerMock(.init()), diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index a87dec6487..a4085f620c 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -157,8 +157,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType actionsSubject.send(.viewItem(.init(item: item, viewModel: activeTimelineViewModel, - namespace: namespace) { [weak self] in - self?.state.currentPreviewItemID = nil + namespace: namespace) { [weak self] itemID in + self?.state.currentPreviewItemID = itemID })) // Set the current item in the next run loop so that (hopefully) the presentation will be ready before we flip the thumbnail. diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift index 583c7982e0..2c9c7ce0f6 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/MediaEventsTimelineScreen.swift @@ -211,12 +211,12 @@ struct MediaEventsTimelineScreen: View { } func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize { - guard item.identifier != context.viewState.currentPreviewItemID else { + if item.identifier == context.viewState.currentPreviewItemID, #available(iOS 18.0, *) { // Remove the flip when presenting a preview so that the zoom transition is the right way up 🙃 - return CGSize(width: 1, height: 1) + CGSize(width: 1, height: 1) + } else { + CGSize(width: isGridLayout ? -1 : 1, height: -1) } - - return CGSize(width: isGridLayout ? -1 : 1, height: -1) } } diff --git a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift index 56cc8ed1a4..c130d43c1e 100644 --- a/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift +++ b/UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift @@ -104,7 +104,6 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") // When choosing to save the image. - let item = context.viewState.currentItem let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } context.send(viewAction: .saveCurrentItem) try await deferred.fulfill() @@ -164,7 +163,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase { private func loadInitialItem() async throws { let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } - context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0])) + context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[context.viewState.initialItemIndex])) try await deferred.fulfill() }