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 20 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Added a new image viewer that appears when you tap an image.
- Removed the like and repost counts from the Main and Profile feeds.
- Fixed an issue where the sheet asking users to set up a NIP-05 username would appear after reinstalling Nos, even if the profile already had a NIP-05 username.
- Fixed a bug where urls with periods after them would include the period.
Expand Down
22 changes: 21 additions & 1 deletion Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,21 @@
037975C72C0E26FC00ADDF37 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = C987F85729BA981800B44E7A /* Font.swift */; };
037975D12C0E341500ADDF37 /* MockFeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037975D02C0E341500ADDF37 /* MockFeatureFlags.swift */; };
037975EA2C0E695A00ADDF37 /* MockFeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037975D02C0E341500ADDF37 /* MockFeatureFlags.swift */; };
038863DE2C6FF51500B09797 /* ZoomableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038863DD2C6FF51500B09797 /* ZoomableContainer.swift */; };
038863DF2C6FF51500B09797 /* ZoomableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038863DD2C6FF51500B09797 /* ZoomableContainer.swift */; };
039C961F2C480F4100A8EB39 /* unsupported_kinds.json in Resources */ = {isa = PBXBuildFile; fileRef = 039C961E2C480F4100A8EB39 /* unsupported_kinds.json */; };
039C96292C48321E00A8EB39 /* long_form_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 039C96282C48321E00A8EB39 /* long_form_data.json */; };
03A3AA3B2C5028FF008FE153 /* PublicKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A3AA3A2C5028FF008FE153 /* PublicKeyTests.swift */; };
03B4E6A22C125CA1006E5F59 /* nostr_build_nip96_upload_response.json in Resources */ = {isa = PBXBuildFile; fileRef = 03B4E6A12C125CA1006E5F59 /* nostr_build_nip96_upload_response.json */; };
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 @@ -557,13 +561,15 @@
0378409C2BB4A2B600E5E901 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
037975BA2C0E24D200ADDF37 /* CompactNoteViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactNoteViewTests.swift; sourceTree = "<group>"; };
037975D02C0E341500ADDF37 /* MockFeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeatureFlags.swift; sourceTree = "<group>"; };
038863DD2C6FF51500B09797 /* ZoomableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableContainer.swift; sourceTree = "<group>"; };
039C961E2C480F4100A8EB39 /* unsupported_kinds.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = unsupported_kinds.json; sourceTree = "<group>"; };
039C96282C48321E00A8EB39 /* long_form_data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = long_form_data.json; sourceTree = "<group>"; };
03A3AA3A2C5028FF008FE153 /* PublicKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyTests.swift; sourceTree = "<group>"; };
03AB2F7D2BF6609500B73DB1 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTests.xctestplan; sourceTree = "<group>"; };
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 +1065,16 @@
path = Generated;
sourceTree = "<group>";
};
03C8B4902C6D061900A07CCD /* Images */ = {
isa = PBXGroup;
children = (
03C8B4952C6D065900A07CCD /* ImageViewer.swift */,
C92DF80729C25FA900400561 /* SquareImage.swift */,
038863DD2C6FF51500B09797 /* ZoomableContainer.swift */,
);
path = Images;
sourceTree = "<group>";
};
03D1B42A2C3C1AE7001778CD /* NostrIdentifier */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1124,6 +1140,7 @@
5B79F6402BA11618002DA9BE /* Components */ = {
isa = PBXGroup;
children = (
03C8B4902C6D061900A07CCD /* Images */,
5B79F6122B98B145002DA9BE /* WizardNavigationStack.swift */,
5B79F6452BA11725002DA9BE /* WizardSheetVStack.swift */,
5B79F64B2BA119AE002DA9BE /* WizardSheetTitleText.swift */,
Expand Down Expand Up @@ -1623,7 +1640,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 @@ -1948,6 +1964,7 @@
C9B71DC22A9003670031ED9F /* CrashReporting.swift in Sources */,
C987F81729BA4C6A00B44E7A /* BigActionButton.swift in Sources */,
C98DC9BB2A795CAD004E5F0F /* ActionBanner.swift in Sources */,
038863DE2C6FF51500B09797 /* ZoomableContainer.swift in Sources */,
C9F204802AE029D90029A858 /* AppDestination.swift in Sources */,
3F30020B29C361C8003D4F8B /* OnboardingTermsOfServiceView.swift in Sources */,
C9C5475B2A4F1D8C006B0741 /* NosNotification+CoreDataProperties.swift in Sources */,
Expand Down Expand Up @@ -1989,6 +2006,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 +2281,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 Expand Up @@ -2315,6 +2334,7 @@
034EBDC72C2489B4006BA35A /* CurrentUserError.swift in Sources */,
C9ADB14229951CB10075E7F8 /* NSManagedObject+Nos.swift in Sources */,
035729AB2BE4167E005FEE85 /* AuthorTests.swift in Sources */,
038863DF2C6FF51500B09797 /* ZoomableContainer.swift in Sources */,
03B4E6AC2C125D13006E5F59 /* FileStorageUploadResponseJSONTests.swift in Sources */,
C92DF80629C25DE900400561 /* URL+Extensions.swift in Sources */,
C9BAB09C2996FBA10003A84E /* EventProcessor.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
78 changes: 78 additions & 0 deletions Nos/Views/Components/Images/ImageViewer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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

var body: some View {
ZStack {
Color.imageViewerBackground

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(.largeTitle)
}
.padding()
}
}
.ignoresSafeArea()
}
}

#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"
)!
)
}
123 changes: 123 additions & 0 deletions Nos/Views/Components/Images/ZoomableContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import SwiftUI

/// A container that allows its content to be zoomed.
/// - Note: Thanks, [Ido](https://stackoverflow.com/users/8157190/ido) for your
/// [answer](https://stackoverflow.com/a/76649224) on StackOverflow!
struct ZoomableContainer<ContainerContent: View>: View {
let content: ContainerContent
private let maxAllowedScale = 4.0

@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(maxAllowedScale: maxAllowedScale, scale: $currentScale, tapLocation: $tapLocation) {
content
}
.onTapGesture(count: 2, perform: doubleTapAction)
}
}

fileprivate struct ZoomableScrollView<ScrollViewContent: View>: UIViewRepresentable {
private var content: ScrollViewContent
private let maxAllowedScale: CGFloat

@Binding private var currentScale: CGFloat
@Binding private var tapLocation: CGPoint

init(
maxAllowedScale: CGFloat,
scale: Binding<CGFloat>,
tapLocation: Binding<CGPoint>,
@ViewBuilder content: () -> ScrollViewContent
) {
self.maxAllowedScale = maxAllowedScale
_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)
// 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 {
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
}
}
}
Loading
Loading