diff --git a/Mlem/App/Logic/ImageFunctions.swift b/Mlem/App/Logic/ImageFunctions.swift index 45f93c3f0..00ba7f7ec 100644 --- a/Mlem/App/Logic/ImageFunctions.swift +++ b/Mlem/App/Logic/ImageFunctions.swift @@ -25,6 +25,12 @@ func saveImage(url: URL) async { } } +func shareImage(url: URL, navigation: NavigationLayer) async { + if let fileUrl = await downloadImageToFileSystem(url: url) { + navigation.shareInfo = .init(url: fileUrl) + } +} + func fullSizeUrl(url: URL?) -> URL? { if let url, var components = URLComponents(url: url, resolvingAgainstBaseURL: true) { components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index 7aea59836..43c24b0da 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -8,6 +8,7 @@ import SwiftUI struct ImageViewer: View { + @Environment(NavigationLayer.self) var navigation @Environment(Palette.self) var palette @Environment(\.dismiss) var dismiss @@ -18,11 +19,29 @@ struct ImageViewer: View { @GestureState var dragState: Bool = false + /// True when the image is zoomed in, false otherwise @State var isZoomed: Bool = false - @State var offset: CGFloat = 0 + + /// True when dimissal is in progress, false otherwise @State var isDismissing: Bool = false + + /// Vertical offset of the viewer + @State var offset: CGFloat = 0 + + /// Opacity of the viewer @State var opacity: CGFloat = 0 + /// Whether the controls should be shown/hidden + @State var showControls: Bool = true + + /// Vertical offset for the control overlay + @State var controlOffset: CGFloat = 0 + + /// When true, enables tapping to show/hide controls + @State var enableControlTap: Bool = true + + @State var quickLookUrl: URL? + init(url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } @@ -31,32 +50,34 @@ struct ImageViewer: View { var body: some View { ZoomableContainer(isZoomed: $isZoomed) { - MediaView(url: url, enableContextMenu: true, playImmediately: true) + MediaView(url: url, 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) + .overlay { + controlLayer + .opacity(opacity) + } + .onChange(of: showControls) { + withAnimation(.easeOut(duration: duration)) { + controlOffset = showControls ? 0 : 100 + } + } + .onChange(of: isZoomed) { + if isZoomed { + showControls = false + } + } + .onTapGesture { + if enableControlTap { + showControls = !showControls } } .simultaneousGesture(DragGesture(minimumDistance: 1.0) .onChanged { value in if !isZoomed, !isDismissing { - offset = value.translation.height - opacity = 1.0 - (abs(value.translation.height) / screenHeight) + handleOffsetUpdate(value.translation.height) } } .updating($dragState) { _, state, _ in @@ -65,23 +86,93 @@ struct ImageViewer: View { } ) .onAppear { - updateOpacity(1.0) + animateOpacityUpdate(1.0) } .onChange(of: dragState) { - if !dragState { + if dragState { + // drag gesture conflicts with control tap, so we disable it for a brief window after detecting a drag + enableControlTap = false + } else { if abs(offset) > 100 { swipeDismiss(finalOffset: offset > 0 ? screenHeight : -screenHeight) } else { - updateDragDistance(0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + enableControlTap = true + } + animateOffsetUpdate(0) } } } + .quickLookPreview($quickLookUrl) .background(ClearBackgroundView()) } + @ViewBuilder + var controlLayer: some View { + VStack { + HStack { + Spacer() + + Button { + fadeDismiss() + } label: { + Label("Close", systemImage: Icons.close) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .background { + Circle().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + .padding(.trailing, Constants.main.standardSpacing) + } + .offset(y: -controlOffset) + + Spacer() + + HStack { + Button { + Task { await saveImage(url: url) } + } label: { + Label("Save", systemImage: Icons.import) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .offset(y: -2) + + Button { + Task { await shareImage(url: url, navigation: navigation) } + } label: { + Label("Share", systemImage: Icons.share) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .offset(y: -2) + + Button { + Task { await showQuickLook(url: url) } + } label: { + Label("QuickLook", systemImage: Icons.menuCircle) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + } + .padding(.horizontal, Constants.main.halfSpacing) + .background { + Capsule().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + .offset(y: controlOffset) + } + .font(.title2) + .fontWeight(.light) + .foregroundStyle(.white) + .labelStyle(.iconOnly) + } + private func fadeDismiss() { isDismissing = true - updateOpacity(0) { + animateOpacityUpdate(0) { withoutAnimation { dismiss() } @@ -90,14 +181,14 @@ struct ImageViewer: View { private func swipeDismiss(finalOffset: CGFloat = UIScreen.main.bounds.height) { isDismissing = true - updateDragDistance(finalOffset) { + animateOffsetUpdate(finalOffset) { withoutAnimation { dismiss() } } } - private func updateOpacity(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { + private func animateOpacityUpdate(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { opacity = newOpacity } @@ -108,10 +199,13 @@ struct ImageViewer: View { } } - private func updateDragDistance(_ newDistance: CGFloat, callback: (() -> Void)? = nil) { + /// Sets the offsets to the given value with animation. If a callback is given, calls it when the animation completes. + /// - Parameters: + /// - newOffset: value to update offsets to + /// - callback: function to call when animation completes + private func animateOffsetUpdate(_ newOffset: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { - offset = newDistance - opacity = 1.0 - (abs(newDistance) / screenHeight) + handleOffsetUpdate(newOffset) } if let callback { DispatchQueue.main.asyncAfter(deadline: .now() + duration) { @@ -119,6 +213,23 @@ struct ImageViewer: View { } } } + + /// Updates offset, controlOffset, and opacity to match the given raw offset˜ + /// - Parameter newOffset: raw offset to update for + private func handleOffsetUpdate(_ newOffset: CGFloat) { + let absOffset = abs(newOffset) + offset = newOffset + if showControls { + controlOffset = absOffset + } + opacity = 1.0 - (absOffset / screenHeight) + } + + private func showQuickLook(url: URL) async { + if let fileUrl = await downloadImageToFileSystem(url: url) { + quickLookUrl = fileUrl + } + } } // https://stackoverflow.com/a/75037657 diff --git a/Mlem/App/Views/Shared/Images/Core/MediaView+Functions.swift b/Mlem/App/Views/Shared/Images/Core/MediaView+Functions.swift index 85652f009..040895cf8 100644 --- a/Mlem/App/Views/Shared/Images/Core/MediaView+Functions.swift +++ b/Mlem/App/Views/Shared/Images/Core/MediaView+Functions.swift @@ -17,12 +17,6 @@ extension MediaView { } } - func shareImage(url: URL) async { - if let fileUrl = await downloadImageToFileSystem(url: url) { - navigation.shareInfo = .init(url: fileUrl) - } - } - func showQuickLook(url: URL) async { if let fileUrl = await downloadImageToFileSystem(url: url) { quickLookUrl = fileUrl diff --git a/Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift b/Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift index 0b49c456c..86264fcba 100644 --- a/Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift +++ b/Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift @@ -168,7 +168,7 @@ extension MediaView { Task { await saveImage(url: url) } } Button("Share Image", systemImage: Icons.share) { - Task { await shareImage(url: url) } + Task { await shareImage(url: url, navigation: navigation) } } Button("Quick Look", systemImage: Icons.imageDetails) { Task { await showQuickLook(url: url) } diff --git a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift index 575610876..2062aa9dc 100644 --- a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift +++ b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift @@ -17,7 +17,7 @@ private struct AnimationControlLayer: ViewModifier { @Binding var animating: Bool var muted: Binding? - // decouple controls state from blurred because the blur animation and material/ProgressView don't get along + // decouple controls state from blurred because the blur animation and material don't get along @State var showControls: Bool = true init(animating: Binding, muted: Binding?) { @@ -27,22 +27,21 @@ private struct AnimationControlLayer: ViewModifier { func body(content: Content) -> some View { content - .background { - if showControls { - ProgressView() - } - } .overlay { if animating { Color.clear.contentShape(.rect) - .onTapGesture { - animating = false - } + .highPriorityGesture(TapGesture() + .onEnded { + animating = false + } + ) } else if showControls { PlayButton(postSize: .large) - .onTapGesture { - animating = true - } + .highPriorityGesture(TapGesture() + .onEnded { + animating = true + } + ) } } .overlay(alignment: .bottomTrailing) { diff --git a/Mlem/Localizable.xcstrings b/Mlem/Localizable.xcstrings index 51b698844..30e97335d 100644 --- a/Mlem/Localizable.xcstrings +++ b/Mlem/Localizable.xcstrings @@ -571,6 +571,9 @@ }, "Click on the link in the email to continue." : { + }, + "Close" : { + }, "Closed" : { @@ -1540,6 +1543,9 @@ }, "Quick Look" : { + }, + "QuickLook" : { + }, "Quote" : { @@ -1820,6 +1826,9 @@ }, "Settings" : { + }, + "Share" : { + }, "Share Image" : {