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.
This commit is contained in:
Doug 2024-12-18 19:10:19 +00:00 committed by GitHub
parent 435dfb8e46
commit e7cc807084
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 33 deletions

View File

@ -126,8 +126,6 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
} }
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) { navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
previewContext.completion?()
}
} }
} }

View File

@ -16,11 +16,12 @@ struct TimelineMediaPreviewContext {
let viewModel: TimelineViewModelProtocol let viewModel: TimelineViewModelProtocol
/// The namespace that the navigation transition's `sourceID` should be defined in. /// The namespace that the navigation transition's `sourceID` should be defined in.
let namespace: Namespace.ID 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 /// This helps work around a bug caused by the flipped scrollview where the zoomed
/// thumbnail starts off upside down while loading the preview screen. /// thumbnail starts off upside down while loading the preview screen.
var completion: (() -> Void)? var itemIDHandler: ((TimelineItemIdentifier?) -> Void)?
} }
struct TimelineMediaPreviewCoordinatorParameters { struct TimelineMediaPreviewCoordinatorParameters {
@ -72,6 +73,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
} }
func toPresentable() -> AnyView { 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))
} }
} }

View File

@ -15,11 +15,19 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
} }
struct TimelineMediaPreviewViewState: BindableState { struct TimelineMediaPreviewViewState: BindableState {
/// All of the items in the timeline that can be previewed.
var previewItems: [TimelineMediaPreviewItem] 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 var currentItem: TimelineMediaPreviewItem
/// All of the available actions for the current item.
var currentItemActions: TimelineItemMenuActions? var currentItemActions: TimelineItemMenuActions?
/// The namespace used for the zoom transition.
let transitionNamespace: Namespace.ID 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<TimelineItemIdentifier, Never>() let fileLoadedPublisher = PassthroughSubject<TimelineItemIdentifier, Never>()
var bindings = TimelineMediaPreviewViewStateBindings() var bindings = TimelineMediaPreviewViewStateBindings()
@ -49,6 +57,21 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
self.timelineItem = timelineItem 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 // MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id } var id: TimelineItemIdentifier { timelineItem.id }

View File

@ -12,6 +12,7 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaP
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let timelineViewModel: TimelineViewModelProtocol private let timelineViewModel: TimelineViewModelProtocol
private let currentItemIDHandler: ((TimelineItemIdentifier?) -> Void)?
private let mediaProvider: MediaProviderProtocol private let mediaProvider: MediaProviderProtocol
private let photoLibraryManager: PhotoLibraryManagerProtocol private let photoLibraryManager: PhotoLibraryManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
@ -28,14 +29,18 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
userIndicatorController: UserIndicatorControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol) { appMediator: AppMediatorProtocol) {
timelineViewModel = context.viewModel timelineViewModel = context.viewModel
currentItemIDHandler = context.itemIDHandler
self.mediaProvider = mediaProvider self.mediaProvider = mediaProvider
self.photoLibraryManager = photoLibraryManager self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.appMediator = appMediator 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, currentItem: currentItem,
transitionNamespace: context.namespace), transitionNamespace: context.namespace),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
@ -76,6 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
state.currentItem = previewItem state.currentItem = previewItem
currentItemIDHandler?(previewItem.id)
rebuildCurrentItemActions() rebuildCurrentItemActions()
if previewItem.fileHandle == nil, let source = previewItem.mediaSource { if previewItem.fileHandle == nil, let source = previewItem.mediaSource {

View File

@ -180,8 +180,11 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
contentType: contentType)) contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen) let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
let timelineController = MockRoomTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item]
return TimelineMediaPreviewViewModel(context: .init(item: item, return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: timelineKind), viewModel: TimelineViewModel.mock(timelineKind: timelineKind,
timelineController: timelineController),
namespace: previewNamespace), namespace: previewNamespace),
mediaProvider: MediaProviderMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()), photoLibraryManager: PhotoLibraryManagerMock(.init()),

View File

@ -138,8 +138,11 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
thumbnailInfo: .mockThumbnail, thumbnailInfo: .mockThumbnail,
contentType: contentType)) contentType: contentType))
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = [item]
return TimelineMediaPreviewViewModel(context: .init(item: item, return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock, viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
namespace: previewNamespace), namespace: previewNamespace),
mediaProvider: MediaProviderMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()), photoLibraryManager: PhotoLibraryManagerMock(.init()),

View File

@ -12,18 +12,16 @@ import SwiftUI
struct TimelineMediaPreviewScreen: View { struct TimelineMediaPreviewScreen: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @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 } private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Color.clear quickLookPreview
.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 }
} }
.introspect(.navigationStack, on: .supportedVersions) { .introspect(.navigationStack, on: .supportedVersions) {
// Fixes a bug where the QuickLook view overrides the .toolbarBackground(.visible) after it loads the real item. // 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) .alert(item: $context.alertInfo)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.onDisappear {
itemIDHandler?(nil)
}
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace) .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 @ViewBuilder
private var caption: some View { private var caption: some View {
if let caption = currentItem.caption { if let caption = currentItem.caption, !isFullScreen {
Text(caption) Text(caption)
.font(.compound.bodyLG) .font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
@ -55,6 +82,7 @@ struct TimelineMediaPreviewScreen: View {
.background { .background {
BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath. 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 { private struct QuickLookView: UIViewControllerRepresentable {
let viewModelContext: TimelineMediaPreviewViewModel.Context let viewModelContext: TimelineMediaPreviewViewModel.Context
func makeUIViewController(context: Context) -> PreviewController { func makeUIViewController(context: Context) -> PreviewController {
PreviewController(coordinator: context.coordinator, let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()
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) { } func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
@ -128,6 +160,8 @@ private struct QuickLookView: UIViewControllerRepresentable {
Coordinator(viewModelContext: viewModelContext) Coordinator(viewModelContext: viewModelContext)
} }
// MARK: Coordinator
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
private let viewModelContext: TimelineMediaPreviewViewModel.Context private let viewModelContext: TimelineMediaPreviewViewModel.Context
@ -148,14 +182,12 @@ private struct QuickLookView: UIViewControllerRepresentable {
} }
} }
// MARK: UIKit
class PreviewController: QLPreviewController { class PreviewController: QLPreviewController {
let coordinator: Coordinator
private var cancellables: Set<AnyCancellable> = [] private var cancellables: Set<AnyCancellable> = []
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) { init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
self.coordinator = coordinator
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
dataSource = coordinator dataSource = coordinator
@ -208,8 +240,12 @@ struct TimelineMediaPreviewScreen_Previews: PreviewProvider {
thumbnailSource: nil, thumbnailSource: nil,
contentType: .pdf)) contentType: .pdf))
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = [item]
return TimelineMediaPreviewViewModel(context: .init(item: item, return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)), viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
namespace: namespace), namespace: namespace),
mediaProvider: MediaProviderMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()), photoLibraryManager: PhotoLibraryManagerMock(.init()),

View File

@ -157,8 +157,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
actionsSubject.send(.viewItem(.init(item: item, actionsSubject.send(.viewItem(.init(item: item,
viewModel: activeTimelineViewModel, viewModel: activeTimelineViewModel,
namespace: namespace) { [weak self] in namespace: namespace) { [weak self] itemID in
self?.state.currentPreviewItemID = nil 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. // Set the current item in the next run loop so that (hopefully) the presentation will be ready before we flip the thumbnail.

View File

@ -211,12 +211,12 @@ struct MediaEventsTimelineScreen: View {
} }
func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize { 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 🙃 // 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)
} }
} }

View File

@ -104,7 +104,6 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image") XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image")
// When choosing to save the image. // When choosing to save the image.
let item = context.viewState.currentItem
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
context.send(viewAction: .saveCurrentItem) context.send(viewAction: .saveCurrentItem)
try await deferred.fulfill() try await deferred.fulfill()
@ -164,7 +163,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
private func loadInitialItem() async throws { private func loadInitialItem() async throws {
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } 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() try await deferred.fulfill()
} }