mastodon-ios/Mastodon/Scene/MediaPreview/MediaPreviewViewController....

418 lines
15 KiB
Swift

//
// MediaPreviewViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-28.
//
import os.log
import UIKit
import Combine
import Pageboy
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
final class MediaPreviewViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: MediaPreviewViewModel!
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let pagingViewController = MediaPreviewPagingViewController()
let topToolbar: UIStackView = {
let stackView = TouchTransparentStackView()
stackView.axis = .horizontal
stackView.distribution = .equalSpacing
stackView.alignment = .fill
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
let closeButton = HUDButton { button in
button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal)
}
let altButton = HUDButton { button in
button.setTitle("ALT", for: .normal)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension MediaPreviewViewController {
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .dark
visualEffectView.frame = view.bounds
visualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(visualEffectView)
pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false
addChild(pagingViewController)
visualEffectView.contentView.addSubview(pagingViewController.view)
visualEffectView.pinTo(to: pagingViewController.view)
pagingViewController.didMove(toParent: self)
view.addSubview(topToolbar)
NSLayoutConstraint.activate([
topToolbar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
topToolbar.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
topToolbar.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
])
topToolbar.addArrangedSubview(closeButton)
NSLayoutConstraint.activate([
closeButton.widthAnchor.constraint(equalToConstant: HUDButton.height).priority(.defaultHigh),
])
topToolbar.addArrangedSubview(altButton)
viewModel.mediaPreviewImageViewControllerDelegate = self
pagingViewController.interPageSpacing = 10
pagingViewController.delegate = self
pagingViewController.dataSource = viewModel
closeButton.button.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside)
altButton.button.addTarget(self, action: #selector(MediaPreviewViewController.altButtonPressed(_:)), for: .touchUpInside)
// bind view model
viewModel.$currentPage
.receive(on: DispatchQueue.main)
.sink { [weak self] index in
guard let self = self else { return }
switch self.viewModel.transitionItem.source {
case .attachment:
break
case .attachments(let mediaGridContainerView):
UIView.animate(withDuration: 0.3) {
mediaGridContainerView.setAlpha(1)
mediaGridContainerView.setAlpha(0, index: index)
}
case .profileAvatar, .profileBanner:
break
}
}
.store(in: &disposeBag)
viewModel.$currentPage
.receive(on: DispatchQueue.main)
.sink { [weak self] index in
guard let self = self else { return }
switch self.viewModel.item {
case .attachment(let previewContext):
let needsHideCloseButton: Bool = {
guard index < previewContext.attachments.count else { return false }
let attachment = previewContext.attachments[index]
return attachment.kind == .video // not hide buttno for audio
}()
self.closeButton.isHidden = needsHideCloseButton
default:
break
}
}
.store(in: &disposeBag)
viewModel.$altText
.receive(on: DispatchQueue.main)
.sink { [weak self] altText in
guard let self else { return }
UIView.animate(withDuration: 0.3) {
if altText == nil {
self.altButton.alpha = 0
} else {
self.altButton.alpha = 1
}
}
}
.store(in: &disposeBag)
viewModel.$showingChrome
.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] showingChrome in
UIView.animate(withDuration: 0.3) {
self?.setNeedsStatusBarAppearanceUpdate()
self?.topToolbar.alpha = showingChrome ? 1 : 0
}
}
.store(in: &disposeBag)
// viewModel.$isPoping
// .receive(on: DispatchQueue.main)
// .removeDuplicates()
// .sink { [weak self] _ in
// guard let self = self else { return }
// // statusBar style update with animation
// self.setNeedsStatusBarAppearanceUpdate()
// UIView.animate(withDuration: 0.3) {
// }
// }
// .store(in: &disposeBag)
}
}
extension MediaPreviewViewController {
override var prefersStatusBarHidden: Bool {
!viewModel.showingChrome
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
}
extension MediaPreviewViewController {
@objc private func closeButtonPressed(_ sender: UIButton) {
dismiss(animated: true, completion: nil)
}
@objc private func altButtonPressed(_ sender: UIButton) {
guard let alt = viewModel.altText else { return }
present(AltViewController(alt: alt, sourceView: sender), animated: true)
}
}
// MARK: - MediaPreviewingViewController
extension MediaPreviewViewController: MediaPreviewingViewController {
func isInteractiveDismissible() -> Bool {
if let mediaPreviewImageViewController = pagingViewController.currentViewController as? MediaPreviewImageViewController {
let previewImageView = mediaPreviewImageViewController.previewImageView
// TODO: allow zooming pan dismiss
guard previewImageView.zoomScale == previewImageView.minimumZoomScale else {
return false
}
let safeAreaInsets = previewImageView.safeAreaInsets
let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
let dismissible = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + 3 // add 3pt tolerance
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible %s", ((#file as NSString).lastPathComponent), #line, #function, dismissible ? "true" : "false")
return dismissible
}
if let _ = pagingViewController.currentViewController as? MediaPreviewVideoViewController {
return true
}
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible false", ((#file as NSString).lastPathComponent), #line, #function)
return false
}
}
// MARK: - PageboyViewControllerDelegate
extension MediaPreviewViewController: PageboyViewControllerDelegate {
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
willScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollTo position: CGPoint,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// do nothing
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didScrollToPageAt index: PageboyViewController.PageIndex,
direction: PageboyViewController.NavigationDirection,
animated: Bool
) {
// update page control
// pageControl.currentPage = index
viewModel.currentPage = index
}
func pageboyViewController(
_ pageboyViewController: PageboyViewController,
didReloadWith currentViewController: UIViewController,
currentPageIndex: PageboyViewController.PageIndex
) {
// do nothing
}
}
// MARK: - MediaPreviewImageViewControllerDelegate
extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
let location = tapGestureRecognizer.location(in: viewController.previewImageView.imageView)
let isContainsTap = viewController.previewImageView.imageView.frame.contains(location)
if isContainsTap {
self.viewModel.showingChrome.toggle()
} else {
dismiss(animated: true, completion: nil)
}
}
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
// do nothing
}
func mediaPreviewImageViewController(
_ viewController: MediaPreviewImageViewController,
contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction
) {
switch action {
case .savePhoto:
guard let assetURL = viewController.viewModel.item.assetURL else { return }
context.photoLibraryService.save(imageSource: .url(assetURL))
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
case .failure(let error):
guard let error = error as? PhotoLibraryService.PhotoLibraryError,
case .noPermission = error else { return }
let alertController = SettingService.openSettingsAlertController(
title: L10n.Common.Alerts.SavePhotoFailure.title,
message: L10n.Common.Alerts.SavePhotoFailure.message
)
_ = self.coordinator.present(
scene: .alertController(alertController: alertController),
from: self,
transition: .alertController(animated: true, completion: nil)
)
case .finished:
break
}
} receiveValue: { _ in
// do nothing
}
.store(in: &context.disposeBag)
case .copyPhoto:
guard let assetURL = viewController.viewModel.item.assetURL else { return }
context.photoLibraryService.copy(imageSource: .url(assetURL))
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
case .finished:
break
}
} receiveValue: { _ in
// do nothing
}
.store(in: &context.disposeBag)
case .share:
let applicationActivities: [UIActivity] = [
SafariActivity(sceneCoordinator: self.coordinator)
]
let activityViewController = UIActivityViewController(
activityItems: {
var activityItems: [Any] = []
if let assetURL = viewController.viewModel.item.assetURL {
activityItems.append(assetURL)
}
return activityItems
}(),
applicationActivities: applicationActivities
)
activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView
self.present(activityViewController, animated: true, completion: nil)
}
}
}
extension MediaPreviewViewController {
func closeKeyCommand(input: String) -> UIKeyCommand {
UIKeyCommand(
title: L10n.Scene.Preview.Keyboard.closePreview,
image: nil,
action: #selector(MediaPreviewViewController.closePreviewKeyCommandHandler(_:)),
input: input,
modifierFlags: [],
propertyList: nil,
alternates: [],
discoverabilityTitle: nil,
attributes: [],
state: .off
)
}
var showNextKeyCommand: UIKeyCommand {
UIKeyCommand(
title: L10n.Scene.Preview.Keyboard.closePreview,
image: nil,
action: #selector(MediaPreviewViewController.showNextKeyCommandHandler(_:)),
input: "j",
modifierFlags: [],
propertyList: nil,
alternates: [],
discoverabilityTitle: nil,
attributes: [],
state: .off
)
}
var showPreviousKeyCommand: UIKeyCommand {
UIKeyCommand(
title: L10n.Scene.Preview.Keyboard.closePreview,
image: nil,
action: #selector(MediaPreviewViewController.showPreviousKeyCommandHandler(_:)),
input: "k",
modifierFlags: [],
propertyList: nil,
alternates: [],
discoverabilityTitle: nil,
attributes: [],
state: .off
)
}
override var keyCommands: [UIKeyCommand] {
return [
closeKeyCommand(input: UIKeyCommand.inputEscape),
closeKeyCommand(input: "i"),
showNextKeyCommand,
showPreviousKeyCommand,
]
}
@objc private func closePreviewKeyCommandHandler(_ sender: UIKeyCommand) {
dismiss(animated: true, completion: nil)
}
@objc private func showNextKeyCommandHandler(_ sender: UIKeyCommand) {
pagingViewController.scrollToPage(.next, animated: true, completion: nil)
}
@objc private func showPreviousKeyCommandHandler(_ sender: UIKeyCommand) {
pagingViewController.scrollToPage(.previous, animated: true, completion: nil)
}
}