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
@@ -21,7 +21,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters {
}
enum MediaEventsTimelineScreenCoordinatorAction {
case viewInRoomTimeline(TimelineItemIdentifier)
case viewItem(TimelineMediaPreviewContext)
}
final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
@@ -68,8 +68,8 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel.actionsPublisher
.sink { [weak self] action in
switch action {
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID))
case .viewItem(let previewContext):
self?.actionsSubject.send(.viewItem(previewContext))
}
}
.store(in: &cancellables)
@@ -5,10 +5,10 @@
// Please see LICENSE in the repository root for full details.
//
import Foundation
import SwiftUI
enum MediaEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(TimelineItemIdentifier)
case viewItem(TimelineMediaPreviewContext)
}
enum MediaEventsTimelineScreenMode {
@@ -29,16 +29,17 @@ 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(RoomTimelineItemViewState)
case tappedItem(item: RoomTimelineItemViewState, namespace: Namespace.ID)
}
@@ -84,8 +84,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward)
case .oldestItemDidDisappear:
isOldestItemVisible = false
case .tappedItem(let item):
handleItemTapped(item)
case .tappedItem(let item, let namespace):
handleItemTapped(item, namespace: namespace)
}
}
@@ -140,7 +140,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
}
private func handleItemTapped(_ item: RoomTimelineItemViewState) {
private func handleItemTapped(_ item: RoomTimelineItemViewState, namespace: Namespace.ID) {
let item: EventBasedMessageTimelineItemProtocol? = switch item.type {
case .audio(let audioItem): audioItem
case .file(let fileItem): fileItem
@@ -149,31 +149,20 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
default: nil
}
guard let item, let mediaProvider = context.mediaProvider else {
MXLog.error("Unexpected item type (or the media provider is missing).")
guard let item else {
MXLog.error("Unexpected item type tapped.")
return
}
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: activeTimelineViewModel,
mediaProvider: mediaProvider,
userIndicatorController: userIndicatorController)
actionsSubject.send(.viewItem(.init(item: item,
viewModel: activeTimelineViewModel,
namespace: namespace,
completion: { [weak self] in
self?.state.currentPreviewItemID = nil
})))
mediaPreviewCancellable = 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
case .loadedMediaFile:
break // Handled by the preview controller
}
}
state.bindings.mediaPreviewViewModel = viewModel
// 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 }
}
private func titleForDate(_ date: Date) -> String {
@@ -11,6 +11,8 @@ import SwiftUI
struct MediaEventsTimelineScreen: View {
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
@Namespace private var zoomTransition
var body: some View {
mainContent
.navigationBarTitleDisplayMode(.inline)
@@ -30,7 +32,6 @@ struct MediaEventsTimelineScreen: View {
.pickerStyle(.segmented)
}
}
.timelineMediaQuickLook(viewModel: $context.mediaPreviewViewModel)
.environmentObject(context.viewState.activeTimelineContextProvider())
.environment(\.timelineContext, context.viewState.activeTimelineContextProvider())
}
@@ -39,7 +40,7 @@ struct MediaEventsTimelineScreen: View {
// * flip the scrollView vertically to keep the items
// at the bottom and have pagination working properly
// * flip the grid vertically to counteract the scroll view
// but also horizontally to preserve the corect item order
// but also horizontally to preserve the correct item order
// * flip the items on both axes have them render correctly
@ViewBuilder
private var mainContent: some View {
@@ -73,7 +74,7 @@ struct MediaEventsTimelineScreen: View {
Section {
ForEach(group.items) { item in
Button {
context.send(viewAction: .tappedItem(item))
tappedItem(item)
} label: {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
@@ -81,8 +82,9 @@ struct MediaEventsTimelineScreen: View {
viewForTimelineItem(item)
}
.clipped()
.scaleEffect(.init(width: -1, height: -1))
.scaleEffect(scale(for: item, isGridLayout: true))
}
.zoomTransitionSource(id: item.identifier, in: zoomTransition)
}
} footer: {
// Use a footer as the header because the scrollView is flipped
@@ -104,11 +106,12 @@ struct MediaEventsTimelineScreen: View {
Divider()
Button {
context.send(viewAction: .tappedItem(item))
tappedItem(item)
} label: {
viewForTimelineItem(item)
.scaleEffect(.init(width: 1, height: -1))
.scaleEffect(scale(for: item, isGridLayout: false))
}
.zoomTransitionSource(id: item.identifier, in: zoomTransition)
}
.padding(.horizontal, 16)
}
@@ -207,6 +210,19 @@ struct MediaEventsTimelineScreen: View {
.multilineTextAlignment(.center)
}
}
func tappedItem(_ item: RoomTimelineItemViewState) {
context.send(viewAction: .tappedItem(item: item, namespace: zoomTransition))
}
func scale(for item: RoomTimelineItemViewState, isGridLayout: Bool) -> CGSize {
guard item.identifier != context.viewState.currentPreviewItemID else {
// Remove the flip when presenting a preview so that the zoom transition is the right way up 🙃
return CGSize(width: 1, height: 1)
}
return CGSize(width: isGridLayout ? -1 : 1, height: -1)
}
}
// MARK: - Previews