Add fullscreen image previewing and zooming

This commit is contained in:
Maurice Parker
2019-10-12 14:45:44 -05:00
parent 5ed508b709
commit 3ee0506b4a
6 changed files with 546 additions and 3 deletions

View File

@@ -21,6 +21,10 @@ enum ArticleViewState: Equatable {
}
class ArticleViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
}
@IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem!
@@ -102,7 +106,10 @@ class ArticleViewController: UIViewController {
self.webViewContainer.addChildAndPin(webView)
webView.navigationDelegate = self
webView.uiDelegate = self
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked)
webView.configuration.userContentController.add(self, name: MessageName.imageWasClicked)
// Even though page.html should be loaded into this webview, we have to do it again
// to work around this bug: http://www.openradar.me/22855188
webView.loadHTMLString(ArticleRenderer.page.html, baseURL: ArticleRenderer.page.baseURL)
@@ -337,6 +344,20 @@ extension ArticleViewController: WKUIDelegate {
}
}
// MARK: WKScriptMessageHandler
extension ArticleViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == MessageName.imageWasClicked, let link = message.body as? String, let url = URL(string: link) {
let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self)
imageVC.url = url
imageVC.modalPresentationStyle = .fullScreen
present(imageVC, animated: true)
}
}
}
// MARK: Private
private extension ArticleViewController {

View File

@@ -0,0 +1,372 @@
//
// ImageScrollView.swift
// Beauty
//
// Created by Nguyen Cong Huy on 1/19/16.
// Copyright © 2016 Nguyen Cong Huy. All rights reserved.
//
import UIKit
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
func imageScrollViewDidChangeOrientation(imageScrollView: ImageScrollView)
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
}
open class ImageScrollView: UIScrollView {
@objc public enum ScaleMode: Int {
case aspectFill
case aspectFit
case widthFill
case heightFill
}
@objc public enum Offset: Int {
case begining
case center
}
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
@objc open var imageContentMode: ScaleMode = .widthFill
@objc open var initialOffset: Offset = .begining
@objc public private(set) var zoomView: UIImageView? = nil
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
var imageSize: CGSize = CGSize.zero
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
private var scaleToRestoreAfterResize: CGFloat = 1.0
var maxScaleFromMinScale: CGFloat = 3.0
override open var frame: CGRect {
willSet {
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
prepareToResize()
}
}
didSet {
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
recoverFromResizing()
}
}
}
override public init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func initialize() {
showsVerticalScrollIndicator = false
showsHorizontalScrollIndicator = false
bouncesZoom = true
decelerationRate = UIScrollView.DecelerationRate.fast
delegate = self
NotificationCenter.default.addObserver(self, selector: #selector(ImageScrollView.changeOrientationNotification), name: UIDevice.orientationDidChangeNotification, object: nil)
}
@objc public func adjustFrameToCenter() {
guard let unwrappedZoomView = zoomView else {
return
}
var frameToCenter = unwrappedZoomView.frame
// center horizontally
if frameToCenter.size.width < bounds.width {
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
}
else {
frameToCenter.origin.x = 0
}
// center vertically
if frameToCenter.size.height < bounds.height {
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
}
else {
frameToCenter.origin.y = 0
}
unwrappedZoomView.frame = frameToCenter
}
private func prepareToResize() {
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
scaleToRestoreAfterResize = zoomScale
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
// allowable scale when the scale is restored.
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
scaleToRestoreAfterResize = 0
}
}
private func recoverFromResizing() {
setMaxMinZoomScalesForCurrentBounds()
// restore zoom scale, first making sure it is within the allowable range.
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
zoomScale = min(maximumZoomScale, maxZoomScale)
// restore center point, first making sure it is within the allowable range.
// convert our desired center point back to our own coordinate space
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
// calculate the content offset that would yield that center point
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
// restore offset, adjusted to be within the allowable range
let maxOffset = maximumContentOffset()
let minOffset = minimumContentOffset()
var realMaxOffset = min(maxOffset.x, offset.x)
offset.x = max(minOffset.x, realMaxOffset)
realMaxOffset = min(maxOffset.y, offset.y)
offset.y = max(minOffset.y, realMaxOffset)
contentOffset = offset
}
private func maximumContentOffset() -> CGPoint {
return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height)
}
private func minimumContentOffset() -> CGPoint {
return CGPoint.zero
}
// MARK: - Set up
open func setup() {
var topSupperView = superview
while topSupperView?.superview != nil {
topSupperView = topSupperView?.superview
}
// Make sure views have already layout with precise frame
topSupperView?.layoutIfNeeded()
}
// MARK: - Display image
@objc open func display(image: UIImage) {
if let zoomView = zoomView {
zoomView.removeFromSuperview()
}
zoomView = UIImageView(image: image)
zoomView!.isUserInteractionEnabled = true
addSubview(zoomView!)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
tapGesture.numberOfTapsRequired = 2
zoomView!.addGestureRecognizer(tapGesture)
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
downSwipeGesture.direction = .down
zoomView!.addGestureRecognizer(downSwipeGesture)
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
upSwipeGesture.direction = .up
zoomView!.addGestureRecognizer(upSwipeGesture)
configureImageForSize(image.size)
}
private func configureImageForSize(_ size: CGSize) {
imageSize = size
contentSize = imageSize
setMaxMinZoomScalesForCurrentBounds()
zoomScale = minimumZoomScale
switch initialOffset {
case .begining:
contentOffset = CGPoint.zero
case .center:
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
switch imageContentMode {
case .aspectFit:
contentOffset = CGPoint.zero
case .aspectFill:
contentOffset = CGPoint(x: xOffset, y: yOffset)
case .heightFill:
contentOffset = CGPoint(x: xOffset, y: 0)
case .widthFill:
contentOffset = CGPoint(x: 0, y: yOffset)
}
}
}
private func setMaxMinZoomScalesForCurrentBounds() {
// calculate min/max zoomscale
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
var minScale: CGFloat = 1
switch imageContentMode {
case .aspectFill:
minScale = max(xScale, yScale)
case .aspectFit:
minScale = min(xScale, yScale)
case .widthFill:
minScale = xScale
case .heightFill:
minScale = yScale
}
let maxScale = maxScaleFromMinScale*minScale
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
if minScale > maxScale {
minScale = maxScale
}
maximumZoomScale = maxScale
minimumZoomScale = minScale * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
}
// MARK: - Gesture
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// zoom out if it bigger than middle scale point. Else, zoom in
if zoomScale >= maximumZoomScale / 2.0 {
setZoomScale(minimumZoomScale, animated: true)
}
else {
let center = gestureRecognizer.location(in: gestureRecognizer.view)
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
zoom(to: zoomRect, animated: true)
}
}
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
}
}
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
}
}
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
// the zoom rect is in the content view's coordinates.
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
zoomRect.size.height = frame.size.height / scale
zoomRect.size.width = frame.size.width / scale
// choose an origin so as to get the right center.
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
return zoomRect
}
open func refresh() {
if let image = zoomView?.image {
display(image: image)
}
}
// MARK: - Actions
@objc func changeOrientationNotification() {
// A weird bug that frames are not update right after orientation changed. Need delay a little bit with async.
DispatchQueue.main.async {
self.configureImageForSize(self.imageSize)
self.imageScrollViewDelegate?.imageScrollViewDidChangeOrientation(imageScrollView: self)
}
}
}
extension ImageScrollView: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
}
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
}
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
}
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
}
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
}
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
return false
}
@available(iOS 11.0, *)
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
}
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomView
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
adjustFrameToCenter()
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
}
}

View File

@@ -0,0 +1,69 @@
//
// ImageViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/12/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
class ImageViewController: UIViewController {
@IBOutlet weak var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet weak var imageScrollView: ImageScrollView!
private var dataTask: URLSessionDataTask? = nil
var url: URL!
override func viewDidLoad() {
super.viewDidLoad()
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
imageScrollView.setup()
imageScrollView.imageScrollViewDelegate = self
imageScrollView.imageContentMode = .aspectFit
imageScrollView.initialOffset = .center
dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.activityIndicatorView.isHidden = true
self.activityIndicatorView.stopAnimating()
self.imageScrollView.display(image: image)
}
}
}
dataTask!.resume()
}
@IBAction func done(_ sender: Any) {
dismiss(animated: true)
}
}
// MARK: ImageScrollViewDelegate
extension ImageViewController: ImageScrollViewDelegate {
func imageScrollViewDidChangeOrientation(imageScrollView: ImageScrollView) {
}
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
}

View File

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15508"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -226,6 +227,61 @@
</objects>
<point key="canvasLocation" x="900" y="-759"/>
</scene>
<!--Image View Controller-->
<scene sceneID="TT4-oA-DBw">
<objects>
<viewController storyboardIdentifier="ImageViewController" id="vO9-a3-Dnu" customClass="ImageViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="w6Q-vH-063">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView verifyAmbiguity="off" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="msG-pz-EKk" customClass="ImageScrollView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" style="large" translatesAutoresizingMaskIntoConstraints="NO" id="iEh-n3-Vkg">
<rect key="frame" x="188.5" y="390.5" width="37" height="37"/>
</activityIndicatorView>
</subviews>
<constraints>
<constraint firstItem="iEh-n3-Vkg" firstAttribute="centerX" secondItem="msG-pz-EKk" secondAttribute="centerX" id="FSP-DY-Vax"/>
<constraint firstItem="iEh-n3-Vkg" firstAttribute="centerY" secondItem="msG-pz-EKk" secondAttribute="centerY" id="rev-zC-wMY"/>
</constraints>
<viewLayoutGuide key="contentLayoutGuide" id="phv-DN-krZ"/>
<viewLayoutGuide key="frameLayoutGuide" id="NNU-C8-Fsz"/>
</scrollView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="cXR-ll-xBx">
<rect key="frame" x="0.0" y="44" width="44" height="44"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="6kc-Gw-KbZ"/>
<constraint firstAttribute="width" constant="44" id="cBq-gs-WzN"/>
</constraints>
<color key="tintColor" name="primaryAccentColor"/>
<state key="normal" image="multiply.circle.fill" catalog="system"/>
<connections>
<action selector="done:" destination="vO9-a3-Dnu" eventType="touchUpInside" id="tgd-ov-4Ft"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="mbY-02-GFL" firstAttribute="bottom" secondItem="msG-pz-EKk" secondAttribute="bottom" id="AtA-bA-jDr"/>
<constraint firstItem="mbY-02-GFL" firstAttribute="trailing" secondItem="msG-pz-EKk" secondAttribute="trailing" id="R49-qV-8nm"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" id="XN1-xN-hYS"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="p1a-s0-wdK"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" id="vJs-LN-Ydd"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="xVN-Qt-WYA"/>
</constraints>
<viewLayoutGuide key="safeArea" id="mbY-02-GFL"/>
</view>
<connections>
<outlet property="activityIndicatorView" destination="iEh-n3-Vkg" id="xue-X1-awS"/>
<outlet property="imageScrollView" destination="msG-pz-EKk" id="dGi-M6-dcO"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ZPN-tH-JAG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3056.521739130435" y="-759.375"/>
</scene>
</scenes>
<resources>
<image name="chevron.down" catalog="system" width="64" height="36"/>
@@ -233,8 +289,12 @@
<image name="chevron.up" catalog="system" width="64" height="36"/>
<image name="circle" catalog="system" width="64" height="60"/>
<image name="gear" catalog="system" width="64" height="58"/>
<image name="multiply.circle.fill" catalog="system" width="64" height="60"/>
<image name="safari" catalog="system" width="64" height="60"/>
<image name="square.and.arrow.up" catalog="system" width="56" height="64"/>
<image name="star" catalog="system" width="64" height="58"/>
<namedColor name="primaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>