├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
└── Sources
├── ImageViewer
└── ImageViewer.swift
└── ImageViewerRemote
└── ImageViewerRemote.swift
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: Jake-Short
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Device Information (please complete the following information):**
27 | - Device/simulator: [e.g. iPhone 11 Pro]
28 | - iOS version: [e.g. iOS 13]
29 |
30 | **Desktop (please complete the following information):**
31 | - macOS: [e.g. macOS 11]
32 | - Xcode version: [e.g. Xcode 12]
33 |
34 | **Additional context**
35 | Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: Jake-Short
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at contact@jakeshort.dev. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jake Short
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "URLImage",
6 | "repositoryURL": "https://github.com/dmytro-anokhin/url-image.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "cedfdb207dcba2aec1229f3e2053bb93460a4244",
10 | "version": "2.1.9"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.1
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SwiftUIImageViewer",
8 | platforms: [
9 | .iOS(.v13)
10 | ],
11 | products: [
12 | // Products define the executables and libraries produced by a package, and make them visible to other packages.
13 | .library(
14 | name: "ImageViewer",
15 | targets: ["ImageViewer"]),
16 | .library(
17 | name: "ImageViewerRemote",
18 | targets: ["ImageViewerRemote"]),
19 | ],
20 | dependencies: [
21 | // Dependencies declare other packages that this package depends on.
22 | // .package(url: /* package url */, from: "1.0.0"),
23 | .package(url: "https://github.com/dmytro-anokhin/url-image.git", from: "2.0.0")
24 | ],
25 | targets: [
26 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
27 | // Targets can depend on other targets in this package, and on products in packages which this package depends on.
28 | .target(
29 | name: "ImageViewer",
30 | dependencies: ["URLImage"]),
31 | .target(
32 | name: "ImageViewerRemote",
33 | dependencies: ["URLImage"])
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Image Viewer
2 |
3 | [](https://opensource.org/licenses/MIT)
4 | [](https://github.com/Jake-Short/swiftui-image-viewer/graphs/commit-activity)
5 | [](http://makeapullrequest.com)
6 |
7 |
8 | # Summary
9 |
10 | An image viewer built using SwiftUI. Featuring drag to dismiss, pinch to zoom, remote and local images, and more.
11 |
12 | 
13 |
14 | # Installation via Swift Package Manager
15 |
16 | File > Swift Packages > Add Package Dependancy
17 |
18 | ```https://github.com/Jake-Short/swiftui-image-viewer.git```
19 |
20 | # Usage
21 |
22 | > **Notes on NavigationView:** The `.overlay` modifier only applies to the view it is applied to. Therefore, the `.overlay` modifier *must* be applied to the NavigationView to appear above all elements! If it is applied to a child view, it will appear beneath the title/navigation buttons.
23 |
24 | ### Local Image:
25 |
26 | The `image` parameter accepts `Binding` in all versions. As of 1.0.20, it also accepts `Binding`
27 |
28 | ```Swift
29 | import ImageViewer
30 |
31 | struct ContentView: View {
32 | @State var showImageViewer: Bool = true
33 | @State var image = Image("example-image")
34 |
35 | var body: some View {
36 | VStack {
37 | Text("Example!")
38 | }
39 | .frame(maxWidth: .infinity, maxHeight: .infinity)
40 | .overlay(ImageViewer(image: self.$image, viewerShown: self.$showImageViewer))
41 | }
42 | }
43 | ```
44 |
45 | ### Remote Image:
46 |
47 | The `imageURL` parameter accepts `Binding`
48 |
49 | ```Swift
50 | import ImageViewerRemote
51 |
52 | struct ContentView: View {
53 | @State var showImageViewer: Bool = true
54 | @State var imgURL: String = "https://..."
55 |
56 | var body: some View {
57 | VStack {
58 | Text("Example!")
59 | }
60 | .frame(maxWidth: .infinity, maxHeight: .infinity)
61 | .overlay(ImageViewerRemote(imageURL: self.$imgURL, viewerShown: self.$showImageViewer))
62 | }
63 | }
64 | ```
65 |
66 | # Customization
67 |
68 | ### Close Button Position
69 |
70 | #### Availability: 2.2.0 or higher
71 |
72 | The close button can be moved to the top right if desired. The `closeButtonTopRight` parameter accepts `bool`.
73 |
74 | Example:
75 | ```Swift
76 | import ImageViewer
77 |
78 | struct ContentView: View {
79 | @State var showImageViewer: Bool = true
80 | @State var image = Image("example-image")
81 |
82 | var body: some View {
83 | VStack {
84 | Text("Example!")
85 | }
86 | .frame(maxWidth: .infinity, maxHeight: .infinity)
87 | .overlay(ImageViewer(image: self.$image, viewerShown: self.$showImageViewer, closeButtonTopRight: true))
88 | }
89 | }
90 | ```
91 |
92 |
93 | ### Caption
94 |
95 | #### Availability: 2.1.0 or higher
96 |
97 | A caption can be added to the image viewer. The caption will appear near the bottom of the image viewer (if the image fills the whole screen the text will appear on top of the image). The `caption` parameter accepts `Text`.
98 |
99 | Example:
100 | ```Swift
101 | import ImageViewer
102 |
103 | struct ContentView: View {
104 | @State var showImageViewer: Bool = true
105 | @State var image = Image("example-image")
106 |
107 | var body: some View {
108 | VStack {
109 | Text("Example!")
110 | }
111 | .frame(maxWidth: .infinity, maxHeight: .infinity)
112 | .overlay(ImageViewer(image: self.$image, viewerShown: self.$showImageViewer, caption: Text("This is a caption!")))
113 | }
114 | }
115 | ```
116 |
117 | ### Explicit Aspect Ratio
118 |
119 | #### Availability: 1.0.21 or higher
120 |
121 | An explcit image aspect ratio can be specified, which fixes an issue of incorrect stretching that occurs in certain situations. The `aspectRatio` parameter accepts `Binding`
122 |
123 | Example:
124 | ```Swift
125 | import ImageViewer
126 |
127 | struct ContentView: View {
128 | @State var showImageViewer: Bool = true
129 | @State var image = Image("example-image")
130 |
131 | var body: some View {
132 | VStack {
133 | Text("Example!")
134 | }
135 | .frame(maxWidth: .infinity, maxHeight: .infinity)
136 | .overlay(ImageViewer(image: self.$image, viewerShown: self.$showImageViewer, aspectRatio: .constant(2)))
137 | }
138 | }
139 | ```
140 |
141 | ### Disable Cache
142 |
143 | #### Availability: 1.0.25 or higher
144 |
145 | To disable cache on the remote image viewer, simply pass a `Bool` value to the `disableCache` parameter
146 |
147 | Example:
148 | ```Swift
149 | import ImageViewerRemote
150 |
151 | struct ContentView: View {
152 | @State var showImageViewer: Bool = true
153 |
154 | var body: some View {
155 | VStack {
156 | Text("Example!")
157 | }
158 | .frame(maxWidth: .infinity, maxHeight: .infinity)
159 | .overlay(ImageViewerRemote(imageURL: URL(string: "https://..."), viewerShown: self.$showImageViewer, disableCache: true))
160 | }
161 | }
162 | ```
163 |
164 | Deprecated
165 |
166 |
167 | ### HTTP Headers
168 |
169 | #### Availability: 1.0.15 to 1.0.25
170 | #### *DEPRECATED*: No longer available as of 2.0.0
171 |
172 | The remote image viewer allows HTTP headers to be included in the URL request. To use them, pass a dictonary to the httpHeaders field. The format should be [Header: Value], both strings.
173 |
174 | Example:
175 | ```Swift
176 | import ImageViewerRemote
177 |
178 | struct ContentView: View {
179 | @State var showImageViewer: Bool = true
180 |
181 | var body: some View {
182 | VStack {
183 | Text("Example!")
184 | }
185 | .frame(maxWidth: .infinity, maxHeight: .infinity)
186 | .overlay(ImageViewerRemote(imageURL: URL(string: "https://..."), viewerShown: self.$showImageViewer, httpHeaders: ["X-Powered-By": "Swift!"]))
187 | }
188 | }
189 | ```
190 |
191 |
192 | # Compatibility
193 |
194 | This package is compatible on iOS 13 and later.
195 |
196 | Previous to 1.0.18, this package used Swift tools version 5.2. If you receive an error while trying to use the package, you may be on an older version of Xcode, and should use version 1.0.18 of this package or later.
197 |
198 | As of 1.0.18 and later, this package uses Swift tools version 5.1, allowing for compatibility with more Xcode versions.
199 |
200 | ## License
201 |
202 | This project is licensed under the MIT license.
203 |
204 | ## Enjoying this project?
205 |
206 | Please consider giving it a star!
207 |
--------------------------------------------------------------------------------
/Sources/ImageViewer/ImageViewer.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 |
4 | @available(iOS 13.0, *)
5 | public struct ImageViewer: View {
6 | @Binding var viewerShown: Bool
7 | @Binding var image: Image
8 | @Binding var imageOpt: Image?
9 | @State var caption: Text?
10 | @State var closeButtonTopRight: Bool?
11 |
12 | var aspectRatio: Binding?
13 |
14 | @State var dragOffset: CGSize = CGSize.zero
15 | @State var dragOffsetPredicted: CGSize = CGSize.zero
16 |
17 | public init(image: Binding, viewerShown: Binding, aspectRatio: Binding? = nil, caption: Text? = nil, closeButtonTopRight: Bool? = false) {
18 | _image = image
19 | _viewerShown = viewerShown
20 | _imageOpt = .constant(nil)
21 | self.aspectRatio = aspectRatio
22 | _caption = State(initialValue: caption)
23 | _closeButtonTopRight = State(initialValue: closeButtonTopRight)
24 | }
25 |
26 | public init(image: Binding, viewerShown: Binding, aspectRatio: Binding? = nil, caption: Text? = nil, closeButtonTopRight: Bool? = false) {
27 | _image = .constant(Image(systemName: ""))
28 | _imageOpt = image
29 | _viewerShown = viewerShown
30 | self.aspectRatio = aspectRatio
31 | _caption = State(initialValue: caption)
32 | _closeButtonTopRight = State(initialValue: closeButtonTopRight)
33 | }
34 |
35 | func getImage() -> Image {
36 | if(self.imageOpt == nil) {
37 | return self.image
38 | }
39 | else {
40 | return self.imageOpt ?? Image(systemName: "questionmark.diamond")
41 | }
42 | }
43 |
44 | @ViewBuilder
45 | public var body: some View {
46 | VStack {
47 | if(viewerShown) {
48 | ZStack {
49 | VStack {
50 | HStack {
51 |
52 | if self.closeButtonTopRight == true {
53 | Spacer()
54 | }
55 |
56 | Button(action: { self.viewerShown = false }) {
57 | Image(systemName: "xmark")
58 | .foregroundColor(Color(UIColor.white))
59 | .font(.system(size: UIFontMetrics.default.scaledValue(for: 24)))
60 | }
61 |
62 | if self.closeButtonTopRight != true {
63 | Spacer()
64 | }
65 | }
66 |
67 | Spacer()
68 | }
69 | .padding()
70 | .zIndex(2)
71 |
72 | VStack {
73 | ZStack {
74 | self.getImage()
75 | .resizable()
76 | .aspectRatio(self.aspectRatio?.wrappedValue, contentMode: .fit)
77 | .offset(x: self.dragOffset.width, y: self.dragOffset.height)
78 | .rotationEffect(.init(degrees: Double(self.dragOffset.width / 30)))
79 | .pinchToZoom()
80 | .gesture(DragGesture()
81 | .onChanged { value in
82 | self.dragOffset = value.translation
83 | self.dragOffsetPredicted = value.predictedEndTranslation
84 | }
85 | .onEnded { value in
86 | if((abs(self.dragOffset.height) + abs(self.dragOffset.width) > 570) || ((abs(self.dragOffsetPredicted.height)) / (abs(self.dragOffset.height)) > 3) || ((abs(self.dragOffsetPredicted.width)) / (abs(self.dragOffset.width))) > 3) {
87 | withAnimation(.spring()) {
88 | self.dragOffset = self.dragOffsetPredicted
89 | }
90 | self.viewerShown = false
91 |
92 | return
93 | }
94 | withAnimation(.interactiveSpring()) {
95 | self.dragOffset = .zero
96 | }
97 | }
98 | )
99 |
100 | if(self.caption != nil) {
101 | VStack {
102 | Spacer()
103 |
104 | VStack {
105 | Spacer()
106 |
107 | HStack {
108 | Spacer()
109 |
110 | self.caption
111 | .foregroundColor(.white)
112 | .multilineTextAlignment(.center)
113 |
114 | Spacer()
115 | }
116 | }
117 | .padding()
118 | .frame(maxWidth: .infinity, maxHeight: .infinity)
119 | }
120 | .frame(maxWidth: .infinity, maxHeight: .infinity)
121 | }
122 | }
123 | }
124 | .frame(maxWidth: .infinity, maxHeight: .infinity)
125 | .background(Color(red: 0.12, green: 0.12, blue: 0.12, opacity: (1.0 - Double(abs(self.dragOffset.width) + abs(self.dragOffset.height)) / 1000)).edgesIgnoringSafeArea(.all))
126 | .zIndex(1)
127 | }
128 | .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2)))
129 | .onAppear() {
130 | self.dragOffset = .zero
131 | self.dragOffsetPredicted = .zero
132 | }
133 | }
134 | }
135 | .frame(maxWidth: .infinity, maxHeight: .infinity)
136 | }
137 | }
138 |
139 |
140 | class PinchZoomView: UIView {
141 |
142 | weak var delegate: PinchZoomViewDelgate?
143 |
144 | private(set) var scale: CGFloat = 0 {
145 | didSet {
146 | delegate?.pinchZoomView(self, didChangeScale: scale)
147 | }
148 | }
149 |
150 | private(set) var anchor: UnitPoint = .center {
151 | didSet {
152 | delegate?.pinchZoomView(self, didChangeAnchor: anchor)
153 | }
154 | }
155 |
156 | private(set) var offset: CGSize = .zero {
157 | didSet {
158 | delegate?.pinchZoomView(self, didChangeOffset: offset)
159 | }
160 | }
161 |
162 | private(set) var isPinching: Bool = false {
163 | didSet {
164 | delegate?.pinchZoomView(self, didChangePinching: isPinching)
165 | }
166 | }
167 |
168 | private var startLocation: CGPoint = .zero
169 | private var location: CGPoint = .zero
170 | private var numberOfTouches: Int = 0
171 |
172 | init() {
173 | super.init(frame: .zero)
174 |
175 | let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
176 | pinchGesture.cancelsTouchesInView = false
177 | addGestureRecognizer(pinchGesture)
178 | }
179 |
180 | required init?(coder: NSCoder) {
181 | fatalError()
182 | }
183 |
184 | @objc private func pinch(gesture: UIPinchGestureRecognizer) {
185 |
186 | switch gesture.state {
187 | case .began:
188 | isPinching = true
189 | startLocation = gesture.location(in: self)
190 | anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height)
191 | numberOfTouches = gesture.numberOfTouches
192 |
193 | case .changed:
194 | if gesture.numberOfTouches != numberOfTouches {
195 | // If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping.
196 | let newLocation = gesture.location(in: self)
197 | let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y)
198 | startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height)
199 |
200 | numberOfTouches = gesture.numberOfTouches
201 | }
202 |
203 | scale = gesture.scale
204 |
205 | location = gesture.location(in: self)
206 | offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
207 |
208 | case .ended, .cancelled, .failed:
209 | withAnimation(.interactiveSpring()) {
210 | isPinching = false
211 | scale = 1.0
212 | anchor = .center
213 | offset = .zero
214 | }
215 | default:
216 | break
217 | }
218 | }
219 |
220 | }
221 |
222 | protocol PinchZoomViewDelgate: AnyObject {
223 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool)
224 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat)
225 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint)
226 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)
227 | }
228 |
229 | struct PinchZoom: UIViewRepresentable {
230 |
231 | @Binding var scale: CGFloat
232 | @Binding var anchor: UnitPoint
233 | @Binding var offset: CGSize
234 | @Binding var isPinching: Bool
235 |
236 | func makeCoordinator() -> Coordinator {
237 | Coordinator(self)
238 | }
239 |
240 | func makeUIView(context: Context) -> PinchZoomView {
241 | let pinchZoomView = PinchZoomView()
242 | pinchZoomView.delegate = context.coordinator
243 | return pinchZoomView
244 | }
245 |
246 | func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
247 |
248 | class Coordinator: NSObject, PinchZoomViewDelgate {
249 | var pinchZoom: PinchZoom
250 |
251 | init(_ pinchZoom: PinchZoom) {
252 | self.pinchZoom = pinchZoom
253 | }
254 |
255 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) {
256 | pinchZoom.isPinching = isPinching
257 | }
258 |
259 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) {
260 | pinchZoom.scale = scale
261 | }
262 |
263 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) {
264 | pinchZoom.anchor = anchor
265 | }
266 |
267 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) {
268 | pinchZoom.offset = offset
269 | }
270 | }
271 | }
272 |
273 | struct PinchToZoom: ViewModifier {
274 | @State var scale: CGFloat = 1.0
275 | @State var anchor: UnitPoint = .center
276 | @State var offset: CGSize = .zero
277 | @State var isPinching: Bool = false
278 |
279 | func body(content: Content) -> some View {
280 | content
281 | .scaleEffect(scale, anchor: anchor)
282 | .offset(offset)
283 | .overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching))
284 | }
285 | }
286 |
287 | extension View {
288 | func pinchToZoom() -> some View {
289 | self.modifier(PinchToZoom())
290 | }
291 | }
292 |
--------------------------------------------------------------------------------
/Sources/ImageViewerRemote/ImageViewerRemote.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 | import URLImage
4 | import Combine
5 |
6 | @available(iOS 13.0, *)
7 | public struct ImageViewerRemote: View {
8 | @Binding var viewerShown: Bool
9 | @Binding var imageURL: String
10 | @State var httpHeaders: [String: String]?
11 | @State var disableCache: Bool?
12 | @State var caption: Text?
13 | @State var closeButtonTopRight: Bool?
14 |
15 | var aspectRatio: Binding?
16 |
17 | @State var dragOffset: CGSize = CGSize.zero
18 | @State var dragOffsetPredicted: CGSize = CGSize.zero
19 |
20 | @ObservedObject var loader: ImageLoader
21 |
22 | public init(imageURL: Binding, viewerShown: Binding, aspectRatio: Binding? = nil, disableCache: Bool? = nil, caption: Text? = nil, closeButtonTopRight: Bool? = false) {
23 | _imageURL = imageURL
24 | _viewerShown = viewerShown
25 | _disableCache = State(initialValue: disableCache)
26 | self.aspectRatio = aspectRatio
27 | _caption = State(initialValue: caption)
28 | _closeButtonTopRight = State(initialValue: closeButtonTopRight)
29 |
30 | loader = ImageLoader(url: imageURL)
31 | }
32 |
33 | @ViewBuilder
34 | public var body: some View {
35 | VStack {
36 | if(viewerShown && imageURL.count > 0) {
37 | ZStack {
38 | VStack {
39 | HStack {
40 |
41 | if self.closeButtonTopRight == true {
42 | Spacer()
43 | }
44 |
45 | Button(action: { self.viewerShown = false }) {
46 | Image(systemName: "xmark")
47 | .foregroundColor(Color(UIColor.white))
48 | .font(.system(size: UIFontMetrics.default.scaledValue(for: 24)))
49 | }
50 |
51 |
52 | if self.closeButtonTopRight != true {
53 | Spacer()
54 | }
55 | }
56 |
57 | Spacer()
58 | }
59 | .padding()
60 | .zIndex(2)
61 |
62 | VStack {
63 | ZStack {
64 | if(self.disableCache == nil || self.disableCache == false) {
65 | URLImage(url: URL(string: self.imageURL) ?? URL(string: "https://via.placeholder.com/150.png")!, content: { image in
66 | image
67 | .resizable()
68 | .aspectRatio(self.aspectRatio?.wrappedValue, contentMode: .fit)
69 | .offset(x: self.dragOffset.width, y: self.dragOffset.height)
70 | .rotationEffect(.init(degrees: Double(self.dragOffset.width / 30)))
71 | .pinchToZoom()
72 | .gesture(DragGesture()
73 | .onChanged { value in
74 | self.dragOffset = value.translation
75 | self.dragOffsetPredicted = value.predictedEndTranslation
76 | }
77 | .onEnded { value in
78 | if((abs(self.dragOffset.height) + abs(self.dragOffset.width) > 570) || ((abs(self.dragOffsetPredicted.height)) / (abs(self.dragOffset.height)) > 3) || ((abs(self.dragOffsetPredicted.width)) / (abs(self.dragOffset.width))) > 3) {
79 | withAnimation(.spring()) {
80 | self.dragOffset = self.dragOffsetPredicted
81 | }
82 | self.viewerShown = false
83 | return
84 | }
85 | withAnimation(.interactiveSpring()) {
86 | self.dragOffset = .zero
87 | }
88 | }
89 | )
90 | })
91 | }
92 | else {
93 | if loader.image != nil {
94 | Image(uiImage: loader.image!)
95 | .resizable()
96 | .aspectRatio(self.aspectRatio?.wrappedValue, contentMode: .fit)
97 | .offset(x: self.dragOffset.width, y: self.dragOffset.height)
98 | .rotationEffect(.init(degrees: Double(self.dragOffset.width / 30)))
99 | .pinchToZoom()
100 | .gesture(DragGesture()
101 | .onChanged { value in
102 | self.dragOffset = value.translation
103 | self.dragOffsetPredicted = value.predictedEndTranslation
104 | }
105 | .onEnded { value in
106 | if((abs(self.dragOffset.height) + abs(self.dragOffset.width) > 570) || ((abs(self.dragOffsetPredicted.height)) / (abs(self.dragOffset.height)) > 3) || ((abs(self.dragOffsetPredicted.width)) / (abs(self.dragOffset.width))) > 3) {
107 | withAnimation(.spring()) {
108 | self.dragOffset = self.dragOffsetPredicted
109 | }
110 | self.viewerShown = false
111 | return
112 | }
113 | withAnimation(.interactiveSpring()) {
114 | self.dragOffset = .zero
115 | }
116 | }
117 | )
118 | }
119 | else {
120 | Text(":/")
121 | }
122 | }
123 |
124 | if(self.caption != nil) {
125 | VStack {
126 | Spacer()
127 |
128 | VStack {
129 | Spacer()
130 |
131 | HStack {
132 | Spacer()
133 |
134 | self.caption
135 | .foregroundColor(.white)
136 | .multilineTextAlignment(.center)
137 |
138 | Spacer()
139 | }
140 | }
141 | .padding()
142 | .frame(maxWidth: .infinity, maxHeight: .infinity)
143 | }
144 | .frame(maxWidth: .infinity, maxHeight: .infinity)
145 | }
146 | }
147 | }
148 | .frame(maxWidth: .infinity, maxHeight: .infinity)
149 | .background(Color(red: 0.12, green: 0.12, blue: 0.12, opacity: (1.0 - Double(abs(self.dragOffset.width) + abs(self.dragOffset.height)) / 1000)).edgesIgnoringSafeArea(.all))
150 | .zIndex(1)
151 | }
152 | .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2)))
153 | .onAppear() {
154 | self.dragOffset = .zero
155 | self.dragOffsetPredicted = .zero
156 | }
157 | }
158 | }
159 | .frame(maxWidth: .infinity, maxHeight: .infinity)
160 | }
161 | }
162 |
163 | class PinchZoomView: UIView {
164 |
165 | weak var delegate: PinchZoomViewDelgate?
166 |
167 | private(set) var scale: CGFloat = 0 {
168 | didSet {
169 | delegate?.pinchZoomView(self, didChangeScale: scale)
170 | }
171 | }
172 |
173 | private(set) var anchor: UnitPoint = .center {
174 | didSet {
175 | delegate?.pinchZoomView(self, didChangeAnchor: anchor)
176 | }
177 | }
178 |
179 | private(set) var offset: CGSize = .zero {
180 | didSet {
181 | delegate?.pinchZoomView(self, didChangeOffset: offset)
182 | }
183 | }
184 |
185 | private(set) var isPinching: Bool = false {
186 | didSet {
187 | delegate?.pinchZoomView(self, didChangePinching: isPinching)
188 | }
189 | }
190 |
191 | private var startLocation: CGPoint = .zero
192 | private var location: CGPoint = .zero
193 | private var numberOfTouches: Int = 0
194 |
195 | init() {
196 | super.init(frame: .zero)
197 |
198 | let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
199 | pinchGesture.cancelsTouchesInView = false
200 | addGestureRecognizer(pinchGesture)
201 | }
202 |
203 | required init?(coder: NSCoder) {
204 | fatalError()
205 | }
206 |
207 | @objc private func pinch(gesture: UIPinchGestureRecognizer) {
208 |
209 | switch gesture.state {
210 | case .began:
211 | isPinching = true
212 | startLocation = gesture.location(in: self)
213 | anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height)
214 | numberOfTouches = gesture.numberOfTouches
215 |
216 | case .changed:
217 | if gesture.numberOfTouches != numberOfTouches {
218 | // If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping.
219 | let newLocation = gesture.location(in: self)
220 | let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y)
221 | startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height)
222 |
223 | numberOfTouches = gesture.numberOfTouches
224 | }
225 |
226 | scale = gesture.scale
227 |
228 | location = gesture.location(in: self)
229 | offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
230 |
231 | case .ended, .cancelled, .failed:
232 | withAnimation(.interactiveSpring()) {
233 | isPinching = false
234 | scale = 1.0
235 | anchor = .center
236 | offset = .zero
237 | }
238 | default:
239 | break
240 | }
241 | }
242 |
243 | }
244 |
245 | protocol PinchZoomViewDelgate: AnyObject {
246 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool)
247 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat)
248 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint)
249 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)
250 | }
251 |
252 | struct PinchZoom: UIViewRepresentable {
253 |
254 | @Binding var scale: CGFloat
255 | @Binding var anchor: UnitPoint
256 | @Binding var offset: CGSize
257 | @Binding var isPinching: Bool
258 |
259 | func makeCoordinator() -> Coordinator {
260 | Coordinator(self)
261 | }
262 |
263 | func makeUIView(context: Context) -> PinchZoomView {
264 | let pinchZoomView = PinchZoomView()
265 | pinchZoomView.delegate = context.coordinator
266 | return pinchZoomView
267 | }
268 |
269 | func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
270 |
271 | class Coordinator: NSObject, PinchZoomViewDelgate {
272 | var pinchZoom: PinchZoom
273 |
274 | init(_ pinchZoom: PinchZoom) {
275 | self.pinchZoom = pinchZoom
276 | }
277 |
278 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) {
279 | pinchZoom.isPinching = isPinching
280 | }
281 |
282 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) {
283 | pinchZoom.scale = scale
284 | }
285 |
286 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) {
287 | pinchZoom.anchor = anchor
288 | }
289 |
290 | func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) {
291 | pinchZoom.offset = offset
292 | }
293 | }
294 | }
295 |
296 | struct PinchToZoom: ViewModifier {
297 | @State var scale: CGFloat = 1.0
298 | @State var anchor: UnitPoint = .center
299 | @State var offset: CGSize = .zero
300 | @State var isPinching: Bool = false
301 |
302 | func body(content: Content) -> some View {
303 | content
304 | .scaleEffect(scale, anchor: anchor)
305 | .offset(offset)
306 | .overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching))
307 | }
308 | }
309 |
310 | extension View {
311 | func pinchToZoom() -> some View {
312 | self.modifier(PinchToZoom())
313 | }
314 | }
315 |
316 |
317 |
318 |
319 |
320 |
321 | class ImageLoader: ObservableObject {
322 | @Published var image: UIImage?
323 | private let url: Binding
324 | private var cancellable: AnyCancellable?
325 |
326 | func getURLRequest(url: String) -> URLRequest {
327 | let url = URL(string: url) ?? URL(string: "https://via.placeholder.com/150.png")!
328 | var request = URLRequest(url: url)
329 | request.httpMethod = "GET"
330 |
331 | return request;
332 | }
333 |
334 | init(url: Binding) {
335 | self.url = url
336 |
337 | if(url.wrappedValue.count > 0) {
338 | load()
339 | }
340 | }
341 |
342 | deinit {
343 | cancellable?.cancel()
344 | }
345 |
346 | func load() {
347 | cancellable = URLSession.shared.dataTaskPublisher(for: getURLRequest(url: self.url.wrappedValue))
348 | .map { UIImage(data: $0.data) }
349 | .replaceError(with: nil)
350 | .receive(on: DispatchQueue.main)
351 | .assign(to: \.image, on: self)
352 | }
353 |
354 | func cancel() {
355 | cancellable?.cancel()
356 | }
357 | }
358 |
--------------------------------------------------------------------------------