NCMediaLayout.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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 mediaSectionHeader = "mediaSectionHeader"
  23. public let mediaSectionFooter = "mediaSectionFooter"
  24. protocol NCMediaLayoutDelegate: UICollectionViewDelegate {
  25. func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath, columnCount: Int, typeLayout: 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. func getLayout() -> String?
  33. func getColumnCount() -> Int
  34. }
  35. public class NCMediaLayout: UICollectionViewLayout {
  36. // MARK: - Private constants
  37. /// How many items to be union into a single rectangle
  38. private let unionSize = 20
  39. // MARK: - Public Properties
  40. public var columnCount: Int = 0 {
  41. didSet {
  42. invalidateIfNotEqual(oldValue, newValue: columnCount)
  43. }
  44. }
  45. public var minimumColumnSpacing: Float = 1.0 {
  46. didSet {
  47. invalidateIfNotEqual(oldValue, newValue: minimumColumnSpacing)
  48. }
  49. }
  50. public var minimumInteritemSpacing: Float = .zero {
  51. didSet {
  52. invalidateIfNotEqual(oldValue, newValue: minimumInteritemSpacing)
  53. }
  54. }
  55. public var headerHeight: Float = .zero {
  56. didSet {
  57. invalidateIfNotEqual(oldValue, newValue: headerHeight)
  58. }
  59. }
  60. public var footerHeight: Float = .zero {
  61. didSet {
  62. invalidateIfNotEqual(oldValue, newValue: footerHeight)
  63. }
  64. }
  65. public var headerInset: UIEdgeInsets = .zero {
  66. didSet {
  67. invalidateIfNotEqual(oldValue, newValue: headerInset)
  68. }
  69. }
  70. public var footerInset: UIEdgeInsets = .zero {
  71. didSet {
  72. invalidateIfNotEqual(oldValue, newValue: footerInset)
  73. }
  74. }
  75. public var sectionInset: UIEdgeInsets = .zero {
  76. didSet {
  77. invalidateIfNotEqual(oldValue, newValue: sectionInset)
  78. }
  79. }
  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. public var frameWidth: Float = 0
  90. public var itemWidth: Float = 0
  91. // MARK: - Private Properties
  92. private weak var delegate: NCMediaLayoutDelegate? {
  93. return collectionView?.delegate as? NCMediaLayoutDelegate
  94. }
  95. private var columnHeights = [Float]()
  96. private var sectionItemAttributes = [[UICollectionViewLayoutAttributes]]()
  97. private var allItemAttributes = [UICollectionViewLayoutAttributes]()
  98. private var headersAttribute = [Int: UICollectionViewLayoutAttributes]()
  99. private var footersAttribute = [Int: UICollectionViewLayoutAttributes]()
  100. private var unionRects = [CGRect]()
  101. // MARK: - UICollectionViewLayout Methods
  102. public override func prepare() {
  103. super.prepare()
  104. guard let numberOfSections = collectionView?.numberOfSections,
  105. let collectionView = collectionView,
  106. let delegate = delegate else { return }
  107. columnCount = delegate.getColumnCount()
  108. (delegate as? NCMedia)?.buildMediaPhotoVideo(columnCount: columnCount)
  109. // Initialize variables
  110. headersAttribute.removeAll(keepingCapacity: false)
  111. footersAttribute.removeAll(keepingCapacity: false)
  112. unionRects.removeAll(keepingCapacity: false)
  113. columnHeights.removeAll(keepingCapacity: false)
  114. allItemAttributes.removeAll(keepingCapacity: false)
  115. sectionItemAttributes.removeAll(keepingCapacity: false)
  116. for _ in 0..<columnCount {
  117. self.columnHeights.append(0)
  118. }
  119. // Create attributes
  120. var top: Float = 0
  121. var attributes: UICollectionViewLayoutAttributes
  122. for section in 0..<numberOfSections {
  123. /*
  124. * 1. Get section-specific metrics (minimumInteritemSpacing, sectionInset)
  125. */
  126. let minimumInteritemSpacing: Float = delegate.collectionView(collectionView, layout: self, minimumInteritemSpacingForSection: section)
  127. let sectionInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForSection: section)
  128. frameWidth = Float(collectionView.frame.size.width - sectionInset.left - sectionInset.right)
  129. itemWidth = ((frameWidth - Float(columnCount - 1) * Float(minimumColumnSpacing)) / Float(columnCount))
  130. /*
  131. * 2. Section header
  132. */
  133. let headerHeight: Float = delegate.collectionView(collectionView, layout: self, heightForHeaderInSection: section)
  134. let headerInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForHeaderInSection: section)
  135. top += Float(headerInset.top)
  136. if headerHeight > 0 {
  137. attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionHeader, with: NSIndexPath(item: 0, section: section) as IndexPath)
  138. attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight))
  139. headersAttribute[section] = attributes
  140. allItemAttributes.append(attributes)
  141. top = Float(attributes.frame.maxY) + Float(headerInset.bottom)
  142. }
  143. top += Float(sectionInset.top)
  144. for idx in 0..<columnCount {
  145. columnHeights[idx] = top
  146. }
  147. /*
  148. * 3. Section items
  149. */
  150. let itemCount = collectionView.numberOfItems(inSection: section)
  151. var itemAttributes = [UICollectionViewLayoutAttributes]()
  152. // Item will be put into shortest column.
  153. for idx in 0..<itemCount {
  154. let indexPath = IndexPath(item: idx, section: section)
  155. let columnIndex = shortestColumnIndex()
  156. let xOffset = Float(sectionInset.left) + Float(itemWidth + minimumColumnSpacing) * Float(columnIndex)
  157. let yOffset = columnHeights[columnIndex]
  158. let typeLayout = delegate.getLayout() ?? NCGlobal.shared.mediaLayoutRatio
  159. let itemSize = delegate.collectionView(collectionView, layout: self, sizeForItemAtIndexPath: indexPath, columnCount: self.columnCount, typeLayout: typeLayout)
  160. var itemHeight: Float = 0.0
  161. if itemSize.height > 0 && itemSize.width > 0 {
  162. itemHeight = Float(itemSize.height) * itemWidth / Float(itemSize.width)
  163. }
  164. attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath as IndexPath)
  165. attributes.frame = CGRect(x: CGFloat(xOffset), y: CGFloat(yOffset), width: CGFloat(itemWidth), height: CGFloat(itemHeight))
  166. itemAttributes.append(attributes)
  167. allItemAttributes.append(attributes)
  168. columnHeights[columnIndex] = Float(attributes.frame.maxY) + minimumInteritemSpacing
  169. }
  170. sectionItemAttributes.append(itemAttributes)
  171. /*
  172. * 4. Section footer
  173. */
  174. let columnIndex = longestColumnIndex()
  175. top = columnHeights[columnIndex] - minimumInteritemSpacing + Float(sectionInset.bottom)
  176. top += Float(footerInset.top)
  177. let footerHeight = delegate.collectionView(collectionView, layout: self, heightForFooterInSection: section)
  178. if footerHeight > 0 {
  179. attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionFooter, with: NSIndexPath(item: 0, section: section) as IndexPath)
  180. attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight))
  181. footersAttribute[section] = attributes
  182. allItemAttributes.append(attributes)
  183. top = Float(attributes.frame.maxY) + Float(footerInset.bottom)
  184. }
  185. for idx in 0..<columnCount {
  186. columnHeights[idx] = top
  187. }
  188. }
  189. // Build union rects
  190. var idx = 0
  191. let itemCounts = allItemAttributes.count
  192. while idx < itemCounts {
  193. let rect1 = allItemAttributes[idx].frame
  194. idx = min(idx + unionSize, itemCounts) - 1
  195. let rect2 = allItemAttributes[idx].frame
  196. unionRects.append(rect1.union(rect2))
  197. idx += 1
  198. }
  199. }
  200. public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
  201. if indexPath.section >= sectionItemAttributes.count {
  202. return nil
  203. }
  204. if indexPath.item >= sectionItemAttributes[indexPath.section].count {
  205. return nil
  206. }
  207. return sectionItemAttributes[indexPath.section][indexPath.item]
  208. }
  209. public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
  210. var attribute: UICollectionViewLayoutAttributes?
  211. if elementKind == mediaSectionHeader {
  212. attribute = headersAttribute[indexPath.section]
  213. } else if elementKind == mediaSectionFooter {
  214. attribute = footersAttribute[indexPath.section]
  215. }
  216. return attribute
  217. }
  218. public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
  219. var begin: Int = 0
  220. var end: Int = unionRects.count
  221. var attrs = [UICollectionViewLayoutAttributes]()
  222. for i in 0..<unionRects.count {
  223. if rect.intersects(unionRects[i]) {
  224. begin = i * unionSize
  225. break
  226. }
  227. }
  228. for i in (0..<unionRects.count).reversed() {
  229. if rect.intersects(unionRects[i]) {
  230. end = min((i + 1) * unionSize, allItemAttributes.count)
  231. break
  232. }
  233. }
  234. for i in begin..<end {
  235. let attr = allItemAttributes[i]
  236. if rect.intersects(attr.frame) {
  237. attrs.append(attr)
  238. }
  239. }
  240. return Array(attrs)
  241. }
  242. public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
  243. let oldBounds = collectionView?.bounds
  244. if newBounds.width != oldBounds?.width {
  245. return true
  246. }
  247. return false
  248. }
  249. }
  250. // MARK: - Private Methods
  251. extension NCMediaLayout {
  252. func shortestColumnIndex() -> Int {
  253. var index: Int = 0
  254. var shortestHeight = MAXFLOAT
  255. for (idx, height) in columnHeights.enumerated() {
  256. if height < shortestHeight {
  257. shortestHeight = height
  258. index = idx
  259. }
  260. }
  261. return index
  262. }
  263. func longestColumnIndex() -> Int {
  264. var index: Int = 0
  265. var longestHeight: Float = 0
  266. for (idx, height) in columnHeights.enumerated() {
  267. if height > longestHeight {
  268. longestHeight = height
  269. index = idx
  270. }
  271. }
  272. return index
  273. }
  274. func invalidateIfNotEqual<T: Equatable>(_ oldValue: T, newValue: T) {
  275. if oldValue != newValue {
  276. invalidateLayout()
  277. }
  278. }
  279. }