// // ButtonGroupPositionLayout.swift // VDS // // Created by Matt Bruce on 11/18/22. // import Foundation import UIKit class ButtonCollectionViewRow { var attributes = [ButtonLayoutAttributes]() init() {} func add(attribute: ButtonLayoutAttributes) { attributes.append(attribute) } var hasButtons: Bool { attributes.contains(where: { $0.isButton }) } var buttonPercentage: CGFloat? var rowWidth: CGFloat { return attributes.reduce(0, { result, attribute -> CGFloat in return result + attribute.frame.width + attribute.spacing }) } var rowHeight: CGFloat { attributes.compactMap{$0.frame.height}.max() ?? 0 } var maxHeightIndexPath: IndexPath { let maxHeight = rowHeight return attributes.first(where: {$0.frame.height == maxHeight})!.indexPath } var rowY: CGFloat = 0 { didSet { for attribute in attributes { attribute.frame.origin.y = rowY } } } func layout(for position: ButtonGroup.Alignment, with collectionViewWidth: CGFloat){ var offset = 0.0 let height = rowHeight attributes.last?.spacing = 0 //filter only the buttons since this is the only //object we can change the frames for. let buttonAttributes = attributes.filter{$0.isButton} if !buttonAttributes.isEmpty { let buttonCount = CGFloat(buttonAttributes.count) ///Calculate the spaces between items in the row let totalSpacingBetweenAttributes = attributes.reduce(0.0) { $0 + $1.spacing } //see how much of the rows width is used for //non-buttons that are BaseButton Subclasses that are not "Button" let nonButtonSpace = attributes.filter { !$0.isButton }.reduce(0.0) { $0 + $1.frame.width } //getting available button space since textlinks need their space let buttonsAvailableSpace = collectionViewWidth - nonButtonSpace - totalSpacingBetweenAttributes let buttonEqualSpacing = buttonsAvailableSpace / buttonCount var maxButtonWidth = buttonEqualSpacing //default to equal spacing var buttonCalculatedPercentage: CGFloat = 0.0 //check to see if you have buttons and there is a percentage if let buttonPercentage, hasButtons, buttonPercentage > 0 { buttonCalculatedPercentage = CGFloat(buttonPercentage / 100.0) let buttonPercentageWidth = buttonCalculatedPercentage * buttonsAvailableSpace maxButtonWidth = min(max(buttonPercentageWidth, Button.Size.large.minimumWidth), maxButtonWidth) } //resize the buttonAttributes if maxButtonWidth >= Button.Size.large.minimumWidth { //if there is enough room for all buttons if maxButtonWidth * buttonCount <= buttonsAvailableSpace { for attribute in buttonAttributes { attribute.frame.size.width = buttonCalculatedPercentage.isZero ? min(attribute.frame.size.width, maxButtonWidth) : maxButtonWidth } } else { //if not enough room, give all buttons the same width for attribute in buttonAttributes { attribute.frame.size.width = buttonEqualSpacing } } } } //update the offset based on position switch position { case .left: break case .center: offset = (collectionViewWidth - rowWidth) / 2 case .right: offset = (collectionViewWidth - rowWidth) } for attribute in attributes { attribute.frame.origin.x = offset if attribute.frame.height < height { //recalibrate the y to vertically center align rect attribute.frame.origin.y += (height - attribute.frame.size.height) / 2 } offset += attribute.frame.width + attribute.spacing } } } protocol ButtongGroupPositionLayoutDelegate: AnyObject { func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize func collectionView(_ collectionView: UICollectionView, buttonBaseAtIndexPath indexPath: IndexPath) -> ButtonBase } class ButtonLayoutAttributes: UICollectionViewLayoutAttributes{ var spacing: CGFloat = 0 var button: ButtonBase? var isButton: Bool { guard button is Button else { return false } return true } convenience init(spacing: CGFloat, button: ButtonBase, forCellWith indexPath: IndexPath) { self.init(forCellWith: indexPath) self.spacing = spacing self.button = button } } class ButtonGroupPositionLayout: UICollectionViewLayout { weak var delegate: ButtongGroupPositionLayoutDelegate? var verticalSpacer: ((ButtonCollectionViewRow, ButtonCollectionViewRow?) -> CGFloat)? var axisSpacer: ((NSLayoutConstraint.Axis, ButtonBase, ButtonBase) -> CGFloat)? // Total height of the content. Will be used to configure the scrollview content var layoutHeight: CGFloat = 0.0 var position: ButtonGroup.Alignment = .left var rowQuantity: Int = 0 var buttonPercentage: CGFloat? private var itemCache: [ButtonLayoutAttributes] = [] override func prepare() { super.prepare() itemCache.removeAll() layoutHeight = 0.0 guard let collectionView, let delegate else { return } // Variable to track the width of the layout at the current state when the item is being drawn var layoutWidthIterator: CGFloat = 0.0 // Only 1 section in the ButtonGroup let section = 0 // Variables to track individual item width and cumultative height of all items as they are being laid out. var itemSize: CGSize = .zero // get number of buttons let totalItems = collectionView.numberOfItems(inSection: section) //create rows var rows = [ButtonCollectionViewRow]() rows.append(ButtonCollectionViewRow()) let collectionViewWidth = collectionView.horizontalPinnedWidth() ?? collectionView.frame.width for item in 0.. collectionViewWidth && rowQuantity == 0 || (rowQuantity > 0 && rowItemCount == rowQuantity) { // If the current row width (after this item being laid out) is exceeding // the width of the collection view content, put it in the next line layoutWidthIterator = 0.0 // set the spacing of the last item of the current row to 0 rows.last?.attributes.last?.spacing = 0 // add a new row rows.append(ButtonCollectionViewRow()) } // get the button let itemButtonBase = delegate.collectionView(collectionView, buttonBaseAtIndexPath: indexPath) // see if there is another item in the array let nextItem = item + 1 // if so, get the button // and get the spacing based of the // current button and the next button if nextItem < totalItems { //get the next button let neighbor = delegate.collectionView(collectionView, buttonBaseAtIndexPath: IndexPath(item: nextItem, section: section)) // get the spacing to go between the current and next button itemSpacing = getAxisSpacing(for: .horizontal, with: itemButtonBase, neighboring: neighbor) } // create the custom layout attribute let attributes = ButtonLayoutAttributes(spacing: itemSpacing, button: itemButtonBase, forCellWith: indexPath) attributes.frame = CGRect(x: 0, y: 0, width: min(itemSize.width, collectionViewWidth), height: itemSize.height) // add it to the array rows.last?.add(attribute: attributes) // update the current width // add the current frame width + the found spacing layoutWidthIterator = layoutWidthIterator + attributes.frame.width + itemSpacing } layoutWidthIterator = 0.0 // calculate the layoutHeight = 0.0 // loop through the rows and set // the row y position for each element // also add to the layoutHeight for item in 0.. 0 { let prevRow = rows[item - 1] rowSpacing = getVerticalSpacing(for: prevRow, neighboringRow: row) row.rowY = layoutHeight + rowSpacing layoutHeight += rowSpacing } layoutHeight += row.rowHeight } // recalculate rows x based off of positions rows.forEach { $0.buttonPercentage = buttonPercentage $0.layout(for: position, with: collectionViewWidth) } let rowAttributes = rows.flatMap { $0.attributes } itemCache = rowAttributes } override func layoutAttributesForElements(in rect: CGRect)-> [UICollectionViewLayoutAttributes]? { var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = [] for attributes in itemCache { if attributes.frame.intersects(rect) { visibleLayoutAttributes.append(attributes) } } return visibleLayoutAttributes } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return itemCache[indexPath.row] } override var collectionViewContentSize: CGSize { return CGSize(width: contentWidth, height: layoutHeight) } private var contentWidth: CGFloat { guard let collectionView = collectionView else { return 0 } return collectionView.bounds.width } private func getAxisSpacing(for axis: NSLayoutConstraint.Axis, with primary: ButtonBase, neighboring: ButtonBase) -> CGFloat { guard let axisSpacer else { return ButtonGroupConstants.getSpacing(for: axis, with: primary, neighboring: neighboring) } return axisSpacer(axis, primary, neighboring) } private func getVerticalSpacing(for row: ButtonCollectionViewRow, neighboringRow: ButtonCollectionViewRow?) -> CGFloat { guard let verticalSpacer else { return ButtonGroupConstants.getVerticalSpacing(for: row, neighboringRow: neighboringRow) } return verticalSpacer(row, neighboringRow) } }