├── .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 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/rushairer/ZoomableImageView) ![GitHub](https://img.shields.io/github/license/rushairer/ZoomableImageView) ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/rushairer/ZoomableImageView?include_prereleases) ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/rushairer/ZoomableImageView.svg) 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 | --------------------------------------------------------------------------------