├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── HISTORY.md
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── ZoomableImageView
│ ├── ZoomableImageUIView.swift
│ └── ZoomableImageView.swift
└── Tests
└── ZoomableImageViewTests
└── ZoomableImageViewTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/HISTORY.md:
--------------------------------------------------------------------------------
1 | # 1.0.2
2 |
3 | `2021-10-20`
4 | ### Added
5 | - Add support for tvOS, macCatalyst
6 |
7 | # 1.0.1
8 |
9 | `2021-08-13`
10 | ### Fixed
11 | - Fix for iOS 15 beta 5.
12 |
13 | # 1.0.0
14 |
15 | `2021-08-02`
16 | ### Added
17 | - Create the package.
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021-present, Aben
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
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: "ZoomableImageView",
8 | platforms: [.iOS(.v13), .tvOS(.v13)],
9 | products: [
10 | .library(
11 | name: "ZoomableImageView",
12 | targets: ["ZoomableImageView"]),
13 | ],
14 | dependencies: [
15 | ],
16 | targets: [
17 | .target(
18 | name: "ZoomableImageView",
19 | dependencies: []),
20 | .testTarget(
21 | name: "ZoomableImageViewTests",
22 | dependencies: ["ZoomableImageView"]),
23 | ],
24 | swiftLanguageVersions: [.v5]
25 | )
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ZoomableImageView
2 |
3 |    
4 |
5 | Simple SwiftUI ImageView that enables dragging and zooming.
6 |
7 | ## Declaration
8 |
9 | ```swift
10 | struct ZoomableImageView
11 | ```
12 |
13 | ### Overview
14 |
15 | Double Tap the view will zoom-in.
16 |
17 | ```swift
18 | ZoomableImageView(image: UIImage(systemName: "photo")!)
19 | ```
20 |
21 | ```swift
22 | @State var image: UIImage = UIImage()
23 |
24 | var body: some View {
25 | ZoomableImageView(image: image, maximumZoomScale: 10)
26 | .task {
27 | do {
28 | let url = URL(string: "https://apod.nasa.gov/apod/image/2108/PlutoEnhancedHiRes_NewHorizons_960.jpg")!
29 | let (imageLocalURL, _) = try await URLSession.shared.download(from: url)
30 | let imageData = try Data(contentsOf: imageLocalURL)
31 | image = UIImage(data: imageData)!
32 | } catch {
33 | print(error)
34 | }
35 | }
36 | }
37 | ```
38 |
39 | ### History
40 |
41 | [History](./HISTORY.md)
42 |
43 |
44 | ### LICENSE
45 |
46 | [The MIT License (MIT)](./LICENSE)
47 |
--------------------------------------------------------------------------------
/Sources/ZoomableImageView/ZoomableImageUIView.swift:
--------------------------------------------------------------------------------
1 | #if os(iOS) || os(tvOS)
2 |
3 | import UIKit
4 |
5 | class ZoomableImageUIView: UIView, UIScrollViewDelegate {
6 |
7 | static let kMaximumZoomScale: CGFloat = 3.0
8 | static let kMinimumZoomScale: CGFloat = 1.0
9 |
10 | private var onSingleTapGesture: ((UITapGestureRecognizer) -> Void)?
11 |
12 | private lazy var scrollView: UIScrollView = {
13 | let scrollView = UIScrollView(frame: self.bounds)
14 | scrollView.bouncesZoom = true
15 | scrollView.maximumZoomScale = self.maximumZoomScale
16 | scrollView.minimumZoomScale = ZoomableImageUIView.kMinimumZoomScale
17 | #if !os(tvOS)
18 | scrollView.isMultipleTouchEnabled = true
19 | #endif
20 | scrollView.delegate = self
21 | #if !os(tvOS)
22 | scrollView.scrollsToTop = false
23 | #endif
24 | scrollView.showsVerticalScrollIndicator = false
25 | scrollView.showsHorizontalScrollIndicator = false
26 | scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
27 | scrollView.delaysContentTouches = false
28 | scrollView.canCancelContentTouches = true
29 | scrollView.alwaysBounceVertical = false
30 | scrollView.contentInsetAdjustmentBehavior = .never
31 | self.addSubview(scrollView)
32 | return scrollView
33 | }()
34 |
35 | private lazy var imageContainerView: UIView = {
36 | let imageContainerView = UIView()
37 | imageContainerView.clipsToBounds = true
38 | imageContainerView.contentMode = .scaleAspectFill
39 | scrollView.addSubview(imageContainerView)
40 | return imageContainerView
41 | }()
42 |
43 | private lazy var imageView: UIImageView = {
44 | let imageView = UIImageView(frame: UIScreen.main.bounds)
45 | imageView.backgroundColor = UIColor(white: 1.0, alpha: 0.5)
46 | imageView.contentMode = .scaleAspectFill
47 | imageView.clipsToBounds = true
48 | imageContainerView.addSubview(imageView)
49 | return imageView
50 | }()
51 |
52 | private lazy var maximumZoomScale: CGFloat = {
53 | return ZoomableImageUIView.kMaximumZoomScale
54 | }()
55 |
56 | init(frame: CGRect, maximumZoomScale: CGFloat = 3.0, onSingleTapGesture: ((UITapGestureRecognizer) -> Void)? = nil) {
57 | super.init(frame: frame)
58 |
59 | self.maximumZoomScale = maximumZoomScale
60 | self.onSingleTapGesture = onSingleTapGesture
61 |
62 | let singleTap = UITapGestureRecognizer(target: self, action: #selector(onSingleTap(tap:)))
63 | self.addGestureRecognizer(singleTap)
64 |
65 | let doubleTap = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap(tap:)))
66 | doubleTap.numberOfTapsRequired = 2
67 | singleTap.require(toFail: doubleTap)
68 | imageContainerView.addGestureRecognizer(doubleTap)
69 | }
70 |
71 | required init?(coder: NSCoder) {
72 | fatalError("init(coder:) has not been implemented")
73 | }
74 |
75 | func updateImage(image: UIImage) {
76 | imageView.image = image
77 | recoverSubviews()
78 | }
79 |
80 | override func updateConstraints() {
81 | NSLayoutConstraint.activate([
82 | scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
83 | scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
84 | scrollView.topAnchor.constraint(equalTo: self.topAnchor),
85 | scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
86 | ])
87 | super.updateConstraints()
88 | }
89 |
90 | override func layoutSubviews() {
91 | super.layoutSubviews()
92 | recoverSubviews()
93 | }
94 |
95 | @objc
96 | func onSingleTap(tap: UITapGestureRecognizer) {
97 | guard let onSingleTapGesture = onSingleTapGesture else { return }
98 | onSingleTapGesture(tap)
99 | }
100 |
101 | @objc
102 | func onDoubleTap(tap: UITapGestureRecognizer) {
103 | if (scrollView.zoomScale > 1.0) {
104 | scrollView.contentInset = .zero
105 | scrollView.setZoomScale(1.0, animated: true)
106 | } else {
107 | let touchPoint: CGPoint = tap.location(in: self.imageView)
108 | let newZoomScale: CGFloat = scrollView.maximumZoomScale
109 | let sizeWidth: CGFloat = UIScreen.main.bounds.size.width / newZoomScale
110 | let sizeHeight: CGFloat = UIScreen.main.bounds.size.height / newZoomScale
111 | scrollView.zoom(to: CGRect(x: touchPoint.x - sizeWidth / 2.0,
112 | y: touchPoint.y - sizeHeight / 2.0,
113 | width: sizeWidth,
114 | height: sizeHeight),
115 | animated: true)
116 | }
117 | }
118 |
119 | func recoverSubviews() {
120 | scrollView.setZoomScale(1.0, animated: false)
121 | resizeSubviews()
122 | }
123 |
124 | func resizeSubviews() {
125 | imageContainerView.frame.size = self.bounds.size
126 | imageContainerView.center = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
127 |
128 | let imageWidth: CGFloat = imageView.image!.size.width
129 | let imageHeight: CGFloat = imageView.image!.size.height
130 |
131 | if (imageWidth / imageHeight > self.bounds.size.width / self.bounds.size.height * 3) {
132 | // 超宽图
133 | imageContainerView.frame.size.width = self.bounds.size.width
134 | imageContainerView.frame.size.height = imageContainerView.bounds.size.width / imageWidth * imageHeight
135 |
136 | scrollView.maximumZoomScale = self.bounds.size.height / imageContainerView.bounds.size.height
137 |
138 | let contentSizeWidth: CGFloat = max(imageContainerView.bounds.size.width, self.bounds.size.width)
139 | scrollView.contentSize = CGSize(width: contentSizeWidth, height: self.bounds.size.height)
140 |
141 | imageContainerView.center.y = self.bounds.size.height / 2.0
142 | imageContainerView.center.x = self.bounds.size.width / 2.0
143 | } else if (imageHeight / imageWidth > self.bounds.size.height / self.bounds.size.width * 3) {
144 | // 超高图
145 | imageContainerView.frame.size.height = self.bounds.size.height
146 | imageContainerView.frame.size.width = imageContainerView.bounds.size.height / imageHeight * imageWidth
147 |
148 | scrollView.maximumZoomScale = self.bounds.size.width / imageContainerView.bounds.size.width
149 |
150 | let contentSizeHeight: CGFloat = max(imageContainerView.bounds.size.height, self.bounds.size.height)
151 | scrollView.contentSize = CGSize(width: self.bounds.size.width, height: contentSizeHeight)
152 |
153 | imageContainerView.center.y = self.bounds.size.height / 2.0
154 | imageContainerView.center.x = self.bounds.size.width / 2.0
155 | } else {
156 | if ((imageWidth / imageHeight) > (self.bounds.size.width / self.bounds.size.height)) {
157 | // 左右对齐
158 | imageContainerView.frame.size.width = self.bounds.size.width
159 |
160 | var height: CGFloat = imageHeight / imageWidth * self.bounds.size.width
161 | if (height < 1 || height.isNaN) { height = self.bounds.size.height }
162 | height = floor(height)
163 | imageContainerView.frame.size.height = height
164 |
165 | let contentSizeHeight: CGFloat = max(imageContainerView.bounds.size.height, self.bounds.size.height)
166 | scrollView.contentSize = CGSize(width: self.bounds.size.width, height: contentSizeHeight)
167 |
168 | imageContainerView.center.y = self.bounds.size.height / 2.0
169 | imageContainerView.center.x = self.bounds.size.width / 2.0
170 | } else {
171 | // 上下对齐
172 | imageContainerView.frame.size.height = self.bounds.size.height
173 |
174 | var width = imageWidth / imageHeight * self.bounds.size.height
175 | if (width < 1 || width.isNaN) { width = self.bounds.size.width }
176 | width = floor(width)
177 | imageContainerView.frame.size.width = width
178 |
179 | let contentSizeWidth: CGFloat = max(imageContainerView.bounds.size.width, self.bounds.size.width)
180 | scrollView.contentSize = CGSize(width: contentSizeWidth, height: self.bounds.size.height)
181 |
182 | imageContainerView.center.y = self.bounds.size.height / 2.0
183 | imageContainerView.center.x = self.bounds.size.width / 2.0
184 | }
185 | scrollView.maximumZoomScale = self.maximumZoomScale
186 | }
187 | scrollView.scrollRectToVisible(self.bounds, animated: false)
188 | scrollView.alwaysBounceVertical = imageContainerView.bounds.size.height > self.bounds.size.height
189 |
190 | imageView.frame = imageContainerView.bounds
191 | }
192 |
193 | func refreshImageContainerViewCenter() {
194 | let offsetX: CGFloat = (scrollView.bounds.size.width > scrollView.contentSize.width) ? ((scrollView.bounds.size.width - scrollView.contentSize.width) * 0.5) : 0.0
195 | let offsetY: CGFloat = (scrollView.bounds.size.height > scrollView.contentSize.height) ? ((scrollView.bounds.size.height - scrollView.contentSize.height) * 0.5) : 0.0
196 | imageContainerView.center = CGPoint(x: scrollView.contentSize.width * 0.5 + offsetX, y: scrollView.contentSize.height * 0.5 + offsetY)
197 | }
198 |
199 | func viewForZooming(in scrollView: UIScrollView) -> UIView? {
200 | return imageContainerView
201 | }
202 |
203 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
204 | scrollView.contentInset = .zero
205 | }
206 |
207 | func scrollViewDidZoom(_ scrollView: UIScrollView) {
208 | refreshImageContainerViewCenter()
209 | }
210 | }
211 |
212 | #endif
213 |
--------------------------------------------------------------------------------
/Sources/ZoomableImageView/ZoomableImageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ZoomableImageView.swift
3 | // ZoomableImageView
4 | //
5 | // Created by Abenx on 2021/8/2.
6 | //
7 | #if os(iOS) || os(tvOS)
8 | import SwiftUI
9 |
10 | /// Simple SwiftUI ImageView that enables dragging and zooming.
11 | ///
12 | /// Double Tap the view will zoom-in.
13 | ///
14 | /// ```swift
15 | /// ZoomableImageView(image: UIImage(systemName: "photo")!)
16 | /// ```
17 | ///
18 | /// ```swift
19 | /// @State var image: UIImage = UIImage()
20 | ///
21 | /// var body: some View {
22 | /// ZoomableImageView(image: image, maximumZoomScale: 10)
23 | /// .task {
24 | /// do {
25 | /// let url = URL(string: "https://apod.nasa.gov/apod/image/2108/PlutoEnhancedHiRes_NewHorizons_960.jpg")!
26 | /// let (imageLocalURL, _) = try await URLSession.shared.download(from: url)
27 | /// let imageData = try Data(contentsOf: imageLocalURL)
28 | /// image = UIImage(data: imageData)!
29 | /// } catch {
30 | /// print(error)
31 | /// }
32 | /// }
33 | /// }
34 | /// ```
35 | public struct ZoomableImageView: View {
36 | var image: UIImage
37 | var onSingleTapGesture: ((UITapGestureRecognizer) -> Void)?
38 | var maximumZoomScale: CGFloat
39 |
40 | /// Create a ZoomableImageView.
41 | /// - Parameters:
42 | /// - image: The image to show.
43 | /// - maximumZoomScale: The maximum zoomScale you can zoom-in the image. Default: 3.
44 | /// - onSingleTapGesture: The callback action when the imageView on single tap gesture.
45 | public init(image: UIImage, maximumZoomScale: CGFloat = 3.0, onSingleTapGesture: ((UITapGestureRecognizer) -> Void)? = nil) {
46 | self.image = image
47 | self.maximumZoomScale = maximumZoomScale
48 | self.onSingleTapGesture = onSingleTapGesture
49 | }
50 |
51 | public var body: some View {
52 | GeometryReader { proxy in
53 | Representable(image: image, maximumZoomScale: maximumZoomScale, frame: proxy.frame(in: .global), onSingleTapGesture: onSingleTapGesture)
54 | }
55 | }
56 | }
57 |
58 | extension ZoomableImageView {
59 | typealias ViewRepresentable = UIViewRepresentable
60 |
61 | struct Representable: ViewRepresentable {
62 | typealias UIViewType = ZoomableImageUIView
63 |
64 | let image: UIImage
65 | let maximumZoomScale: CGFloat
66 | let frame: CGRect
67 | var onSingleTapGesture: ((UITapGestureRecognizer) -> Void)?
68 |
69 | func makeUIView(context: Context) -> ZoomableImageUIView {
70 | return ZoomableImageUIView(frame: frame, maximumZoomScale: maximumZoomScale, onSingleTapGesture: onSingleTapGesture)
71 | }
72 |
73 | func updateUIView(_ uiView: ZoomableImageUIView, context: Context) {
74 | uiView.updateImage(image: image)
75 | }
76 | }
77 | }
78 |
79 | @available(iOS 15.0, *)
80 | @available(tvOS 15.0, *)
81 | @available(macCatalyst 15.0, *)
82 | struct ZoomableImageView_Previews: PreviewProvider {
83 |
84 | static var previews: some View {
85 | TestForZoomableImageView()
86 | }
87 |
88 | struct TestForZoomableImageView: View {
89 | @State var image: UIImage = UIImage()
90 |
91 | var body: some View {
92 | #if (os(iOS) && !targetEnvironment(macCatalyst)) || os(tvOS)
93 | ZoomableImageView(image: image, maximumZoomScale: 10)
94 | .task {
95 | do {
96 | let url = URL(string: "https://apod.nasa.gov/apod/image/2108/PlutoEnhancedHiRes_NewHorizons_960.jpg")!
97 | let (imageLocalURL, _) = try await URLSession.shared.download(from: url)
98 | let imageData = try Data(contentsOf: imageLocalURL)
99 | image = UIImage(data: imageData)!
100 | } catch {
101 | print(error)
102 | }
103 | }
104 | #else
105 | ZoomableImageView(image: UIImage(systemName: "photo")!)
106 | #endif
107 | }
108 | }
109 | }
110 |
111 | #endif
112 |
--------------------------------------------------------------------------------
/Tests/ZoomableImageViewTests/ZoomableImageViewTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ZoomableImageView
3 |
4 | #if os(iOS) && !targetEnvironment(macCatalyst)
5 | final class ZoomableImageViewTests: XCTestCase {
6 | @available(iOS 15.0, *)
7 | func testRequestImageFromURL() async throws {
8 |
9 | let url = URL(string: "https://apod.nasa.gov/apod/image/2108/PlutoEnhancedHiRes_NewHorizons_960.jpg")!
10 | let (imageLocalURL, response) = try await URLSession.shared.download(from: url)
11 | let imageData = try Data(contentsOf: imageLocalURL)
12 | XCTAssertEqual((response as! HTTPURLResponse).statusCode, 200)
13 | XCTAssertNotNil(imageData)
14 |
15 | let image = UIImage(data: imageData)
16 | XCTAssertNotNil(image)
17 | }
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------