Tusker/Tusker/Screens/Large Image/LargeImageViewController.swift

398 lines
17 KiB
Swift

//
// LargeImageViewController.swift
// Tusker
//
// Created by Shadowfacts on 8/31/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeImageAnimatableViewController {
weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { self }
var animationImage: UIImage? { contentView.animationImage }
var dismissInteractionController: LargeImageInteractionController?
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var topControlsView: UIView!
@IBOutlet weak var descriptionTextView: UITextView!
private var shareContainer: UIView!
private var closeContainer: UIView!
private var shareImage: UIImageView!
private var shareButtonTopConstraint: NSLayoutConstraint!
private var shareButtonLeadingConstraint: NSLayoutConstraint!
private var closeButtonTopConstraint: NSLayoutConstraint!
private var closeButtonTrailingConstraint: NSLayoutConstraint!
var contentView: LargeImageContentView {
didSet {
oldValue.removeFromSuperview()
setupContentView()
}
}
var contentViewLeadingConstraint: NSLayoutConstraint!
var contentViewTopConstraint: NSLayoutConstraint!
var imageDescription: String?
var initialControlsVisible = true
private(set) var controlsVisible = true {
didSet {
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
}
var shrinkGestureEnabled = true
private var isInitialAppearance = true
private var skipUpdatingControlsWhileZooming = false
private var prevZoomScale: CGFloat?
private var isGrayscale = false
private var contentViewSizeObservation: NSKeyValueObservation?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
#if !os(visionOS)
setNeedsStatusBarAppearanceUpdate()
#endif
}
}
override var prefersStatusBarHidden: Bool {
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
init(contentView: LargeImageContentView, description: String?, sourceView: UIImageView?) {
self.imageDescription = description
self.animationSourceView = sourceView
self.contentView = contentView
super.init(nibName: "LargeImageViewController", bundle: nil)
modalPresentationStyle = .fullScreen
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupContentView()
setupControls()
setControlsVisible(initialControlsVisible, animated: false)
if contentView.activityItemsForSharing.isEmpty {
shareContainer.isUserInteractionEnabled = false
shareImage.tintColor = .systemGray
}
scrollView.delegate = self
if let imageDescription = imageDescription,
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
descriptionTextView.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionTextView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
// i'm not sure why .automatic doesn't work for this
descriptionTextView.contentInsetAdjustmentBehavior = .always
let height = min(150, descriptionTextView.contentSize.height)
descriptionTextView.topAnchor.constraint(equalTo: descriptionTextView.safeAreaLayoutGuide.bottomAnchor, constant: -(height + 16)).isActive = true
} else {
descriptionTextView.isHidden = true
}
if shrinkGestureEnabled {
dismissInteractionController = LargeImageInteractionController(viewController: self)
}
let singleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
doubleTap.numberOfTapsRequired = 2
// this requirement is needed to make sure the double tap is ever recognized
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
accessibilityElements = [
topControlsView!,
contentView,
descriptionTextView!,
]
}
private func setupContentView() {
contentView.owner = self
contentView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentView)
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewTopConstraint = contentView.topAnchor.constraint(equalTo: scrollView.topAnchor)
NSLayoutConstraint.activate([
contentViewLeadingConstraint,
contentViewTopConstraint,
])
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in
MainActor.runUnsafely {
self.centerImage()
}
})
}
private func setupControls() {
shareContainer = UIView()
shareContainer.isAccessibilityElement = true
shareContainer.accessibilityTraits = .button
shareContainer.accessibilityLabel = "Share"
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
shareContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(shareContainer)
shareImage = UIImageView(image: UIImage(systemName: "square.and.arrow.up"))
shareImage.tintColor = .white
shareImage.contentMode = .scaleAspectFit
shareImage.translatesAutoresizingMaskIntoConstraints = false
shareContainer.addSubview(shareImage)
shareButtonTopConstraint = shareImage.topAnchor.constraint(greaterThanOrEqualTo: shareContainer.topAnchor)
shareButtonLeadingConstraint = shareImage.leadingAnchor.constraint(greaterThanOrEqualTo: shareContainer.leadingAnchor)
NSLayoutConstraint.activate([
shareContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
shareContainer.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor),
shareContainer.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
shareContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
shareContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
shareImage.centerXAnchor.constraint(equalTo: shareContainer.centerXAnchor),
shareImage.centerYAnchor.constraint(equalTo: shareContainer.centerYAnchor),
shareButtonTopConstraint,
shareButtonLeadingConstraint,
shareImage.widthAnchor.constraint(equalToConstant: 24),
shareImage.heightAnchor.constraint(equalToConstant: 24),
])
closeContainer = UIView()
closeContainer.isAccessibilityElement = true
closeContainer.accessibilityTraits = .button
closeContainer.accessibilityLabel = "Close"
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
closeContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeContainer)
let closeImage = UIImageView(image: UIImage(systemName: "xmark"))
closeImage.tintColor = .white
closeImage.contentMode = .scaleAspectFit
closeImage.translatesAutoresizingMaskIntoConstraints = false
closeContainer.addSubview(closeImage)
closeButtonTopConstraint = closeImage.topAnchor.constraint(greaterThanOrEqualTo: closeContainer.topAnchor)
closeButtonTrailingConstraint = closeContainer.trailingAnchor.constraint(greaterThanOrEqualTo: closeImage.trailingAnchor)
NSLayoutConstraint.activate([
closeContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
closeContainer.trailingAnchor.constraint(equalTo: topControlsView.trailingAnchor),
closeContainer.bottomAnchor.constraint(equalTo: closeContainer.bottomAnchor),
closeContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
closeContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
closeImage.centerXAnchor.constraint(equalTo: closeContainer.centerXAnchor),
closeImage.centerYAnchor.constraint(equalTo: closeContainer.centerYAnchor),
closeButtonTopConstraint,
closeButtonTrailingConstraint,
closeImage.widthAnchor.constraint(equalToConstant: 24),
closeImage.heightAnchor.constraint(equalToConstant: 24),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// limit the image height to the safe area height, so the image doesn't overlap the top controls
// while zoomed all the way out
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
let heightScale = maxHeight / contentView.intrinsicContentSize.height
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
let minScale = min(widthScale, heightScale)
skipUpdatingControlsWhileZooming = true
scrollView.minimumZoomScale = minScale
scrollView.zoomScale = minScale
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
skipUpdatingControlsWhileZooming = false
centerImage()
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini
]
let pillDeviceTopInsets: [CGFloat] = [
59, // iPhone 14 Pro, 14 Pro Max
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
let notchWidth: CGFloat = 210
let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - shareImage.bounds.width) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if pillDeviceTopInsets.contains(view.safeAreaInsets.top) {
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
}
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// the controls view transforms take the safe area insets into account, so they need to be updated
updateControlsView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// on the first appearance, the text view flashes its own scroll indicators automatically
// so we only need to do it on subsequent appearances
if isInitialAppearance {
isInitialAppearance = false
} else {
if animated && controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
}
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
contentView.grayscaleStateChanged()
}
}
func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
self.controlsVisible = controlsVisible
if animated {
UIView.animate(withDuration: 0.2) {
// note: the value of animated: is passed to ImageAnalysisInteractino.setSupplementaryInterfaceHidden which (as of iOS 17.0.2 (21A350)):
// - does not animate with animated:false when wrapped in a UIView.animate block
// - does not animate with animated:true unless also wrapped in a UIView.animate block
self.contentView.setControlsVisible(controlsVisible, animated: true)
self.updateControlsView()
}
if controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
} else {
contentView.setControlsVisible(controlsVisible, animated: false)
updateControlsView()
}
}
func updateControlsView() {
let topOffset = self.controlsVisible ? 0 : -(self.topControlsView.bounds.height + self.view.safeAreaInsets.top)
self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset)
if self.imageDescription != nil {
let bottomOffset = self.controlsVisible ? 0 : self.descriptionTextView.bounds.height + self.view.safeAreaInsets.bottom
self.descriptionTextView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
if !skipUpdatingControlsWhileZooming {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true)
} else if scrollView.zoomScale > prevZoomScale {
setControlsVisible(false, animated: true)
}
}
self.prevZoomScale = scrollView.zoomScale
}
func centerImage() {
let yOffset = max(0, (view.bounds.size.height - contentView.frame.height) / 2)
contentViewTopConstraint.constant = yOffset
let xOffset = max(0, (view.bounds.size.width - contentView.frame.width) / 2)
contentViewLeadingConstraint.constant = xOffset
}
func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
zoomRect.size.width = contentView.frame.width / scale
zoomRect.size.height = contentView.frame.height / scale
let newCenter = scrollView.convert(center, to: contentView)
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
return zoomRect
}
// MARK: Interaction
func animateZoomOut() {
UIView.animate(withDuration: 0.3, animations: {
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
self.view.layoutIfNeeded()
})
}
@objc func scrollViewPressed(_ sender: UITapGestureRecognizer) {
if scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true)
}
}
@objc func scrollViewDoubleTapped(_ recognizer: UITapGestureRecognizer) {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
let point = recognizer.location(in: recognizer.view)
let scale: CGFloat
if scrollView.minimumZoomScale < 1 {
if 1 - scrollView.zoomScale <= 0.5 {
scale = scrollView.zoomScale + 1
} else {
scale = 1
}
} else {
scale = scrollView.maximumZoomScale
}
let rect = zoomRectFor(scale: scale, center: point)
UIView.animate(withDuration: 0.3) {
self.scrollView.zoom(to: rect, animated: false)
self.view.layoutIfNeeded()
}
} else {
animateZoomOut()
}
}
@IBAction func closeButtonPressed(_ sender: Any) {
dismiss(animated: true)
}
@IBAction func sharePressed(_ sender: Any) {
let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: [SaveToPhotosActivity()])
activityVC.popoverPresentationController?.sourceView = shareImage
present(activityVC, animated: true)
}
}