diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index a0fcc256d..c22fb8159 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -347,6 +347,7 @@ CD332D792CA7175500A53988 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD332D782CA7175200A53988 /* PlayButton.swift */; }; CD332D7C2CA71E6F00A53988 /* GifView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD332D7B2CA71E6E00A53988 /* GifView.swift */; }; CD332D7E2CA7486000A53988 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD332D7D2CA7485D00A53988 /* String+Extensions.swift */; }; + CD33CA522D3C18BF00106C8C /* ImageViewer+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */; }; CD4368C12AE23FD400BD8BD1 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = CD4368C02AE23FD400BD8BD1 /* Semaphore */; }; CD43E8B32BF2C24E007C3D71 /* ContentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */; }; CD45CB0D2D1880E8008BC729 /* FiltersSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */; }; @@ -814,6 +815,7 @@ CD332D782CA7175200A53988 /* PlayButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; CD332D7B2CA71E6E00A53988 /* GifView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifView.swift; sourceTree = ""; }; CD332D7D2CA7485D00A53988 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageViewer+Views.swift"; sourceTree = ""; }; CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLoader.swift; sourceTree = ""; }; CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersSettingsView.swift; sourceTree = ""; }; CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; @@ -1235,6 +1237,7 @@ 0382A7EE2C09F0F800C79DDA /* Pages */ = { isa = PBXGroup; children = ( + CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */, 033FCAF22C598435007B7CD1 /* Community */, 033FCAF12C598406007B7CD1 /* Person */, 030BCB192C3EA5E20037680F /* Instance */, @@ -2305,6 +2308,7 @@ 039F589D2C7B6C0A00C61658 /* FormChevron.swift in Sources */, 03B25B352CC4446400EB6DF5 /* FediseerOpinionListView.swift in Sources */, CDB2EC7D2BFADAB300DBC0EF /* CompactPostView.swift in Sources */, + CD33CA522D3C18BF00106C8C /* ImageViewer+Views.swift in Sources */, 0315B1C12C74C71A006D4F82 /* CommentEditorView+Context.swift in Sources */, 03D283FE2D25EEC500A6659B /* SearchView+Views.swift in Sources */, 03AFD0DF2C3B2E000054B8AD /* PersonListRow.swift in Sources */, 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+Views.swift b/Mlem/App/Views/Pages/ImageViewer+Views.swift new file mode 100644 index 000000000..3af13b35e --- /dev/null +++ b/Mlem/App/Views/Pages/ImageViewer+Views.swift @@ -0,0 +1,106 @@ +// +// ImageViewer+Views.swift +// Mlem +// +// Created by Eric Andrews on 2025-01-18. +// + +import SwiftUI + +extension ImageViewer { + + @ViewBuilder + var controlOverlay: some View { + VStack { + topControlBar + .offset(y: -controlOffset) + + Spacer() + + bottomControlBar + .offset(y: controlOffset) + } + .font(.title2) + .fontWeight(.light) + .foregroundStyle(.white) + .labelStyle(.iconOnly) + .opacity(controlOpacity) + } + + // MARK: Top control bar + + @ViewBuilder + var topControlBar: some View { + HStack { + Spacer() + closeButton + .padding(.trailing, Constants.main.standardSpacing) + } + } + + @ViewBuilder + var closeButton: some View { + Button { + fadeDismiss() + } label: { + Label("Close", systemImage: Icons.close) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .background { + Circle().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + } + + // MARK: Bottom control bar + + @ViewBuilder + var bottomControlBar: some View { + HStack { + saveButton + shareButton + quickLookButton + } + .padding(.horizontal, Constants.main.halfSpacing) + .background { + Capsule().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + } + + @ViewBuilder + var saveButton: some View { + Button { + Task { await saveImage(url: url) } + } label: { + Label("Save", systemImage: Icons.import) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .offset(y: -2) + } + + @ViewBuilder + var shareButton: some View { + Button { + Task { await shareImage(url: url, navigation: navigation) } + } label: { + Label("Share", systemImage: Icons.share) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .offset(y: -2) + } + + @ViewBuilder + var quickLookButton: some View { + Button { + Task { await showQuickLook(url: url) } + } label: { + Label("QuickLook", systemImage: Icons.menuCircle) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + } +} diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index 7aea59836..531b1a94a 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -8,21 +8,44 @@ import SwiftUI struct ImageViewer: View { + @Environment(NavigationLayer.self) var navigation @Environment(Palette.self) var palette @Environment(\.dismiss) var dismiss let url: URL - + let duration: CGFloat = 0.25 + let maxControlOffset: CGFloat = 50 let screenHeight: CGFloat = UIScreen.main.bounds.height @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 + /// Vertical offset for the control overlay + @State var controlOffset: CGFloat = 0 + + /// Opacity for the control overlay + @State var controlOpacity: CGFloat = 1 + + /// When true, enables tapping to show/hide controls + @State var enableControlTap: Bool = true + + @State var quickLookUrl: URL? + + // Whether the controls are currently visible + var controlsShown: Bool { controlOpacity == 1 } + init(url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = components.queryItems?.filter { $0.name != "thumbnail" } @@ -31,32 +54,32 @@ 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) + .overlay(controlOverlay) .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) + .onChange(of: isZoomed) { + if isZoomed { + hideControls(withSlide: true) + } else { + showControls(withSlide: true) + } + } + .onTapGesture { + if enableControlTap { + if controlsShown { + hideControls() + } else { + showControls() } - .padding(Constants.main.standardSpacing) } } .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 +88,30 @@ 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()) } - private func fadeDismiss() { + func fadeDismiss() { isDismissing = true - updateOpacity(0) { + animateOpacityUpdate(0) { withoutAnimation { dismiss() } @@ -90,14 +120,37 @@ 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 hideControls(withSlide: Bool = false) { + withAnimation(.easeOut(duration: duration)) { + if withSlide { + controlOffset = maxControlOffset + } + controlOpacity = 0 + } + } + + /// Returns controls to a visible state + private func showControls(withSlide: Bool = false) { + guard !controlsShown else { return } + + controlOffset = withSlide ? maxControlOffset : 0 + + withAnimation(.easeIn(duration: duration)) { + controlOpacity = 1 + if withSlide { + controlOffset = 0 + } + } + } + + private func animateOpacityUpdate(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { opacity = newOpacity } @@ -108,10 +161,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 +175,22 @@ 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 + controlOffset = absOffset + controlOpacity = 1.0 - (absOffset / maxControlOffset) + opacity = 1.0 - (absOffset / screenHeight) + } + + 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/App/Views/Shared/ZoomableContainer.swift b/Mlem/App/Views/Shared/ZoomableContainer.swift index 7f098fbad..adc851a2e 100644 --- a/Mlem/App/Views/Shared/ZoomableContainer.swift +++ b/Mlem/App/Views/Shared/ZoomableContainer.swift @@ -17,6 +17,15 @@ struct ZoomableContainer: View { let content: Content @State private var currentScale: CGFloat = 1.0 @State private var tapLocation: CGPoint = .zero + + /// True when currently zooming, false otherwise + @State private var zooming: Bool = false + + /// Tracks whether currently responding to a double tap action + @State private var handlingDoubleTap: Bool = false + + /// True when the current zoom is not 1.0, false otherwise. If the view is double tapped, this + /// value is set immediately instead of waiting for the zoom to complete. @Binding var isZoomed: Bool init(isZoomed: Binding = .constant(false), @ViewBuilder content: () -> Content) { @@ -25,17 +34,31 @@ struct ZoomableContainer: View { } func doubleTapAction(location: CGPoint) { + handlingDoubleTap = true tapLocation = location - currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0 + if currentScale == 1.0 { + isZoomed = true + currentScale = maxAllowedScale + } else { + isZoomed = false + currentScale = 1.0 + } } var body: some View { - ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) { + ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation, zooming: $zooming) { content } .onTapGesture(count: 2, perform: doubleTapAction) - .onChange(of: currentScale) { - isZoomed = currentScale != 1.0 + .onChange(of: zooming) { + if !handlingDoubleTap { + if zooming { + isZoomed = true + } else { + isZoomed = currentScale != 1.0 + } + } + handlingDoubleTap = false } } @@ -43,12 +66,18 @@ struct ZoomableContainer: View { private var content: ScollContent @Binding private var currentScale: CGFloat @Binding private var tapLocation: CGPoint - - init(scale: Binding, tapLocation: Binding, @ViewBuilder content: () -> ScollContent) { - _currentScale = scale - _tapLocation = tapLocation - self.content = content() - } + @Binding private var zooming: Bool + + init( + scale: Binding, + tapLocation: Binding, + zooming: Binding, + @ViewBuilder content: () -> ScollContent) { + _currentScale = scale + _tapLocation = tapLocation + _zooming = zooming + self.content = content() + } func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() @@ -73,7 +102,7 @@ struct ZoomableContainer: View { } func makeCoordinator() -> Coordinator { - Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale) + Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale, zooming: $zooming) } func updateUIView(_ uiView: UIScrollView, context: Context) { @@ -102,10 +131,12 @@ struct ZoomableContainer: View { class Coordinator: NSObject, UIScrollViewDelegate { var hostingController: UIHostingController @Binding var currentScale: CGFloat + @Binding var zooming: Bool - init(hostingController: UIHostingController, scale: Binding) { + init(hostingController: UIHostingController, scale: Binding, zooming: Binding) { self.hostingController = hostingController _currentScale = scale + _zooming = zooming } func viewForZooming(in _: UIScrollView) -> UIView? { @@ -113,8 +144,15 @@ struct ZoomableContainer: View { } func scrollViewDidEndZooming(_: UIScrollView, with _: UIView?, atScale scale: CGFloat) { + zooming = false currentScale = scale } + + func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + Task { + self.zooming = true + } + } } // swiftlint:enable nesting } 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" : {