Skip to content

Commit

Permalink
Add a fullscreen button to TimelineMediaPreviewScreen and hook up swi…
Browse files Browse the repository at this point in the history
…ping 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.
  • Loading branch information
pixlwave authored Dec 18, 2024
1 parent 435dfb8 commit e7cc807
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
}
.store(in: &cancellables)

navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) {
previewContext.completion?()
}
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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) { }
Expand All @@ -128,6 +160,8 @@ private struct QuickLookView: UIViewControllerRepresentable {
Coordinator(viewModelContext: viewModelContext)
}

// MARK: Coordinator

class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
private let viewModelContext: TimelineMediaPreviewViewModel.Context

Expand All @@ -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
Expand Down Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
3 changes: 1 addition & 2 deletions UnitTests/Sources/TimelineMediaPreviewViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}

Expand Down

0 comments on commit e7cc807

Please sign in to comment.