forked from shadowfacts/Tusker
parent
f327cfd197
commit
be977dbea9
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -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"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -131,7 +131,7 @@
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
|
||||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
|
||||||
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.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 */; };
|
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
|
||||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6552366289870790048A653 /* ScreenCorners */; };
|
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 */; };
|
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
|
||||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
|
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
|
||||||
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.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 */; };
|
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
|
||||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
|
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
|
||||||
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; };
|
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 = "<group>"; };
|
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
|
||||||
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
|
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
|
||||||
D642E8392BA75F4C004BFD6A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
D642E8392BA75F4C004BFD6A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
D642E83D2BA7AD0F004BFD6A /* GalleryVC */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = GalleryVC; sourceTree = "<group>"; };
|
||||||
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
|
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
|
||||||
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
|
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
|
||||||
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
|
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -531,7 +541,7 @@
|
||||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
|
||||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = "<group>"; };
|
||||||
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
|
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = "<group>"; };
|
||||||
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
|
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
|
||||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
||||||
D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; };
|
D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -598,6 +608,14 @@
|
||||||
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
|
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
|
||||||
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
|
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = "<group>"; };
|
||||||
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
||||||
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
|
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = "<group>"; };
|
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = "<group>"; };
|
||||||
|
@ -790,6 +808,7 @@
|
||||||
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
|
D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */,
|
||||||
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
|
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
|
||||||
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
|
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */,
|
||||||
|
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */,
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||||
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
|
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
|
||||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||||
|
@ -830,6 +849,13 @@
|
||||||
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */,
|
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */,
|
||||||
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */,
|
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */,
|
||||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.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";
|
path = "Attachment Gallery";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1177,6 +1203,7 @@
|
||||||
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
D6BD395729B6441F005FFD2B /* ComposeUI */,
|
||||||
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */,
|
||||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */,
|
||||||
|
D642E83D2BA7AD0F004BFD6A /* GalleryVC */,
|
||||||
);
|
);
|
||||||
path = Packages;
|
path = Packages;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1461,7 +1488,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */,
|
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */,
|
||||||
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */,
|
D6531DED246B81C9000F9538 /* GifvPlayerView.swift */,
|
||||||
|
D6934F372BA8E2B7002B1C8D /* GifvController.swift */,
|
||||||
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */,
|
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */,
|
||||||
);
|
);
|
||||||
path = Attachments;
|
path = Attachments;
|
||||||
|
@ -1722,6 +1750,7 @@
|
||||||
D6BD395829B64426005FFD2B /* ComposeUI */,
|
D6BD395829B64426005FFD2B /* ComposeUI */,
|
||||||
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
D6CA6ED129EF6091003EC5DF /* TuskerPreferences */,
|
||||||
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
|
D60BB3932B30076F00DAEA65 /* HTMLStreamer */,
|
||||||
|
D6934F2B2BA7AD32002B1C8D /* GalleryVC */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1996,6 +2025,7 @@
|
||||||
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
|
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
|
||||||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||||
|
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||||
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
|
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
|
||||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||||
|
@ -2011,10 +2041,12 @@
|
||||||
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
|
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
|
||||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||||
|
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
|
||||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||||
|
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
|
||||||
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */,
|
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */,
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
||||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
|
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
|
||||||
|
@ -2029,6 +2061,7 @@
|
||||||
D6D94955298963A900C59229 /* Colors.swift in Sources */,
|
D6D94955298963A900C59229 /* Colors.swift in Sources */,
|
||||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||||
|
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||||
|
@ -2056,13 +2089,15 @@
|
||||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
||||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
|
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
|
||||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||||
|
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */,
|
D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */,
|
||||||
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
|
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
|
||||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */,
|
||||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||||
|
D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */,
|
||||||
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
|
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
|
||||||
D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */,
|
D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */,
|
||||||
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
|
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
|
||||||
|
@ -2174,6 +2209,7 @@
|
||||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
||||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||||
|
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
|
||||||
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
||||||
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
|
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
|
||||||
|
@ -2247,6 +2283,7 @@
|
||||||
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */,
|
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */,
|
||||||
D6958F3D2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift in Sources */,
|
D6958F3D2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift in Sources */,
|
||||||
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */,
|
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */,
|
||||||
|
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
|
||||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||||
|
@ -3050,6 +3087,10 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Pachyderm;
|
productName = Pachyderm;
|
||||||
};
|
};
|
||||||
|
D6934F2B2BA7AD32002B1C8D /* GalleryVC */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = GalleryVC;
|
||||||
|
};
|
||||||
D6A4532329EF665200032932 /* ComposeUI */ = {
|
D6A4532329EF665200032932 /* ComposeUI */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = ComposeUI;
|
productName = ComposeUI;
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -27,7 +27,8 @@ class GifvAttachmentViewController: UIViewController {
|
||||||
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
let asset = AVURLAsset(url: attachment.url)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<AttachmentView>
|
||||||
|
|
||||||
|
init(attachments: [Attachment], sourceViews: NSHashTable<AttachmentView>) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -145,7 +145,7 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
class LargeImageGifvContentView: GifvPlayerView, LargeImageContentView {
|
||||||
private(set) var animationImage: UIImage?
|
private(set) var animationImage: UIImage?
|
||||||
var activityItemsForSharing: [Any] {
|
var activityItemsForSharing: [Any] {
|
||||||
[GifvActivityItemSource(asset: asset, attachment: attachment)]
|
[GifvActivityItemSource(asset: asset, attachment: attachment)]
|
||||||
|
@ -166,11 +166,12 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.asset = AVURLAsset(url: attachment.url)
|
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.animationImage = source.image
|
||||||
|
|
||||||
self.player.play()
|
controller.play()
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
@ -192,7 +193,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func grayscaleStateChanged() {
|
func grayscaleStateChanged() {
|
||||||
// no-op, GifvAttachmentView observes the grayscale state itself
|
// no-op, GifvPlayerView observes the grayscale state itself
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol StatusEditCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate {
|
protocol StatusEditCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate {
|
||||||
|
@ -234,16 +235,10 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusEditCollectionViewCell: AttachmentViewDelegate {
|
extension StatusEditCollectionViewCell: AttachmentViewDelegate {
|
||||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
func attachmentViewGallery(startingAt index: Int) -> UIViewController? {
|
||||||
guard let delegate else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let attachments = attachmentsView.attachments!
|
let attachments = attachmentsView.attachments!
|
||||||
let sourceViews = attachments.map {
|
let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable<AttachmentView>
|
||||||
attachmentsView.getAttachmentView(for: $0)
|
return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: attachments, sourceViews: sourceViews), initialItemIndex: index)
|
||||||
}
|
|
||||||
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
|
|
||||||
return gallery
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
|
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import ComposeUI
|
import ComposeUI
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
|
@ -126,27 +127,6 @@ extension TuskerNavigationDelegate {
|
||||||
compose(editing: draft, animated: animated)
|
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 {
|
private func moreOptions(forURL url: URL) -> UIActivityViewController {
|
||||||
let customActivites: [UIActivity] = [
|
let customActivites: [UIActivity] = [
|
||||||
OpenInSafariActivity()
|
OpenInSafariActivity()
|
||||||
|
|
|
@ -13,7 +13,7 @@ import TuskerComponents
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol AttachmentViewDelegate: AnyObject {
|
protocol AttachmentViewDelegate: AnyObject {
|
||||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
|
func attachmentViewGallery(startingAt index: Int) -> UIViewController?
|
||||||
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
|
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ class AttachmentView: GIFImageView {
|
||||||
|
|
||||||
weak var delegate: AttachmentViewDelegate?
|
weak var delegate: AttachmentViewDelegate?
|
||||||
|
|
||||||
var playImageView: UIImageView?
|
private var playImageView: UIImageView?
|
||||||
var gifvView: GifvAttachmentView?
|
private(set) var gifvView: GifvPlayerView?
|
||||||
private var badgeContainer: UIStackView?
|
private var badgeContainer: UIStackView?
|
||||||
|
|
||||||
var attachment: Attachment!
|
var attachment: Attachment!
|
||||||
|
@ -32,6 +32,16 @@ class AttachmentView: GIFImageView {
|
||||||
|
|
||||||
private var loadAttachmentTask: Task<Void, Never>?
|
private var loadAttachmentTask: Task<Void, Never>?
|
||||||
private var source: Source?
|
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 {
|
private var autoplayGifs: Bool {
|
||||||
Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled
|
Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled
|
||||||
|
@ -103,9 +113,9 @@ class AttachmentView: GIFImageView {
|
||||||
} else if self.attachment.kind == .gifv,
|
} else if self.attachment.kind == .gifv,
|
||||||
let gifvView = self.gifvView {
|
let gifvView = self.gifvView {
|
||||||
if self.autoplayGifs {
|
if self.autoplayGifs {
|
||||||
gifvView.player.play()
|
gifvView.controller.play()
|
||||||
} else {
|
} else {
|
||||||
gifvView.player.pause()
|
gifvView.controller.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -274,11 +284,12 @@ class AttachmentView: GIFImageView {
|
||||||
|
|
||||||
private func loadGifv() {
|
private func loadGifv() {
|
||||||
let asset = AVURLAsset(url: attachment.url)
|
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
|
self.gifvView = gifvView
|
||||||
gifvView.translatesAutoresizingMaskIntoConstraints = false
|
gifvView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
if autoplayGifs {
|
if autoplayGifs {
|
||||||
gifvView.player.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
addSubview(gifvView)
|
addSubview(gifvView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -419,7 +430,6 @@ fileprivate extension AttachmentView {
|
||||||
enum Source {
|
enum Source {
|
||||||
case image(URL, Data?, UIImage)
|
case image(URL, Data?, UIImage)
|
||||||
case gifData(URL, Data, UIImage?)
|
case gifData(URL, Data, UIImage?)
|
||||||
// case cgImage(URL, CGImage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Badges: OptionSet {
|
struct Badges: OptionSet {
|
||||||
|
|
|
@ -1,47 +1,72 @@
|
||||||
//
|
//
|
||||||
// GifvAttachmentView.swift
|
// GifvController.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 5/12/20.
|
// Created by Shadowfacts on 3/18/24.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import Foundation
|
||||||
import AVFoundation
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
class GifvAttachmentView: UIView {
|
@MainActor
|
||||||
|
class GifvController {
|
||||||
override class var layerClass: AnyClass {
|
private let asset: AVAsset
|
||||||
return AVPlayerLayer.self
|
|
||||||
}
|
|
||||||
|
|
||||||
private var playerLayer: AVPlayerLayer {
|
|
||||||
layer as! AVPlayerLayer
|
|
||||||
}
|
|
||||||
|
|
||||||
private var asset: AVAsset
|
|
||||||
private(set) var item: AVPlayerItem
|
private(set) var item: AVPlayerItem
|
||||||
let player: AVPlayer
|
let player: AVPlayer
|
||||||
|
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
|
|
||||||
init(asset: AVAsset, gravity: AVLayerVideoGravity) {
|
let presentationSizeSubject = PassthroughSubject<CGSize, Never>()
|
||||||
|
private var presentationSizeObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(asset: AVAsset) {
|
||||||
self.asset = asset
|
self.asset = asset
|
||||||
item = GifvAttachmentView.createItem(asset: asset)
|
self.item = AVPlayerItem(asset: asset)
|
||||||
player = AVPlayer(playerItem: item)
|
self.player = AVPlayer(playerItem: item)
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
|
||||||
|
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
playerLayer.player = player
|
|
||||||
playerLayer.videoGravity = gravity
|
|
||||||
player.isMuted = true
|
player.isMuted = true
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
|
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
|
updatePresentationSizeObservation()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
func play() {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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 {
|
private static func createItem(asset: AVAsset) -> AVPlayerItem {
|
||||||
|
@ -63,21 +88,5 @@ class GifvAttachmentView: UIView {
|
||||||
}
|
}
|
||||||
return item
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
||||||
|
@ -352,7 +353,8 @@ class ProfileHeaderView: UIView {
|
||||||
let avatar = account.avatar else {
|
let avatar = account.avatar else {
|
||||||
return
|
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() {
|
@objc func headerPressed() {
|
||||||
|
@ -360,7 +362,8 @@ class ProfileHeaderView: UIView {
|
||||||
let header = account.header else {
|
let header = account.header else {
|
||||||
return
|
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() {
|
@IBAction func followPressed() {
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
import GalleryVC
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
|
protocol StatusCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate, MenuActionProvider {
|
||||||
|
@ -328,14 +329,12 @@ extension StatusCollectionViewCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusCollectionViewCell {
|
extension StatusCollectionViewCell {
|
||||||
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
func attachmentViewGallery(startingAt index: Int) -> UIViewController? {
|
||||||
guard let delegate = delegate,
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
let status = mastodonController.persistentContainer.status(for: statusID) else { return nil }
|
return nil
|
||||||
let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:))
|
}
|
||||||
let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index)
|
let sourceViews = attachmentsView.attachmentViews.copy() as! NSHashTable<AttachmentView>
|
||||||
// TODO: PiP
|
return GalleryVC.GalleryViewController(dataSource: StatusAttachmentsGalleryDataSource(attachments: status.attachments, sourceViews: sourceViews), initialItemIndex: index)
|
||||||
// gallery.avPlayerViewControllerDelegate = self
|
|
||||||
return gallery
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
|
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
|
||||||
|
|
Loading…
Reference in New Issue