Gallery rewrite

See #450
This commit is contained in:
Shadowfacts 2024-03-19 14:58:51 -04:00
parent f327cfd197
commit be977dbea9
28 changed files with 1906 additions and 100 deletions

8
Packages/GalleryVC/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -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"]),
]
)

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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;

View File

@ -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
}

View File

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

View File

@ -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
[]
}
}

View File

@ -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 []
}
}
}

View File

@ -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()]
}
}

View File

@ -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),
])
}
}

View File

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

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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()

View File

@ -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 {

View File

@ -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()
}
}
}

View File

@ -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")
}
}

View File

@ -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() {

View File

@ -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) {