GalleryViewController.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import UIKit
  2. import DcCore
  3. import QuickLook
  4. class GalleryViewController: UIViewController {
  5. private let dcContext: DcContext
  6. // MARK: - data
  7. private let chatId: Int
  8. private var mediaMessageIds: [Int]
  9. private var galleryItemCache: [Int: GalleryItem] = [:]
  10. // MARK: - subview specs
  11. private let gridDefaultSpacing: CGFloat = 5
  12. private lazy var gridLayout: GridCollectionViewFlowLayout = {
  13. let layout = GridCollectionViewFlowLayout()
  14. layout.minimumLineSpacing = gridDefaultSpacing
  15. layout.minimumInteritemSpacing = gridDefaultSpacing
  16. layout.format = .square
  17. return layout
  18. }()
  19. private lazy var grid: UICollectionView = {
  20. let collection = UICollectionView(frame: .zero, collectionViewLayout: gridLayout)
  21. collection.dataSource = self
  22. collection.delegate = self
  23. collection.register(GalleryCell.self, forCellWithReuseIdentifier: GalleryCell.reuseIdentifier)
  24. collection.contentInset = UIEdgeInsets(top: gridDefaultSpacing, left: gridDefaultSpacing, bottom: gridDefaultSpacing, right: gridDefaultSpacing)
  25. collection.backgroundColor = DcColors.defaultBackgroundColor
  26. collection.delaysContentTouches = false
  27. collection.alwaysBounceVertical = true
  28. collection.isPrefetchingEnabled = true
  29. collection.prefetchDataSource = self
  30. return collection
  31. }()
  32. private lazy var timeLabel: GalleryTimeLabel = {
  33. let view = GalleryTimeLabel()
  34. view.hide(animated: false)
  35. return view
  36. }()
  37. private lazy var emptyStateView: EmptyStateLabel = {
  38. let label = EmptyStateLabel()
  39. label.text = String.localized(chatId == 0 ? "tab_all_media_empty_hint" : "tab_gallery_empty_hint")
  40. label.isHidden = true
  41. return label
  42. }()
  43. private lazy var contextMenu: ContextMenuProvider = {
  44. let deleteItem = ContextMenuProvider.ContextMenuItem(
  45. title: String.localized("delete"),
  46. imageName: "trash",
  47. isDestructive: true,
  48. action: #selector(GalleryCell.itemDelete(_:)),
  49. onPerform: { [weak self] indexPath in
  50. self?.askToDeleteItem(at: indexPath)
  51. }
  52. )
  53. let showInChatItem = ContextMenuProvider.ContextMenuItem(
  54. title: String.localized("show_in_chat"),
  55. imageName: "doc.text.magnifyingglass",
  56. action: #selector(GalleryCell.showInChat(_:)),
  57. onPerform: { [weak self] indexPath in
  58. self?.redirectToMessage(of: indexPath)
  59. }
  60. )
  61. let config = ContextMenuProvider()
  62. config.setMenu([showInChatItem, deleteItem])
  63. return config
  64. }()
  65. init(context: DcContext, chatId: Int, mediaMessageIds: [Int]) {
  66. self.dcContext = context
  67. self.chatId = chatId
  68. self.mediaMessageIds = mediaMessageIds
  69. super.init(nibName: nil, bundle: nil)
  70. }
  71. required init?(coder: NSCoder) {
  72. fatalError("init(coder:) has not been implemented")
  73. }
  74. // MARK: - lifecycle
  75. override func viewDidLoad() {
  76. super.viewDidLoad()
  77. setupSubviews()
  78. title = String.localized("images_and_videos")
  79. if mediaMessageIds.isEmpty {
  80. emptyStateView.isHidden = false
  81. }
  82. }
  83. override func viewWillAppear(_ animated: Bool) {
  84. galleryItemCache = [:]
  85. grid.reloadData()
  86. setupContextMenuIfNeeded()
  87. }
  88. override func viewWillLayoutSubviews() {
  89. super.viewWillLayoutSubviews()
  90. self.reloadCollectionViewLayout()
  91. }
  92. // MARK: - setup
  93. private func setupSubviews() {
  94. view.addSubview(grid)
  95. grid.translatesAutoresizingMaskIntoConstraints = false
  96. grid.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0).isActive = true
  97. grid.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
  98. grid.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0).isActive = true
  99. grid.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
  100. view.addSubview(timeLabel)
  101. timeLabel.translatesAutoresizingMaskIntoConstraints = false
  102. timeLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10).isActive = true
  103. timeLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
  104. emptyStateView.addCenteredTo(parentView: view)
  105. }
  106. private func setupContextMenuIfNeeded() {
  107. UIMenuController.shared.menuItems = contextMenu.menuItems
  108. UIMenuController.shared.update()
  109. }
  110. // MARK: - updates
  111. private func updateFloatingTimeLabel() {
  112. if let indexPath = grid.indexPathsForVisibleItems.min() {
  113. let msgId = mediaMessageIds[indexPath.row]
  114. let msg = dcContext.getMessage(id: msgId)
  115. timeLabel.update(date: msg.sentDate)
  116. }
  117. }
  118. // MARK: - actions
  119. private func askToDeleteItem(at indexPath: IndexPath) {
  120. let chat = dcContext.getChat(chatId: chatId)
  121. let title = chat.isDeviceTalk ?
  122. String.localized(stringID: "ask_delete_messages_simple", count: 1) :
  123. String.localized(stringID: "ask_delete_messages", count: 1)
  124. let alertController = UIAlertController(title: title, message: nil, preferredStyle: .safeActionSheet)
  125. let okAction = UIAlertAction(title: String.localized("delete"), style: .destructive, handler: { [weak self] _ in
  126. self?.deleteItem(at: indexPath)
  127. })
  128. let cancelAction = UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil)
  129. alertController.addAction(okAction)
  130. alertController.addAction(cancelAction)
  131. present(alertController, animated: true, completion: nil)
  132. }
  133. private func deleteItem(at indexPath: IndexPath) {
  134. let msgId = mediaMessageIds.remove(at: indexPath.row)
  135. self.dcContext.deleteMessage(msgId: msgId)
  136. self.grid.deleteItems(at: [indexPath])
  137. }
  138. }
  139. extension GalleryViewController: UICollectionViewDataSourcePrefetching {
  140. func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
  141. indexPaths.forEach {
  142. if galleryItemCache[$0.row] == nil {
  143. let message = dcContext.getMessage(id: mediaMessageIds[$0.row])
  144. let item = GalleryItem(msg: message)
  145. galleryItemCache[$0.row] = item
  146. }
  147. }
  148. }
  149. }
  150. // MARK: - UICollectionViewDataSource, UICollectionViewDelegate
  151. extension GalleryViewController: UICollectionViewDataSource, UICollectionViewDelegate {
  152. func numberOfSections(in collectionView: UICollectionView) -> Int {
  153. return 1
  154. }
  155. func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
  156. return mediaMessageIds.count
  157. }
  158. func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
  159. guard let galleryCell = collectionView.dequeueReusableCell(
  160. withReuseIdentifier: GalleryCell.reuseIdentifier,
  161. for: indexPath) as? GalleryCell else {
  162. return UICollectionViewCell()
  163. }
  164. let msgId = mediaMessageIds[indexPath.row]
  165. var item: GalleryItem
  166. if let galleryItem = galleryItemCache[indexPath.row] {
  167. item = galleryItem
  168. } else {
  169. let message = dcContext.getMessage(id: msgId)
  170. let galleryItem = GalleryItem(msg: message)
  171. galleryItemCache[indexPath.row] = galleryItem
  172. item = galleryItem
  173. }
  174. galleryCell.update(item: item)
  175. UIMenuController.shared.setMenuVisible(false, animated: true)
  176. return galleryCell
  177. }
  178. func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
  179. let previewController = PreviewController(dcContext: dcContext, type: .multi(mediaMessageIds, indexPath.row))
  180. previewController.delegate = self
  181. navigationController?.pushViewController(previewController, animated: true)
  182. collectionView.deselectItem(at: indexPath, animated: true)
  183. UIMenuController.shared.setMenuVisible(false, animated: true)
  184. }
  185. func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
  186. updateFloatingTimeLabel()
  187. timeLabel.show(animated: true)
  188. }
  189. func scrollViewDidScroll(_ scrollView: UIScrollView) {
  190. updateFloatingTimeLabel()
  191. }
  192. func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
  193. timeLabel.hide(animated: true)
  194. }
  195. // MARK: - context menu
  196. // context menu for iOS 11, 12
  197. func collectionView(_ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath) -> Bool {
  198. return true
  199. }
  200. func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
  201. return contextMenu.canPerformAction(action: action)
  202. }
  203. func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) {
  204. contextMenu.performAction(action: action, indexPath: indexPath)
  205. }
  206. // context menu for iOS 13+
  207. @available(iOS 13, *)
  208. func collectionView(
  209. _ collectionView: UICollectionView,
  210. contextMenuConfigurationForItemAt indexPath: IndexPath,
  211. point: CGPoint) -> UIContextMenuConfiguration? {
  212. guard let galleryCell = collectionView.cellForItem(at: indexPath) as? GalleryCell, let item = galleryCell.item else {
  213. return nil
  214. }
  215. return UIContextMenuConfiguration(
  216. identifier: nil,
  217. previewProvider: {
  218. let contextMenuController = ContextMenuController(item: item)
  219. return contextMenuController
  220. },
  221. actionProvider: { [weak self] _ in
  222. self?.contextMenu.actionProvider(indexPath: indexPath)
  223. }
  224. )
  225. }
  226. }
  227. // MARK: - grid layout + updates
  228. private extension GalleryViewController {
  229. func reloadCollectionViewLayout() {
  230. // columns specification
  231. let phonePortrait = 3
  232. let phoneLandscape = 5
  233. let padPortrait = 5
  234. let padLandscape = 8
  235. let orientation = UIApplication.shared.statusBarOrientation
  236. let deviceType = UIDevice.current.userInterfaceIdiom
  237. let gridDisplay: GridDisplay
  238. if deviceType == .phone {
  239. if orientation.isPortrait {
  240. gridDisplay = .grid(columns: phonePortrait)
  241. } else {
  242. gridDisplay = .grid(columns: phoneLandscape)
  243. }
  244. } else {
  245. if orientation.isPortrait {
  246. gridDisplay = .grid(columns: padPortrait)
  247. } else {
  248. gridDisplay = .grid(columns: padLandscape)
  249. }
  250. }
  251. gridLayout.display = gridDisplay
  252. let containerWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right - 2 * gridDefaultSpacing
  253. gridLayout.containerWidth = containerWidth
  254. }
  255. }
  256. // MARK: - coordinator
  257. private extension GalleryViewController {
  258. func redirectToMessage(of indexPath: IndexPath) {
  259. let msgId = mediaMessageIds[indexPath.row]
  260. let chatId = dcContext.getMessage(id: msgId).chatId
  261. if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
  262. appDelegate.appCoordinator.showChat(chatId: chatId, msgId: msgId, animated: false, clearViewControllerStack: true)
  263. }
  264. }
  265. }
  266. // MARK: - QLPreviewControllerDataSource
  267. extension GalleryViewController: QLPreviewControllerDelegate {
  268. func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? {
  269. let indexPath = IndexPath(row: controller.currentPreviewItemIndex, section: 0)
  270. return grid.cellForItem(at: indexPath)
  271. }
  272. }