From a752f8604b750a1e5609afd4498d2b112d5e4acc Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Fri, 17 Jan 2025 11:06:22 -0500 Subject: [PATCH 1/7] shadow --- Mlem/App/Views/Pages/ImageViewer.swift | 75 +++++++++++++++++++++----- Mlem/Localizable.xcstrings | 9 ++++ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index 7aea59836..56e4fa6df 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -22,6 +22,7 @@ struct ImageViewer: View { @State var offset: CGFloat = 0 @State var isDismissing: Bool = false @State var opacity: CGFloat = 0 + @State var showingControlLayer: Bool = true init(url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! @@ -36,22 +37,15 @@ struct ImageViewer: View { .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 { + if showingControlLayer { + controlLayer + .opacity(opacity) } } + .onTapGesture { + showingControlLayer = !showingControlLayer + } .simultaneousGesture(DragGesture(minimumDistance: 1.0) .onChanged { value in if !isZoomed, !isDismissing { @@ -79,6 +73,59 @@ struct ImageViewer: View { .background(ClearBackgroundView()) } + @ViewBuilder + var controlLayer: some View { + VStack { + HStack { + Spacer() + + Button { + fadeDismiss() + } label: { + Label("Close", systemImage: Icons.close) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + .padding(.trailing, Constants.main.standardSpacing) + } + .offset(y: -abs(offset)) + + Spacer() + + HStack { + Button { + print("Save") + } label: { + Label("Save", systemImage: Icons.import) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + + Button { + print("Share") + } label: { + Label("Share", systemImage: Icons.share) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + + Button { + print("QuickLook") + } label: { + Label("QuickLook", systemImage: Icons.imageDetails) + } + .padding(Constants.main.standardSpacing) + .contentShape(.rect) + } + .offset(y: abs(offset)) + } + .font(.title) + .fontWeight(.light) + .foregroundStyle(.white) + .labelStyle(.iconOnly) + .shadow(color: .black, radius: 2) + } + private func fadeDismiss() { isDismissing = true updateOpacity(0) { diff --git a/Mlem/Localizable.xcstrings b/Mlem/Localizable.xcstrings index 16f64cefb..9a971f5e1 100644 --- a/Mlem/Localizable.xcstrings +++ b/Mlem/Localizable.xcstrings @@ -568,6 +568,9 @@ }, "Click on the link in the email to continue." : { + }, + "Close" : { + }, "Closed" : { @@ -1477,6 +1480,9 @@ }, "Quick Look" : { + }, + "QuickLook" : { + }, "Quote" : { @@ -1748,6 +1754,9 @@ }, "Settings" : { + }, + "Share" : { + }, "Share Image" : { From 2f2f05bac145b653beb4f3661cfda77edf0746db Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Fri, 17 Jan 2025 16:57:54 -0500 Subject: [PATCH 2/7] image controls --- Mlem/App/Logic/ImageFunctions.swift | 6 + Mlem/App/Views/Pages/ImageViewer.swift | 116 ++++++++++++++---- .../Images/Core/MediaView+Functions.swift | 6 - .../Shared/Images/Core/MediaView+Views.swift | 2 +- 4 files changed, 97 insertions(+), 33 deletions(-) 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 56e4fa6df..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,28 @@ 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 - @State var showingControlLayer: Bool = true + + /// 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)! @@ -32,25 +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 { - if showingControlLayer { - controlLayer - .opacity(opacity) + controlLayer + .opacity(opacity) + } + .onChange(of: showControls) { + withAnimation(.easeOut(duration: duration)) { + controlOffset = showControls ? 0 : 100 + } + } + .onChange(of: isZoomed) { + if isZoomed { + showControls = false } } .onTapGesture { - showingControlLayer = !showingControlLayer + 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 @@ -59,17 +86,24 @@ 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()) } @@ -86,49 +120,59 @@ struct ImageViewer: View { } .padding(Constants.main.standardSpacing) .contentShape(.rect) + .background { + Circle().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } .padding(.trailing, Constants.main.standardSpacing) } - .offset(y: -abs(offset)) + .offset(y: -controlOffset) Spacer() HStack { Button { - print("Save") + Task { await saveImage(url: url) } } label: { Label("Save", systemImage: Icons.import) } .padding(Constants.main.standardSpacing) .contentShape(.rect) + .offset(y: -2) Button { - print("Share") + Task { await shareImage(url: url, navigation: navigation) } } label: { Label("Share", systemImage: Icons.share) } .padding(Constants.main.standardSpacing) .contentShape(.rect) + .offset(y: -2) Button { - print("QuickLook") + Task { await showQuickLook(url: url) } } label: { - Label("QuickLook", systemImage: Icons.imageDetails) + Label("QuickLook", systemImage: Icons.menuCircle) } .padding(Constants.main.standardSpacing) .contentShape(.rect) } - .offset(y: abs(offset)) + .padding(.horizontal, Constants.main.halfSpacing) + .background { + Capsule().fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + .offset(y: controlOffset) } - .font(.title) + .font(.title2) .fontWeight(.light) .foregroundStyle(.white) .labelStyle(.iconOnly) - .shadow(color: .black, radius: 2) } private func fadeDismiss() { isDismissing = true - updateOpacity(0) { + animateOpacityUpdate(0) { withoutAnimation { dismiss() } @@ -137,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 } @@ -155,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) { @@ -166,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) } From 1d757b70722148a0f094899dee09a27d75815f8d Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Fri, 17 Jan 2025 17:04:39 -0500 Subject: [PATCH 3/7] fix animation layer gesture being ignored --- .../Images/Helpers/AnimationControlLayer.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift index 575610876..dc13a0510 100644 --- a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift +++ b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift @@ -35,14 +35,18 @@ private struct AnimationControlLayer: ViewModifier { .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) { From 8680967936f7e6e5437e049e248389c7704479f7 Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Fri, 17 Jan 2025 17:24:03 -0500 Subject: [PATCH 4/7] remove progressview from animationcontrollayer --- .../Shared/Images/Helpers/AnimationControlLayer.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift index dc13a0510..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,11 +27,6 @@ private struct AnimationControlLayer: ViewModifier { func body(content: Content) -> some View { content - .background { - if showControls { - ProgressView() - } - } .overlay { if animating { Color.clear.contentShape(.rect) From 83918ba10c19af7159f9dcc95c716505155d5868 Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Sat, 18 Jan 2025 12:07:53 -0500 Subject: [PATCH 5/7] ugly code but almost works --- Mlem/App/Views/Pages/ImageViewer.swift | 73 ++++++++++++++++--- Mlem/App/Views/Shared/ZoomableContainer.swift | 45 +++++++++--- 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index 43c24b0da..a534d2055 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -13,7 +13,7 @@ struct ImageViewer: View { @Environment(\.dismiss) var dismiss let url: URL - + let duration: CGFloat = 0.25 let screenHeight: CGFloat = UIScreen.main.bounds.height @@ -31,12 +31,16 @@ struct ImageViewer: View { /// Opacity of the viewer @State var opacity: CGFloat = 0 - /// Whether the controls should be shown/hidden - @State var showControls: Bool = true + // Whether the controls should be shown/hidden + // @State var controlsShown: Bool = true + var controlsShown: Bool { controlOffset == 0 && controlOpacity == 1 } /// 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 @@ -57,21 +61,36 @@ struct ImageViewer: View { .opacity(opacity) .overlay { controlLayer + .opacity(controlOpacity) .opacity(opacity) } - .onChange(of: showControls) { - withAnimation(.easeOut(duration: duration)) { - controlOffset = showControls ? 0 : 100 - } - } +// .onChange(of: controlsShown) { +// if controlsShown { +// showControls() +// } else { +// hideControls() +// } +//// withAnimation(.easeOut(duration: duration)) { +//// controlOffset = controlsShown ? 0 : 100 +//// } +// } .onChange(of: isZoomed) { if isZoomed { - showControls = false + hideControls(withSlide: true) + // hideControls(fade: false) + // controlsShown = false + } else { + showControls() } } .onTapGesture { if enableControlTap { - showControls = !showControls + if controlsShown { + hideControls() + } else { + showControls() + } + // controlsShown = !controlsShown } } .simultaneousGesture(DragGesture(minimumDistance: 1.0) @@ -188,6 +207,38 @@ struct ImageViewer: View { } } + private func hideControls(withSlide: Bool = false) { + withAnimation(.easeOut(duration: duration)) { + if withSlide { + controlOffset = 50 + } + controlOpacity = 0 + } +// if fade { +// withAnimation(.easeOut(duration: duration)) { +// controlOpacity = 0 +// } +// } else { +// withAnimation(.easeOut(duration: duration)) { +// controlOffset = 100 +// } +// } + } + + /// Returns controls to a visible state + private func showControls() { + if controlOffset > 0 { + controlOpacity = 1 + withAnimation(.easeOut(duration: duration)) { + controlOffset = 0 + } + } else if controlOpacity < 1 { + withAnimation(.easeOut(duration: duration)) { + controlOpacity = 1 + } + } + } + private func animateOpacityUpdate(_ newOpacity: CGFloat, callback: (() -> Void)? = nil) { withAnimation(.easeOut(duration: duration)) { opacity = newOpacity @@ -219,7 +270,7 @@ struct ImageViewer: View { private func handleOffsetUpdate(_ newOffset: CGFloat) { let absOffset = abs(newOffset) offset = newOffset - if showControls { + if controlsShown { controlOffset = absOffset } opacity = 1.0 - (absOffset / screenHeight) diff --git a/Mlem/App/Views/Shared/ZoomableContainer.swift b/Mlem/App/Views/Shared/ZoomableContainer.swift index 7f098fbad..b95670e9a 100644 --- a/Mlem/App/Views/Shared/ZoomableContainer.swift +++ b/Mlem/App/Views/Shared/ZoomableContainer.swift @@ -17,6 +17,7 @@ struct ZoomableContainer: View { let content: Content @State private var currentScale: CGFloat = 1.0 @State private var tapLocation: CGPoint = .zero + @State private var activelyZooming: Bool = false @Binding var isZoomed: Bool init(isZoomed: Binding = .constant(false), @ViewBuilder content: () -> Content) { @@ -30,12 +31,19 @@ struct ZoomableContainer: View { } var body: some View { - ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) { + ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation, activelyZooming: $activelyZooming) { content } .onTapGesture(count: 2, perform: doubleTapAction) - .onChange(of: currentScale) { - isZoomed = currentScale != 1.0 +// .onChange(of: currentScale) { +// isZoomed = currentScale != 1.0 +// } + .onChange(of: activelyZooming) { + if activelyZooming { + isZoomed = true + } else { + isZoomed = currentScale != 1.0 + } } } @@ -43,12 +51,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 activelyZooming: Bool + + init( + scale: Binding, + tapLocation: Binding, + activelyZooming: Binding, + @ViewBuilder content: () -> ScollContent) { + _currentScale = scale + _tapLocation = tapLocation + _activelyZooming = activelyZooming + self.content = content() + } func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() @@ -73,7 +87,7 @@ struct ZoomableContainer: View { } func makeCoordinator() -> Coordinator { - Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale) + Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale, activelyZooming: $activelyZooming) } func updateUIView(_ uiView: UIScrollView, context: Context) { @@ -102,10 +116,12 @@ struct ZoomableContainer: View { class Coordinator: NSObject, UIScrollViewDelegate { var hostingController: UIHostingController @Binding var currentScale: CGFloat + @Binding var activelyZooming: Bool - init(hostingController: UIHostingController, scale: Binding) { + init(hostingController: UIHostingController, scale: Binding, activelyZooming: Binding) { self.hostingController = hostingController _currentScale = scale + _activelyZooming = activelyZooming } func viewForZooming(in _: UIScrollView) -> UIView? { @@ -113,8 +129,15 @@ struct ZoomableContainer: View { } func scrollViewDidEndZooming(_: UIScrollView, with _: UIView?, atScale scale: CGFloat) { + activelyZooming = false currentScale = scale } + + func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + Task { + self.activelyZooming = true + } + } } // swiftlint:enable nesting } From 6d887e3be2ba7c023268290466bac1dcf26c5c95 Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Sat, 18 Jan 2025 12:40:33 -0500 Subject: [PATCH 6/7] cleaned up --- Mlem.xcodeproj/project.pbxproj | 4 + Mlem/App/Views/Pages/ImageViewer+Views.swift | 106 +++++++++++++++ Mlem/App/Views/Pages/ImageViewer.swift | 125 +++--------------- Mlem/App/Views/Shared/ZoomableContainer.swift | 55 +++++--- 4 files changed, 162 insertions(+), 128 deletions(-) create mode 100644 Mlem/App/Views/Pages/ImageViewer+Views.swift 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/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 a534d2055..9834d3d72 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -15,6 +15,7 @@ struct ImageViewer: View { let url: URL let duration: CGFloat = 0.25 + let maxControlOffset: CGFloat = 50 let screenHeight: CGFloat = UIScreen.main.bounds.height @GestureState var dragState: Bool = false @@ -31,10 +32,6 @@ struct ImageViewer: View { /// Opacity of the viewer @State var opacity: CGFloat = 0 - // Whether the controls should be shown/hidden - // @State var controlsShown: Bool = true - var controlsShown: Bool { controlOffset == 0 && controlOpacity == 1 } - /// Vertical offset for the control overlay @State var controlOffset: CGFloat = 0 @@ -46,6 +43,9 @@ struct ImageViewer: View { @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" } @@ -58,29 +58,13 @@ struct ImageViewer: View { } .offset(y: offset) .background(.black) + .overlay(controlOverlay) .opacity(opacity) - .overlay { - controlLayer - .opacity(controlOpacity) - .opacity(opacity) - } -// .onChange(of: controlsShown) { -// if controlsShown { -// showControls() -// } else { -// hideControls() -// } -//// withAnimation(.easeOut(duration: duration)) { -//// controlOffset = controlsShown ? 0 : 100 -//// } -// } .onChange(of: isZoomed) { if isZoomed { hideControls(withSlide: true) - // hideControls(fade: false) - // controlsShown = false } else { - showControls() + showControls(withSlide: true) } } .onTapGesture { @@ -90,7 +74,6 @@ struct ImageViewer: View { } else { showControls() } - // controlsShown = !controlsShown } } .simultaneousGesture(DragGesture(minimumDistance: 1.0) @@ -126,70 +109,7 @@ struct ImageViewer: View { .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() { + func fadeDismiss() { isDismissing = true animateOpacityUpdate(0) { withoutAnimation { @@ -210,32 +130,23 @@ struct ImageViewer: View { private func hideControls(withSlide: Bool = false) { withAnimation(.easeOut(duration: duration)) { if withSlide { - controlOffset = 50 + controlOffset = maxControlOffset } controlOpacity = 0 } -// if fade { -// withAnimation(.easeOut(duration: duration)) { -// controlOpacity = 0 -// } -// } else { -// withAnimation(.easeOut(duration: duration)) { -// controlOffset = 100 -// } -// } } /// Returns controls to a visible state - private func showControls() { - if controlOffset > 0 { + private func showControls(withSlide: Bool = false) { + guard !controlsShown else { return } + + controlOffset = withSlide ? maxControlOffset : 0 + + withAnimation(.easeIn(duration: duration)) { controlOpacity = 1 - withAnimation(.easeOut(duration: duration)) { + if withSlide { controlOffset = 0 } - } else if controlOpacity < 1 { - withAnimation(.easeOut(duration: duration)) { - controlOpacity = 1 - } } } @@ -270,13 +181,11 @@ struct ImageViewer: View { private func handleOffsetUpdate(_ newOffset: CGFloat) { let absOffset = abs(newOffset) offset = newOffset - if controlsShown { - controlOffset = absOffset - } + controlOffset = absOffset opacity = 1.0 - (absOffset / screenHeight) } - private func showQuickLook(url: URL) async { + func showQuickLook(url: URL) async { if let fileUrl = await downloadImageToFileSystem(url: url) { quickLookUrl = fileUrl } diff --git a/Mlem/App/Views/Shared/ZoomableContainer.swift b/Mlem/App/Views/Shared/ZoomableContainer.swift index b95670e9a..adc851a2e 100644 --- a/Mlem/App/Views/Shared/ZoomableContainer.swift +++ b/Mlem/App/Views/Shared/ZoomableContainer.swift @@ -17,7 +17,15 @@ struct ZoomableContainer: View { let content: Content @State private var currentScale: CGFloat = 1.0 @State private var tapLocation: CGPoint = .zero - @State private var activelyZooming: Bool = false + + /// 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) { @@ -26,24 +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, activelyZooming: $activelyZooming) { + ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation, zooming: $zooming) { content } .onTapGesture(count: 2, perform: doubleTapAction) -// .onChange(of: currentScale) { -// isZoomed = currentScale != 1.0 -// } - .onChange(of: activelyZooming) { - if activelyZooming { - isZoomed = true - } else { - isZoomed = currentScale != 1.0 + .onChange(of: zooming) { + if !handlingDoubleTap { + if zooming { + isZoomed = true + } else { + isZoomed = currentScale != 1.0 + } } + handlingDoubleTap = false } } @@ -51,16 +66,16 @@ struct ZoomableContainer: View { private var content: ScollContent @Binding private var currentScale: CGFloat @Binding private var tapLocation: CGPoint - @Binding private var activelyZooming: Bool + @Binding private var zooming: Bool init( scale: Binding, tapLocation: Binding, - activelyZooming: Binding, + zooming: Binding, @ViewBuilder content: () -> ScollContent) { _currentScale = scale _tapLocation = tapLocation - _activelyZooming = activelyZooming + _zooming = zooming self.content = content() } @@ -87,7 +102,7 @@ struct ZoomableContainer: View { } func makeCoordinator() -> Coordinator { - Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale, activelyZooming: $activelyZooming) + Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale, zooming: $zooming) } func updateUIView(_ uiView: UIScrollView, context: Context) { @@ -116,12 +131,12 @@ struct ZoomableContainer: View { class Coordinator: NSObject, UIScrollViewDelegate { var hostingController: UIHostingController @Binding var currentScale: CGFloat - @Binding var activelyZooming: Bool + @Binding var zooming: Bool - init(hostingController: UIHostingController, scale: Binding, activelyZooming: Binding) { + init(hostingController: UIHostingController, scale: Binding, zooming: Binding) { self.hostingController = hostingController _currentScale = scale - _activelyZooming = activelyZooming + _zooming = zooming } func viewForZooming(in _: UIScrollView) -> UIView? { @@ -129,13 +144,13 @@ struct ZoomableContainer: View { } func scrollViewDidEndZooming(_: UIScrollView, with _: UIView?, atScale scale: CGFloat) { - activelyZooming = false + zooming = false currentScale = scale } func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { Task { - self.activelyZooming = true + self.zooming = true } } } From 310d89451be6e0895c981d4053ca458305b20586 Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Sat, 18 Jan 2025 12:56:29 -0500 Subject: [PATCH 7/7] tune slide and fade --- Mlem/App/Views/Pages/ImageViewer.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mlem/App/Views/Pages/ImageViewer.swift b/Mlem/App/Views/Pages/ImageViewer.swift index 9834d3d72..531b1a94a 100644 --- a/Mlem/App/Views/Pages/ImageViewer.swift +++ b/Mlem/App/Views/Pages/ImageViewer.swift @@ -182,6 +182,7 @@ struct ImageViewer: View { let absOffset = abs(newOffset) offset = newOffset controlOffset = absOffset + controlOpacity = 1.0 - (absOffset / maxControlOffset) opacity = 1.0 - (absOffset / screenHeight) }