Rework the presentation of the media browser quick look view to use SwiftUI. (#3619)

* Embed the media preview quick look inside a full screen cover

With a zoom transition on iOS 18.

* Use a the representable coordinator properly.

* Fix a bug with the toolbar appearance.

* Format

* Try prevent the zoom transition being upside down.

* Fix the snapshot test configuration.
This commit is contained in:
Doug
2024-12-16 15:27:50 +00:00
committed by GitHub
parent 45a630dd85
commit 3a82b88859
23 changed files with 593 additions and 473 deletions
@@ -0,0 +1,8 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
@@ -1,180 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import QuickLook
import SwiftUI
class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDataSource {
private let viewModel: TimelineMediaPreviewViewModel
private var cancellables: Set<AnyCancellable> = []
private let headerHostingController: UIHostingController<HeaderView>
private let captionHostingController: UIHostingController<CaptionView>
private let detailsHostingController: UIHostingController<TimelineMediaPreviewDetailsView>
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 }
init(viewModel: TimelineMediaPreviewViewModel) {
self.viewModel = viewModel
headerHostingController = UIHostingController(rootView: HeaderView(context: viewModel.context))
headerHostingController.view.backgroundColor = .clear
headerHostingController.sizingOptions = .intrinsicContentSize
captionHostingController = UIHostingController(rootView: CaptionView(context: viewModel.context))
captionHostingController.view.backgroundColor = .clear
captionHostingController.sizingOptions = .intrinsicContentSize
detailsHostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(context: viewModel.context))
detailsHostingController.view.backgroundColor = .compound.bgCanvasDefault
// let materialView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
// captionHostingController.view.insertMatchedSubview(materialView, at: 0)
super.init(nibName: nil, bundle: nil)
view.addSubview(captionView)
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
Task { await self.viewModel.updateCurrentItem(currentPreviewItem) }
}
.store(in: &cancellables)
viewModel.actions
.sink { [weak self] action in
switch action {
case .loadedMediaFile:
self?.refreshCurrentPreviewItem()
case .viewInRoomTimeline, .dismiss:
self?.dismiss(animated: true) // Dismiss the details sheet.
// And let the view model handle the rest.
}
}
.store(in: &cancellables)
dataSource = self
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
overrideUserInterfaceStyle = .dark
if let toolbar {
captionView.isHidden = toolbar.alpha == 0
if captionView.constraints.isEmpty {
captionView.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()
}
// MARK: QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
viewModel.state.previewItems.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
viewModel.state.previewItems[index]
}
// MARK: Private
@objc func presentMediaDetails() {
detailsHostingController.overrideUserInterfaceStyle = .dark
detailsHostingController.sheetPresentationController?.detents = [.medium()]
detailsHostingController.sheetPresentationController?.prefersGrabberVisible = true
present(detailsHostingController, animated: true)
}
private var detailsButtonIcon: UIImage {
guard let bundle = Bundle(url: Bundle.main.bundleURL.appending(path: "CompoundDesignTokens_CompoundDesignTokens.bundle")) else {
return UIImage(systemSymbol: .infoCircle)
}
return UIImage(named: "info", in: bundle, compatibleWith: nil) ?? UIImage(systemSymbol: .infoCircle)
}
private func updateBarButtons() {
if navigationBar?.topItem?.rightBarButtonItems?.count == 1 {
let button = UIBarButtonItem(image: detailsButtonIcon, style: .plain, target: self, action: #selector(presentMediaDetails))
navigationBar?.topItem?.rightBarButtonItems?.append(button)
}
}
}
// MARK: - Subviews
private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
}
}
private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
if let caption = currentItem.caption {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(5)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.background {
BlurView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath.
}
}
}
}
private struct BlurView: UIViewRepresentable {
var style: UIBlurEffect.Style
func makeUIView(context: Context) -> UIVisualEffectView {
UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
@@ -0,0 +1,74 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE 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 completion to be called immediately *after* the preview has been dismissed.
///
/// 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)?
}
struct TimelineMediaPreviewCoordinatorParameters {
let context: TimelineMediaPreviewContext
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
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,
userIndicatorController: parameters.userIndicatorController)
}
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 {
AnyView(TimelineMediaPreviewView(context: viewModel.context))
}
}
@@ -5,10 +5,11 @@
// Please see LICENSE in the repository root for full details.
//
import Combine
import QuickLook
import SwiftUI
enum TimelineMediaPreviewViewModelAction: Equatable {
case loadedMediaFile
case viewInRoomTimeline(TimelineItemIdentifier)
case dismiss
}
@@ -18,15 +19,19 @@ struct TimelineMediaPreviewViewState: BindableState {
var currentItem: TimelineMediaPreviewItem
var currentItemActions: TimelineItemMenuActions?
let transitionNamespace: Namespace.ID
let fileLoadedPublisher = PassthroughSubject<TimelineItemIdentifier, Never>()
var bindings = TimelineMediaPreviewViewStateBindings()
}
struct TimelineMediaPreviewViewStateBindings {
var isPresentingRedactConfirmation = false
var mediaDetailsItem: TimelineMediaPreviewItem?
var redactConfirmationItem: TimelineMediaPreviewItem?
}
/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
@@ -34,6 +39,8 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
self.timelineItem = timelineItem
}
// MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id }
// MARK: QLPreviewItem
@@ -45,18 +52,7 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
}
var previewItemTitle: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
filename
}
// MARK: Event details
@@ -167,6 +163,10 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
}
enum TimelineMediaPreviewViewAction {
case menuAction(TimelineItemMenuAction)
case redactConfirmation
case updateCurrentItem(TimelineMediaPreviewItem)
case saveCurrentItem
case showCurrentItemDetails
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem)
case redactConfirmation(item: TimelineMediaPreviewItem)
case dismiss
}
@@ -0,0 +1,212 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import QuickLook
import SwiftUI
struct TimelineMediaPreviewView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
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 }
}
.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)
}
.preferredColorScheme(.dark)
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
}
@ViewBuilder
private var caption: some View {
if let caption = currentItem.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.
}
}
}
@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
}
ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
CompoundIcon(\.info)
}
.tint(.compound.textActionPrimary)
}
ToolbarItem(placement: .bottomBar) {
bottomBarContent
.tint(.compound.textActionPrimary)
}
}
private var toolbarHeader: some View {
VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
}
private var bottomBarContent: some View {
HStack(spacing: 8) {
if let url = currentItem.fileHandle?.url {
ShareLink(item: url, subject: nil, message: currentItem.caption.map(Text.init)) {
CompoundIcon(\.shareIos)
}
Spacer()
Button { context.send(viewAction: .saveCurrentItem) } label: {
CompoundIcon(\.download)
}
}
}
}
}
private struct QuickLookView: UIViewControllerRepresentable {
let viewModelContext: TimelineMediaPreviewViewModel.Context
func makeUIViewController(context: Context) -> PreviewController {
PreviewController(coordinator: context.coordinator,
fileLoadedPublisher: viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher())
}
func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(viewModelContext: viewModelContext)
}
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
private let viewModelContext: TimelineMediaPreviewViewModel.Context
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
self.viewModelContext = viewModelContext
}
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
viewModelContext.send(viewAction: .updateCurrentItem(item))
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
viewModelContext.viewState.previewItems.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
viewModelContext.viewState.previewItems[index]
}
}
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
delegate = coordinator
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
coordinator.updateCurrentItem(currentPreviewItem)
}
.store(in: &cancellables)
fileLoadedPublisher
.sink { [weak self] itemID in
guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
refreshCurrentPreviewItem()
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
// MARK: - Previews
struct TimelineMediaPreviewView_Previews: PreviewProvider {
@Namespace private static var namespace
static let viewModel = makeViewModel()
static var previews: some View {
QuickLookView(viewModelContext: viewModel.context)
}
static func makeViewModel() -> 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))
return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)),
namespace: namespace),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -20,20 +20,20 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.eraseToAnyPublisher()
}
init(initialItem: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineViewModelProtocol,
init(context: TimelineMediaPreviewContext,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.timelineViewModel = timelineViewModel
timelineViewModel = context.viewModel
self.mediaProvider = mediaProvider
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
self.userIndicatorController = userIndicatorController
let currentItem = TimelineMediaPreviewItem(timelineItem: initialItem)
let currentItem = TimelineMediaPreviewItem(timelineItem: context.item)
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: [currentItem],
currentItem: currentItem),
currentItem: currentItem,
transitionNamespace: context.namespace),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
@@ -48,23 +48,32 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction {
case .menuAction(let action):
case .updateCurrentItem(let item):
Task { await updateCurrentItem(item) }
case .saveCurrentItem:
Task { await saveCurrentItem() }
case .showCurrentItemDetails:
state.bindings.mediaDetailsItem = state.currentItem
case .menuAction(let action, let item):
switch action {
case .viewInRoomTimeline:
actionsSubject.send(.viewInRoomTimeline(state.currentItem.id))
actionsSubject.send(.viewInRoomTimeline(item.id))
case .redact:
state.bindings.isPresentingRedactConfirmation = true
state.bindings.redactConfirmationItem = item
default:
MXLog.error("Received unexpected action: \(action)")
}
case .redactConfirmation:
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: state.currentItem.id, action: .redact))
state.bindings.isPresentingRedactConfirmation = false
actionsSubject.send(.dismiss) // Will dismiss the details sheet and the QuickLook view.
case .redactConfirmation(let item):
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
state.bindings.redactConfirmationItem = nil
state.bindings.mediaDetailsItem = nil
actionsSubject.send(.dismiss)
case .dismiss:
actionsSubject.send(.dismiss)
}
}
func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
state.currentItem = previewItem
rebuildCurrentItemActions()
@@ -72,10 +81,10 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
showDownloadingIndicator(itemID: previewItem.id)
defer { hideDownloadingIndicator(itemID: previewItem.id) }
switch await mediaProvider.loadFileFromSource(source) {
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
case .success(let handle):
previewItem.fileHandle = handle
actionsSubject.send(.loadedMediaFile)
state.fileLoadedPublisher.send(previewItem.id)
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
showDownloadErrorIndicator()
@@ -83,7 +92,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
}
func rebuildCurrentItemActions() {
private func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context
let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
@@ -98,6 +107,17 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
state.currentItemActions = provider.makeActions()
}
private func saveCurrentItem() async {
guard let url = state.currentItem.fileHandle?.url else {
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
return
}
showErrorIndicator()
}
// MARK: - Indicators
private func showDownloadingIndicator(itemID: TimelineItemIdentifier) {
let indicatorID = makeDownloadIndicatorID(itemID: itemID)
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID,
@@ -120,6 +140,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
iconName: "exclamationmark.circle.fill"))
}
private func showErrorIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: errorIndicatorID,
type: .modal,
title: L10n.errorUnknown,
iconName: "xmark"))
}
private var errorIndicatorID: String { "\(Self.self)-Error" }
private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" }
private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
"\(Self.self)-Download-\(itemID.uniqueID.id)"
@@ -1,156 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE 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 timelineMediaQuickLook(viewModel: Binding<TimelineMediaPreviewViewModel?>) -> some View {
modifier(TimelineMediaQuickLookModifier(viewModel: viewModel))
}
}
private struct TimelineMediaQuickLookModifier: ViewModifier {
@Binding var viewModel: TimelineMediaPreviewViewModel?
@State private var dismissalPublisher = PassthroughSubject<Void, Never>()
func body(content: Content) -> some View {
content.background {
if let viewModel {
EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: dismissalPublisher) {
self.viewModel = nil
}
} else {
// Work around QLPreviewController dismissal issues, see below.
let _ = dismissalPublisher.send(())
}
}
}
}
/// When this view is put in the background of a SwiftUI view hierarchy,
/// it will present a QLPreviewController on top of the entire app.
private struct EmbeddedQuickLookPresenter: UIViewControllerRepresentable {
let viewModel: TimelineMediaPreviewViewModel
let dismissalPublisher: PassthroughSubject<Void, Never>
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> PresentingController {
PresentingController(viewModel: viewModel, dismissalPublisher: dismissalPublisher, onDismiss: onDismiss)
}
func updateUIViewController(_ uiViewController: PresentingController, 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 PresentingController: UIViewController, QLPreviewControllerDelegate {
private let previewController: QLPreviewController
private let sourceView = UIView()
private var hasPresented = false
private let onDismiss: () -> Void
private var dismissalObserver: AnyCancellable?
init(viewModel: TimelineMediaPreviewViewModel,
dismissalPublisher: PassthroughSubject<Void, Never>,
onDismiss: @escaping () -> Void) {
previewController = TimelineMediaPreviewController(viewModel: viewModel)
self.onDismiss = onDismiss
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: 200),
sourceView.heightAnchor.constraint(equalToConstant: 200)
])
}
// 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 !hasPresented else { return }
previewController.delegate = self
present(previewController, animated: true)
hasPresented = true
}
// MARK: QLPreviewControllerDelegate
func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? {
sourceView
}
func previewControllerDidDismiss(_ controller: QLPreviewController) {
onDismiss()
}
}
}
// MARK: - Previews
struct TimelineMediaQuickLook_Previews: PreviewProvider {
static let viewModel = makeViewModel()
static var previews: some View {
EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: .init()) { }
}
static func makeViewModel() -> 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))
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -9,10 +9,9 @@ import Compound
import SwiftUI
struct TimelineMediaPreviewDetailsView: View {
let item: TimelineMediaPreviewItem
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
@@ -21,9 +20,13 @@ struct TimelineMediaPreviewDetailsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.padding(.top, 19) // For the drag indicator
.sheet(isPresented: $context.isPresentingRedactConfirmation) {
TimelineMediaPreviewRedactConfirmationView(context: context)
.presentationBackground(.compound.bgCanvasDefault)
.preferredColorScheme(.dark)
.sheet(item: $context.redactConfirmationItem) { item in
TimelineMediaPreviewRedactConfirmationView(item: item, context: context)
}
}
@@ -31,20 +34,20 @@ struct TimelineMediaPreviewDetailsView: View {
VStack(alignment: .leading, spacing: 24) {
DetailsRow(title: L10n.screenMediaDetailsUploadedBy) {
HStack(spacing: 8) {
LoadableAvatarImage(url: currentItem.sender.avatarURL,
name: currentItem.sender.displayName,
contentID: currentItem.sender.id,
LoadableAvatarImage(url: item.sender.avatarURL,
name: item.sender.displayName,
contentID: item.sender.id,
avatarSize: .user(on: .mediaPreviewDetails),
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
if let displayName = currentItem.sender.displayName {
if let displayName = item.sender.displayName {
Text(displayName)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: currentItem.sender.id).text)
.foregroundStyle(.compound.decorativeColor(for: item.sender.id).text)
}
Text(currentItem.sender.id)
Text(item.sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
@@ -52,21 +55,21 @@ struct TimelineMediaPreviewDetailsView: View {
}
DetailsRow(title: L10n.screenMediaDetailsUploadedOn) {
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .shortened))
Text(item.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
DetailsRow(title: L10n.screenMediaDetailsFilename) {
Text(currentItem.filename ?? "")
Text(item.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
if let contentType = currentItem.contentType {
if let contentType = item.contentType {
DetailsRow(title: L10n.screenMediaDetailsFileFormat) {
Group {
if let fileSize = currentItem.fileSize {
if let fileSize = item.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
@@ -93,7 +96,7 @@ struct TimelineMediaPreviewDetailsView: View {
ForEach(actions.actions, id: \.self) { action in
Button(role: action.isDestructive ? .destructive : nil) {
context.send(viewAction: .menuAction(action))
context.send(viewAction: .menuAction(action, item: item))
} label: {
action.label
}
@@ -107,7 +110,7 @@ struct TimelineMediaPreviewDetailsView: View {
ForEach(actions.secondaryActions, id: \.self) { action in
Button(role: action.isDestructive ? .destructive : nil) {
context.send(viewAction: .menuAction(action))
context.send(viewAction: .menuAction(action, item: item))
} label: {
action.label
}
@@ -139,19 +142,24 @@ 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 unknownTypeViewModel = makeViewModel()
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
static var previews: some View {
TimelineMediaPreviewDetailsView(context: viewModel.context)
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem,
context: viewModel.context)
.previewDisplayName("Image")
.snapshotPreferences(delay: 0.1)
TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context)
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem,
context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type")
.snapshotPreferences(delay: 0.1)
TimelineMediaPreviewDetailsView(context: presentedOnRoomViewModel.context)
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem,
context: presentedOnRoomViewModel.context)
.previewDisplayName("Incoming on Room")
.snapshotPreferences(delay: 0.1)
}
@@ -172,8 +180,9 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind),
return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock(timelineKind: timelineKind),
namespace: previewNamespace),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
@@ -11,10 +11,9 @@ import SwiftUI
struct TimelineMediaPreviewRedactConfirmationView: View {
@Environment(\.dismiss) private var dismiss
let item: TimelineMediaPreviewItem
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
ScrollView {
VStack(spacing: 0) {
@@ -54,13 +53,13 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
@ViewBuilder
private var preview: some View {
HStack(spacing: 12) {
if let mediaSource = currentItem.thumbnailMediaSource {
if let mediaSource = item.thumbnailMediaSource {
Color.clear
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id),
blurhash: currentItem.blurhash,
mediaType: .timelineItem(uniqueID: item.id.uniqueID.id),
blurhash: item.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
}
@@ -70,13 +69,13 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
}
VStack(alignment: .leading, spacing: 4) {
Text(currentItem.filename ?? "")
Text(item.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
if let contentType = currentItem.contentType {
if let contentType = item.contentType {
Group {
if let fileSize = currentItem.fileSize {
if let fileSize = item.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
@@ -95,7 +94,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
private var buttons: some View {
VStack(spacing: 16) {
Button(L10n.actionRemove, role: .destructive) {
context.send(viewAction: .redactConfirmation)
context.send(viewAction: .redactConfirmation(item: item))
}
.buttonStyle(.compound(.primary))
@@ -117,10 +116,11 @@ 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 {
TimelineMediaPreviewRedactConfirmationView(context: viewModel.context)
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem, context: viewModel.context)
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
@@ -138,8 +138,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
thumbnailInfo: .mockThumbnail,
contentType: contentType))
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
return TimelineMediaPreviewViewModel(context: .init(item: item,
viewModel: TimelineViewModel.mock,
namespace: previewNamespace),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}