Revert back to UIKit for the presentation of the timeline media preview. (#3719)

* Revert back to UIKit for the presentation of the timeline media preview.

* Fix a presentation issue where the media is clipped until the animation finishes.

* Workaround for the preview controller replacing the info button when swiping.

* Use a self-sizing detent on the media info sheet.
This commit is contained in:
Doug
2025-01-31 17:28:14 +00:00
committed by GitHub
parent ef340fb70f
commit 6cd583d904
25 changed files with 695 additions and 583 deletions
@@ -95,8 +95,9 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions
.sink { [weak self] action in
switch action {
case .viewItem(let previewContext):
self?.presentMediaPreview(for: previewContext)
case .viewInRoomTimeline(let itemID):
self?.navigationStackCoordinator.pop(animated: false)
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
}
}
.store(in: &cancellables)
@@ -105,27 +106,4 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
self?.actionsSubject.send(.finished)
}
}
private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) {
let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController,
appMediator: appMediator)
let coordinator = TimelineMediaPreviewCoordinator(parameters: parameters)
coordinator.actionsPublisher
.sink { [weak self] action in
switch action {
case .viewInRoomTimeline(let itemID):
self?.navigationStackCoordinator.pop(animated: false)
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
self?.navigationStackCoordinator.setFullScreenCoverCoordinator(nil)
case .dismiss:
self?.navigationStackCoordinator.setFullScreenCoverCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
}
}
@@ -1,81 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct TimelineMediaPreviewContext {
/// The initial item to preview from the provided timeline.
/// This item's `id` will be used as the navigation transition's `sourceID`.
let item: EventBasedMessageTimelineItemProtocol
/// The timeline that the preview comes from, to allow for swiping to other media.
let viewModel: TimelineViewModelProtocol
/// The namespace that the navigation transition's `sourceID` should be defined in.
let namespace: Namespace.ID
/// 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 itemIDHandler: ((TimelineItemIdentifier?) -> Void)?
}
struct TimelineMediaPreviewCoordinatorParameters {
let context: TimelineMediaPreviewContext
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let appMediator: AppMediatorProtocol
}
enum TimelineMediaPreviewCoordinatorAction {
case viewInRoomTimeline(TimelineItemIdentifier)
case dismiss
}
final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
private let parameters: TimelineMediaPreviewCoordinatorParameters
private let viewModel: TimelineMediaPreviewViewModel
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<TimelineMediaPreviewCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: TimelineMediaPreviewCoordinatorParameters) {
self.parameters = parameters
viewModel = TimelineMediaPreviewViewModel(context: parameters.context,
mediaProvider: parameters.mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: parameters.userIndicatorController,
appMediator: parameters.appMediator)
}
func start() {
viewModel.actions.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .viewInRoomTimeline(let itemID):
actionsSubject.send(.viewInRoomTimeline(itemID))
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
// 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))
}
}
@@ -156,13 +156,14 @@ enum TimelineMediaPreviewItem: Equatable {
// MARK: QLPreviewItem
var previewItemURL: URL? {
// Falling back to a clear image allows the presentation animation to work when
// the item is in the event cache and just needs to be loaded from the store.
fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png")
fileHandle?.url
}
var previewItemTitle: String? {
filename
switch fileHandle?.url {
case .some: filename
case .none: " " // Don't show any background text when the preview is still loading.
}
}
// MARK: Event details
@@ -13,6 +13,42 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
case dismiss
}
enum TimelineMediaPreviewDriverAction {
case itemLoaded(TimelineItemIdentifier)
case showItemDetails(TimelineMediaPreviewItem.Media)
case exportFile(TimelineMediaPreviewFileExportPicker.File)
case authorizationRequired(appMediator: AppMediatorProtocol)
case dismissDetailsSheet
var isItemLoaded: Bool {
switch self {
case .itemLoaded: true
default: false
}
}
var isShowItemDetails: Bool {
switch self {
case .showItemDetails: true
default: false
}
}
var isExportFile: Bool {
switch self {
case .exportFile: true
default: false
}
}
var isAuthorizationRequired: Bool {
switch self {
case .authorizationRequired: true
default: false
}
}
}
struct TimelineMediaPreviewViewState: BindableState {
/// The data source for all of the preview-able items.
var dataSource: TimelineMediaPreviewDataSource
@@ -22,23 +58,15 @@ struct TimelineMediaPreviewViewState: BindableState {
/// 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>()
/// A publisher that the view model uses to signal actions to the QLPreviewController.
let previewControllerDriver = PassthroughSubject<TimelineMediaPreviewDriverAction, Never>()
var bindings = TimelineMediaPreviewViewStateBindings()
}
struct TimelineMediaPreviewViewStateBindings {
/// A binding that will present the Details view for the specified item.
var mediaDetailsItem: TimelineMediaPreviewItem.Media?
/// A binding that will present a confirmation to redact the specified item.
var redactConfirmationItem: TimelineMediaPreviewItem.Media?
/// A binding that will present a document picker to export the specified file.
var fileToExport: TimelineMediaPreviewFileExportPicker.File?
var alertInfo: AlertInfo<TimelineMediaPreviewAlertType>?
}
enum TimelineMediaPreviewAlertType {
@@ -51,5 +79,4 @@ enum TimelineMediaPreviewViewAction {
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media)
case redactConfirmation(item: TimelineMediaPreviewItem.Media)
case timelineEndReached
case dismiss
}
@@ -0,0 +1,188 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import QuickLook
import SwiftUI
extension View {
/// Preview a media file using a QuickLook Preview Controller. The preview is interactive with
/// the dismiss gesture working as expected if it was presented from UIKit.
func timelineMediaPreview(viewModel: Binding<TimelineMediaPreviewViewModel?>) -> some View {
modifier(TimelineMediaPreviewModifier(viewModel: viewModel))
}
}
private struct TimelineMediaPreviewModifier: ViewModifier {
@Binding var viewModel: TimelineMediaPreviewViewModel?
@State private var dismissalPublisher = PassthroughSubject<Void, Never>()
func body(content: Content) -> some View {
content.background {
if let viewModel {
MediaPreviewViewController(viewModel: viewModel,
dismissalPublisher: dismissalPublisher) { self.viewModel = nil }
.id(viewModel.instanceID) // Fixes a bug where opening a second preview too quickly can break presentation.
} else {
// Work around QLPreviewController dismissal issues, see below.
let _ = dismissalPublisher.send(())
}
}
}
}
private struct MediaPreviewViewController: UIViewControllerRepresentable {
let viewModel: TimelineMediaPreviewViewModel
let dismissalPublisher: PassthroughSubject<Void, Never>
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> PreviewHostingController {
PreviewHostingController(viewModel: viewModel,
dismissalPublisher: dismissalPublisher,
onDismiss: onDismiss)
}
func updateUIViewController(_ uiViewController: PreviewHostingController, context: Context) { }
/// A view controller that hosts the QuickLook preview.
///
/// This wrapper somehow allows the preview controller to do presentation/dismissal
/// animations and interactions which don't work if you represent it directly to SwiftUI 🤷‍♂️
class PreviewHostingController: UIViewController, QLPreviewControllerDelegate {
let onDismiss: () -> Void
let sourceView = UIView()
private let previewController: TimelineMediaPreviewController
private var hasBeenPresented = false
private var dismissalObserver: AnyCancellable?
private var cancellables: Set<AnyCancellable> = []
init(viewModel: TimelineMediaPreviewViewModel,
dismissalPublisher: PassthroughSubject<Void, Never>,
onDismiss: @escaping () -> Void) {
self.onDismiss = onDismiss
previewController = TimelineMediaPreviewController(context: viewModel.context)
super.init(nibName: nil, bundle: nil)
// The QLPreviewController will not automatically dismiss itself when the underlying view is removed
// (e.g. switching rooms from a notification) and it continues to hold on to the whole hierarcy.
// Manually tell it to dismiss itself here.
dismissalObserver = dismissalPublisher.sink { [weak self] _ in
// Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal
DispatchQueue.main.async { [weak self] in
self?.dismiss(animated: true)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
view.backgroundColor = .clear
view.addSubview(sourceView)
sourceView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
sourceView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
sourceView.centerYAnchor.constraint(equalTo: view.bottomAnchor),
sourceView.widthAnchor.constraint(equalToConstant: 100),
sourceView.heightAnchor.constraint(equalToConstant: 100)
])
}
// Don't use viewWillAppear due to the following warning:
// Presenting view controller <QLPreviewController> from detached view controller <HostingController> is not supported,
// and may result in incorrect safe area insets and a corrupt root presentation. Make sure <HostingController> is in
// the view controller hierarchy before presenting from it. Will become a hard exception in a future release.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasBeenPresented else { return }
previewController.delegate = self
present(previewController, animated: true)
hasBeenPresented = true
}
// MARK: QLPreviewControllerDelegate
func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
.disabled
}
func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? {
sourceView
}
func previewControllerDidDismiss(_ controller: QLPreviewController) {
onDismiss()
}
}
}
// MARK: - Previews
struct TimelineMediaPreviewModifier_Previews: PreviewProvider {
static let viewModel = makeViewModel()
static let downloadingViewModel = makeViewModel(isDownloading: true)
static let downloadErrorViewModel = makeViewModel(isDownloadError: true)
static var previews: some View {
MediaPreviewViewController(viewModel: viewModel, dismissalPublisher: .init()) { }
.previewDisplayName("Normal")
MediaPreviewViewController(viewModel: downloadingViewModel, dismissalPublisher: .init()) { }
.previewDisplayName("Downloading")
MediaPreviewViewController(viewModel: downloadErrorViewModel, dismissalPublisher: .init()) { }
.previewDisplayName("Download Error")
}
static func makeViewModel(isDownloading: Bool = false, isDownloadError: Bool = false) -> TimelineMediaPreviewViewModel {
let item = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: .pdf))
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = [item]
let mediaProvider = MediaProviderMock(configuration: .init())
if isDownloading {
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in
try? await Task.sleep(for: .seconds(3600))
return .failure(.failedRetrievingFile)
}
} else if isDownloadError {
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
}
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManagerMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock())
}
}
@@ -11,8 +11,9 @@ import Foundation
typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction>
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
let instanceID = UUID()
private let timelineViewModel: TimelineViewModelProtocol
private let currentItemIDHandler: ((TimelineItemIdentifier?) -> Void)?
private let mediaProvider: MediaProviderProtocol
private let photoLibraryManager: PhotoLibraryManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -23,13 +24,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.eraseToAnyPublisher()
}
init(context: TimelineMediaPreviewContext,
init(initialItem: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineViewModelProtocol,
mediaProvider: MediaProviderProtocol,
photoLibraryManager: PhotoLibraryManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol) {
timelineViewModel = context.viewModel
currentItemIDHandler = context.itemIDHandler
self.timelineViewModel = timelineViewModel
self.mediaProvider = mediaProvider
self.photoLibraryManager = photoLibraryManager
self.userIndicatorController = userIndicatorController
@@ -38,9 +39,8 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
let timelineState = timelineViewModel.context.viewState.timelineState
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates,
initialItem: context.item,
paginationState: timelineState.paginationState),
transitionNamespace: context.namespace),
initialItem: initialItem,
paginationState: timelineState.paginationState)),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
@@ -72,10 +72,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
case .updateCurrentItem(let item):
Task { await updateCurrentItem(item) }
case .showItemDetails(let mediaItem):
state.bindings.mediaDetailsItem = mediaItem
state.previewControllerDriver.send(.showItemDetails(mediaItem))
case .menuAction(let action, let item):
switch action {
case .viewInRoomTimeline:
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.viewInRoomTimeline(item.id))
case .save:
Task { await saveCurrentItem() }
@@ -88,8 +89,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
redactItem(item)
case .timelineEndReached:
showTimelineEndIndicator()
case .dismiss:
actionsSubject.send(.dismiss)
}
}
@@ -101,13 +100,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
rebuildCurrentItemActions()
if case let .media(mediaItem) = previewItem {
currentItemIDHandler?(mediaItem.id)
if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource {
switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) {
case .success(let handle):
mediaItem.fileHandle = handle
state.fileLoadedPublisher.send(mediaItem.id)
state.previewControllerDriver.send(.itemLoaded(mediaItem.id))
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
@@ -143,12 +140,12 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
// Dismiss the details sheet (nicer flow for images/video but _required_ in order to select a file directory).
state.bindings.mediaDetailsItem = nil
state.previewControllerDriver.send(.dismissDetailsSheet)
do {
switch mediaItem.timelineItem {
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
state.bindings.fileToExport = .init(url: fileURL)
state.previewControllerDriver.send(.exportFile(.init(url: fileURL)))
return // Don't show the indicator.
case is ImageRoomTimelineItem:
try await photoLibraryManager.addResource(.photo, at: fileURL).get()
@@ -161,10 +158,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
showSavedIndicator()
} catch PhotoLibraryManagerError.notAuthorized {
MXLog.error("Not authorised to save item to photo library")
state.bindings.alertInfo = .init(id: .authorizationRequired,
title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName),
primaryButton: .init(title: L10n.commonSettings) { self.appMediator.openAppSettings() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
state.previewControllerDriver.send(.authorizationRequired(appMediator: appMediator))
} catch {
MXLog.error("Failed saving item: \(error)")
showErrorIndicator()
@@ -174,7 +168,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private func redactItem(_ item: TimelineMediaPreviewItem.Media) {
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
state.bindings.redactConfirmationItem = nil
state.bindings.mediaDetailsItem = nil
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.dismiss)
showRedactedIndicator()
}
@@ -0,0 +1,342 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import QuickLook
import SwiftUI
class TimelineMediaPreviewController: QLPreviewController {
private let context: TimelineMediaPreviewViewModel.Context
private let headerHostingController: UIHostingController<HeaderView>
private let detailsButtonHostingController: UIHostingController<DetailsButton>
private let captionHostingController: UIHostingController<CaptionView>
private let downloadIndicatorHostingController: UIHostingController<DownloadIndicatorView>
private var detailsHostingController: UIHostingController<TimelineMediaPreviewDetailsView>?
private var barButtonTimer: Timer?
private var cancellables: Set<AnyCancellable> = []
private var navigationBar: UINavigationBar? { view.subviews.first?.subviews.first { $0 is UINavigationBar } as? UINavigationBar }
private var toolbar: UIToolbar? { view.subviews.first?.subviews.last { $0 is UIToolbar } as? UIToolbar }
private var captionView: UIView { captionHostingController.view }
override var overrideUserInterfaceStyle: UIUserInterfaceStyle {
get { .dark }
set { }
}
init(context: TimelineMediaPreviewViewModel.Context) {
self.context = context
headerHostingController = UIHostingController(rootView: HeaderView(context: context))
headerHostingController.view.backgroundColor = .clear
headerHostingController.sizingOptions = .intrinsicContentSize
detailsButtonHostingController = UIHostingController(rootView: DetailsButton(context: context))
detailsButtonHostingController.view.backgroundColor = .clear
detailsButtonHostingController.sizingOptions = .intrinsicContentSize
captionHostingController = UIHostingController(rootView: CaptionView(context: context))
captionHostingController.view.backgroundColor = .clear
captionHostingController.sizingOptions = .intrinsicContentSize
downloadIndicatorHostingController = UIHostingController(rootView: DownloadIndicatorView(context: context))
downloadIndicatorHostingController.view.backgroundColor = .clear
downloadIndicatorHostingController.sizingOptions = .intrinsicContentSize
super.init(nibName: nil, bundle: nil)
view.addSubview(captionView)
// Constraints added later as the toolbar isn't available yet.
view.addSubview(downloadIndicatorHostingController.view)
downloadIndicatorHostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
downloadIndicatorHostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
downloadIndicatorHostingController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
// This isn't removing duplicates which may try to download and/or write to disk concurrently????
self?.loadCurrentItem()
}
.store(in: &cancellables)
context.viewState.dataSource.previewItemsPaginationPublisher
.sink { [weak self] in
self?.handleUpdatedItems()
}
.store(in: &cancellables)
context.viewState.previewControllerDriver
.sink { [weak self] action in
switch action {
case .itemLoaded(let itemID):
self?.handleFileLoaded(itemID: itemID)
case .showItemDetails(let mediaItem):
self?.presentMediaDetails(for: mediaItem)
case .exportFile(let file):
self?.exportFile(file)
case .authorizationRequired(let appMediator):
self?.presentAuthorizationRequiredAlert(appMediator: appMediator)
case .dismissDetailsSheet:
self?.dismiss(animated: true)
}
}
.store(in: &cancellables)
dataSource = context.viewState.dataSource
currentPreviewItemIndex = context.viewState.dataSource.initialItemIndex
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Layout
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if let toolbar {
// Using the toolbar's visibility doesn't work so check its frame.
captionView.isHidden = toolbar.frame.minY >= view.frame.maxY
if captionView.constraints.isEmpty {
captionHostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
captionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor),
captionView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
captionView.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor)
])
}
}
navigationBar?.topItem?.titleView = headerHostingController.view
updateBarButtons()
// Ridiculous hack to undo the controller's attempt to replace our info button with the list button.
if barButtonTimer == nil {
barButtonTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
self?.updateBarButtons()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
barButtonTimer?.invalidate()
barButtonTimer = nil
}
private func updateBarButtons() {
guard let topItem = navigationBar?.topItem else { return }
if topItem.leftBarButtonItem?.customView == nil {
let button = UIBarButtonItem(customView: detailsButtonHostingController.view)
navigationBar?.topItem?.leftBarButtonItem = button
}
}
// MARK: Item loading
private func loadCurrentItem() {
headerHostingController.view.sizeToFit() // Resizing isn't automatic in the toolbar 😒
if let previewItem = currentPreviewItem as? TimelineMediaPreviewItem.Media {
context.send(viewAction: .updateCurrentItem(.media(previewItem)))
} else if let loadingItem = currentPreviewItem as? TimelineMediaPreviewItem.Loading {
switch loadingItem.state {
case .paginating:
context.send(viewAction: .updateCurrentItem(.loading(loadingItem)))
case .timelineStart:
Task { await returnToIndex(context.viewState.dataSource.firstPreviewItemIndex) }
case .timelineEnd:
Task { await returnToIndex(context.viewState.dataSource.lastPreviewItemIndex) }
}
} else {
MXLog.error("Unexpected preview item type: \(type(of: currentPreviewItem))")
}
}
private func returnToIndex(_ index: Int) async {
// Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow.
try? await Task.sleep(for: .seconds(0.1))
currentPreviewItemIndex = index
context.send(viewAction: .timelineEndReached)
}
private func handleUpdatedItems() {
if currentPreviewItem is TimelineMediaPreviewItem.Loading {
let dataSource = context.viewState.dataSource
if dataSource.previewController(self, previewItemAt: currentPreviewItemIndex) is TimelineMediaPreviewItem.Media {
refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
}
}
}
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
refreshCurrentPreviewItem()
}
// MARK: - Actions
private func presentMediaDetails(for mediaItem: TimelineMediaPreviewItem.Media) {
let safeArea = view.safeAreaInsets.bottom
let sheetHeightBinding = Binding { safeArea } set: { [weak self] newValue, _ in
self?.detailsHostingController?.sheetPresentationController?.detents = [.height(newValue + safeArea)]
}
let hostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(item: mediaItem,
context: context,
sheetHeight: sheetHeightBinding))
hostingController.view.backgroundColor = .compound.bgCanvasDefault
hostingController.overrideUserInterfaceStyle = .dark
hostingController.sheetPresentationController?.detents = [.height(safeArea)]
hostingController.sheetPresentationController?.prefersGrabberVisible = true
present(hostingController, animated: true)
detailsHostingController = hostingController
}
private func exportFile(_ file: TimelineMediaPreviewFileExportPicker.File) {
let hostingController = UIHostingController(rootView: TimelineMediaPreviewFileExportPicker(file: file))
present(hostingController, animated: true)
}
private func presentAuthorizationRequiredAlert(appMediator: AppMediatorProtocol) {
let alertController = UIAlertController(title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName),
message: nil,
preferredStyle: .alert)
alertController.addAction(.init(title: L10n.commonSettings, style: .default) { _ in appMediator.openAppSettings() })
alertController.addAction(.init(title: L10n.actionCancel, style: .cancel))
present(alertController, animated: true)
}
}
// MARK: - Subviews
private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
switch currentItem {
case .media(let mediaItem):
VStack(spacing: 0) {
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
.fixedSize(horizontal: true, vertical: false)
case .loading:
Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
.fixedSize(horizontal: true, vertical: false)
}
}
}
private struct DetailsButton: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var isHidden: Bool {
switch currentItem {
case .media: false
case .loading: true
}
}
var body: some View {
if case .media(let mediaItem) = currentItem {
Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: {
CompoundIcon(\.info)
}
}
}
}
private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(5)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.background {
BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath.
.ignoresSafeArea()
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private struct DownloadIndicatorView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
private var shouldShowDownloadIndicator: Bool {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating
}
}
var body: some View {
if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil {
VStack(spacing: 24) {
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
.padding(.vertical, 24.5)
.padding(.horizontal, 28.5)
VStack(spacing: 2) {
Text(L10n.commonDownloadFailed)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenMediaBrowserDownloadErrorMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
} else if shouldShowDownloadIndicator {
ProgressView()
.controlSize(.large)
.tint(.compound.iconPrimary)
}
}
}
private extension UISheetPresentationController.Detent {
static func height(_ height: CGFloat) -> UISheetPresentationController.Detent {
.custom { _ in height }
}
}
@@ -12,7 +12,7 @@ struct TimelineMediaPreviewDetailsView: View {
let item: TimelineMediaPreviewItem.Media
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var sheetHeight: CGFloat = .zero
@Binding var sheetHeight: CGFloat
private let topPadding: CGFloat = 19
var body: some View {
@@ -169,16 +169,16 @@ struct TimelineMediaPreviewDetailsView: View {
import UniformTypeIdentifiers
struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview {
@Namespace private static var previewNamespace
static let viewModel = makeViewModel(contentType: .jpeg, isOutgoing: true)
static let loadingViewModel = makeViewModel(contentType: .jpeg, isOutgoing: true, isDownloaded: false)
static let unknownTypeViewModel = makeViewModel()
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
@State static var sheetHeight: CGFloat = .zero
static var previews: some View {
if case let .media(mediaItem) = viewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context)
TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Image")
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
@@ -186,7 +186,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
}
if case let .media(mediaItem) = loadingViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context)
TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Loading")
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
@@ -194,12 +194,12 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
}
if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context)
TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Unknown type")
}
if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context)
TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Incoming on Room")
}
}
@@ -226,10 +226,9 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
let timelineController = MockRoomTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item]
let viewModel = TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: timelineKind,
timelineController: timelineController),
namespace: previewNamespace),
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind,
timelineController: timelineController),
mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
@@ -121,7 +121,6 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
import UniformTypeIdentifiers
struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, TestablePreview {
@Namespace private static var previewNamespace
static let viewModel = makeViewModel(contentType: .jpeg)
static var previews: some View {
@@ -147,10 +146,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = [item]
return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
namespace: previewNamespace),
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
@@ -1,326 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import QuickLook
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 }
private var currentItemID: TimelineItemIdentifier? {
guard case .media(let mediaItem) = currentItem else { return nil }
return mediaItem.id
}
private var shouldShowDownloadIndicator: Bool {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating
}
}
var body: some View {
NavigationStack {
quickLookPreview
}
.introspect(.navigationStack, on: .supportedVersions) {
// Fixes a bug where the QuickLook view overrides the .toolbarBackground(.visible) after it loads the real item.
$0.navigationBar.scrollEdgeAppearance = $0.navigationBar.standardAppearance
$0.toolbar.scrollEdgeAppearance = $0.toolbar.standardAppearance
}
.sheet(item: $context.mediaDetailsItem) { item in
TimelineMediaPreviewDetailsView(item: item, context: context)
}
.sheet(item: $context.fileToExport) { file in
TimelineMediaPreviewFileExportPicker(file: file)
.preferredColorScheme(.dark)
}
.alert(item: $context.alertInfo)
.preferredColorScheme(.dark)
.onDisappear {
itemIDHandler?(nil)
}
.zoomTransition(sourceID: currentItemID, 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 }
.overlay { downloadStatusIndicator }
.toolbar { toolbar }
.toolbar(toolbarVisibility, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷‍♂️
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
}
@ViewBuilder
private var fullScreenButton: some View {
if case .media = currentItem {
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 downloadStatusIndicator: some View {
if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil {
VStack(spacing: 24) {
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
.padding(.vertical, 24.5)
.padding(.horizontal, 28.5)
VStack(spacing: 2) {
Text(L10n.commonDownloadFailed)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenMediaBrowserDownloadErrorMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
} else if shouldShowDownloadIndicator {
ProgressView()
.controlSize(.large)
.tint(.compound.iconPrimary)
}
}
@ViewBuilder
private var caption: some View {
if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption, !isFullScreen {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(5)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.background {
BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath.
.ignoresSafeArea()
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .dismiss) } label: {
Image(systemSymbol: .chevronBackward)
.fontWeight(.semibold)
}
.tint(.compound.textActionPrimary) // These fix a bug where the light tint is shown when foregrounding the app.
}
ToolbarItem(placement: .principal) {
toolbarHeader
}
if case let .media(mediaItem) = currentItem {
ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: {
CompoundIcon(\.info)
}
.tint(.compound.textActionPrimary)
}
}
}
@ViewBuilder
private var toolbarHeader: some View {
switch currentItem {
case .media(let mediaItem):
VStack(spacing: 0) {
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
case .loading:
Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
}
}
}
// MARK: - QuickLook
private struct QuickLookView: UIViewControllerRepresentable {
let viewModelContext: TimelineMediaPreviewViewModel.Context
func makeUIViewController(context: Context) -> QLPreviewController {
context.coordinator.previewController
}
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(viewModelContext: viewModelContext)
}
// MARK: Coordinator
@MainActor class Coordinator {
let previewController = QLPreviewController()
private let viewModelContext: TimelineMediaPreviewViewModel.Context
private var cancellables: Set<AnyCancellable> = []
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
self.viewModelContext = viewModelContext
// Observation of currentPreviewItem doesn't work, so use the index instead.
previewController.publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
// This isn't removing duplicates which may try to download and/or write to disk concurrently????
self?.loadCurrentItem()
}
.store(in: &cancellables)
viewModelContext.viewState.dataSource.previewItemsPaginationPublisher
.sink { [weak self] in
self?.handleUpdatedItems()
}
.store(in: &cancellables)
viewModelContext.viewState.fileLoadedPublisher
.sink { [weak self] itemID in
self?.handleFileLoaded(itemID: itemID)
}
.store(in: &cancellables)
previewController.dataSource = viewModelContext.viewState.dataSource
previewController.currentPreviewItemIndex = viewModelContext.viewState.dataSource.initialItemIndex
}
private func loadCurrentItem() {
if let previewItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media {
viewModelContext.send(viewAction: .updateCurrentItem(.media(previewItem)))
} else if let loadingItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Loading {
switch loadingItem.state {
case .paginating:
viewModelContext.send(viewAction: .updateCurrentItem(.loading(loadingItem)))
case .timelineStart:
Task { await returnToIndex(viewModelContext.viewState.dataSource.firstPreviewItemIndex) }
case .timelineEnd:
Task { await returnToIndex(viewModelContext.viewState.dataSource.lastPreviewItemIndex) }
}
} else {
MXLog.error("Unexpected preview item type: \(type(of: previewController.currentPreviewItem))")
}
}
private func returnToIndex(_ index: Int) async {
// Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow.
try? await Task.sleep(for: .seconds(0.1))
previewController.currentPreviewItemIndex = index
viewModelContext.send(viewAction: .timelineEndReached)
}
private func handleUpdatedItems() {
if previewController.currentPreviewItem is TimelineMediaPreviewItem.Loading {
let dataSource = viewModelContext.viewState.dataSource
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem.Media {
previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
}
}
}
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
previewController.refreshCurrentPreviewItem()
}
}
}
// MARK: - Previews
struct TimelineMediaPreviewScreen_Previews: PreviewProvider {
@Namespace private static var namespace
static let viewModel = makeViewModel()
static let downloadingViewModel = makeViewModel(isDownloading: true)
static let downloadErrorViewModel = makeViewModel(isDownloadError: true)
static var previews: some View {
TimelineMediaPreviewScreen(context: viewModel.context)
.previewDisplayName("Normal")
TimelineMediaPreviewScreen(context: downloadingViewModel.context)
.previewDisplayName("Downloading")
TimelineMediaPreviewScreen(context: downloadErrorViewModel.context)
.previewDisplayName("Download Error")
}
static func makeViewModel(isDownloading: Bool = false, isDownloadError: Bool = false) -> TimelineMediaPreviewViewModel {
let item = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: .pdf))
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = [item]
let mediaProvider = MediaProviderMock(configuration: .init())
if isDownloading {
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in
try? await Task.sleep(for: .seconds(3600))
return .failure(.failedRetrievingFile)
}
} else if isDownloadError {
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
}
return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
timelineController: timelineController),
namespace: namespace),
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManagerMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock())
}
}
@@ -21,7 +21,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters {
}
enum MediaEventsTimelineScreenCoordinatorAction {
case viewItem(TimelineMediaPreviewContext)
case viewInRoomTimeline(TimelineItemIdentifier)
}
final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
@@ -63,13 +63,14 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel,
filesTimelineViewModel: filesTimelineViewModel,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
appMediator: parameters.appMediator)
viewModel.actionsPublisher
.sink { [weak self] action in
switch action {
case .viewItem(let previewContext):
self?.actionsSubject.send(.viewItem(previewContext))
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
}
}
.store(in: &cancellables)
@@ -8,7 +8,7 @@
import SwiftUI
enum MediaEventsTimelineScreenViewModelAction {
case viewItem(TimelineMediaPreviewContext)
case viewInRoomTimeline(TimelineItemIdentifier)
}
enum MediaEventsTimelineScreenMode {
@@ -31,17 +31,16 @@ struct MediaEventsTimelineScreenViewState: BindableState {
var activeTimelineContextProvider: (() -> TimelineViewModel.Context)!
var bindings: MediaEventsTimelineScreenViewStateBindings
var currentPreviewItemID: TimelineItemIdentifier?
}
struct MediaEventsTimelineScreenViewStateBindings {
var screenMode: MediaEventsTimelineScreenMode
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum MediaEventsTimelineScreenViewAction {
case changedScreenMode
case oldestItemDidAppear
case oldestItemDidDisappear
case tappedItem(item: RoomTimelineItemViewState, namespace: Namespace.ID)
case tappedItem(item: RoomTimelineItemViewState)
}
@@ -13,7 +13,9 @@ typealias MediaEventsTimelineScreenViewModelType = StateStoreViewModel<MediaEven
class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType, MediaEventsTimelineScreenViewModelProtocol {
private let mediaTimelineViewModel: TimelineViewModelProtocol
private let filesTimelineViewModel: TimelineViewModelProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private var isOldestItemVisible = false
@@ -37,10 +39,13 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
filesTimelineViewModel: TimelineViewModelProtocol,
initialViewState: MediaEventsTimelineScreenViewState = .init(bindings: .init(screenMode: .media)),
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol) {
self.mediaTimelineViewModel = mediaTimelineViewModel
self.filesTimelineViewModel = filesTimelineViewModel
self.mediaProvider = mediaProvider
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
super.init(initialViewState: initialViewState, mediaProvider: mediaProvider)
@@ -84,8 +89,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward)
case .oldestItemDidDisappear:
isOldestItemVisible = false
case .tappedItem(let item, let namespace):
handleItemTapped(item, namespace: namespace)
case .tappedItem(let item):
handleItemTapped(item)
}
}
@@ -141,7 +146,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
}
private func handleItemTapped(_ item: RoomTimelineItemViewState, namespace: Namespace.ID) {
private func handleItemTapped(_ item: RoomTimelineItemViewState) {
let item: EventBasedMessageTimelineItemProtocol? = switch item.type {
case .audio(let audioItem): audioItem
case .file(let fileItem): fileItem
@@ -155,14 +160,25 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
return
}
actionsSubject.send(.viewItem(.init(item: item,
viewModel: activeTimelineViewModel,
namespace: namespace) { [weak self] itemID in
self?.state.currentPreviewItemID = itemID
}))
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: activeTimelineViewModel,
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .viewInRoomTimeline(let itemID):
state.bindings.mediaPreviewViewModel = nil
actionsSubject.send(.viewInRoomTimeline(itemID))
case .dismiss:
state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)
// Set the current item in the next run loop so that (hopefully) the presentation will be ready before we flip the thumbnail.
Task { state.currentPreviewItemID = item.id }
state.bindings.mediaPreviewViewModel = viewModel
}
private func titleForDate(_ date: Date) -> String {
@@ -11,8 +11,6 @@ import SwiftUI
struct MediaEventsTimelineScreen: View {
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
@Namespace private var zoomTransition
var body: some View {
mainContent
.navigationBarTitleDisplayMode(.inline)
@@ -25,6 +23,7 @@ struct MediaEventsTimelineScreen: View {
.onChange(of: context.screenMode) { _, _ in
context.send(viewAction: .changedScreenMode)
}
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
}
// The scale effects do the following:
@@ -65,9 +64,8 @@ struct MediaEventsTimelineScreen: View {
tappedItem(item)
} label: {
viewForTimelineItem(item)
.scaleEffect(scale(for: item, isGridLayout: true))
.scaleEffect(CGSize(width: -1, height: -1))
}
.zoomTransitionSource(id: item.identifier, in: zoomTransition)
}
} footer: {
// Use a footer as the header because the scrollView is flipped
@@ -92,9 +90,8 @@ struct MediaEventsTimelineScreen: View {
tappedItem(item)
} label: {
viewForTimelineItem(item)
.scaleEffect(scale(for: item, isGridLayout: false))
.scaleEffect(CGSize(width: 1, height: -1))
}
.zoomTransitionSource(id: item.identifier, in: zoomTransition)
}
.padding(.horizontal, 16)
}
@@ -216,16 +213,7 @@ struct MediaEventsTimelineScreen: View {
}
func tappedItem(_ item: RoomTimelineItemViewState) {
context.send(viewAction: .tappedItem(item: item, namespace: zoomTransition))
}
func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize {
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 🙃
CGSize(width: 1, height: 1)
} else {
CGSize(width: isGridLayout ? -1 : 1, height: -1)
}
context.send(viewAction: .tappedItem(item: item))
}
}
@@ -265,7 +253,8 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
filesTimelineViewModel: makeTimelineViewModel(empty: empty),
initialViewState: .init(bindings: .init(screenMode: screenMode)),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock())
}
private static func makeTimelineViewModel(empty: Bool) -> TimelineViewModel {