mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
Add a fullscreen button to TimelineMediaPreviewScreen and hook up swiping through the timeline. (#3638)
* Add a fullscreen button to media previews - Not ideal but the gestures conflict with the preview controller. * Don't un-flip the preview thumbnail until the preview has disappeared, and only do it on iOS 18. * Add all of the loaded items for previewing in the preview controller.
This commit is contained in:
parent
435dfb8e46
commit
e7cc807084
@ -126,8 +126,6 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator) {
|
||||
previewContext.completion?()
|
||||
}
|
||||
navigationStackCoordinator.setFullScreenCoverCoordinator(coordinator)
|
||||
}
|
||||
}
|
||||
|
@ -16,11 +16,12 @@ struct TimelineMediaPreviewContext {
|
||||
let viewModel: TimelineViewModelProtocol
|
||||
/// The namespace that the navigation transition's `sourceID` should be defined in.
|
||||
let namespace: Namespace.ID
|
||||
/// A completion to be called immediately *after* the preview has been dismissed.
|
||||
/// A closure to be called whenever a different preview item is shown. It should also
|
||||
/// be called *after* the preview has been dismissed, with an ID of `nil`.
|
||||
///
|
||||
/// This helps work around a bug caused by the flipped scrollview where the zoomed
|
||||
/// thumbnail starts off upside down while loading the preview screen.
|
||||
var completion: (() -> Void)?
|
||||
var itemIDHandler: ((TimelineItemIdentifier?) -> Void)?
|
||||
}
|
||||
|
||||
struct TimelineMediaPreviewCoordinatorParameters {
|
||||
@ -72,6 +73,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(TimelineMediaPreviewScreen(context: viewModel.context))
|
||||
// Calling the completion onDisappear isn't ideal, but we don't push away from the screen so it should be
|
||||
// a good enough approximation of didDismiss, given that the only other option is our navigation callbacks
|
||||
// which are essentially willDismiss callbacks and happen too early for this particular completion handler.
|
||||
AnyView(TimelineMediaPreviewScreen(context: viewModel.context, itemIDHandler: parameters.context.itemIDHandler))
|
||||
}
|
||||
}
|
||||
|
@ -15,11 +15,19 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
|
||||
}
|
||||
|
||||
struct TimelineMediaPreviewViewState: BindableState {
|
||||
/// All of the items in the timeline that can be previewed.
|
||||
var previewItems: [TimelineMediaPreviewItem]
|
||||
/// The index of the initial item inside of `previewItems` that is to be shown.
|
||||
let initialItemIndex: Int
|
||||
|
||||
/// The media item that is currently being previewed.
|
||||
var currentItem: TimelineMediaPreviewItem
|
||||
/// All of the available actions for the current item.
|
||||
var currentItemActions: TimelineItemMenuActions?
|
||||
|
||||
/// The namespace used for the zoom transition.
|
||||
let transitionNamespace: Namespace.ID
|
||||
/// A publisher that the view model uses to signal to the QLPreviewController when the current item has been loaded.
|
||||
let fileLoadedPublisher = PassthroughSubject<TimelineItemIdentifier, Never>()
|
||||
|
||||
var bindings = TimelineMediaPreviewViewStateBindings()
|
||||
@ -49,6 +57,21 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
|
||||
self.timelineItem = timelineItem
|
||||
}
|
||||
|
||||
init?(roomTimelineItemViewState: RoomTimelineItemViewState) {
|
||||
switch roomTimelineItemViewState.type {
|
||||
case .audio(let audioRoomTimelineItem):
|
||||
timelineItem = audioRoomTimelineItem
|
||||
case .file(let fileRoomTimelineItem):
|
||||
timelineItem = fileRoomTimelineItem
|
||||
case .image(let imageRoomTimelineItem):
|
||||
timelineItem = imageRoomTimelineItem
|
||||
case .video(let videoRoomTimelineItem):
|
||||
timelineItem = videoRoomTimelineItem
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Identifiable
|
||||
|
||||
var id: TimelineItemIdentifier { timelineItem.id }
|
||||
|
@ -12,6 +12,7 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaP
|
||||
|
||||
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
private let timelineViewModel: TimelineViewModelProtocol
|
||||
private let currentItemIDHandler: ((TimelineItemIdentifier?) -> Void)?
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let photoLibraryManager: PhotoLibraryManagerProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
@ -28,14 +29,18 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appMediator: AppMediatorProtocol) {
|
||||
timelineViewModel = context.viewModel
|
||||
currentItemIDHandler = context.itemIDHandler
|
||||
self.mediaProvider = mediaProvider
|
||||
self.photoLibraryManager = photoLibraryManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
|
||||
let currentItem = TimelineMediaPreviewItem(timelineItem: context.item)
|
||||
let previewItems = timelineViewModel.context.viewState.timelineState.itemViewStates.compactMap(TimelineMediaPreviewItem.init)
|
||||
let initialItemIndex = previewItems.firstIndex { $0.id == context.item.id } ?? 0
|
||||
let currentItem = previewItems[initialItemIndex]
|
||||
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: [currentItem],
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems,
|
||||
initialItemIndex: initialItemIndex,
|
||||
currentItem: currentItem,
|
||||
transitionNamespace: context.namespace),
|
||||
mediaProvider: mediaProvider)
|
||||
@ -76,6 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
|
||||
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
|
||||
state.currentItem = previewItem
|
||||
currentItemIDHandler?(previewItem.id)
|
||||
rebuildCurrentItemActions()
|
||||
|
||||
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
|
||||
|
@ -180,8 +180,11 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
||||
contentType: contentType))
|
||||
|
||||
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
|
||||
let timelineController = MockRoomTimelineController(timelineKind: timelineKind)
|
||||
timelineController.timelineItems = [item]
|
||||
return TimelineMediaPreviewViewModel(context: .init(item: item,
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineKind),
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineKind,
|
||||
timelineController: timelineController),
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
|
@ -138,8 +138,11 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
||||
thumbnailInfo: .mockThumbnail,
|
||||
contentType: contentType))
|
||||
|
||||
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
|
||||
timelineController.timelineItems = [item]
|
||||
return TimelineMediaPreviewViewModel(context: .init(item: item,
|
||||
viewModel: TimelineViewModel.mock,
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
|
||||
timelineController: timelineController),
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
|
@ -12,18 +12,16 @@ import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewScreen: View {
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
var itemIDHandler: ((TimelineItemIdentifier?) -> Void)?
|
||||
|
||||
@State private var isFullScreen = false
|
||||
private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible }
|
||||
|
||||
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Color.clear
|
||||
.overlay { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Overlay to stop QL hijacking the toolbar.
|
||||
.toolbar { toolbar }
|
||||
.toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷♂️
|
||||
.toolbarBackground(.visible, for: .bottomBar)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
|
||||
quickLookPreview
|
||||
}
|
||||
.introspect(.navigationStack, on: .supportedVersions) {
|
||||
// Fixes a bug where the QuickLook view overrides the .toolbarBackground(.visible) after it loads the real item.
|
||||
@ -39,12 +37,41 @@ struct TimelineMediaPreviewScreen: View {
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
.preferredColorScheme(.dark)
|
||||
.onDisappear {
|
||||
itemIDHandler?(nil)
|
||||
}
|
||||
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
|
||||
}
|
||||
|
||||
var quickLookPreview: some View {
|
||||
Color.clear // A completely clear view breaks any SwiftUI gestures (such as drag to dismiss).
|
||||
.background { QuickLookView(viewModelContext: context).ignoresSafeArea() } // Not the root view to stop QL hijacking the toolbar.
|
||||
.overlay(alignment: .topTrailing) { fullScreenButton }
|
||||
.toolbar { toolbar }
|
||||
.toolbar(toolbarVisibility, for: .navigationBar)
|
||||
.toolbar(toolbarVisibility, for: .bottomBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar) // The toolbar's scrollEdgeAppearance isn't aware of the quicklook view 🤷♂️
|
||||
.toolbarBackground(.visible, for: .bottomBar)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
|
||||
}
|
||||
|
||||
private var fullScreenButton: some View {
|
||||
Button {
|
||||
withAnimation { isFullScreen.toggle() }
|
||||
} label: {
|
||||
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
|
||||
.padding(6)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
.padding(.top, 12)
|
||||
.padding(.trailing, 14)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var caption: some View {
|
||||
if let caption = currentItem.caption {
|
||||
if let caption = currentItem.caption, !isFullScreen {
|
||||
Text(caption)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
@ -55,6 +82,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.background {
|
||||
BlurEffectView(style: .systemChromeMaterial) // Darkest material available, matches the bottom bar when content is beneath.
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,12 +142,16 @@ struct TimelineMediaPreviewScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QuickLook
|
||||
|
||||
private struct QuickLookView: UIViewControllerRepresentable {
|
||||
let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
func makeUIViewController(context: Context) -> PreviewController {
|
||||
PreviewController(coordinator: context.coordinator,
|
||||
fileLoadedPublisher: viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher())
|
||||
let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()
|
||||
let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher)
|
||||
controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
|
||||
@ -128,6 +160,8 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
Coordinator(viewModelContext: viewModelContext)
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
|
||||
private let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
@ -148,14 +182,12 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIKit
|
||||
|
||||
class PreviewController: QLPreviewController {
|
||||
let coordinator: Coordinator
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
|
||||
self.coordinator = coordinator
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
dataSource = coordinator
|
||||
@ -208,8 +240,12 @@ struct TimelineMediaPreviewScreen_Previews: PreviewProvider {
|
||||
thumbnailSource: nil,
|
||||
contentType: .pdf))
|
||||
|
||||
let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
|
||||
timelineController.timelineItems = [item]
|
||||
|
||||
return TimelineMediaPreviewViewModel(context: .init(item: item,
|
||||
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)),
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineController.timelineKind,
|
||||
timelineController: timelineController),
|
||||
namespace: namespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
|
@ -157,8 +157,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
|
||||
actionsSubject.send(.viewItem(.init(item: item,
|
||||
viewModel: activeTimelineViewModel,
|
||||
namespace: namespace) { [weak self] in
|
||||
self?.state.currentPreviewItemID = nil
|
||||
namespace: namespace) { [weak self] itemID in
|
||||
self?.state.currentPreviewItemID = itemID
|
||||
}))
|
||||
|
||||
// Set the current item in the next run loop so that (hopefully) the presentation will be ready before we flip the thumbnail.
|
||||
|
@ -211,12 +211,12 @@ struct MediaEventsTimelineScreen: View {
|
||||
}
|
||||
|
||||
func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize {
|
||||
guard item.identifier != context.viewState.currentPreviewItemID else {
|
||||
if item.identifier == context.viewState.currentPreviewItemID, #available(iOS 18.0, *) {
|
||||
// Remove the flip when presenting a preview so that the zoom transition is the right way up 🙃
|
||||
return CGSize(width: 1, height: 1)
|
||||
CGSize(width: 1, height: 1)
|
||||
} else {
|
||||
CGSize(width: isGridLayout ? -1 : 1, height: -1)
|
||||
}
|
||||
|
||||
return CGSize(width: isGridLayout ? -1 : 1, height: -1)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,6 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image")
|
||||
|
||||
// When choosing to save the image.
|
||||
let item = context.viewState.currentItem
|
||||
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
|
||||
context.send(viewAction: .saveCurrentItem)
|
||||
try await deferred.fulfill()
|
||||
@ -164,7 +163,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
||||
|
||||
private func loadInitialItem() async throws {
|
||||
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
|
||||
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0]))
|
||||
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[context.viewState.initialItemIndex]))
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user