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 (UIScrollView version) #1413

Merged
merged 24 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9465df8
#1164: first pass at new image viewer
joshuatbrown Aug 14, 2024
e46fe9a
allow dragging the image; constrain it a bit, but not enough
joshuatbrown Aug 15, 2024
ca15c4c
Merge branch 'main' into new-image-viewer
joshuatbrown Aug 15, 2024
97c4013
constrain panning to the right values
joshuatbrown Aug 15, 2024
b7247f2
fix drag lag
joshuatbrown Aug 15, 2024
4ed9cf3
refactor, and fix a little offset bug
joshuatbrown Aug 15, 2024
832d301
double-tap or pinch to zoom on a specific point in the image
joshuatbrown Aug 15, 2024
1e7c58e
Merge branch 'main' into new-image-viewer
joshuatbrown Aug 15, 2024
93c14ac
simpler version using scroll view
joshuatbrown Aug 15, 2024
5d9a007
simplify further
joshuatbrown Aug 15, 2024
4443e46
present with sheet; add presentation to ImageLinkButton
joshuatbrown Aug 16, 2024
40a9cf8
ignore safe area; fix sizing issue
joshuatbrown Aug 16, 2024
dd7843f
add ImageViewer to test target to fix error
joshuatbrown Aug 16, 2024
fa28638
fix double-tap to zoom
joshuatbrown Aug 16, 2024
7c80f1a
Display images in image viewer (UIScrollView edition)
joshuatbrown Aug 16, 2024
3095f38
add docs, simplify code per PR suggestions
joshuatbrown Aug 16, 2024
388a6a9
move ZoomableContainer to its own file; give credit where credit is due
joshuatbrown Aug 16, 2024
3f6998f
remove lots of unused code from a different implementation
joshuatbrown Aug 16, 2024
43bdf94
rename image viewer background color; update CHANGELOG
joshuatbrown Aug 16, 2024
0f6ee06
make close button bigger
joshuatbrown Aug 16, 2024
281648e
set image viewer background to black
joshuatbrown Aug 19, 2024
7e684f4
Merge branch 'main' into uiscrollview-image-viewer
joshuatbrown Aug 19, 2024
8dd06e8
Merge branch 'main' into uiscrollview-image-viewer
joshuatbrown Aug 20, 2024
1a77f24
Merge branch 'main' into uiscrollview-image-viewer
joshuatbrown Aug 20, 2024
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
}
}
20 changes: 20 additions & 0 deletions Nos/Assets/Colors.xcassets/image-background.colorset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved
{
"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
230 changes: 230 additions & 0 deletions Nos/Views/Components/Images/ImageViewer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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 zoomScale: CGFloat = 1.0

/// 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 maxZoomScale: CGFloat = 10.0

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

var body: some View {
ZStack {
Color.imageBackground

ZoomableContainer {
WebImage(url: url)
.resizable()
.aspectRatio(contentMode: .fit)
}

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()
}

func resetImageState() {
withAnimation(.interactiveSpring()) {
zoomScale = minZoomScale
}
}

func onImageDoubleTapped(value: TapGesture.Value) {
if zoomScale == minZoomScale {
withAnimation(.spring()) {
zoomScale = 4
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
resetImageState()
}
}

var doubleTapGesture: some Gesture {
TapGesture(count: 2)
.onEnded(onImageDoubleTapped)
}
}

fileprivate let maxAllowedScale = 4.0

struct ZoomableContainer<ContainerContent: View>: View {
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved
let content: ContainerContent

@State private var currentScale: CGFloat = 1.0
@State private var tapLocation: CGPoint = .zero

init(@ViewBuilder content: () -> ContainerContent) {
self.content = content()
}

func doubleTapAction(location: CGPoint) {
tapLocation = location
currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
}

var body: some View {
ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
content
}
.onTapGesture(count: 2, perform: doubleTapAction)
}

fileprivate struct ZoomableScrollView<ScrollViewContent: View>: UIViewRepresentable {
private var content: ScrollViewContent
@Binding private var currentScale: CGFloat
@Binding private var tapLocation: CGPoint

init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> ScrollViewContent) {
_currentScale = scale
_tapLocation = tapLocation
self.content = content()
}

func makeUIView(context: Context) -> UIScrollView {
// Setup the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.clipsToBounds = false

// Create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
hostedView.backgroundColor = .clear
scrollView.addSubview(hostedView)

return scrollView
}

func makeCoordinator() -> Coordinator {
Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale)
}

func updateUIView(_ uiView: UIScrollView, context: Context) {
// Update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = content

if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
uiView.setZoomScale(currentScale, animated: true)
} else if tapLocation != .zero { // Scale in to a specific point
uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)

Check failure on line 148 in Nos/Views/Components/Images/ImageViewer.swift

View workflow job for this annotation

GitHub Actions / swift_lint

Line should be 120 characters or less; currently it has 123 characters (line_length)

Check failure on line 148 in Nos/Views/Components/Images/ImageViewer.swift

View workflow job for this annotation

GitHub Actions / swift_lint

Line should be 120 characters or less; currently it has 123 characters (line_length)
// Reset the location to prevent scaling to it in case of a negative scale (manual pinch)
// Use the main thread to prevent unexpected behavior
DispatchQueue.main.async { tapLocation = .zero }
}

assert(context.coordinator.hostingController.view.superview == uiView)
}

// MARK: - Utils

func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
let scrollViewSize = scrollView.bounds.size

let width = scrollViewSize.width / scale
let height = scrollViewSize.height / scale
let xPosition = center.x - (width / 2.0)
let yPosition = center.y - (height / 2.0)

return CGRect(x: xPosition, y: yPosition, width: width, height: height)
}

// MARK: - Coordinator

class Coordinator: NSObject, UIScrollViewDelegate {

Check failure on line 172 in Nos/Views/Components/Images/ImageViewer.swift

View workflow job for this annotation

GitHub Actions / swift_lint

Types should be nested at most 1 level deep (nesting)

Check failure on line 172 in Nos/Views/Components/Images/ImageViewer.swift

View workflow job for this annotation

GitHub Actions / swift_lint

Types should be nested at most 1 level deep (nesting)
var hostingController: UIHostingController<ScrollViewContent>
@Binding var currentScale: CGFloat

init(hostingController: UIHostingController<ScrollViewContent>, scale: Binding<CGFloat>) {
self.hostingController = hostingController
_currentScale = scale
}

func viewForZooming(in scrollView: UIScrollView) -> UIView? {
hostingController.view
}

func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
currentScale = scale
}
}
}
}

#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