// // 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) } }