MediaPreviewViewController.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. //
  2. // MediaPreviewViewController.swift
  3. // Mastodon
  4. //
  5. // Created by MainasuK Cirno on 2021-4-28.
  6. //
  7. import os.log
  8. import UIKit
  9. import Combine
  10. import Pageboy
  11. import MastodonAsset
  12. import MastodonCore
  13. import MastodonUI
  14. import MastodonLocalization
  15. final class MediaPreviewViewController: UIViewController, NeedsDependency {
  16. static let closeButtonSize = CGSize(width: 30, height: 30)
  17. weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
  18. weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
  19. var disposeBag = Set<AnyCancellable>()
  20. var viewModel: MediaPreviewViewModel!
  21. let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
  22. let pagingViewController = MediaPreviewPagingViewController()
  23. let closeButtonBackground: UIVisualEffectView = {
  24. let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
  25. backgroundView.alpha = 0.9
  26. backgroundView.layer.masksToBounds = true
  27. backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5
  28. return backgroundView
  29. }()
  30. let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)))
  31. let closeButton: UIButton = {
  32. let button = HighlightDimmableButton()
  33. button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
  34. button.imageView?.tintColor = .label
  35. button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal)
  36. return button
  37. }()
  38. deinit {
  39. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  40. }
  41. }
  42. extension MediaPreviewViewController {
  43. override func viewDidLoad() {
  44. super.viewDidLoad()
  45. overrideUserInterfaceStyle = .dark
  46. visualEffectView.frame = view.bounds
  47. visualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  48. view.addSubview(visualEffectView)
  49. pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false
  50. addChild(pagingViewController)
  51. visualEffectView.contentView.addSubview(pagingViewController.view)
  52. NSLayoutConstraint.activate([
  53. visualEffectView.topAnchor.constraint(equalTo: pagingViewController.view.topAnchor),
  54. visualEffectView.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor),
  55. visualEffectView.leadingAnchor.constraint(equalTo: pagingViewController.view.leadingAnchor),
  56. visualEffectView.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor),
  57. ])
  58. pagingViewController.didMove(toParent: self)
  59. closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false
  60. view.addSubview(closeButtonBackground)
  61. NSLayoutConstraint.activate([
  62. closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
  63. closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)
  64. ])
  65. closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  66. closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView)
  67. closeButton.translatesAutoresizingMaskIntoConstraints = false
  68. closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton)
  69. NSLayoutConstraint.activate([
  70. closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor),
  71. closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor),
  72. closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor),
  73. closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor),
  74. closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh),
  75. closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh),
  76. ])
  77. viewModel.mediaPreviewImageViewControllerDelegate = self
  78. pagingViewController.interPageSpacing = 10
  79. pagingViewController.delegate = self
  80. pagingViewController.dataSource = viewModel
  81. closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside)
  82. // bind view model
  83. viewModel.$currentPage
  84. .receive(on: DispatchQueue.main)
  85. .sink { [weak self] index in
  86. guard let self = self else { return }
  87. switch self.viewModel.transitionItem.source {
  88. case .attachment:
  89. break
  90. case .attachments(let mediaGridContainerView):
  91. UIView.animate(withDuration: 0.3) {
  92. mediaGridContainerView.setAlpha(1)
  93. mediaGridContainerView.setAlpha(0, index: index)
  94. }
  95. case .profileAvatar, .profileBanner:
  96. break
  97. }
  98. }
  99. .store(in: &disposeBag)
  100. viewModel.$currentPage
  101. .receive(on: DispatchQueue.main)
  102. .sink { [weak self] index in
  103. guard let self = self else { return }
  104. switch self.viewModel.item {
  105. case .attachment(let previewContext):
  106. let needsHideCloseButton: Bool = {
  107. guard index < previewContext.attachments.count else { return false }
  108. let attachment = previewContext.attachments[index]
  109. return attachment.kind == .video // not hide buttno for audio
  110. }()
  111. self.closeButtonBackground.isHidden = needsHideCloseButton
  112. default:
  113. break
  114. }
  115. }
  116. .store(in: &disposeBag)
  117. // viewModel.$isPoping
  118. // .receive(on: DispatchQueue.main)
  119. // .removeDuplicates()
  120. // .sink { [weak self] _ in
  121. // guard let self = self else { return }
  122. // // statusBar style update with animation
  123. // self.setNeedsStatusBarAppearanceUpdate()
  124. // UIView.animate(withDuration: 0.3) {
  125. // }
  126. // }
  127. // .store(in: &disposeBag)
  128. }
  129. }
  130. extension MediaPreviewViewController {
  131. @objc private func closeButtonPressed(_ sender: UIButton) {
  132. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  133. dismiss(animated: true, completion: nil)
  134. }
  135. }
  136. // MARK: - MediaPreviewingViewController
  137. extension MediaPreviewViewController: MediaPreviewingViewController {
  138. func isInteractiveDismissible() -> Bool {
  139. if let mediaPreviewImageViewController = pagingViewController.currentViewController as? MediaPreviewImageViewController {
  140. let previewImageView = mediaPreviewImageViewController.previewImageView
  141. // TODO: allow zooming pan dismiss
  142. guard previewImageView.zoomScale == previewImageView.minimumZoomScale else {
  143. return false
  144. }
  145. let safeAreaInsets = previewImageView.safeAreaInsets
  146. let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
  147. let dismissible = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + 3 // add 3pt tolerance
  148. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible %s", ((#file as NSString).lastPathComponent), #line, #function, dismissible ? "true" : "false")
  149. return dismissible
  150. }
  151. if let _ = pagingViewController.currentViewController as? MediaPreviewVideoViewController {
  152. return true
  153. }
  154. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissible false", ((#file as NSString).lastPathComponent), #line, #function)
  155. return false
  156. }
  157. }
  158. // MARK: - PageboyViewControllerDelegate
  159. extension MediaPreviewViewController: PageboyViewControllerDelegate {
  160. func pageboyViewController(
  161. _ pageboyViewController: PageboyViewController,
  162. willScrollToPageAt index: PageboyViewController.PageIndex,
  163. direction: PageboyViewController.NavigationDirection,
  164. animated: Bool
  165. ) {
  166. // do nothing
  167. }
  168. func pageboyViewController(
  169. _ pageboyViewController: PageboyViewController,
  170. didScrollTo position: CGPoint,
  171. direction: PageboyViewController.NavigationDirection,
  172. animated: Bool
  173. ) {
  174. // do nothing
  175. }
  176. func pageboyViewController(
  177. _ pageboyViewController: PageboyViewController,
  178. didScrollToPageAt index: PageboyViewController.PageIndex,
  179. direction: PageboyViewController.NavigationDirection,
  180. animated: Bool
  181. ) {
  182. // update page control
  183. // pageControl.currentPage = index
  184. viewModel.currentPage = index
  185. }
  186. func pageboyViewController(
  187. _ pageboyViewController: PageboyViewController,
  188. didReloadWith currentViewController: UIViewController,
  189. currentPageIndex: PageboyViewController.PageIndex
  190. ) {
  191. // do nothing
  192. }
  193. }
  194. // MARK: - MediaPreviewImageViewControllerDelegate
  195. extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
  196. func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
  197. let location = tapGestureRecognizer.location(in: viewController.previewImageView.imageView)
  198. let isContainsTap = viewController.previewImageView.imageView.frame.contains(location)
  199. guard !isContainsTap else { return }
  200. dismiss(animated: true, completion: nil)
  201. }
  202. func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
  203. // do nothing
  204. }
  205. func mediaPreviewImageViewController(
  206. _ viewController: MediaPreviewImageViewController,
  207. contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction
  208. ) {
  209. switch action {
  210. case .savePhoto:
  211. let _savePublisher: AnyPublisher<Void, Error>? = {
  212. switch viewController.viewModel.item {
  213. case .remote(let previewContext):
  214. guard let assetURL = previewContext.assetURL else { return nil }
  215. return context.photoLibraryService.save(imageSource: .url(assetURL))
  216. case .local(let previewContext):
  217. return context.photoLibraryService.save(imageSource: .image(previewContext.image))
  218. }
  219. }()
  220. guard let savePublisher = _savePublisher else {
  221. return
  222. }
  223. savePublisher
  224. .sink { [weak self] completion in
  225. guard let self = self else { return }
  226. switch completion {
  227. case .failure(let error):
  228. guard let error = error as? PhotoLibraryService.PhotoLibraryError,
  229. case .noPermission = error else { return }
  230. let alertController = SettingService.openSettingsAlertController(
  231. title: L10n.Common.Alerts.SavePhotoFailure.title,
  232. message: L10n.Common.Alerts.SavePhotoFailure.message
  233. )
  234. self.coordinator.present(
  235. scene: .alertController(alertController: alertController),
  236. from: self,
  237. transition: .alertController(animated: true, completion: nil)
  238. )
  239. case .finished:
  240. break
  241. }
  242. } receiveValue: { _ in
  243. // do nothing
  244. }
  245. .store(in: &context.disposeBag)
  246. case .copyPhoto:
  247. let _copyPublisher: AnyPublisher<Void, Error>? = {
  248. switch viewController.viewModel.item {
  249. case .remote(let previewContext):
  250. guard let assetURL = previewContext.assetURL else { return nil }
  251. return context.photoLibraryService.copy(imageSource: .url(assetURL))
  252. case .local(let previewContext):
  253. return context.photoLibraryService.copy(imageSource: .image(previewContext.image))
  254. }
  255. }()
  256. guard let copyPublisher = _copyPublisher else {
  257. return
  258. }
  259. copyPublisher
  260. .sink { completion in
  261. switch completion {
  262. case .failure(let error):
  263. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: copy photo fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
  264. case .finished:
  265. break
  266. }
  267. } receiveValue: { _ in
  268. // do nothing
  269. }
  270. .store(in: &context.disposeBag)
  271. case .share:
  272. let applicationActivities: [UIActivity] = [
  273. SafariActivity(sceneCoordinator: self.coordinator)
  274. ]
  275. let activityViewController = UIActivityViewController(
  276. activityItems: {
  277. var activityItems: [Any] = []
  278. switch viewController.viewModel.item {
  279. case .remote(let previewContext):
  280. if let assetURL = previewContext.assetURL {
  281. activityItems.append(assetURL)
  282. }
  283. case .local(let previewContext):
  284. activityItems.append(previewContext.image)
  285. }
  286. return activityItems
  287. }(),
  288. applicationActivities: applicationActivities
  289. )
  290. activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView
  291. self.present(activityViewController, animated: true, completion: nil)
  292. }
  293. }
  294. }
  295. extension MediaPreviewViewController {
  296. var closeKeyCommand: UIKeyCommand {
  297. UIKeyCommand(
  298. title: L10n.Scene.Preview.Keyboard.closePreview,
  299. image: nil,
  300. action: #selector(MediaPreviewViewController.closePreviewKeyCommandHandler(_:)),
  301. input: "i",
  302. modifierFlags: [],
  303. propertyList: nil,
  304. alternates: [],
  305. discoverabilityTitle: nil,
  306. attributes: [],
  307. state: .off
  308. )
  309. }
  310. var showNextKeyCommand: UIKeyCommand {
  311. UIKeyCommand(
  312. title: L10n.Scene.Preview.Keyboard.closePreview,
  313. image: nil,
  314. action: #selector(MediaPreviewViewController.showNextKeyCommandHandler(_:)),
  315. input: "j",
  316. modifierFlags: [],
  317. propertyList: nil,
  318. alternates: [],
  319. discoverabilityTitle: nil,
  320. attributes: [],
  321. state: .off
  322. )
  323. }
  324. var showPreviousKeyCommand: UIKeyCommand {
  325. UIKeyCommand(
  326. title: L10n.Scene.Preview.Keyboard.closePreview,
  327. image: nil,
  328. action: #selector(MediaPreviewViewController.showPreviousKeyCommandHandler(_:)),
  329. input: "k",
  330. modifierFlags: [],
  331. propertyList: nil,
  332. alternates: [],
  333. discoverabilityTitle: nil,
  334. attributes: [],
  335. state: .off
  336. )
  337. }
  338. override var keyCommands: [UIKeyCommand] {
  339. return [
  340. closeKeyCommand,
  341. showNextKeyCommand,
  342. showPreviousKeyCommand,
  343. ]
  344. }
  345. @objc private func closePreviewKeyCommandHandler(_ sender: UIKeyCommand) {
  346. dismiss(animated: true, completion: nil)
  347. }
  348. @objc private func showNextKeyCommandHandler(_ sender: UIKeyCommand) {
  349. pagingViewController.scrollToPage(.next, animated: true, completion: nil)
  350. }
  351. @objc private func showPreviousKeyCommandHandler(_ sender: UIKeyCommand) {
  352. pagingViewController.scrollToPage(.previous, animated: true, completion: nil)
  353. }
  354. }