// // 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 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() let scrollView = UIScrollView() self.scrollView = scrollView 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: content.view.leadingAnchor), overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor), overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor), overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor), ]) } topControlsView = UIView() topControlsView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(topControlsView) var shareConfig = UIButton.Configuration.plain() shareConfig.baseForegroundColor = .white shareConfig.image = UIImage(systemName: "square.and.arrow.up") shareButton = UIButton(configuration: shareConfig) shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside) shareButton.translatesAutoresizingMaskIntoConstraints = false updateShareButton() topControlsView.addSubview(shareButton) var closeConfig = UIButton.Configuration.plain() closeConfig.baseForegroundColor = .white closeConfig.image = UIImage(systemName: "xmark") let closeButton = UIButton(configuration: closeConfig) closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside) 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) closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor) closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor) shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor) shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor) 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), closeButtonTrailingConstraint, closeButtonTopConstraint, closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor), bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor), captionTextView.heightAnchor.constraint(equalToConstant: 150), ]) singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed)) singleTap.delegate = self doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed)) doubleTap.delegate = self 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) } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() updateZoomScale(resetZoom: false) // Ensure the transform is correct if the controls are hidden setControlsVisible(controlsVisible, animated: false) updateTopControlsInsets() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() centerContent() } 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) 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) { 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) } if animated { let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters()) animator.addAnimations(updateControlsViews) animator.startAnimation() } else { updateControlsViews() } setNeedsUpdateOfHomeIndicatorAutoHidden() } private func updateZoomScale(resetZoom: Bool) { 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.safeAreaLayoutGuide.layoutFrame.height / content.contentSize.height let widthScale = view.safeAreaLayoutGuide.layoutFrame.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 ] let islandDeviceTopInsets: [CGFloat] = [ 59, // iPhone 14 Pro, 14 Pro Max, 15 Pro, 15 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 - (shareButton.imageView?.bounds.width ?? 0)) / 2 shareButtonLeadingConstraint.constant = offset closeButtonTrailingConstraint.constant = offset } else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) { 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) } } @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) } } 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) } else { setControlsVisible(false, animated: 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 } } }