Tusker/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.s...

571 lines
24 KiB
Swift

//
// GalleryItemViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
}
class GalleryItemViewController: UIViewController {
private weak var delegate: GalleryItemViewControllerDelegate?
let itemIndex: Int
let content: GalleryContentViewController
private var overlayVC: UIViewController?
private var activityIndicator: UIActivityIndicatorView?
private(set) var scrollView: UIScrollView!
private var topControlsView: UIView!
private var shareButton: UIButton!
private var shareButtonLeadingConstraint: NSLayoutConstraint!
private var shareButtonTopConstraint: NSLayoutConstraint!
private var closeButtonTrailingConstraint: NSLayoutConstraint!
private var closeButtonTopConstraint: NSLayoutConstraint!
private var bottomControlsView: UIStackView!
private(set) var captionTextView: UITextView!
private var singleTap: UITapGestureRecognizer!
private var doubleTap: UITapGestureRecognizer!
private var contentViewLeadingConstraint: NSLayoutConstraint?
private var contentViewTopConstraint: NSLayoutConstraint?
private(set) var controlsVisible: Bool = true
private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
init(delegate: GalleryItemViewControllerDelegate, itemIndex: Int, content: GalleryContentViewController) {
self.delegate = delegate
self.itemIndex = itemIndex
self.content = content
super.init(nibName: nil, bundle: nil)
content.container = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
view.addSubview(scrollView)
addContent()
centerContent()
overlayVC = content.contentOverlayAccessoryViewController
if let overlayVC {
overlayVC.view.isHidden = activityIndicator != nil
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
topControlsView = UIView()
topControlsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topControlsView)
var shareConfig = UIButton.Configuration.gray()
shareConfig.cornerStyle = .dynamic
shareConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
shareConfig.baseForegroundColor = .white
shareConfig.image = UIImage(systemName: "square.and.arrow.up")
shareConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
shareButton = UIButton(configuration: shareConfig)
shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside)
shareButton.isPointerInteractionEnabled = true
shareButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
shareButton.preferredBehavioralStyle = .pad
shareButton.translatesAutoresizingMaskIntoConstraints = false
updateShareButton()
topControlsView.addSubview(shareButton)
var closeConfig = UIButton.Configuration.gray()
closeConfig.cornerStyle = .dynamic
closeConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
closeConfig.baseForegroundColor = .white
closeConfig.image = UIImage(systemName: "xmark")
closeConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
let closeButton = UIButton(configuration: closeConfig)
closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside)
closeButton.isPointerInteractionEnabled = true
closeButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
closeButton.preferredBehavioralStyle = .pad
closeButton.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeButton)
bottomControlsView = UIStackView()
bottomControlsView.translatesAutoresizingMaskIntoConstraints = false
bottomControlsView.axis = .vertical
bottomControlsView.alignment = .fill
bottomControlsView.backgroundColor = .black.withAlphaComponent(0.5)
view.addSubview(bottomControlsView)
if let controlsAccessory = content.bottomControlsAccessoryViewController {
addChild(controlsAccessory)
bottomControlsView.addArrangedSubview(controlsAccessory.view)
controlsAccessory.didMove(toParent: self)
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
}
captionTextView = UITextView()
captionTextView.backgroundColor = .clear
captionTextView.textColor = .white
captionTextView.isEditable = false
captionTextView.isSelectable = true
captionTextView.font = .preferredFont(forTextStyle: .body)
captionTextView.adjustsFontForContentSizeCategory = true
captionTextView.alwaysBounceVertical = true
updateCaptionTextView()
bottomControlsView.addArrangedSubview(captionTextView)
#if targetEnvironment(macCatalyst)
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
#else
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
#endif
closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor)
shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
topControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
topControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
topControlsView.topAnchor.constraint(equalTo: view.topAnchor),
shareButtonLeadingConstraint,
shareButtonTopConstraint,
shareButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
shareButton.widthAnchor.constraint(equalTo: shareButton.heightAnchor),
closeButtonTrailingConstraint,
closeButtonTopConstraint,
closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
closeButton.widthAnchor.constraint(equalTo: closeButton.heightAnchor),
bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
captionTextView.heightAnchor.constraint(equalToConstant: 150),
])
updateTopControlsInsets()
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
singleTap.delegate = self
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
doubleTap.delegate = self
doubleTap.numberOfTapsRequired = 2
// This is needed to prevent a delay between tapping a button on and the action firing on Catalyst and Designed for iPad
doubleTap.delaysTouchesEnded = false
// this requirement is needed to make sure the double tap is ever recognized
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateZoomScale(resetZoom: false)
// Ensure the transform is correct if the controls are hidden
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
updateTopControlsInsets()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
updateZoomScale(resetZoom: true)
}
centerContent()
// Ensure the transform is correct if the controls are hidden and their size changed.
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if controlsVisible && !captionTextView.isHidden {
captionTextView.flashScrollIndicators()
}
}
func takeContent() -> GalleryContentViewController {
content.willMove(toParent: nil)
content.removeFromParent()
content.view.removeFromSuperview()
return content
}
func addContent() {
content.loadViewIfNeeded()
content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
content.view.translatesAutoresizingMaskIntoConstraints = false
if content.parent != self {
addChild(content)
content.didMove(toParent: self)
}
if scrollAndZoomEnabled {
scrollView.addSubview(content.view)
contentViewLeadingConstraint = content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewLeadingConstraint!.isActive = true
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
contentViewTopConstraint!.isActive = true
updateZoomScale(resetZoom: true)
} else {
// If the content was previously added, deactivate the old constraints.
contentViewLeadingConstraint?.isActive = false
contentViewTopConstraint?.isActive = false
view.addSubview(content.view)
NSLayoutConstraint.activate([
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.view.topAnchor.constraint(equalTo: view.topAnchor),
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
if let overlayVC {
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
content.view.layoutIfNeeded()
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
controlsVisible = visible
guard let topControlsView,
let bottomControlsView else {
return
}
func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
animator.addAnimations(updateControlsViews)
animator.startAnimation()
} else {
updateControlsViews()
}
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
func updateZoomScale(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else {
scrollView.maximumZoomScale = 1
scrollView.minimumZoomScale = 1
scrollView.zoomScale = 1
return
}
guard content.contentSize.width > 0 && content.contentSize.height > 0 else {
return
}
let heightScale = view.bounds.height / content.contentSize.height
let widthScale = view.bounds.width / content.contentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = maxScale
if resetZoom {
scrollView.zoomScale = minScale
} else {
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
}
centerContent()
}
private func centerContent() {
guard scrollAndZoomEnabled else {
return
}
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
// which means it's already been scaled by the zoom factor.
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
contentViewLeadingConstraint!.constant = xOffset
}
private func updateShareButton() {
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
}
private func updateCaptionTextView() {
guard let caption = content.caption,
!caption.isEmpty else {
captionTextView.isHidden = true
return
}
captionTextView.text = caption
}
private func updateTopControlsInsets() {
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
]
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 - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if view.safeAreaInsets.top == 0 {
// square corner devices
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
closeButtonTrailingConstraint.constant = 8
closeButtonTopConstraint.constant = 8
} else {
// dynamic island devices
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
}
}
private func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
zoomRect.size.width = content.view.frame.width / scale
zoomRect.size.height = content.view.frame.height / scale
let newCenter = scrollView.convert(center, to: content.view)
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
return zoomRect
}
private func animateZoomOut() {
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
self.scrollView.layoutIfNeeded()
}
animator.startAnimation()
}
// MARK: Interaction
@objc private func viewPressed() {
if scrollAndZoomEnabled,
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
}
}
@objc private func viewDoublePressed(_ recognizer: UITapGestureRecognizer) {
guard scrollAndZoomEnabled else {
return
}
if scrollView.zoomScale <= scrollView.minimumZoomScale {
let point = recognizer.location(in: recognizer.view)
let scale = min(
max(
scrollView.bounds.width / content.contentSize.width,
scrollView.bounds.height / content.contentSize.height,
scrollView.zoomScale + 0.75
),
scrollView.maximumZoomScale
)
let rect = zoomRectFor(scale: scale, center: point)
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoom(to: rect, animated: false)
self.view.layoutIfNeeded()
}
animator.startAnimation()
} else {
animateZoomOut()
}
}
@objc private func closeButtonPressed() {
delegate?.galleryItemClose(self)
}
@objc private func shareButtonPressed() {
let items = content.activityItemsForSharing
guard !items.isEmpty else {
return
}
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: delegate?.galleryItemApplicationActivities(self))
activityVC.popoverPresentationController?.sourceView = shareButton
present(activityVC, animated: true)
}
}
extension GalleryItemViewController: GalleryContentViewControllerContainer {
var galleryControlsVisible: Bool {
controlsVisible
}
func setGalleryContentLoading(_ loading: Bool) {
if loading {
overlayVC?.view.isHidden = true
if activityIndicator == nil {
let activityIndicator = UIActivityIndicatorView(style: .large)
self.activityIndicator = activityIndicator
activityIndicator.startAnimating()
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
} else {
if let activityIndicator {
// If we're in the middle of the presentation animation,
// wait until it finishes to hide the loading indicator.
// Since the updated content frame won't affect the animation,
// make sure the loading indicator remains visible.
if let delegate,
delegate.isGalleryBeingPresented() {
delegate.addPresentationAnimationCompletion { [unowned self] in
self.setGalleryContentLoading(false)
}
} else {
activityIndicator.removeFromSuperview()
self.activityIndicator = nil
self.overlayVC?.view.isHidden = false
}
}
}
}
func galleryContentChanged() {
updateZoomScale(resetZoom: true)
updateShareButton()
updateCaptionTextView()
}
func disableGalleryScrollAndZoom() {
scrollAndZoomEnabled = false
updateZoomScale(resetZoom: true)
scrollView.isScrollEnabled = false
// Make sure the content is re-added with the correct constraints
if content.parent == self {
addContent()
}
}
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
}
}
extension GalleryItemViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
if scrollAndZoomEnabled {
return content.view
} else {
return nil
}
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else {
setControlsVisible(false, animated: true, dueToUserInteraction: true)
}
centerContent()
scrollView.layoutIfNeeded()
}
}
extension GalleryItemViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == singleTap {
let loc = gestureRecognizer.location(in: view)
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
} else if gestureRecognizer == doubleTap {
let loc = gestureRecognizer.location(in: content.view)
return content.view.bounds.contains(loc)
} else {
return true
}
}
}