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)
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) {
previewContext.completion?()
}
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
}
}

View File

@ -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))
}
}

View File

@ -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<TimelineItemIdentifier, Never>()
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 }

View File

@ -12,6 +12,7 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaP
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let timelineViewModel: TimelineViewModelProtocol
private let currentItemIDHandler: ((TimelineItemIdentifier?) -> 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 {

View File

@ -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()),

View File

@ -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()),

View File

@ -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<AnyCancellable> = []
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
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()),

View File

@ -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.

View File

@ -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)
}
}

View File

@ -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()
}