CallFlowLayout.swift 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. //
  2. // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. // Based on https://stackoverflow.com/a/41409642
  5. import UIKit
  6. @objcMembers
  7. class CallFlowLayout: UICollectionViewFlowLayout {
  8. private let targetAspectRatioPortrait = 1.0
  9. private let targetAspectRatioLandscape = 1.5
  10. private var numberOfColumns = 1
  11. private var numberOfRows = 1
  12. private var targetAspectRatio: Double
  13. override init() {
  14. self.targetAspectRatio = self.targetAspectRatioLandscape
  15. super.init()
  16. commonInit()
  17. }
  18. required init?(coder aDecoder: NSCoder) {
  19. self.targetAspectRatio = self.targetAspectRatioLandscape
  20. super.init(coder: aDecoder)
  21. commonInit()
  22. }
  23. func commonInit() {
  24. self.minimumInteritemSpacing = 8
  25. self.minimumLineSpacing = 8
  26. self.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
  27. }
  28. func isPortrait() -> Bool {
  29. guard let collectionView = collectionView else { return false }
  30. return collectionView.bounds.size.width < collectionView.bounds.size.height
  31. }
  32. func columnsMax() -> Int {
  33. guard let collectionView = collectionView else { return 1 }
  34. let contentSize = collectionView.bounds.size
  35. let cellMinWidth = kCallParticipantCellMinHeight * targetAspectRatio + minimumInteritemSpacing
  36. if (contentSize.width / cellMinWidth).rounded(.down) < 1 {
  37. return 1
  38. }
  39. return Int((contentSize.width / cellMinWidth).rounded(.down))
  40. }
  41. func rowsMax() -> Int {
  42. guard let collectionView = collectionView else { return 1 }
  43. let contentSize = collectionView.bounds.size
  44. let cellMinHeight = kCallParticipantCellMinHeight + minimumLineSpacing
  45. if (contentSize.height / cellMinHeight).rounded(.down) < 1 {
  46. return 1
  47. }
  48. return Int((contentSize.height / cellMinHeight).rounded(.down))
  49. }
  50. // Based on the makeGrid method of web:
  51. // https://github.com/nextcloud/spreed/blob/5ba554c3f751ba8b8035c7fc8404ca6194d3c16a/src/components/CallView/Grid/Grid.vue#L664
  52. func makeGrid() {
  53. guard let collectionView = collectionView else { return }
  54. let numberOfCells = collectionView.numberOfItems(inSection: 0)
  55. if numberOfCells == 0 {
  56. self.numberOfColumns = 0
  57. self.numberOfRows = 0
  58. return
  59. }
  60. if self.isPortrait() {
  61. self.targetAspectRatio = self.targetAspectRatioPortrait
  62. } else {
  63. self.targetAspectRatio = self.targetAspectRatioLandscape
  64. }
  65. // Start with the maximum number of allowed columns/rows
  66. self.numberOfColumns = self.columnsMax()
  67. self.numberOfRows = self.rowsMax()
  68. // Try to adjust the number of columns/rows based on the number of cells
  69. self.shrinkGrid()
  70. }
  71. func shrinkGrid() {
  72. if self.numberOfRows == 1, self.numberOfColumns == 1 {
  73. return
  74. }
  75. guard let collectionView = collectionView else { return }
  76. let contentSize = collectionView.bounds.size
  77. var currentColumns = self.numberOfColumns
  78. var currentRows = self.numberOfRows
  79. var currentSlots = currentColumns * currentRows
  80. let numberOfCells = collectionView.numberOfItems(inSection: 0)
  81. while numberOfCells < currentSlots {
  82. let previousColumns = currentColumns
  83. let previousRows = currentRows
  84. let videoWidth = contentSize.width / CGFloat(currentColumns)
  85. let videoHeight = contentSize.height / CGFloat(currentRows)
  86. let videoWidthWithOneColumnLess = contentSize.width / CGFloat(currentColumns - 1)
  87. let videoHeightWithOneRowLess = contentSize.height / CGFloat(currentRows - 1)
  88. let aspectRatioWithOneColumnLess = videoWidthWithOneColumnLess / videoHeight
  89. let aspectRatioWithOneRowLess = videoWidth / videoHeightWithOneRowLess
  90. let deltaAspectRatioWithOneColumnLess = abs(aspectRatioWithOneColumnLess - targetAspectRatio)
  91. let deltaAspectRatioWithOneRowLess = abs(aspectRatioWithOneRowLess - targetAspectRatio)
  92. // Based on the aspect ratio we want to achieve, try to either reduce the number of columns or rows
  93. if deltaAspectRatioWithOneColumnLess <= deltaAspectRatioWithOneRowLess {
  94. if currentColumns >= 2 {
  95. currentColumns -= 1
  96. }
  97. currentSlots = currentColumns * currentRows
  98. if numberOfCells > currentSlots {
  99. currentColumns += 1
  100. break
  101. }
  102. } else {
  103. if currentRows >= 2 {
  104. currentRows -= 1
  105. }
  106. currentSlots = currentColumns * currentRows
  107. if numberOfCells > currentSlots {
  108. currentRows += 1
  109. break
  110. }
  111. }
  112. if previousColumns == currentColumns, previousRows == currentRows {
  113. break
  114. }
  115. }
  116. self.numberOfColumns = currentColumns
  117. self.numberOfRows = currentRows
  118. }
  119. override func prepare() {
  120. super.prepare()
  121. guard let collectionView = collectionView else { return }
  122. let contentSize = collectionView.bounds.size
  123. self.makeGrid()
  124. // Calculate cell width
  125. let sectionInsetWidth = sectionInset.left + sectionInset.right
  126. let safeAreaInsetWidth = collectionView.safeAreaInsets.left + collectionView.safeAreaInsets.right
  127. let marginsAndInsetsWidth = sectionInsetWidth + safeAreaInsetWidth + minimumInteritemSpacing * CGFloat(numberOfColumns - 1)
  128. let itemWidth = ((contentSize.width - marginsAndInsetsWidth) / CGFloat(numberOfColumns)).rounded(.down)
  129. // Calculate cell height
  130. let sectionInsetHeight = sectionInset.top + sectionInset.bottom
  131. let safeAreaInsetHeight = collectionView.safeAreaInsets.top + collectionView.safeAreaInsets.bottom
  132. let marginsAndInsetsHeight = sectionInsetHeight + safeAreaInsetHeight + minimumLineSpacing * CGFloat(numberOfRows - 1)
  133. var itemHeight = ((contentSize.height - marginsAndInsetsHeight) / CGFloat(numberOfRows)).rounded(.down)
  134. // Enfore minimum cell height
  135. if itemHeight < kCallParticipantCellMinHeight {
  136. itemHeight = kCallParticipantCellMinHeight
  137. }
  138. itemSize = CGSize(width: itemWidth, height: itemHeight)
  139. }
  140. override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
  141. let context = super.invalidationContext(forBoundsChange: newBounds)
  142. if let context = context as? UICollectionViewFlowLayoutInvalidationContext {
  143. context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
  144. }
  145. return context
  146. }
  147. }