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