mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
+10
-11
@@ -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(),
|
||||
|
||||
+3
-5
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user