Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Image Controls #1617

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
165 changes: 138 additions & 27 deletions Mlem/App/Views/Pages/ImageViewer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import SwiftUI

struct ImageViewer: View {
@Environment(NavigationLayer.self) var navigation
@Environment(Palette.self) var palette
@Environment(\.dismiss) var dismiss

Expand All @@ -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" }
Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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
}
Expand All @@ -108,17 +199,37 @@ 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
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
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
23 changes: 11 additions & 12 deletions Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ private struct AnimationControlLayer: ViewModifier {
@Binding var animating: Bool
var muted: Binding<Bool>?

// 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<Bool>, muted: Binding<Bool>?) {
Expand All @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions Mlem/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,9 @@
},
"Click on the link in the email to continue." : {

},
"Close" : {

},
"Closed" : {

Expand Down Expand Up @@ -1540,6 +1543,9 @@
},
"Quick Look" : {

},
"QuickLook" : {

},
"Quote" : {

Expand Down Expand Up @@ -1820,6 +1826,9 @@
},
"Settings" : {

},
"Share" : {

},
"Share Image" : {

Expand Down
Loading