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

Display images with new image viewer (DragGesture version) #1407

Closed
wants to merge 14 commits into from
Closed
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
16 changes: 15 additions & 1 deletion Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@
03B4E6AC2C125D13006E5F59 /* FileStorageUploadResponseJSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B4E6AB2C125D13006E5F59 /* FileStorageUploadResponseJSONTests.swift */; };
03B4E6AE2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B4E6AD2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift */; };
03B4E6AF2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B4E6AD2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift */; };
03C8B4962C6D065900A07CCD /* ImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C8B4952C6D065900A07CCD /* ImageViewer.swift */; };
03D1B4282C3C1A5D001778CD /* NostrIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1B4272C3C1A5D001778CD /* NostrIdentifier.swift */; };
03D1B4292C3C1AC9001778CD /* NostrIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1B4272C3C1A5D001778CD /* NostrIdentifier.swift */; };
03D1B42C2C3C1B0D001778CD /* TLVElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1B42B2C3C1B0D001778CD /* TLVElement.swift */; };
03D1B42D2C3C1B0D001778CD /* TLVElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1B42B2C3C1B0D001778CD /* TLVElement.swift */; };
03E9C6792C6FBBE400C9B843 /* ImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C8B4952C6D065900A07CCD /* ImageViewer.swift */; };
03ED93472C46C48400C8D443 /* JSONEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ED93462C46C48400C8D443 /* JSONEventTests.swift */; };
03F7C4F32C10DF79006FF613 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F7C4F22C10DF79006FF613 /* URLSessionProtocol.swift */; };
03F7C4F42C10E05B006FF613 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F7C4F22C10DF79006FF613 /* URLSessionProtocol.swift */; };
Expand Down Expand Up @@ -564,6 +566,7 @@
03B4E6A12C125CA1006E5F59 /* nostr_build_nip96_upload_response.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = nostr_build_nip96_upload_response.json; sourceTree = "<group>"; };
03B4E6AB2C125D13006E5F59 /* FileStorageUploadResponseJSONTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileStorageUploadResponseJSONTests.swift; sourceTree = "<group>"; };
03B4E6AD2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileStorageUploadResponseJSON.swift; sourceTree = "<group>"; };
03C8B4952C6D065900A07CCD /* ImageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = "<group>"; };
03D1B4272C3C1A5D001778CD /* NostrIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentifier.swift; sourceTree = "<group>"; };
03D1B42B2C3C1B0D001778CD /* TLVElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVElement.swift; sourceTree = "<group>"; };
03ED93462C46C48400C8D443 /* JSONEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEventTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1059,6 +1062,15 @@
path = Generated;
sourceTree = "<group>";
};
03C8B4902C6D061900A07CCD /* Images */ = {
isa = PBXGroup;
children = (
03C8B4952C6D065900A07CCD /* ImageViewer.swift */,
C92DF80729C25FA900400561 /* SquareImage.swift */,
);
path = Images;
sourceTree = "<group>";
};
03D1B42A2C3C1AE7001778CD /* NostrIdentifier */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1124,6 +1136,7 @@
5B79F6402BA11618002DA9BE /* Components */ = {
isa = PBXGroup;
children = (
03C8B4902C6D061900A07CCD /* Images */,
5B79F6122B98B145002DA9BE /* WizardNavigationStack.swift */,
5B79F6452BA11725002DA9BE /* WizardSheetVStack.swift */,
5B79F64B2BA119AE002DA9BE /* WizardSheetTitleText.swift */,
Expand Down Expand Up @@ -1623,7 +1636,6 @@
CD09A74329A50F1D0063464F /* SideMenu.swift */,
C9A0DAD929C685E500466635 /* SideMenuButton.swift */,
CD09A74529A50F750063464F /* SideMenuContent.swift */,
C92DF80729C25FA900400561 /* SquareImage.swift */,
3FFB1D9B29A7DF9D002A755D /* StackedAvatarsView.swift */,
2D06BB9C2AE249D70085F509 /* ThreadRootView.swift */,
3F60F42829B27D3E000D62C4 /* ThreadView.swift */,
Expand Down Expand Up @@ -1989,6 +2001,7 @@
5B79F6532BA11B08002DA9BE /* WizardSheetDescriptionText.swift in Sources */,
5B6EB48E29EDBE0E006E750C /* NoteParser.swift in Sources */,
C9F84C23298DC7B900C6714D /* SettingsView.swift in Sources */,
03C8B4962C6D065900A07CCD /* ImageViewer.swift in Sources */,
5B79F6092B98AC33002DA9BE /* ClaimYourUniqueIdentitySheet.swift in Sources */,
C973AB652A323167002AED16 /* EventReference+CoreDataProperties.swift in Sources */,
C973AB632A323167002AED16 /* Relay+CoreDataProperties.swift in Sources */,
Expand Down Expand Up @@ -2263,6 +2276,7 @@
C92E7F6B2C4EFF7200B80638 /* WebSocketConnection.swift in Sources */,
035729A02BE41653005FEE85 /* SocialGraphTests.swift in Sources */,
037975BC2C0E258E00ADDF37 /* CompactNoteView.swift in Sources */,
03E9C6792C6FBBE400C9B843 /* ImageViewer.swift in Sources */,
03D1B4292C3C1AC9001778CD /* NostrIdentifier.swift in Sources */,
A32B6C7129A672BC00653FF5 /* CurrentUser.swift in Sources */,
C98A32282A05795E00E3FA13 /* Task+Timeout.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x24",
"green" : "0x0E",
"red" : "0x16"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC6",
"green" : "0x81",
"red" : "0x9A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x16",
"green" : "0x07",
"red" : "0x0D"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
1 change: 1 addition & 0 deletions Nos/Views/CompactNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ struct CompactNoteView_Previews: PreviewProvider {
CompactNoteView(note: previewData.linkNote, allowUserInteraction: false)
CompactNoteView(note: previewData.shortNote)
CompactNoteView(note: previewData.longNote)
CompactNoteView(note: previewData.imageNote)
CompactNoteView(note: previewData.doubleImageNote)
CompactNoteView(note: previewData.doubleImageNote, showLinkPreviews: false)
}
Expand Down
206 changes: 206 additions & 0 deletions Nos/Views/Components/Images/ImageViewer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import SDWebImageSwiftUI
import SwiftUI

/// A viewer for images. Supports full-screen zoom and panning.
struct ImageViewer: View {
/// The URL of the image to display.
let url: URL

@Environment(\.dismiss) private var dismiss

/// The current zoom scale of the image.
@State private var scale: CGFloat = 1.0

/// The offset of the image. This is updated when the user has zoomed and is panning up, down, left, and right.
@State private var offset: CGSize = .zero

/// The previous offset of the image.
/// - SeeAlso: `offset`
@State private var lastOffset: CGSize = .zero

/// The size of the image. Will be set to a non-zero value when the image has loaded.
@State private var imageSize: CGSize = .zero

/// The maximum zoom scale for the image.
private let maxScale: CGFloat = 10.0
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved

/// The minimum zoom scale for the image.
private let minScale: CGFloat = 1.0

var body: some View {
ZStack {
Color.imageBackground
.ignoresSafeArea()

GeometryReader { geometry in
WebImage(url: url)
.onSuccess { image, _, _ in
imageSize = image.size
}
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(x: offset.width, y: offset.height)
.gesture(
DragGesture()
.onChanged { value in
withAnimation {
offset = CGSize(
width: lastOffset.width + value.translation.width * 1.5,
height: lastOffset.height + value.translation.height * 1.5
Comment on lines +49 to +50
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@martindsq you said this feels weird, like the image is moving too much. We can adjust these values and see how it works for you:

Suggested change
width: lastOffset.width + value.translation.width * 1.5,
height: lastOffset.height + value.translation.height * 1.5
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height

)
}
}
.onEnded { value in
withAnimation {
let isLargeSwipeDown: Bool
if scale == minScale {
isLargeSwipeDown = value.translation.height > 50
} else {
isLargeSwipeDown = value.translation.height > 200
}
if isLargeSwipeDown {
dismiss()
} else {
let predictedEndOffset = CGSize(
width: lastOffset.width + value.predictedEndTranslation.width,
height: lastOffset.height + value.predictedEndTranslation.height
)
offset = predictedEndOffset
keepImageOnScreen(geometry: geometry)
lastOffset = offset
}
}
}
.simultaneously(
with: TapGesture(count: 2)
.onEnded {
withAnimation {
if scale == minScale {
scale = 3.0
} else {
scale = minScale
offset = .zero
lastOffset = .zero
}
}
}
)
)
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
.ignoresSafeArea()
}

ZStack(alignment: .topLeading) {
Color.clear

Button {
dismiss()
} label: {
Image(systemName: "xmark")
.symbolVariant(.fill.circle)
.symbolRenderingMode(.palette)
.foregroundStyle(Color.buttonCloseForeground, Color.buttonCloseBackground)
.font(.title)
}
.padding()
}
}
.ignoresSafeArea()
}

/// Moves the edges of the image to the edges of the view if they were moved to the middle.
/// - Parameter geometry: Used to keep the edges of the image at the edges of the view.
func keepImageOnScreen(geometry: GeometryProxy) {
let imageRatio = imageSize.width / imageSize.height
let geometryRatio = geometry.size.width / geometry.size.height
if imageRatio > geometryRatio { // the image width is constrained by the screen width
let scaledImageWidth = geometry.size.width * scale
let horizontalPanningRange = scaledImageWidth - geometry.size.width
let maxWidthOffset = horizontalPanningRange / 2
let minWidthOffset = -maxWidthOffset

if offset.width < minWidthOffset {
offset.width = minWidthOffset
} else if offset.width > maxWidthOffset {
offset.width = maxWidthOffset
}

let scaledImageHeight =
(geometry.size.width / imageSize.width) * imageSize.height * scale
let verticalPanningRange = max(scaledImageHeight - geometry.size.height, 0)
let maxHeightOffset = verticalPanningRange / 2
let minHeightOffset = -maxHeightOffset

if offset.height < minHeightOffset {
offset.height = minHeightOffset
} else if offset.height > maxHeightOffset {
offset.height = maxHeightOffset
}
} else { // the image height is constrained by the screen height
let scaledImageHeight = geometry.size.height * scale
let verticalPanningRange = scaledImageHeight - geometry.size.height
let maxHeightOffset = verticalPanningRange / 2
let minHeightOffset = -maxHeightOffset

if offset.height < minHeightOffset {
offset.height = minHeightOffset
} else if offset.height > maxHeightOffset {
offset.height = maxHeightOffset
}

let scaledImageWidth =
(geometry.size.height / imageSize.height) * imageSize.width * scale
let horizontalPanningRange = max(scaledImageWidth - geometry.size.width, 0)
let maxWidthOffset = horizontalPanningRange / 2
let minWidthOffset = -maxWidthOffset

if offset.width < minWidthOffset {
offset.width = minWidthOffset
} else if offset.width > maxWidthOffset {
offset.width = maxWidthOffset
}
}
}
}

#Preview {
ImageViewer(
url: URL(
string: "https://image.nostr.build/92d0ed5e3c53fa33e379f0982d52058f0dde98f0c287669fd1e7c5b4b86b5dbb.jpg"
)!
)
}

#Preview {
ImageViewer(
url: URL(
string: "https://images.unsplash.com/photo-1715686529501-e097bd9caea7"
)!
)
}

#Preview {
ImageViewer(
url: URL(
string: "https://images.unsplash.com/photo-1723160004469-1b34c81272f3"
)!
)
}

#Preview {
ImageViewer(
url: URL(
string: "https://image.nostr.build/9640e78f03afc4927d80a15fd1c4bd1404dc654a8663efb92cc9ee1b8b0719a3.jpg"
)!
)
}

#Preview {
ImageViewer(
url: URL(
string: "https://images.unsplash.com/photo-1716783841007-7de314270444"
)!
)
}
Loading
Loading