// // ButtonGroup.swift // VDS // // Created by Matt Bruce on 11/3/22. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine @objc(VDSButtonGroup) open class ButtonGroup: View, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDelegate, ButtongGroupPositionLayoutDelegate { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- //An object containing number of Button components per row, in each viewport open var rowQuantityPhone: Int = 0 { didSet { didChange() } } open var rowQuantityTablet: Int = 0 { didSet { didChange() } } public var rowQuantity: Int { UIDevice.isIPad ? rowQuantityTablet : rowQuantityPhone } //If provided, aligns TextLink/TextLinkCaret alignment when rowQuantity is set one. open var buttonPosition: ButtonPosition = .center { didSet { didChange() }} open var buttons: [Buttonable] = [] { didSet { didChange() }} //If provided, width of Button components will be rendered based on this value. If omitted, default button widths are rendered. open var buttonWidth: CGFloat? { didSet { buttons.forEach { button in if let button = button as? Button { button.width = buttonWidth } } didChange() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private let lineSpacing: CGFloat = 12.0 private let itemSpacing: CGFloat = 16.0 private let estimatedCellHeight: CGFloat = 40.0 private let estimatedCellWidth: CGFloat = 150.0 fileprivate lazy var positionLayout = ButtonGroupPositionLayout().with { $0.position = .center $0.delegate = self } fileprivate lazy var collectionView: SelfSizingCollectionView = { return SelfSizingCollectionView(frame: .zero, collectionViewLayout: positionLayout).with { $0.backgroundColor = .clear $0.showsHorizontalScrollIndicator = false $0.showsVerticalScrollIndicator = false $0.isScrollEnabled = false $0.translatesAutoresizingMaskIntoConstraints = false $0.dataSource = self $0.delegate = self $0.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "collectionViewCell") } }() //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- override public var disabled: Bool { didSet { buttons.forEach { button in button.disabled = disabled } } } override public var surface: Surface { didSet { buttons.forEach { button in button.surface = surface } } } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) initialSetup() } public override init(frame: CGRect) { super.init(frame: .zero) initialSetup() } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } //-------------------------------------------------- // MARK: - Public Functions //-------------------------------------------------- open override func setup() { super.setup() isAccessibilityElement = true accessibilityTraits = .button addSubview(collectionView) collectionView.pinToSuperView() } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- open override func updateView() { super.updateView() positionLayout.position = buttonPosition collectionView.reloadData() } open override func layoutSubviews() { super.layoutSubviews() // Accounts for any collection size changes DispatchQueue.main.async { self.collectionView.collectionViewLayout.invalidateLayout() } } //-------------------------------------------------- // MARK: - UICollectionViewDataSource //-------------------------------------------------- public func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return buttons.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let button = buttons[indexPath.row] let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "collectionViewCell", for: indexPath) cell.subviews.forEach { $0.removeFromSuperview() } cell.subviews.forEach { $0.removeFromSuperview() } cell.addSubview(button) button.pinToSuperView() return cell } public func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize { buttons[indexPath.row].intrinsicContentSize } public func collectionView(_ collectionView: UICollectionView, insetsForItemsInSection section: Int) -> UIEdgeInsets { UIEdgeInsets.zero } public func collectionView(_ collectionView: UICollectionView, itemSpacingInSection section: Int) -> CGFloat { itemSpacing } } final class SelfSizingCollectionView: UICollectionView { private var contentSizeObservation: NSKeyValueObservation? // MARK: - Lifecycle override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) self.setupContentSizeObservation() } required init?(coder: NSCoder) { super.init(coder: coder) self.setupContentSizeObservation() } // MARK: - UIView override var intrinsicContentSize: CGSize { let contentSize = self.contentSize //print(#function, contentSize) return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { //print(type(of: self), #function) super.traitCollectionDidChange(previousTraitCollection) // We need to handle any change that will affect layout and/or anything that affects size of a UILabel if self.traitCollection.hasDifferentTextAppearance(comparedTo: previousTraitCollection) { self.collectionViewLayout.invalidateLayout() } } override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize { let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority) //print(type(of: self), #function, targetSize, "->", size) return size } // MARK: - Private private func setupContentSizeObservation() { // Observing the value of contentSize seems to be the only reliable way to get the contentSize after the collection view lays out its subviews. self.contentSizeObservation = self.observe(\.contentSize, options: [.old, .new]) { [weak self] _, change in // If we don't specify `options: [.old, .new]`, the change.oldValue and .newValue will always be `nil`. if change.newValue != change.oldValue { self?.invalidateIntrinsicContentSize() } } } } extension UITraitCollection { func hasDifferentTextAppearance(comparedTo traitCollection: UITraitCollection?) -> Bool { var result = self.preferredContentSizeCategory != traitCollection?.preferredContentSizeCategory if #available(iOS 13.0, *) { result = result || self.legibilityWeight != traitCollection?.legibilityWeight } return result } }