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