293 lines
13 KiB
Swift
293 lines
13 KiB
Swift
// Copyright © 2020 Metabolist. All rights reserved.
|
|
|
|
import AVFoundation
|
|
import BlurHash
|
|
import Mastodon
|
|
import SDWebImage
|
|
import UIKit
|
|
import ViewModels
|
|
|
|
enum ImageError: Error {
|
|
case unableToLoad
|
|
}
|
|
|
|
extension ImageError: LocalizedError {
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unableToLoad:
|
|
return NSLocalizedString("image-error.unable-to-load", comment: "")
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ImageViewController: UIViewController {
|
|
let scrollView = UIScrollView()
|
|
let imageView = SDAnimatedImageView()
|
|
let playerView = PlayerView()
|
|
|
|
private let viewModel: AttachmentViewModel?
|
|
private let imageURL: URL?
|
|
private let contentView = UIView()
|
|
private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
|
|
private let descriptionTextView = UITextView()
|
|
|
|
init(viewModel: AttachmentViewModel? = nil, imageURL: URL? = nil) {
|
|
self.viewModel = viewModel
|
|
self.imageURL = imageURL
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// swiftlint:disable:next function_body_length
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .secondarySystemBackground
|
|
|
|
view.addSubview(scrollView)
|
|
scrollView.delegate = self
|
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
scrollView.showsHorizontalScrollIndicator = false
|
|
scrollView.showsVerticalScrollIndicator = false
|
|
scrollView.maximumZoomScale = Self.maximumZoomScale
|
|
|
|
let doubleTapGestureRecognizer = UITapGestureRecognizer(
|
|
target: self,
|
|
action: #selector(handleDoubleTap(gestureRecognizer:)))
|
|
|
|
doubleTapGestureRecognizer.numberOfTapsRequired = 2
|
|
scrollView.addGestureRecognizer(doubleTapGestureRecognizer)
|
|
|
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
scrollView.addSubview(contentView)
|
|
|
|
contentView.addSubview(imageView)
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
imageView.contentMode = .scaleAspectFit
|
|
imageView.sd_imageIndicator = SDWebImageActivityIndicator.large
|
|
imageView.autoPlayAnimatedImage = false
|
|
|
|
contentView.addSubview(playerView)
|
|
playerView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
view.addSubview(descriptionBackgroundView)
|
|
descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
|
descriptionBackgroundView.isHidden = viewModel?.attachment.description == nil
|
|
|| viewModel?.attachment.description == ""
|
|
|
|
descriptionBackgroundView.contentView.addSubview(descriptionTextView)
|
|
descriptionTextView.translatesAutoresizingMaskIntoConstraints = false
|
|
descriptionTextView.backgroundColor = .clear
|
|
descriptionTextView.font = .preferredFont(forTextStyle: .caption1)
|
|
descriptionTextView.adjustsFontForContentSizeCategory = true
|
|
descriptionTextView.text = viewModel?.attachment.description
|
|
descriptionTextView.isScrollEnabled = false
|
|
descriptionTextView.isEditable = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
|
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
|
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
|
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
|
|
contentView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
|
|
contentView.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
|
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
playerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
playerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
playerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
playerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
descriptionBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
descriptionBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
descriptionBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
descriptionTextView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, multiplier: 1 / 4),
|
|
descriptionTextView.leadingAnchor.constraint(
|
|
equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor),
|
|
descriptionTextView.topAnchor.constraint(equalTo: descriptionBackgroundView.topAnchor),
|
|
descriptionTextView.trailingAnchor.constraint(
|
|
equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor),
|
|
descriptionTextView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
|
])
|
|
|
|
if let viewModel = viewModel {
|
|
switch viewModel.attachment.type {
|
|
case .image:
|
|
imageView.tag = viewModel.tag
|
|
playerView.isHidden = true
|
|
|
|
let placeholderImage: UIImage?
|
|
let cachedImageKey = viewModel.attachment.previewUrl?.url.absoluteString
|
|
let cachedImage = SDImageCache.shared.imageFromCache(forKey: cachedImageKey)
|
|
|
|
if cachedImage != nil {
|
|
placeholderImage = cachedImage
|
|
imageView.sd_imageIndicator = nil
|
|
} else if let blurHash = viewModel.attachment.blurhash {
|
|
placeholderImage = UIImage(blurHash: blurHash, size: .blurHashSize)
|
|
} else {
|
|
placeholderImage = nil
|
|
}
|
|
|
|
imageView.sd_setImage(with: viewModel.attachment.url.url,
|
|
placeholderImage: placeholderImage) { _, error, _, _ in
|
|
if error != nil {
|
|
let alertItem = AlertItem(error: ImageError.unableToLoad)
|
|
|
|
self.present(alertItem: alertItem)
|
|
}
|
|
}
|
|
case .gifv:
|
|
playerView.tag = viewModel.tag
|
|
imageView.isHidden = true
|
|
let player = PlayerCache.shared.player(url: viewModel.attachment.url.url)
|
|
|
|
player.isMuted = true
|
|
|
|
playerView.player = player
|
|
player.play()
|
|
default: break
|
|
}
|
|
|
|
var accessibilityLabel = viewModel.attachment.type.accessibilityName
|
|
|
|
if let description = viewModel.attachment.description {
|
|
accessibilityLabel.appendWithSeparator(description)
|
|
}
|
|
} else if let imageURL = imageURL {
|
|
imageView.tag = imageURL.hashValue
|
|
playerView.isHidden = true
|
|
imageView.sd_setImage(with: imageURL)
|
|
}
|
|
|
|
contentView.accessibilityLabel = viewModel?.attachment.type.accessibilityName
|
|
?? Attachment.AttachmentType.image.accessibilityName
|
|
contentView.isAccessibilityElement = true
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
let textHeight = descriptionTextView.sizeThatFits(.init(
|
|
width: descriptionTextView.frame.width,
|
|
height: .greatestFiniteMagnitude)).height
|
|
descriptionTextView.isScrollEnabled = textHeight > descriptionTextView.frame.height
|
|
}
|
|
}
|
|
|
|
extension ImageViewController {
|
|
func toggleDescriptionVisibility() {
|
|
UIView.animate(withDuration: .shortAnimationDuration) {
|
|
self.descriptionBackgroundView.alpha = self.descriptionBackgroundView.alpha > 0 ? 0 : 1
|
|
}
|
|
}
|
|
|
|
func presentActivityViewController() {
|
|
if let image = imageView.image {
|
|
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: [])
|
|
|
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
activityViewController.popoverPresentationController?
|
|
.barButtonItem = parent?.navigationItem.rightBarButtonItem
|
|
}
|
|
|
|
present(activityViewController, animated: true)
|
|
} else if let asset = playerView.player?.currentItem?.asset as? AVURLAsset {
|
|
asset.exportWithoutAudioTrack { result in
|
|
DispatchQueue.main.async {
|
|
switch result {
|
|
case let .success(url):
|
|
let activityViewController = UIActivityViewController(
|
|
activityItems: [url],
|
|
applicationActivities: [])
|
|
|
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
activityViewController.popoverPresentationController?
|
|
.barButtonItem = self.parent?.navigationItem.rightBarButtonItem
|
|
}
|
|
|
|
activityViewController.completionWithItemsHandler = { _, _, _, _ in
|
|
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
|
}
|
|
|
|
self.present(activityViewController, animated: true)
|
|
case .failure:
|
|
let alertController = UIAlertController(
|
|
title: nil,
|
|
message: NSLocalizedString("attachment.unable-to-export-media", comment: ""),
|
|
preferredStyle: .alert)
|
|
|
|
let okAction = UIAlertAction(
|
|
title: NSLocalizedString("ok", comment: ""),
|
|
style: .default) { _ in }
|
|
|
|
alertController.addAction(okAction)
|
|
|
|
self.present(alertController, animated: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ImageViewController: UIScrollViewDelegate {
|
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
|
contentView
|
|
}
|
|
|
|
// https://stackoverflow.com/a/40480610/2484482
|
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
|
if scrollView.zoomScale > 1,
|
|
let contentSize = imageView.image?.size ?? playerView.player?.currentItem?.presentationSize {
|
|
let ratio = min(contentView.frame.width / contentSize.width, contentView.frame.height / contentSize.height)
|
|
|
|
let newWidth = contentSize.width * ratio
|
|
let newHeight = contentSize.height * ratio
|
|
|
|
let horizontalInset = 0.5 * (newWidth * scrollView.zoomScale > contentView.frame.width
|
|
? (newWidth - contentView.frame.width)
|
|
: (scrollView.frame.width - scrollView.contentSize.width))
|
|
let verticalInset = 0.5 * (newHeight * scrollView.zoomScale > contentView.frame.height
|
|
? (newHeight - contentView.frame.height)
|
|
: (scrollView.frame.height - scrollView.contentSize.height))
|
|
|
|
scrollView.contentInset = .init(
|
|
top: verticalInset,
|
|
left: horizontalInset,
|
|
bottom: verticalInset,
|
|
right: horizontalInset)
|
|
} else {
|
|
scrollView.contentInset = .zero
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension ImageViewController {
|
|
static let maximumZoomScale: CGFloat = 4
|
|
|
|
@objc func handleDoubleTap(gestureRecognizer: UITapGestureRecognizer) {
|
|
if scrollView.zoomScale == scrollView.minimumZoomScale {
|
|
let width = contentView.frame.size.width / scrollView.maximumZoomScale
|
|
let height = contentView.frame.size.height / scrollView.maximumZoomScale
|
|
let center = scrollView.convert(gestureRecognizer.location(in: gestureRecognizer.view), from: contentView)
|
|
|
|
scrollView.zoom(
|
|
to: CGRect(x: center.x - (width / 2), y: center.y - (height / 2), width: width, height: height),
|
|
animated: true)
|
|
} else {
|
|
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
|
|
}
|
|
}
|
|
}
|