diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index b10d68493..612e7ec32 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -286,6 +286,7 @@ CD09BA7F2CB4698E00C93926 /* OledPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD09BA7E2CB4698600C93926 /* OledPalette.swift */; }; CD0E06F72C0E739F00445849 /* PostType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0E06F62C0E739F00445849 /* PostType+Extensions.swift */; }; CD0F280A2C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0F28092C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift */; }; + CD10CC182D0A0A650006C20F /* MediaState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD10CC172D0A0A5C0006C20F /* MediaState.swift */; }; CD10FA772C7A8622008985AD /* ImageSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD10FA762C7A8622008985AD /* ImageSaver.swift */; }; CD13CC572C582DD8001AF428 /* DynamicMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD13CC562C582DD8001AF428 /* DynamicMediaView.swift */; }; CD13CC592C583C7A001AF428 /* WebsitePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD13CC582C583C7A001AF428 /* WebsitePreviewView.swift */; }; @@ -707,6 +708,7 @@ CD0E06F62C0E739F00445849 /* PostType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostType+Extensions.swift"; sourceTree = ""; }; CD0E07002C12707700445849 /* ToolbarEllipsisMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarEllipsisMenu.swift; sourceTree = ""; }; CD0F28092C6CEFBE00C1F65B /* View+IsAtTopSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsAtTopSubscriber.swift"; sourceTree = ""; }; + CD10CC172D0A0A5C0006C20F /* MediaState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaState.swift; sourceTree = ""; }; CD10FA762C7A8622008985AD /* ImageSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaver.swift; sourceTree = ""; }; CD13CC562C582DD8001AF428 /* DynamicMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicMediaView.swift; sourceTree = ""; }; CD13CC582C583C7A001AF428 /* WebsitePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsitePreviewView.swift; sourceTree = ""; }; @@ -1666,13 +1668,14 @@ CD4D59212B87BD0800B82964 /* Definitions */ = { isa = PBXGroup; children = ( - CDCA44AE2C17672900C092B3 /* HapticManager */, CD4D58B22B86BFD400B82964 /* AccountsTracker.swift */, 036CC3AE2B8145C30098B6A1 /* AppState.swift */, 0380965E2C10AA80003ED1D8 /* AppState+Transition.swift */, + CD10CC172D0A0A5C0006C20F /* MediaState.swift */, CD317D4B2BE97FED008F63E2 /* Palette.swift */, CD4D58A42B86BD1B00B82964 /* PersistenceRepository.swift */, CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */, + CDCA44AE2C17672900C092B3 /* HapticManager */, ); path = Definitions; sourceTree = ""; @@ -2338,6 +2341,7 @@ CD7DB9762C4D6C0A00DCC542 /* FeedCommentView.swift in Sources */, 03D3A1EF2BB9CA1D009DE55E /* MenuButton.swift in Sources */, CD0507732C6AA0C8008B1505 /* FeedSelection.swift in Sources */, + CD10CC182D0A0A650006C20F /* MediaState.swift in Sources */, 039F58862C7A810100C61658 /* ExpandedPostView+Logic.swift in Sources */, 031E2D512BEF961D0003BC45 /* SubscriptionListView.swift in Sources */, CDD4A09E2C8B69FC0001AD1A /* Button.swift in Sources */, diff --git a/Mlem/App/Globals/Definitions/MediaState.swift b/Mlem/App/Globals/Definitions/MediaState.swift new file mode 100644 index 000000000..95c4eb4f9 --- /dev/null +++ b/Mlem/App/Globals/Definitions/MediaState.swift @@ -0,0 +1,13 @@ +// +// MediaState.swift +// Mlem +// +// Created by Eric Andrews on 2024-12-11. +// + +import SwiftUI + +@Observable +class MediaState { + var url: URL? +} diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index f8d0c60ce..24b81d7c4 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -9,9 +9,20 @@ import SwiftUI struct ImageViewer: View { @Environment(Palette.self) var palette + @Environment(MediaState.self) var mediaState let url: URL + let duration: CGFloat = 0.25 + let screenHeight: CGFloat = UIScreen.main.bounds.height + + @GestureState var dragState: Bool = false + + @State var isZoomed: Bool = false + @State var offset: CGFloat = 0 + @State var isDismissing: Bool = false + @State var opacity: CGFloat = 0 + init(url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } @@ -19,15 +30,85 @@ struct ImageViewer: View { } var body: some View { - ZoomableContainer { - DynamicMediaView(url: url, playImmediately: true) + ZoomableContainer(isZoomed: $isZoomed) { + DynamicMediaView(url: url, cornerRadius: 0, playImmediately: true) + } + .offset(y: offset) + .background(.black) + .opacity(opacity) + .overlay(alignment: .topTrailing) { + if offset == 0 { + Button { + fadeDismiss() + } label: { + Image(systemName: Icons.close) + .resizable() + .frame(width: 15, height: 15) + .foregroundStyle(.white) + .padding([.top, .trailing], Constants.main.standardSpacing) + .padding([.bottom, .leading], Constants.main.doubleSpacing) + .contentShape(.rect) + } .padding(Constants.main.standardSpacing) + } + } + .simultaneousGesture(DragGesture(minimumDistance: 0.0) + .updating($dragState) { value, state, _ in + state = true + if !isZoomed, !isDismissing { + offset = value.translation.height + opacity = 1.0 - (abs(value.translation.height) / screenHeight) + } + } + ) + .onAppear { + updateOpacity(1.0) + } + .onChange(of: dragState) { + if !dragState { + if abs(offset) > 100 { + swipeDismiss(finalOffset: offset > 0 ? screenHeight : -screenHeight) + } else { + updateDragDistance(0) + } + } + } + } + + private func fadeDismiss() { + isDismissing = true + updateOpacity(0) { + mediaState.url = nil + } + } + + private func swipeDismiss(finalOffset: CGFloat = UIScreen.main.bounds.height) { + isDismissing = true + updateDragDistance(finalOffset) { + mediaState.url = nil + } + } + + private func updateOpacity(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { + withAnimation(.easeOut(duration: duration)) { + opacity = newOpacity + } + if let callback { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + callback() + } + } + } + + private func updateDragDistance(_ newDistance: CGFloat, callback: (() -> Void)? = nil) { + withAnimation(.easeOut(duration: duration)) { + offset = newDistance + opacity = 1.0 - (abs(newDistance) / screenHeight) } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButtonView() + if let callback { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + callback() } } - .background(palette.background) } } diff --git a/Mlem/App/Views/Root/ContentView.swift b/Mlem/App/Views/Root/ContentView.swift index 5a1cc108f..6b501b8b2 100644 --- a/Mlem/App/Views/Root/ContentView.swift +++ b/Mlem/App/Views/Root/ContentView.swift @@ -32,6 +32,7 @@ struct ContentView: View { var palette: Palette { .main } var tabReselectTracker: TabReselectTracker { .main } var navigationModel: NavigationModel { .main } + var mediaState: MediaState = .init() @State var avatarImage: UIImage? @State var selectedAvatarImage: UIImage? @@ -102,6 +103,7 @@ struct ContentView: View { } } } + .environment(mediaState) .environment(AppState.main) } } @@ -170,5 +172,14 @@ struct ContentView: View { location: .top ) } + .overlay { + if let url = mediaState.url { + NavigationLayerView( + layer: .init(root: .imageViewer(url), + model: navigationModel, + hasNavigationStack: false), + hasSheetModifiers: false) + } + } } } diff --git a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift index d948d1776..62c51ccf0 100644 --- a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift +++ b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift @@ -26,7 +26,7 @@ private struct AnimationControlLayer: ViewModifier { } } } - .overlay(alignment: .topTrailing) { + .overlay(alignment: .bottomTrailing) { if let muted { Image(systemName: muted.wrappedValue ? Icons.muted : Icons.unmuted) .resizable() diff --git a/Mlem/App/Views/Shared/Images/Wrappers/LargeImageView.swift b/Mlem/App/Views/Shared/Images/Wrappers/LargeImageView.swift index b267b9f51..f2bd14b0b 100644 --- a/Mlem/App/Views/Shared/Images/Wrappers/LargeImageView.swift +++ b/Mlem/App/Views/Shared/Images/Wrappers/LargeImageView.swift @@ -12,6 +12,8 @@ import SwiftUI // and anything else that can't go in thumbnail views etc. struct LargeImageView: View { + @Environment(MediaState.self) var mediaState + @Environment(NavigationLayer.self) private var navigation @Setting(\.blurNsfw) var blurNsfw @@ -55,12 +57,7 @@ struct LargeImageView: View { onTapActions() } if let loading, loading == .done, let url { - // Sheets don't cover the whole screen on iPad, so use a fullScreenCover instead - if UIDevice.isPad { - navigation.showFullScreenCover(.imageViewer(url)) - } else { - navigation.openSheet(.imageViewer(url)) - } + mediaState.url = url } } .onPreferenceChange(MediaLoadingPreferenceKey.self, perform: { loading = $0 }) diff --git a/Mlem/App/Views/Shared/Images/Wrappers/ThumbnailImageView.swift b/Mlem/App/Views/Shared/Images/Wrappers/ThumbnailImageView.swift index e081533e7..93bbacc75 100644 --- a/Mlem/App/Views/Shared/Images/Wrappers/ThumbnailImageView.swift +++ b/Mlem/App/Views/Shared/Images/Wrappers/ThumbnailImageView.swift @@ -12,6 +12,7 @@ import SwiftUI struct ThumbnailImageView: View { @Environment(Palette.self) var palette @Environment(NavigationLayer.self) var navigation + @Environment(MediaState.self) var mediaState @Environment(\.openURL) var openURL @State var loading: MediaLoadingState? @@ -55,12 +56,7 @@ struct ThumbnailImageView: View { if let loading, loading == .done || loading == .proxyFailed { post.markRead() - // Sheets don't cover the whole screen on iPad, so use a fullScreenCover instead - if UIDevice.isPad { - navigation.showFullScreenCover(.imageViewer(url)) - } else { - navigation.openSheet(.imageViewer(url)) - } + mediaState.url = url } } .contextMenu { diff --git a/Mlem/App/Views/Shared/Navigation/NavigationModel.swift b/Mlem/App/Views/Shared/Navigation/NavigationModel.swift index 1b11f28fc..7ed9b3e4e 100644 --- a/Mlem/App/Views/Shared/Navigation/NavigationModel.swift +++ b/Mlem/App/Views/Shared/Navigation/NavigationModel.swift @@ -23,6 +23,8 @@ class NavigationModel { var showingFilePicker: Bool = false var filePickerCallback: ((URL) -> Void)? + var mediaUrl: URL? + private func openSheet(_ page: NavigationPage, hasNavigationStack: Bool? = nil, isFullScreenCover: Bool) { layers.append( .init( diff --git a/Mlem/App/Views/Shared/ZoomableContainer.swift b/Mlem/App/Views/Shared/ZoomableContainer.swift index 7fbc75250..7f098fbad 100644 --- a/Mlem/App/Views/Shared/ZoomableContainer.swift +++ b/Mlem/App/Views/Shared/ZoomableContainer.swift @@ -17,9 +17,11 @@ struct ZoomableContainer: View { let content: Content @State private var currentScale: CGFloat = 1.0 @State private var tapLocation: CGPoint = .zero + @Binding var isZoomed: Bool - init(@ViewBuilder content: () -> Content) { + init(isZoomed: Binding = .constant(false), @ViewBuilder content: () -> Content) { self.content = content() + self._isZoomed = isZoomed } func doubleTapAction(location: CGPoint) { @@ -32,6 +34,9 @@ struct ZoomableContainer: View { content } .onTapGesture(count: 2, perform: doubleTapAction) + .onChange(of: currentScale) { + isZoomed = currentScale != 1.0 + } } fileprivate struct ZoomableScrollView: UIViewRepresentable {