Skip to content

Commit

Permalink
Merge pull request #1413 from planetary-social/uiscrollview-image-viewer
Browse files Browse the repository at this point in the history
Display images with new image viewer (UIScrollView version)
  • Loading branch information
joshuatbrown authored Aug 21, 2024
2 parents 3a11648 + 1a77f24 commit fc98136
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 16 deletions.
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.
- Included the npub in the properties list sent to analytics.
- 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.
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" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"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"
)!
)
}
File renamed without changes.
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

0 comments on commit fc98136

Please sign in to comment.