Skip to content

Commit

Permalink
Image Controls (#1617)
Browse files Browse the repository at this point in the history
  • Loading branch information
EricBAndrews authored Jan 18, 2025
1 parent d278fb5 commit 6fce5b1
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 59 deletions.
4 changes: 4 additions & 0 deletions Mlem.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -814,6 +815,7 @@
CD332D782CA7175200A53988 /* PlayButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = "<group>"; };
CD332D7B2CA71E6E00A53988 /* GifView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifView.swift; sourceTree = "<group>"; };
CD332D7D2CA7485D00A53988 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageViewer+Views.swift"; sourceTree = "<group>"; };
CD43E8B22BF2C24E007C3D71 /* ContentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLoader.swift; sourceTree = "<group>"; };
CD45CB0C2D1880E3008BC729 /* FiltersSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersSettingsView.swift; sourceTree = "<group>"; };
CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1235,6 +1237,7 @@
0382A7EE2C09F0F800C79DDA /* Pages */ = {
isa = PBXGroup;
children = (
CD33CA512D3C18AE00106C8C /* ImageViewer+Views.swift */,
033FCAF22C598435007B7CD1 /* Community */,
033FCAF12C598406007B7CD1 /* Person */,
030BCB192C3EA5E20037680F /* Instance */,
Expand Down Expand Up @@ -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 */,
Expand Down
6 changes: 6 additions & 0 deletions Mlem/App/Logic/ImageFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
106 changes: 106 additions & 0 deletions Mlem/App/Views/Pages/ImageViewer+Views.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
128 changes: 100 additions & 28 deletions Mlem/App/Views/Pages/ImageViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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
}
Expand All @@ -108,17 +161,36 @@ 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) {
callback()
}
}
}

/// 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
Expand Down
6 changes: 0 additions & 6 deletions Mlem/App/Views/Shared/Images/Core/MediaView+Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Mlem/App/Views/Shared/Images/Core/MediaView+Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Loading

0 comments on commit 6fce5b1

Please sign in to comment.