├── .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 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/Jake-Short/swiftui-image-viewer/graphs/commit-activity) 5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | ![image](https://media2.giphy.com/media/LSKUWsW9KogOLIS2ZS/giphy.gif?cid=4d1e4f29cacda6de9a149bb9b7a2717faec03a9ebd6d5fdd&rid=giphy.gif) 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 | --------------------------------------------------------------------------------