diff --git a/Packages/GalleryVC/.gitignore b/Packages/GalleryVC/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/Packages/GalleryVC/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/GalleryVC/Package.swift b/Packages/GalleryVC/Package.swift new file mode 100644 index 00000000..e2cb5171 --- /dev/null +++ b/Packages/GalleryVC/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "GalleryVC", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "GalleryVC", + targets: ["GalleryVC"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "GalleryVC"), + .testTarget( + name: "GalleryVCTests", + dependencies: ["GalleryVC"]), + ] +) diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift new file mode 100644 index 00000000..02da96a3 --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift @@ -0,0 +1,28 @@ +// +// GalleryContentViewController.swift +// GalleryVC +// +// Created by Shadowfacts on 3/17/24. +// + +import UIKit + +@MainActor +public protocol GalleryContentViewController: UIViewController { + var container: GalleryContentViewControllerContainer? { get set } + var contentSize: CGSize { get } + var activityItemsForSharing: [Any] { get } + var caption: String? { get } + var bottomControlsAccessoryViewController: UIViewController? { get } + var canAnimateFromSourceView: Bool { get } +} + +public extension GalleryContentViewController { + var bottomControlsAccessoryViewController: UIViewController? { + nil + } + + var canAnimateFromSourceView: Bool { + true + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift new file mode 100644 index 00000000..e4cf81a4 --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewControllerContainer.swift @@ -0,0 +1,15 @@ +// +// GalleryContentViewControllerContainer.swift +// GalleryVC +// +// Created by Shadowfacts on 12/28/23. +// + +import Foundation + +@MainActor +public protocol GalleryContentViewControllerContainer { + func setGalleryContentLoading(_ loading: Bool) + func galleryContentChanged() + func disableGalleryScrollAndZoom() +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDataSource.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDataSource.swift new file mode 100644 index 00000000..8985967f --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDataSource.swift @@ -0,0 +1,22 @@ +// +// GalleryDataSource.swift +// GalleryVC +// +// Created by Shadowfacts on 12/28/23. +// + +import UIKit + +@MainActor +public protocol GalleryDataSource { + func galleryItemsCount() -> Int + func galleryContentViewController(forItemAt index: Int) -> GalleryContentViewController + func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? + func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? +} + +public extension GalleryDataSource { + func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? { + nil + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift new file mode 100644 index 00000000..7cb40574 --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift @@ -0,0 +1,127 @@ +// +// GalleryDismissAnimationController.swift +// GalleryVC +// +// Created by Shadowfacts on 3/1/24. +// + +import UIKit + +class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + private let sourceView: UIView + private let interactiveTranslation: CGPoint? + private let interactiveVelocity: CGPoint? + + init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) { + self.sourceView = sourceView + self.interactiveTranslation = interactiveTranslation + self.interactiveVelocity = interactiveVelocity + } + + func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval { + return 0.3 + } + + func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) { + guard let to = transitionContext.viewController(forKey: .to), + let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else { + fatalError() + } + + let itemViewController = from.currentItemViewController + + if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) { + animateCrossFadeTransition(using: transitionContext) + return + } + + let container = transitionContext.containerView + let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView) + let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view) + + let origSourceTransform = sourceView.transform + let appliedSourceToDestTransform: Bool + if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 { + appliedSourceToDestTransform = true + let sourceToDestTransform = origSourceTransform + .translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY) + .scaledBy(x: destFrameInContainer.width / sourceFrameInContainer.width, y: destFrameInContainer.height / sourceFrameInContainer.height) + sourceView.transform = sourceToDestTransform + } else { + appliedSourceToDestTransform = false + } + + let content = itemViewController.takeContent() + content.view.translatesAutoresizingMaskIntoConstraints = true + content.view.layer.masksToBounds = true + + container.addSubview(to.view) + container.addSubview(from.view) + container.addSubview(content.view) + + content.view.frame = destFrameInContainer + content.view.layer.opacity = 1 + + container.layoutIfNeeded() + + let duration = self.transitionDuration(using: transitionContext) + var initialVelocity: CGVector + if let interactiveVelocity, + let interactiveTranslation, + // very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot + sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100, + sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 { + let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX + let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY + initialVelocity = CGVector( + dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance, + dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance + ) + } else { + initialVelocity = .zero + } + initialVelocity.dx = max(-10, min(10, initialVelocity.dx)) + initialVelocity.dy = max(-10, min(10, initialVelocity.dy)) + // no bounce for the dismiss animation + let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity) + let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring) + + animator.addAnimations { + from.view.layer.opacity = 0 + + if appliedSourceToDestTransform { + self.sourceView.transform = origSourceTransform + } + content.view.frame = sourceFrameInContainer + content.view.layer.opacity = 0 + + itemViewController.setControlsVisible(false, animated: false) + } + + animator.addCompletion { _ in + transitionContext.completeTransition(true) + } + + animator.startAnimation() + } + + private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from), + let toVC = transitionContext.viewController(forKey: .to) else { + return + } + + transitionContext.containerView.addSubview(toVC.view) + transitionContext.containerView.addSubview(fromVC.view) + + let duration = transitionDuration(using: transitionContext) + let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) + animator.addAnimations { + fromVC.view.alpha = 0 + } + animator.addCompletion { _ in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + animator.startAnimation() + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift new file mode 100644 index 00000000..766f58aa --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift @@ -0,0 +1,82 @@ +// +// GalleryDismissInteraction.swift +// GalleryVC +// +// Created by Shadowfacts on 3/1/24. +// + +import UIKit + +@MainActor +class GalleryDismissInteraction: NSObject { + + private let viewController: GalleryViewController + + private var content: GalleryContentViewController? + private var origContentFrameInGallery: CGRect? + private var origControlsVisible: Bool? + + private(set) var isActive = false + private(set) var dismissVelocity: CGPoint? + private(set) var dismissTranslation: CGPoint? + + init(viewController: GalleryViewController) { + self.viewController = viewController + super.init() + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized)) + panRecognizer.delegate = self + viewController.view.addGestureRecognizer(panRecognizer) + } + + @objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + isActive = true + + origContentFrameInGallery = viewController.view.convert(viewController.currentItemViewController.content.view.bounds, from: viewController.currentItemViewController.content.view) + content = viewController.currentItemViewController.takeContent() + content!.view.translatesAutoresizingMaskIntoConstraints = true + content!.view.frame = origContentFrameInGallery! + viewController.view.addSubview(content!.view) + + origControlsVisible = viewController.currentItemViewController.controlsVisible + if origControlsVisible! { + viewController.currentItemViewController.setControlsVisible(false, animated: true) + } + + case .changed: + let translation = recognizer.translation(in: viewController.view) + content!.view.frame = origContentFrameInGallery!.offsetBy(dx: translation.x, dy: translation.y) + + case .ended: + let translation = recognizer.translation(in: viewController.view) + let velocity = recognizer.velocity(in: viewController.view) + + dismissVelocity = velocity + dismissTranslation = translation + viewController.dismiss(animated: true) + + // don't unset this until after dismiss is called, so that the dismiss animation controller can read it + isActive = false + + default: + break + } + } + +} + +extension GalleryDismissInteraction: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let itemVC = viewController.currentItemViewController + if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil { + return false + } else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale { + return false + } else if !itemVC.scrollAndZoomEnabled { + return false + } else { + return true + } + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift new file mode 100644 index 00000000..fbc29409 --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift @@ -0,0 +1,467 @@ +// +// 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 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 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() + + 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 + 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), + ]) + + let singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed)) + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed)) + 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.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), + ]) + } + 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) + } + 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 { + func setGalleryContentLoading(_ loading: Bool) { + if loading { + 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 + } + } + } + } + + 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() + } + } +} + +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() + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift new file mode 100644 index 00000000..72a3fa96 --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift @@ -0,0 +1,135 @@ +// +// GalleryPresentationAnimationController.swift +// GalleryVC +// +// Created by Shadowfacts on 12/28/23. +// + +import UIKit + +class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + private let sourceView: UIView + private var completionHandlers: [() -> Void] = [] + + init(sourceView: UIView) { + self.sourceView = sourceView + } + + func addCompletionHandler(_ block: @escaping () -> Void) { + completionHandlers.append(block) + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else { + fatalError() + } + + to.presentationAnimationController = self + + let itemViewController = to.currentItemViewController + + if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions { + animateCrossFadeTransition(using: transitionContext) + return + } + + let container = transitionContext.containerView + itemViewController.view.layoutIfNeeded() + + let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView) + let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view) + + // Use a transformation to make the actual source view appear to move into the destination frame. + // Doing this while having the content view fade-in papers over the z-index change when + // there was something overlapping the source view. + let origSourceTransform = sourceView.transform + let sourceToDestTransform: CGAffineTransform? + if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 { + sourceToDestTransform = origSourceTransform + .translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY) + .scaledBy(x: destFrameInContainer.width / sourceFrameInContainer.width, y: destFrameInContainer.height / sourceFrameInContainer.height) + } else { + sourceToDestTransform = nil + } + + let content = itemViewController.takeContent() + content.view.translatesAutoresizingMaskIntoConstraints = true + + container.addSubview(to.view) + container.addSubview(content.view) + + to.view.layer.opacity = 0 + content.view.frame = sourceFrameInContainer + content.view.layer.opacity = 0 + + container.layoutIfNeeded() + + // This needs to take place after the layout, so that the transform is correct. + itemViewController.setControlsVisible(false, animated: false) + + let duration = self.transitionDuration(using: transitionContext) + // rougly equivalent to duration: 0.4, bounce: 0.3 + let spring = UISpringTimingParameters(mass: 1, stiffness: 247, damping: 22, initialVelocity: .zero) + let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring) + + animator.addAnimations { + to.view.layer.opacity = 1 + + content.view.frame = destFrameInContainer + content.view.layer.opacity = 1 + + itemViewController.setControlsVisible(true, animated: false) + + if let sourceToDestTransform { + self.sourceView.transform = sourceToDestTransform + } + } + + animator.addCompletion { _ in + if sourceToDestTransform != nil { + self.sourceView.transform = origSourceTransform + } + + itemViewController.addContent() + + transitionContext.completeTransition(true) + + for block in self.completionHandlers { + block() + } + + to.presentationAnimationController = nil + } + + animator.startAnimation() + } + + private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else { + return + } + + transitionContext.containerView.addSubview(to.view) + to.view.alpha = 0 + + let duration = transitionDuration(using: transitionContext) + let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) + animator.addAnimations { + to.view.alpha = 1 + } + animator.addCompletion { _ in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + + for block in self.completionHandlers { + block() + } + + to.presentationAnimationController = nil + } + animator.startAnimation() + } +} diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift new file mode 100644 index 00000000..67d456fe --- /dev/null +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift @@ -0,0 +1,142 @@ +// +// GalleryViewController.swift +// GalleryVC +// +// Created by Shadowfacts on 12/28/23. +// + +import UIKit + +public class GalleryViewController: UIPageViewController { + + let galleryDataSource: GalleryDataSource + let initialItemIndex: Int + private let _itemsCount: Int + private var itemsCount: Int { + get { + precondition(_itemsCount == galleryDataSource.galleryItemsCount(), "GalleryDataSource item count cannot change") + return _itemsCount + } + } + + var currentItemViewController: GalleryItemViewController { + viewControllers![0] as! GalleryItemViewController + } + + private var dismissInteraction: GalleryDismissInteraction! + var presentationAnimationController: GalleryPresentationAnimationController? + + override public var prefersStatusBarHidden: Bool { + true + } + override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { + .none + } + override public var childForHomeIndicatorAutoHidden: UIViewController? { + currentItemViewController + } + + public init(dataSource: GalleryDataSource, initialItemIndex: Int) { + self.galleryDataSource = dataSource + self.initialItemIndex = initialItemIndex + self._itemsCount = dataSource.galleryItemsCount() + precondition(initialItemIndex >= 0 && initialItemIndex < _itemsCount, "initialItemIndex is out of bounds") + + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [ + .interPageSpacing: 50 + ]) + + modalPresentationStyle = .fullScreen + transitioningDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + dismissInteraction = GalleryDismissInteraction(viewController: self) + + view.backgroundColor = .black + overrideUserInterfaceStyle = .dark + + dataSource = self + + setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false) + } + + private func makeItemVC(index: Int) -> GalleryItemViewController { + let content = galleryDataSource.galleryContentViewController(forItemAt: index) + return GalleryItemViewController(delegate: self, itemIndex: index, content: content) + } +} + +extension GalleryViewController: UIPageViewControllerDataSource { + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let viewController = viewController as? GalleryItemViewController else { + preconditionFailure("VC must be GalleryItemViewController") + } + guard viewController.itemIndex > 0 else { + return nil + } + return makeItemVC(index: viewController.itemIndex - 1) + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let viewController = viewController as? GalleryItemViewController else { + preconditionFailure("VC must be GalleryItemViewController") + } + guard viewController.itemIndex < itemsCount - 1 else { + return nil + } + return makeItemVC(index: viewController.itemIndex + 1) + } +} + +extension GalleryViewController: GalleryItemViewControllerDelegate { + func isGalleryBeingPresented() -> Bool { + isBeingPresented + } + + func addPresentationAnimationCompletion(_ block: @escaping () -> Void) { + presentationAnimationController?.addCompletionHandler(block) + } + + func galleryItemClose(_ item: GalleryItemViewController) { + dismiss(animated: true) + } + + func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? { + galleryDataSource.galleryApplicationActivities(forItemAt: item.itemIndex) + } +} + +extension GalleryViewController: UIViewControllerTransitioningDelegate { + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) { + return GalleryPresentationAnimationController(sourceView: sourceView) + } else { + return nil + } + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) { + let translation: CGPoint? + let velocity: CGPoint? + if let dismissInteraction, + dismissInteraction.isActive { + translation = dismissInteraction.dismissTranslation + velocity = dismissInteraction.dismissVelocity + } else { + translation = nil + velocity = nil + } + return GalleryDismissAnimationController(sourceView: sourceView, interactiveTranslation: translation, interactiveVelocity: velocity) + } else { + return nil + } + } +} diff --git a/Packages/GalleryVC/Tests/GalleryVCTests/GalleryVCTests.swift b/Packages/GalleryVC/Tests/GalleryVCTests/GalleryVCTests.swift new file mode 100644 index 00000000..b512e12a --- /dev/null +++ b/Packages/GalleryVC/Tests/GalleryVCTests/GalleryVCTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import GalleryVC + +final class GalleryVCTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 80c87994..4c61c826 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -131,7 +131,7 @@ D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; - D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; }; + D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; }; D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6552366289870790048A653 /* ScreenCorners */; }; @@ -195,6 +195,15 @@ D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; }; D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; }; D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; }; + D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; }; + D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; }; + D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; }; + D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */; }; + D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; }; + D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; }; + D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; }; + D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */; }; + D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; }; D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; }; D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; }; D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; }; @@ -513,6 +522,7 @@ D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = ""; }; D642E8392BA75F4C004BFD6A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + D642E83D2BA7AD0F004BFD6A /* GalleryVC */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = GalleryVC; sourceTree = ""; }; D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = ""; }; D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = ""; }; D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = ""; }; @@ -531,7 +541,7 @@ D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; }; - D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = ""; }; + D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = ""; }; D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = ""; }; @@ -598,6 +608,14 @@ D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = ""; }; D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = ""; }; D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = ""; }; + D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = ""; }; + D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = ""; }; + D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = ""; }; + D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.swift; sourceTree = ""; }; + D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvGalleryContentViewController.swift; sourceTree = ""; }; + D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = ""; }; + D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackGalleryContentViewController.swift; sourceTree = ""; }; + D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = ""; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; }; D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; }; D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = ""; }; @@ -790,6 +808,7 @@ D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */, D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */, + D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, @@ -830,6 +849,13 @@ D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */, D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */, D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */, + D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */, + D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */, + D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */, + D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */, + D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */, + D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */, + D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */, ); path = "Attachment Gallery"; sourceTree = ""; @@ -1177,6 +1203,7 @@ D6BD395729B6441F005FFD2B /* ComposeUI */, D6CA6ED029EF6060003EC5DF /* TuskerPreferences */, D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */, + D642E83D2BA7AD0F004BFD6A /* GalleryVC */, ); path = Packages; sourceTree = ""; @@ -1461,7 +1488,8 @@ isa = PBXGroup; children = ( D6C94D882139E6EC00CB5196 /* AttachmentView.swift */, - D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */, + D6531DED246B81C9000F9538 /* GifvPlayerView.swift */, + D6934F372BA8E2B7002B1C8D /* GifvController.swift */, D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */, ); path = Attachments; @@ -1722,6 +1750,7 @@ D6BD395829B64426005FFD2B /* ComposeUI */, D6CA6ED129EF6091003EC5DF /* TuskerPreferences */, D60BB3932B30076F00DAEA65 /* HTMLStreamer */, + D6934F2B2BA7AD32002B1C8D /* GalleryVC */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1996,6 +2025,7 @@ D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, + D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, @@ -2011,10 +2041,12 @@ D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */, D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */, D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, + D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */, D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */, D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */, + D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */, D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */, D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */, D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */, @@ -2029,6 +2061,7 @@ D6D94955298963A900C59229 /* Colors.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */, + D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, @@ -2056,13 +2089,15 @@ D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, + D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, - D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, + D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, + D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */, D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, @@ -2174,6 +2209,7 @@ D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, + D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */, @@ -2247,6 +2283,7 @@ D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */, D6958F3D2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift in Sources */, D65B4B562971F98300DABDFB /* ReportView.swift in Sources */, + D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */, D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, @@ -3050,6 +3087,10 @@ isa = XCSwiftPackageProductDependency; productName = Pachyderm; }; + D6934F2B2BA7AD32002B1C8D /* GalleryVC */ = { + isa = XCSwiftPackageProductDependency; + productName = GalleryVC; + }; D6A4532329EF665200032932 /* ComposeUI */ = { isa = XCSwiftPackageProductDependency; productName = ComposeUI; diff --git a/Tusker/Screens/Attachment Gallery/FallbackGalleryContentViewController.swift b/Tusker/Screens/Attachment Gallery/FallbackGalleryContentViewController.swift new file mode 100644 index 00000000..0ac74023 --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/FallbackGalleryContentViewController.swift @@ -0,0 +1,94 @@ +// +// FallbackGalleryContentViewController.swift +// Tusker +// +// Created by Shadowfacts on 3/18/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import GalleryVC +import QuickLook +import Pachyderm + +private class FallbackGalleryContentViewController: QLPreviewController { + private let previewItem = GalleryPreviewItem() + + init(url: URL) { + super.init(nibName: nil, bundle: nil) + + self.previewItem.previewItemURL = url + + dataSource = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + overrideUserInterfaceStyle = .dark + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(closePressed)) + ] + } + + @objc private func closePressed() { + self.dismiss(animated: true) + } + +} + +extension FallbackGalleryContentViewController: QLPreviewControllerDataSource { + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { + previewItem + } +} + +class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController { + init(url: URL) { + super.init(nibName: nil, bundle: nil) + self.viewControllers = [FallbackGalleryContentViewController(url: url)] + } + + override func viewDidLoad() { + super.viewDidLoad() + + container?.disableGalleryScrollAndZoom() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: GalleryContentViewController + + var container: (any GalleryVC.GalleryContentViewControllerContainer)? + + var contentSize: CGSize { + .zero + } + + var activityItemsForSharing: [Any] { + [] + } + + var caption: String? { + nil + } + + var canAnimateFromSourceView: Bool { + false + } +} + +private class GalleryPreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? = nil +} diff --git a/Tusker/Screens/Attachment Gallery/GifvAttachmentViewController.swift b/Tusker/Screens/Attachment Gallery/GifvAttachmentViewController.swift index 11196108..107c6899 100644 --- a/Tusker/Screens/Attachment Gallery/GifvAttachmentViewController.swift +++ b/Tusker/Screens/Attachment Gallery/GifvAttachmentViewController.swift @@ -27,7 +27,8 @@ class GifvAttachmentViewController: UIViewController { override func loadView() { let asset = AVURLAsset(url: attachment.url) - self.view = GifvAttachmentView(asset: asset, gravity: .resizeAspect) + let controller = GifvController(asset: asset) + self.view = GifvPlayerView(controller: controller, gravity: .resizeAspect) } } diff --git a/Tusker/Screens/Attachment Gallery/GifvGalleryContentViewController.swift b/Tusker/Screens/Attachment Gallery/GifvGalleryContentViewController.swift new file mode 100644 index 00000000..2075e90a --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/GifvGalleryContentViewController.swift @@ -0,0 +1,68 @@ +// +// GifvGalleryContentViewController.swift +// Tusker +// +// Created by Shadowfacts on 3/18/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import GalleryVC +import Combine + +class GifvGalleryContentViewController: UIViewController, GalleryContentViewController { + let controller: GifvController + let caption: String? + + private var presentationSizeCancellable: AnyCancellable? + + init(controller: GifvController, caption: String?) { + self.controller = controller + self.caption = caption + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let playerView = GifvPlayerView(controller: controller, gravity: .resizeAspect) + playerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(playerView) + NSLayoutConstraint.activate([ + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + presentationSizeCancellable = controller.presentationSizeSubject + .sink { [unowned self] _ in + self.container?.galleryContentChanged() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + controller.play() + } + + // MARK: GalleryContentViewController + + var container: (any GalleryVC.GalleryContentViewControllerContainer)? + + var contentSize: CGSize { + controller.item.presentationSize + } + + var activityItemsForSharing: [Any] { + // TODO: share gifv + [] + } + +} diff --git a/Tusker/Screens/Attachment Gallery/ImageGalleryContentViewController.swift b/Tusker/Screens/Attachment Gallery/ImageGalleryContentViewController.swift new file mode 100644 index 00000000..e5a7ea8c --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/ImageGalleryContentViewController.swift @@ -0,0 +1,77 @@ +// +// ImageGalleryContentViewController.swift +// Tusker +// +// Created by Shadowfacts on 3/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import GalleryVC +import Pachyderm +import TuskerComponents + +class ImageGalleryContentViewController: UIViewController, GalleryContentViewController { + let url: URL + let caption: String? + let originalData: Data? + let image: UIImage + let gifController: GIFController? + + init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) { + self.url = url + self.caption = caption + self.originalData = originalData + self.image = image + self.gifController = gifController + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let imageView = GIFImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + if let gifController { + gifController.attach(to: imageView) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let gifController { + gifController.startAnimating() + } + } + + // MARK: GalleryContentViewController + + var container: (any GalleryVC.GalleryContentViewControllerContainer)? + + var contentSize: CGSize { + image.size + } + + var activityItemsForSharing: [Any] { + if let data = originalData ?? image.pngData() { + return [ImageActivityItemSource(data: data, url: url, image: image)] + } else { + return [] + } + } +} diff --git a/Tusker/Screens/Attachment Gallery/ImageGalleryDataSource.swift b/Tusker/Screens/Attachment Gallery/ImageGalleryDataSource.swift new file mode 100644 index 00000000..22b6d4dc --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/ImageGalleryDataSource.swift @@ -0,0 +1,76 @@ +// +// ImageGalleryDataSource.swift +// Tusker +// +// Created by Shadowfacts on 3/18/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import GalleryVC +import TuskerComponents + +class ImageGalleryDataSource: GalleryDataSource { + let url: URL + let cache: ImageCache + let sourceView: UIView + + init(url: URL, cache: ImageCache, sourceView: UIView) { + self.url = url + self.cache = cache + self.sourceView = sourceView + } + + func galleryItemsCount() -> Int { + 1 + } + + func galleryContentViewController(forItemAt index: Int) -> any GalleryVC.GalleryContentViewController { + if let entry = cache.get(url, loadOriginal: true) { + let gifController: GIFController? = + if url.pathExtension == "gif", + let data = entry.data { + GIFController(gifData: data) + } else { + nil + } + return ImageGalleryContentViewController( + url: url, + caption: nil, + originalData: entry.data, + image: entry.image, + gifController: gifController + ) + } else { + return LoadingGalleryContentViewController { + let (data, image) = await self.cache.get(self.url, loadOriginal: true) + if let image { + let gifController: GIFController? = + if self.url.pathExtension == "gif", + let data { + GIFController(gifData: data) + } else { + nil + } + return ImageGalleryContentViewController( + url: self.url, + caption: nil, + originalData: data, + image: image, + gifController: gifController + ) + } else { + return nil + } + } + } + } + + func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? { + sourceView + } + + func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? { + [SaveToPhotosActivity()] + } +} diff --git a/Tusker/Screens/Attachment Gallery/LoadingGalleryContentViewController.swift b/Tusker/Screens/Attachment Gallery/LoadingGalleryContentViewController.swift new file mode 100644 index 00000000..25155730 --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/LoadingGalleryContentViewController.swift @@ -0,0 +1,102 @@ +// +// LoadingGalleryContentViewController.swift +// Tusker +// +// Created by Shadowfacts on 3/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import GalleryVC + +class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController { + private let provider: () async -> (any GalleryContentViewController)? + private var wrapped: (any GalleryContentViewController)! + + var container: GalleryContentViewControllerContainer? + + var contentSize: CGSize { + wrapped?.contentSize ?? .zero + } + + var activityItemsForSharing: [Any] { + wrapped?.activityItemsForSharing ?? [] + } + + var caption: String? { + wrapped?.caption + } + + var canAnimateFromSourceView: Bool { + wrapped?.canAnimateFromSourceView ?? true + } + + init(provider: @escaping () async -> (any GalleryContentViewController)?) { + self.provider = provider + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + container?.setGalleryContentLoading(true) + + Task { + if let wrapped = await provider() { + self.wrapped = wrapped + wrapped.container = container + + addChild(wrapped) + wrapped.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(wrapped.view) + NSLayoutConstraint.activate([ + wrapped.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + wrapped.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + wrapped.view.topAnchor.constraint(equalTo: view.topAnchor), + wrapped.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + wrapped.didMove(toParent: self) + + container?.galleryContentChanged() + } else { + showErrorView() + } + + container?.setGalleryContentLoading(false) + } + } + + private func showErrorView() { + let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!) + image.tintColor = .secondaryLabel + image.contentMode = .scaleAspectFit + + let label = UILabel() + label.text = "Error Loading" + label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! + label.textColor = .secondaryLabel + label.adjustsFontForContentSizeCategory = true + + let stackView = UIStackView(arrangedSubviews: [ + image, + label, + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 8 + view.addSubview(stackView) + NSLayoutConstraint.activate([ + image.widthAnchor.constraint(equalToConstant: 64), + image.heightAnchor.constraint(equalToConstant: 64), + stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + +} diff --git a/Tusker/Screens/Attachment Gallery/StatusAttachmentsGalleryDataSource.swift b/Tusker/Screens/Attachment Gallery/StatusAttachmentsGalleryDataSource.swift new file mode 100644 index 00000000..eeffebc4 --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/StatusAttachmentsGalleryDataSource.swift @@ -0,0 +1,106 @@ +// +// StatusAttachmentsGalleryDataSource.swift +// Tusker +// +// Created by Shadowfacts on 3/17/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import GalleryVC +import Pachyderm +import TuskerComponents +import AVFoundation + +class StatusAttachmentsGalleryDataSource: GalleryDataSource { + let attachments: [Attachment] + let sourceViews: NSHashTable + + init(attachments: [Attachment], sourceViews: NSHashTable) { + self.attachments = attachments + self.sourceViews = sourceViews + } + + func galleryItemsCount() -> Int { + attachments.count + } + + func galleryContentViewController(forItemAt index: Int) -> any GalleryContentViewController { + let attachment = attachments[index] + switch attachment.kind { + case .image: + if let view = attachmentView(for: attachment), + let image = view.image { + return ImageGalleryContentViewController( + url: attachment.url, + caption: attachment.description, + originalData: view.originalData, + image: image, + // TODO: if automatically play gifs is off, this will start the source view playing too + gifController: view.gifController + ) + } else { + return LoadingGalleryContentViewController { + let (data, image) = await ImageCache.attachments.get(attachment.url, loadOriginal: true) + if let image { + let gifController: GIFController? = + if attachment.url.pathExtension == "gif", + let data { + GIFController(gifData: data) + } else { + nil + } + return ImageGalleryContentViewController( + url: attachment.url, + caption: attachment.description, + originalData: data, + image: image, + gifController: gifController + ) + } else { + return nil + } + } + } + case .gifv: + let controller: GifvController = + if Preferences.shared.automaticallyPlayGifs, + let view = attachmentView(for: attachment), + let controller = view.gifvView?.controller { + controller + } else { + GifvController(asset: AVAsset(url: attachment.url)) + } + return GifvGalleryContentViewController(controller: controller, caption: attachment.description) + case .video: + return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description) + case .audio: + // TODO: use separate content VC with audio visualization? + return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description) + case .unknown: + return LoadingGalleryContentViewController { + do { + let (data, _) = try await URLSession.shared.data(from: attachment.url) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent) + try data.write(to: url) + return FallbackGalleryNavigationController(url: url) + } catch { + return nil + } + } + } + } + + func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView? { + let attachment = attachments[index] + return attachmentView(for: attachment) + } + + func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? { + [SaveToPhotosActivity()] + } + + private func attachmentView(for attachment: Attachment) -> AttachmentView? { + return sourceViews.allObjects.first(where: { $0.attachment?.id == attachment.id }) + } +} diff --git a/Tusker/Screens/Attachment Gallery/VideoGalleryContentViewController.swift b/Tusker/Screens/Attachment Gallery/VideoGalleryContentViewController.swift new file mode 100644 index 00000000..6a0882e5 --- /dev/null +++ b/Tusker/Screens/Attachment Gallery/VideoGalleryContentViewController.swift @@ -0,0 +1,132 @@ +// +// VideoGalleryContentViewController.swift +// Tusker +// +// Created by Shadowfacts on 3/19/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import GalleryVC +import AVFoundation + +class VideoGalleryContentViewController: UIViewController, GalleryContentViewController { + private let url: URL + let caption: String? + private let item: AVPlayerItem + private let player: AVPlayer + + private var presentationSizeObservation: NSKeyValueObservation? + private var statusObservation: NSKeyValueObservation? + private var isFirstAppearance = true + + init(url: URL, caption: String?) { + self.url = url + self.caption = caption + + let asset = AVAsset(url: url) + self.item = AVPlayerItem(asset: asset) + self.player = AVPlayer(playerItem: item) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + container?.setGalleryContentLoading(true) + + let playerView = PlayerView(item: item, player: player) + playerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(playerView) + NSLayoutConstraint.activate([ + playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + playerView.topAnchor.constraint(equalTo: view.topAnchor), + playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in + MainActor.runUnsafely { + self.container?.galleryContentChanged() + } + }) + statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in + MainActor.runUnsafely { + if item.status == .readyToPlay { + self.container?.setGalleryContentLoading(false) + statusObservation = nil + } + } + }) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if isFirstAppearance { + isFirstAppearance = false + player.play() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + player.pause() + } + + // MARK: GalleryContentViewController + + var container: (any GalleryVC.GalleryContentViewControllerContainer)? + + var contentSize: CGSize { + item.presentationSize + } + + var activityItemsForSharing: [Any] { + // TODO: share videos + [] + } + +} + +private class PlayerView: UIView { + override class var layerClass: AnyClass { + AVPlayerLayer.self + } + + private var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } + + private let player: AVPlayer + private var presentationSizeObservation: NSKeyValueObservation? + + override var intrinsicContentSize: CGSize { + player.currentItem?.presentationSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) + } + + init(item: AVPlayerItem, player: AVPlayer) { + self.player = player + + super.init(frame: .zero) + + playerLayer.player = player + playerLayer.videoGravity = .resizeAspect + + presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in + MainActor.runUnsafely { + self.invalidateIntrinsicContentSize() + } + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift index c7652f08..a3e2c8f5 100644 --- a/Tusker/Screens/Large Image/LargeImageContentView.swift +++ b/Tusker/Screens/Large Image/LargeImageContentView.swift @@ -145,7 +145,7 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView { } } -class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { +class LargeImageGifvContentView: GifvPlayerView, LargeImageContentView { private(set) var animationImage: UIImage? var activityItemsForSharing: [Any] { [GifvActivityItemSource(asset: asset, attachment: attachment)] @@ -166,11 +166,12 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { self.attachment = attachment self.asset = AVURLAsset(url: attachment.url) - super.init(asset: asset, gravity: .resizeAspect) + let controller = GifvController(asset: asset) + super.init(controller: controller, gravity: .resizeAspect) self.animationImage = source.image - self.player.play() + controller.play() Task { do { @@ -192,7 +193,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { } func grayscaleStateChanged() { - // no-op, GifvAttachmentView observes the grayscale state itself + // no-op, GifvPlayerView observes the grayscale state itself } } diff --git a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift index bc40c964..061bbff9 100644 --- a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift +++ b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import GalleryVC @MainActor protocol StatusEditCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate { @@ -234,16 +235,10 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { } extension StatusEditCollectionViewCell: AttachmentViewDelegate { - func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? { - guard let delegate else { - return nil - } + func attachmentViewGallery(startingAt index: Int) -> UIViewController? { let attachments = attachmentsView.attachments! - let sourceViews = attachments.map { - attachmentsView.getAttachmentView(for: $0) - } - let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index) - return gallery + let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable + return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: attachments, sourceViews: sourceViews), initialItemIndex: index) } func attachmentViewPresent(_ vc: UIViewController, animated: Bool) { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 5de7f567..e76fb625 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -10,6 +10,7 @@ import UIKit import SafariServices import Pachyderm import ComposeUI +import GalleryVC @MainActor protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { @@ -126,27 +127,6 @@ extension TuskerNavigationDelegate { compose(editing: draft, animated: animated) } - func showLoadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) { - let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description) - vc.animationSourceView = sourceView - #if !os(visionOS) - vc.transitioningDelegate = self - #endif - present(vc, animated: true) - } - - func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController { - let vc = GalleryViewController(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex) - #if !os(visionOS) - vc.transitioningDelegate = self - #endif - return vc - } - - func showGallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) { - present(gallery(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex), animated: true) - } - private func moreOptions(forURL url: URL) -> UIActivityViewController { let customActivites: [UIActivity] = [ OpenInSafariActivity() diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 56a40a88..6eb9343a 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -13,7 +13,7 @@ import TuskerComponents @MainActor protocol AttachmentViewDelegate: AnyObject { - func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? + func attachmentViewGallery(startingAt index: Int) -> UIViewController? func attachmentViewPresent(_ vc: UIViewController, animated: Bool) } @@ -23,8 +23,8 @@ class AttachmentView: GIFImageView { weak var delegate: AttachmentViewDelegate? - var playImageView: UIImageView? - var gifvView: GifvAttachmentView? + private var playImageView: UIImageView? + private(set) var gifvView: GifvPlayerView? private var badgeContainer: UIStackView? var attachment: Attachment! @@ -32,6 +32,16 @@ class AttachmentView: GIFImageView { private var loadAttachmentTask: Task? private var source: Source? + var originalData: Data? { + switch source { + case .image(_, let data, _): + return data + case .gifData(_, let data, _): + return data + case nil: + return nil + } + } private var autoplayGifs: Bool { Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled @@ -103,9 +113,9 @@ class AttachmentView: GIFImageView { } else if self.attachment.kind == .gifv, let gifvView = self.gifvView { if self.autoplayGifs { - gifvView.player.play() + gifvView.controller.play() } else { - gifvView.player.pause() + gifvView.controller.pause() } } } @@ -274,11 +284,12 @@ class AttachmentView: GIFImageView { private func loadGifv() { let asset = AVURLAsset(url: attachment.url) - let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) + let controller = GifvController(asset: asset) + let gifvView = GifvPlayerView(controller: controller, gravity: .resizeAspectFill) self.gifvView = gifvView gifvView.translatesAutoresizingMaskIntoConstraints = false if autoplayGifs { - gifvView.player.play() + controller.play() } addSubview(gifvView) NSLayoutConstraint.activate([ @@ -419,7 +430,6 @@ fileprivate extension AttachmentView { enum Source { case image(URL, Data?, UIImage) case gifData(URL, Data, UIImage?) -// case cgImage(URL, CGImage) } struct Badges: OptionSet { diff --git a/Tusker/Views/Attachments/GifvAttachmentView.swift b/Tusker/Views/Attachments/GifvController.swift similarity index 62% rename from Tusker/Views/Attachments/GifvAttachmentView.swift rename to Tusker/Views/Attachments/GifvController.swift index 36350e85..e0259994 100644 --- a/Tusker/Views/Attachments/GifvAttachmentView.swift +++ b/Tusker/Views/Attachments/GifvController.swift @@ -1,47 +1,72 @@ // -// GifvAttachmentView.swift +// GifvController.swift // Tusker // -// Created by Shadowfacts on 5/12/20. -// Copyright © 2020 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 3/18/24. +// Copyright © 2024 Shadowfacts. All rights reserved. // -import UIKit -import AVFoundation +import Foundation +import AVKit +import Combine -class GifvAttachmentView: UIView { - - override class var layerClass: AnyClass { - return AVPlayerLayer.self - } - - private var playerLayer: AVPlayerLayer { - layer as! AVPlayerLayer - } - - private var asset: AVAsset +@MainActor +class GifvController { + private let asset: AVAsset private(set) var item: AVPlayerItem let player: AVPlayer + private var isGrayscale = false - init(asset: AVAsset, gravity: AVLayerVideoGravity) { + let presentationSizeSubject = PassthroughSubject() + private var presentationSizeObservation: NSKeyValueObservation? + + init(asset: AVAsset) { self.asset = asset - item = GifvAttachmentView.createItem(asset: asset) - player = AVPlayer(playerItem: item) - isGrayscale = Preferences.shared.grayscaleImages - - super.init(frame: .zero) + self.item = AVPlayerItem(asset: asset) + self.player = AVPlayer(playerItem: item) + + self.isGrayscale = Preferences.shared.grayscaleImages - playerLayer.player = player - playerLayer.videoGravity = gravity player.isMuted = true - + NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + + updatePresentationSizeObservation() } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + func play() { + if player.rate == 0 { + player.play() + } + } + + func pause() { + player.pause() + } + + private func updatePresentationSizeObservation() { + presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in + self.presentationSizeSubject.send(item.presentationSize) + }) + } + + @objc private func restartItem() { + item.seek(to: .zero) { (success) in + guard success else { return } + self.player.play() + } + } + + @objc private func preferencesChanged() { + if isGrayscale != Preferences.shared.grayscaleImages { + isGrayscale = Preferences.shared.grayscaleImages + item = GifvController.createItem(asset: asset) + player.replaceCurrentItem(with: item) + self.updatePresentationSizeObservation() + player.play() + } } private static func createItem(asset: AVAsset) -> AVPlayerItem { @@ -63,21 +88,5 @@ class GifvAttachmentView: UIView { } return item } - - @objc private func preferencesChanged() { - if isGrayscale != Preferences.shared.grayscaleImages { - isGrayscale = Preferences.shared.grayscaleImages - item = GifvAttachmentView.createItem(asset: asset) - player.replaceCurrentItem(with: item) - player.play() - } - } - - @objc private func restartItem() { - item.seek(to: .zero) { (success) in - guard success else { return } - self.player.play() - } - } } diff --git a/Tusker/Views/Attachments/GifvPlayerView.swift b/Tusker/Views/Attachments/GifvPlayerView.swift new file mode 100644 index 00000000..cf977777 --- /dev/null +++ b/Tusker/Views/Attachments/GifvPlayerView.swift @@ -0,0 +1,48 @@ +// +// GifvPlayerView.swift +// Tusker +// +// Created by Shadowfacts on 5/12/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import AVFoundation +import Combine + +class GifvPlayerView: UIView { + + override class var layerClass: AnyClass { + return AVPlayerLayer.self + } + + private var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } + + let controller: GifvController + private var presentationSizeCancellable: AnyCancellable? + + override var intrinsicContentSize: CGSize { + controller.item.presentationSize + } + + init(controller: GifvController, gravity: AVLayerVideoGravity) { + self.controller = controller + + super.init(frame: .zero) + + playerLayer.player = controller.player + playerLayer.videoGravity = gravity + + presentationSizeCancellable = controller.presentationSizeSubject + .sink(receiveValue: { [unowned self] _ in + self.invalidateIntrinsicContentSize() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index bfaeb935..38a1ac24 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import GalleryVC @MainActor protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider { @@ -352,7 +353,8 @@ class ProfileHeaderView: UIView { let avatar = account.avatar else { return } - delegate?.showLoadingLargeImage(url: avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView) + let gallery = GalleryVC.GalleryViewController(dataSource: ImageGalleryDataSource(url: avatar, cache: .avatars, sourceView: avatarImageView), initialItemIndex: 0) + delegate?.present(gallery, animated: true) } @objc func headerPressed() { @@ -360,7 +362,8 @@ class ProfileHeaderView: UIView { let header = account.header else { return } - delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView) + let gallery = GalleryVC.GalleryViewController(dataSource: ImageGalleryDataSource(url: header, cache: .headers, sourceView: headerImageView), initialItemIndex: 0) + delegate?.present(gallery, animated: true) } @IBAction func followPressed() { diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 4f52918e..f1a45151 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -9,6 +9,7 @@ import UIKit import Pachyderm import Combine +import GalleryVC @MainActor protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider { @@ -328,14 +329,12 @@ extension StatusCollectionViewCell { } extension StatusCollectionViewCell { - func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? { - guard let delegate = delegate, - let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } - let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:)) - let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) - // TODO: PiP -// gallery.avPlayerViewControllerDelegate = self - return gallery + func attachmentViewGallery(startingAt index: Int) -> UIViewController? { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + return nil + } + let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable + return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: status.attachments, sourceViews: sourceViews), initialItemIndex: index) } func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {