NCMediaLayout.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. //
  2. // NCMediaLayout.swift
  3. //
  4. // Created by Marino Faggiana on 26/02/24.
  5. // Based on CHTCollectionViewWaterfallLayout by Nelson Tai
  6. // Copyright © 2024 Marino Faggiana. All rights reserved.
  7. //
  8. // This program is free software: you can redistribute it and/or modify
  9. // it under the terms of the GNU General Public License as published by
  10. // the Free Software Foundation, either version 3 of the License, or
  11. // (at your option) any later version.
  12. //
  13. // This program is distributed in the hope that it will be useful,
  14. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. // GNU General Public License for more details.
  17. //
  18. // You should have received a copy of the GNU General Public License
  19. // along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. //
  21. import UIKit
  22. public let collectionViewMediaElementKindSectionHeader = "collectionViewMediaElementKindSectionHeader"
  23. public let collectionViewMediaElementKindSectionFooter = "collectionViewMediaElementKindSectionFooter"
  24. protocol NCMediaLayoutDelegate: UICollectionViewDelegate {
  25. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath, columnCount: Int, mediaLayout: String) -> CGSize
  26. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForHeaderInSection section: Int) -> Float
  27. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForFooterInSection section: Int) -> Float
  28. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, insetForSection section: Int) -> UIEdgeInsets
  29. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, insetForHeaderInSection section: Int) -> UIEdgeInsets
  30. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, insetForFooterInSection section: Int) -> UIEdgeInsets
  31. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, minimumInteritemSpacingForSection section: Int) -> Float
  32. }
  33. public class NCMediaLayout: UICollectionViewLayout {
  34. // MARK: - Private constants
  35. /// How many items to be union into a single rectangle
  36. private let unionSize = 20
  37. // MARK: - Public Properties
  38. public var columnCount: Int = 0 {
  39. didSet {
  40. invalidateIfNotEqual(oldValue, newValue: columnCount)
  41. }
  42. }
  43. public var minimumColumnSpacing: Float = 2.0 {
  44. didSet {
  45. invalidateIfNotEqual(oldValue, newValue: minimumColumnSpacing)
  46. }
  47. }
  48. public var minimumInteritemSpacing: Float = 2.0 {
  49. didSet {
  50. invalidateIfNotEqual(oldValue, newValue: minimumInteritemSpacing)
  51. }
  52. }
  53. public var headerHeight: Float = 0.0 {
  54. didSet {
  55. invalidateIfNotEqual(oldValue, newValue: headerHeight)
  56. }
  57. }
  58. public var footerHeight: Float = 0.0 {
  59. didSet {
  60. invalidateIfNotEqual(oldValue, newValue: footerHeight)
  61. }
  62. }
  63. public var headerInset: UIEdgeInsets = .zero {
  64. didSet {
  65. invalidateIfNotEqual(oldValue, newValue: headerInset)
  66. }
  67. }
  68. public var footerInset: UIEdgeInsets = .zero {
  69. didSet {
  70. invalidateIfNotEqual(oldValue, newValue: footerInset)
  71. }
  72. }
  73. public var sectionInset: UIEdgeInsets = .zero {
  74. didSet {
  75. invalidateIfNotEqual(oldValue, newValue: sectionInset)
  76. }
  77. }
  78. var mediaViewController: NCMedia?
  79. var mediaLayout = ""
  80. public override var collectionViewContentSize: CGSize {
  81. let numberOfSections = collectionView?.numberOfSections
  82. if numberOfSections == 0 {
  83. return CGSize.zero
  84. }
  85. var contentSize = collectionView?.bounds.size
  86. contentSize?.height = CGFloat(columnHeights[0])
  87. return contentSize!
  88. }
  89. // MARK: - Private Properties
  90. private weak var delegate: NCMediaLayoutDelegate? {
  91. return collectionView?.delegate as? NCMediaLayoutDelegate
  92. }
  93. private var columnHeights = [Float]()
  94. private var sectionItemAttributes = [[UICollectionViewLayoutAttributes]]()
  95. private var allItemAttributes = [UICollectionViewLayoutAttributes]()
  96. private var headersAttribute = [Int: UICollectionViewLayoutAttributes]()
  97. private var footersAttribute = [Int: UICollectionViewLayoutAttributes]()
  98. private var unionRects = [CGRect]()
  99. // MARK: - UICollectionViewLayout Methods
  100. public override func prepare() {
  101. super.prepare()
  102. guard let numberOfSections = collectionView?.numberOfSections,
  103. let collectionView = collectionView,
  104. let delegate = delegate else { return }
  105. mediaLayout = NCKeychain().mediaTypeLayout
  106. columnCount = NCKeychain().mediaColumnCount
  107. mediaViewController?.buildMediaPhotoVideo(columnCount: columnCount)
  108. if UIDevice.current.userInterfaceIdiom == .phone,
  109. (UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight) {
  110. columnCount += 2
  111. }
  112. // Initialize variables
  113. headersAttribute.removeAll(keepingCapacity: false)
  114. footersAttribute.removeAll(keepingCapacity: false)
  115. unionRects.removeAll(keepingCapacity: false)
  116. columnHeights.removeAll(keepingCapacity: false)
  117. allItemAttributes.removeAll(keepingCapacity: false)
  118. sectionItemAttributes.removeAll(keepingCapacity: false)
  119. for _ in 0..<columnCount {
  120. self.columnHeights.append(0)
  121. }
  122. // Create attributes
  123. var top: Float = 0
  124. var attributes: UICollectionViewLayoutAttributes
  125. for section in 0..<numberOfSections {
  126. /*
  127. * 1. Get section-specific metrics (minimumInteritemSpacing, sectionInset)
  128. */
  129. let minimumInteritemSpacing: Float = delegate.collectionView(collectionView, layout: self, minimumInteritemSpacingForSection: section)
  130. let sectionInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForSection: section)
  131. let width = Float(collectionView.frame.size.width - sectionInset.left - sectionInset.right)
  132. let itemWidth = floorf((width - Float(columnCount - 1) * Float(minimumColumnSpacing)) / Float(columnCount))
  133. /*
  134. * 2. Section header
  135. */
  136. let headerHeight: Float = delegate.collectionView(collectionView, layout: self, heightForHeaderInSection: section)
  137. let headerInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForHeaderInSection: section)
  138. top += Float(headerInset.top)
  139. if headerHeight > 0 {
  140. attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: collectionViewMediaElementKindSectionHeader, with: NSIndexPath(item: 0, section: section) as IndexPath)
  141. attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight))
  142. headersAttribute[section] = attributes
  143. allItemAttributes.append(attributes)
  144. top = Float(attributes.frame.maxY) + Float(headerInset.bottom)
  145. }
  146. top += Float(sectionInset.top)
  147. for idx in 0..<columnCount {
  148. columnHeights[idx] = top
  149. }
  150. /*
  151. * 3. Section items
  152. */
  153. let itemCount = collectionView.numberOfItems(inSection: section)
  154. var itemAttributes = [UICollectionViewLayoutAttributes]()
  155. // Item will be put into shortest column.
  156. for idx in 0..<itemCount {
  157. let indexPath = NSIndexPath(item: idx, section: section)
  158. let columnIndex = shortestColumnIndex()
  159. let xOffset = Float(sectionInset.left) + Float(itemWidth + minimumColumnSpacing) * Float(columnIndex)
  160. let yOffset = columnHeights[columnIndex]
  161. let itemSize = delegate.collectionView(collectionView, layout: self, sizeForItemAtIndexPath: indexPath, columnCount: self.columnCount, mediaLayout: self.mediaLayout)
  162. var itemHeight: Float = 0.0
  163. if itemSize.height > 0 && itemSize.width > 0 {
  164. itemHeight = Float(itemSize.height) * itemWidth / Float(itemSize.width)
  165. }
  166. attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath as IndexPath)
  167. attributes.frame = CGRect(x: CGFloat(xOffset), y: CGFloat(yOffset), width: CGFloat(itemWidth), height: CGFloat(itemHeight))
  168. itemAttributes.append(attributes)
  169. allItemAttributes.append(attributes)
  170. columnHeights[columnIndex] = Float(attributes.frame.maxY) + minimumInteritemSpacing
  171. }
  172. sectionItemAttributes.append(itemAttributes)
  173. /*
  174. * 4. Section footer
  175. */
  176. let columnIndex = longestColumnIndex()
  177. top = columnHeights[columnIndex] - minimumInteritemSpacing + Float(sectionInset.bottom)
  178. top += Float(footerInset.top)
  179. if footerHeight > 0 {
  180. attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: collectionViewMediaElementKindSectionFooter, with: NSIndexPath(item: 0, section: section) as IndexPath)
  181. attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight))
  182. footersAttribute[section] = attributes
  183. allItemAttributes.append(attributes)
  184. top = Float(attributes.frame.maxY) + Float(footerInset.bottom)
  185. }
  186. for idx in 0..<columnCount {
  187. columnHeights[idx] = top
  188. }
  189. }
  190. // Build union rects
  191. var idx = 0
  192. let itemCounts = allItemAttributes.count
  193. while idx < itemCounts {
  194. let rect1 = allItemAttributes[idx].frame
  195. idx = min(idx + unionSize, itemCounts) - 1
  196. let rect2 = allItemAttributes[idx].frame
  197. unionRects.append(rect1.union(rect2))
  198. idx += 1
  199. }
  200. }
  201. public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
  202. if indexPath.section >= sectionItemAttributes.count {
  203. return nil
  204. }
  205. if indexPath.item >= sectionItemAttributes[indexPath.section].count {
  206. return nil
  207. }
  208. return sectionItemAttributes[indexPath.section][indexPath.item]
  209. }
  210. public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
  211. var attribute: UICollectionViewLayoutAttributes?
  212. if elementKind == collectionViewMediaElementKindSectionHeader {
  213. attribute = headersAttribute[indexPath.section]
  214. } else if elementKind == collectionViewMediaElementKindSectionFooter {
  215. attribute = footersAttribute[indexPath.section]
  216. }
  217. return attribute
  218. }
  219. public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
  220. var begin: Int = 0
  221. var end: Int = unionRects.count
  222. var attrs = [UICollectionViewLayoutAttributes]()
  223. for i in 0..<unionRects.count {
  224. if rect.intersects(unionRects[i]) {
  225. begin = i * unionSize
  226. break
  227. }
  228. }
  229. for i in (0..<unionRects.count).reversed() {
  230. if rect.intersects(unionRects[i]) {
  231. end = min((i + 1) * unionSize, allItemAttributes.count)
  232. break
  233. }
  234. }
  235. for i in begin..<end {
  236. let attr = allItemAttributes[i]
  237. if rect.intersects(attr.frame) {
  238. attrs.append(attr)
  239. }
  240. }
  241. return Array(attrs)
  242. }
  243. public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
  244. let oldBounds = collectionView?.bounds
  245. if newBounds.width != oldBounds?.width {
  246. return true
  247. }
  248. return false
  249. }
  250. }
  251. // MARK: - Private Methods
  252. private extension NCMediaLayout {
  253. func shortestColumnIndex() -> Int {
  254. var index: Int = 0
  255. var shortestHeight = MAXFLOAT
  256. for (idx, height) in columnHeights.enumerated() {
  257. if height < shortestHeight {
  258. shortestHeight = height
  259. index = idx
  260. }
  261. }
  262. return index
  263. }
  264. func longestColumnIndex() -> Int {
  265. var index: Int = 0
  266. var longestHeight: Float = 0
  267. for (idx, height) in columnHeights.enumerated() {
  268. if height > longestHeight {
  269. longestHeight = height
  270. index = idx
  271. }
  272. }
  273. return index
  274. }
  275. func invalidateIfNotEqual<T: Equatable>(_ oldValue: T, newValue: T) {
  276. if oldValue != newValue {
  277. invalidateLayout()
  278. }
  279. }
  280. }