|
| 1 | +// |
| 2 | +// ImageScrollView.swift |
| 3 | +// Beauty |
| 4 | +// |
| 5 | +// Created by Nguyen Cong Huy on 1/19/16. |
| 6 | +// Copyright © 2016 Nguyen Cong Huy. All rights reserved. |
| 7 | +// |
| 8 | + |
| 9 | +import UIKit |
| 10 | + |
| 11 | +@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate { |
| 12 | + func imageScrollViewDidChangeOrientation(imageScrollView: ImageScrollView) |
| 13 | +} |
| 14 | + |
| 15 | +open class ImageScrollView: UIScrollView { |
| 16 | + |
| 17 | +// @objc public enum ScaleMode: Int { |
| 18 | +// case aspectFill |
| 19 | +// case aspectFit |
| 20 | +// case widthFill |
| 21 | +// case heightFill |
| 22 | +// } |
| 23 | + |
| 24 | + @objc public enum Offset: Int { |
| 25 | + case begining |
| 26 | + case center |
| 27 | + } |
| 28 | + |
| 29 | + static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2 |
| 30 | + |
| 31 | +// @objc open var imageContentMode: ScaleMode = .widthFill |
| 32 | + @objc open var initialOffset: Offset = .begining |
| 33 | + |
| 34 | + private var _zoomView: UIImageView? = nil |
| 35 | + @objc public var zoomView: UIImageView? { |
| 36 | + get { |
| 37 | + return self._zoomView |
| 38 | + } |
| 39 | + set { |
| 40 | + if (_zoomView != nil) { |
| 41 | + _zoomView?.removeFromSuperview() |
| 42 | + } |
| 43 | + self._zoomView = newValue |
| 44 | + _zoomView!.isUserInteractionEnabled = true |
| 45 | + addSubview(_zoomView!) |
| 46 | + |
| 47 | + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(ImageScrollView.doubleTapGestureRecognizer(_:))) |
| 48 | + tapGesture.numberOfTapsRequired = 2 |
| 49 | + zoomView!.addGestureRecognizer(tapGesture) |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + @objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate? |
| 54 | + |
| 55 | + var imageSize: CGSize = CGSize.zero |
| 56 | + private var pointToCenterAfterResize: CGPoint = CGPoint.zero |
| 57 | + private var scaleToRestoreAfterResize: CGFloat = 1.0 |
| 58 | + open var maxScaleFromMinScale: CGFloat = 3.0 |
| 59 | + |
| 60 | + override open var frame: CGRect { |
| 61 | + willSet { |
| 62 | + if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false { |
| 63 | + prepareToResize() |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + didSet { |
| 68 | + if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false { |
| 69 | + recoverFromResizing() |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + override public init(frame: CGRect) { |
| 75 | + super.init(frame: frame) |
| 76 | + |
| 77 | + initialize() |
| 78 | + } |
| 79 | + |
| 80 | + required public init?(coder aDecoder: NSCoder) { |
| 81 | + super.init(coder: aDecoder) |
| 82 | + |
| 83 | + initialize() |
| 84 | + } |
| 85 | + |
| 86 | + deinit { |
| 87 | + NotificationCenter.default.removeObserver(self) |
| 88 | + } |
| 89 | + |
| 90 | + private func initialize() { |
| 91 | + showsVerticalScrollIndicator = false |
| 92 | + showsHorizontalScrollIndicator = false |
| 93 | + bouncesZoom = true |
| 94 | + decelerationRate = UIScrollView.DecelerationRate.fast |
| 95 | + delegate = self |
| 96 | + |
| 97 | + NotificationCenter.default.addObserver(self, selector: #selector(ImageScrollView.changeOrientationNotification), name: UIDevice.orientationDidChangeNotification, object: nil) |
| 98 | + } |
| 99 | + |
| 100 | + @objc public func adjustFrameToCenter() { |
| 101 | + |
| 102 | + guard let unwrappedZoomView = _zoomView else { |
| 103 | + return |
| 104 | + } |
| 105 | + |
| 106 | + var frameToCenter = unwrappedZoomView.frame |
| 107 | + |
| 108 | + // center horizontally |
| 109 | + if frameToCenter.size.width < bounds.width { |
| 110 | + frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2 |
| 111 | + } |
| 112 | + else { |
| 113 | + frameToCenter.origin.x = 0 |
| 114 | + } |
| 115 | + |
| 116 | + // center vertically |
| 117 | + if frameToCenter.size.height < bounds.height { |
| 118 | + frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2 |
| 119 | + } |
| 120 | + else { |
| 121 | + frameToCenter.origin.y = 0 |
| 122 | + } |
| 123 | + |
| 124 | + unwrappedZoomView.frame = frameToCenter |
| 125 | + } |
| 126 | + |
| 127 | + private func prepareToResize() { |
| 128 | + let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY) |
| 129 | + pointToCenterAfterResize = convert(boundsCenter, to: _zoomView) |
| 130 | + |
| 131 | + scaleToRestoreAfterResize = zoomScale |
| 132 | + |
| 133 | + // If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum |
| 134 | + // allowable scale when the scale is restored. |
| 135 | + if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) { |
| 136 | + scaleToRestoreAfterResize = 0 |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + private func recoverFromResizing() { |
| 141 | + setMaxMinZoomScalesForCurrentBounds() |
| 142 | + |
| 143 | + // restore zoom scale, first making sure it is within the allowable range. |
| 144 | + let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize) |
| 145 | + zoomScale = min(maximumZoomScale, maxZoomScale) |
| 146 | + |
| 147 | + // restore center point, first making sure it is within the allowable range. |
| 148 | + |
| 149 | + // convert our desired center point back to our own coordinate space |
| 150 | + let boundsCenter = convert(pointToCenterAfterResize, to: _zoomView) |
| 151 | + |
| 152 | + // calculate the content offset that would yield that center point |
| 153 | + var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0) |
| 154 | + |
| 155 | + // restore offset, adjusted to be within the allowable range |
| 156 | + let maxOffset = maximumContentOffset() |
| 157 | + let minOffset = minimumContentOffset() |
| 158 | + |
| 159 | + var realMaxOffset = min(maxOffset.x, offset.x) |
| 160 | + offset.x = max(minOffset.x, realMaxOffset) |
| 161 | + |
| 162 | + realMaxOffset = min(maxOffset.y, offset.y) |
| 163 | + offset.y = max(minOffset.y, realMaxOffset) |
| 164 | + |
| 165 | + contentOffset = offset |
| 166 | + } |
| 167 | + |
| 168 | + private func maximumContentOffset() -> CGPoint { |
| 169 | + return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height) |
| 170 | + } |
| 171 | + |
| 172 | + private func minimumContentOffset() -> CGPoint { |
| 173 | + return CGPoint.zero |
| 174 | + } |
| 175 | + |
| 176 | + |
| 177 | + @objc func updateForImage(_ size: CGSize) { |
| 178 | + imageSize = size |
| 179 | + contentSize = imageSize |
| 180 | +// setMaxMinZoomScalesForCurrentBounds() |
| 181 | +// zoomScale = minimumZoomScale |
| 182 | + |
| 183 | + switch initialOffset { |
| 184 | + case .begining: |
| 185 | + contentOffset = CGPoint.zero |
| 186 | + case .center: |
| 187 | + let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2 |
| 188 | + let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2 |
| 189 | + |
| 190 | + switch _zoomView?.contentMode { |
| 191 | + case .scaleAspectFit: |
| 192 | + contentOffset = CGPoint.zero |
| 193 | + case .scaleAspectFill: |
| 194 | + contentOffset = CGPoint(x: xOffset, y: yOffset) |
| 195 | + case .scaleToFill: |
| 196 | + contentOffset = CGPoint(x: xOffset, y: yOffset) |
| 197 | + default: |
| 198 | + contentOffset = CGPoint.zero |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + private func setMaxMinZoomScalesForCurrentBounds() { |
| 204 | + // calculate min/max zoomscale |
| 205 | +// let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise |
| 206 | +// let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise |
| 207 | +// |
| 208 | +// var minScale: CGFloat = 1 |
| 209 | +// |
| 210 | +// switch imageContentMode { |
| 211 | +// case .aspectFill: |
| 212 | +// minScale = max(xScale, yScale) |
| 213 | +// case .aspectFit: |
| 214 | +// minScale = min(xScale, yScale) |
| 215 | +// case .widthFill: |
| 216 | +// minScale = xScale |
| 217 | +// case .heightFill: |
| 218 | +// minScale = yScale |
| 219 | +// } |
| 220 | +// |
| 221 | +// |
| 222 | +// let maxScale = maxScaleFromMinScale*minScale |
| 223 | +// |
| 224 | +// // don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.) |
| 225 | +// if minScale > maxScale { |
| 226 | +// minScale = maxScale |
| 227 | +// } |
| 228 | + |
| 229 | +// maximumZoomScale = maxScale |
| 230 | +// minimumZoomScale = minScale * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController |
| 231 | + } |
| 232 | + |
| 233 | + // MARK: - Gesture |
| 234 | + |
| 235 | + @objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { |
| 236 | + // zoom out if it bigger than the scale factor after double-tap scaling. Else, zoom in |
| 237 | + if zoomScale >= minimumZoomScale * ImageScrollView.kZoomInFactorFromMinWhenDoubleTap - 0.01 { |
| 238 | + setZoomScale(minimumZoomScale, animated: true) |
| 239 | + } else { |
| 240 | + let center = gestureRecognizer.location(in: gestureRecognizer.view) |
| 241 | + let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center) |
| 242 | + zoom(to: zoomRect, animated: true) |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect { |
| 247 | + var zoomRect = CGRect.zero |
| 248 | + |
| 249 | + // the zoom rect is in the content view's coordinates. |
| 250 | + // at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds. |
| 251 | + // as the zoom scale decreases, so more content is visible, the size of the rect grows. |
| 252 | + zoomRect.size.height = frame.size.height / scale |
| 253 | + zoomRect.size.width = frame.size.width / scale |
| 254 | + |
| 255 | + // choose an origin so as to get the right center. |
| 256 | + zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0) |
| 257 | + zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0) |
| 258 | + |
| 259 | + return zoomRect |
| 260 | + } |
| 261 | + |
| 262 | + // MARK: - Actions |
| 263 | + |
| 264 | + @objc func changeOrientationNotification() { |
| 265 | + // A weird bug that frames are not update right after orientation changed. Need delay a little bit with async. |
| 266 | + DispatchQueue.main.async { |
| 267 | + self.updateForImage(self.imageSize) |
| 268 | + self.imageScrollViewDelegate?.imageScrollViewDidChangeOrientation(imageScrollView: self) |
| 269 | + } |
| 270 | + } |
| 271 | +} |
| 272 | + |
| 273 | +extension ImageScrollView: UIScrollViewDelegate { |
| 274 | + |
| 275 | + public func scrollViewDidScroll(_ scrollView: UIScrollView) { |
| 276 | + imageScrollViewDelegate?.scrollViewDidScroll?(scrollView) |
| 277 | + } |
| 278 | + |
| 279 | + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { |
| 280 | + imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView) |
| 281 | + } |
| 282 | + |
| 283 | + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { |
| 284 | + imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) |
| 285 | + } |
| 286 | + |
| 287 | + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { |
| 288 | + imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) |
| 289 | + } |
| 290 | + |
| 291 | + public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { |
| 292 | + imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView) |
| 293 | + } |
| 294 | + |
| 295 | + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { |
| 296 | + imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView) |
| 297 | + } |
| 298 | + |
| 299 | + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { |
| 300 | + imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) |
| 301 | + } |
| 302 | + |
| 303 | + public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { |
| 304 | + imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view) |
| 305 | + } |
| 306 | + |
| 307 | + public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { |
| 308 | + imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) |
| 309 | + } |
| 310 | + |
| 311 | + public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { |
| 312 | + return false |
| 313 | + } |
| 314 | + |
| 315 | + @available(iOS 11.0, *) |
| 316 | + public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { |
| 317 | + imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView) |
| 318 | + } |
| 319 | + |
| 320 | + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { |
| 321 | + return _zoomView |
| 322 | + } |
| 323 | + |
| 324 | + public func scrollViewDidZoom(_ scrollView: UIScrollView) { |
| 325 | + adjustFrameToCenter() |
| 326 | + imageScrollViewDelegate?.scrollViewDidZoom?(scrollView) |
| 327 | + } |
| 328 | + |
| 329 | +} |
0 commit comments