// // ButtonGroup.swift // VDS // // Created by Matt Bruce on 11/3/22. // import Foundation import UIKit import VDSCoreTokens import Combine /// A button group contains combinations of related CTAs including ``Button``, ``TextLink``, and ``TextLinkCaret``. This group component controls a combination's orientation, spacing, size and allowable size pairings. @objc(VDSButtonGroup) open class ButtonGroup: View { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Enums //-------------------------------------------------- /// Enum used to describe the alignment of the buttons. public enum Alignment: String, CaseIterable { case left, center, right } /// Enum used to describe the width of any buttons. public enum ChildWidth { case percentage(CGFloat) case value(CGFloat) } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// An object containing number of Button components per row for iPhones open var rowQuantityPhone: Int = 0 { didSet { setNeedsUpdate() } } /// An object containing number of Button components per row for iPads open var rowQuantityTablet: Int = 0 { didSet { setNeedsUpdate() } } /// An object containing number of Button components per row open var rowQuantity: Int { UIDevice.isIPad ? rowQuantityTablet : rowQuantityPhone } /// If provided, aligns TextLink/TextLinkCaret alignment when rowQuantity is set one. open var alignment: Alignment = .center { didSet { setNeedsUpdate() } } /// Array of Buttonable Views that are shown in the group. open var buttons: [ButtonBase] = [] { didSet { setNeedsUpdate() } } private var _childWidth: ChildWidth? /// If provided, width of Button components will be rendered based on this value. If omitted, default button widths are rendered. open var childWidth: ChildWidth? { get { _childWidth } set { if let newValue { switch newValue { case .percentage(let percentage): if percentage <= 100.0, rowQuantity > 0 { _childWidth = newValue } case .value(let value): if value > 0 { _childWidth = newValue } } } else { _childWidth = nil } setNeedsUpdate() } } /// Whether this object is enabled or not override open var isEnabled: Bool { didSet { buttons.forEach { $0.isEnabled = isEnabled } } } /// Current Surface and this is used to pass down to child objects that implement Surfacable override open var surface: Surface { didSet { buttons.forEach { $0.surface = surface } } } open var contentSizePublisher: AnyPublisher { collectionView.contentSizePublisher } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- fileprivate lazy var positionLayout = ButtonGroupPositionLayout().with { $0.position = .center $0.delegate = self } /// CollectionView that renders the array of buttonBase obects. 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(ButtonGroupCollectionViewCell.self, forCellWithReuseIdentifier: "collectionViewCell") } }() //-------------------------------------------------- // MARK: - Public Methods //-------------------------------------------------- /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. open override func setup() { super.setup() addSubview(collectionView) collectionView.pinToSuperView() } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() positionLayout.position = alignment positionLayout.rowQuantity = rowQuantity var percentage: CGFloat? var width: CGFloat? if let childWidth { switch childWidth { case .value(let value): width = value case .percentage(let value): percentage = value } } buttons.forEach { buttonBase in //only allow 1 line for any button buttonBase.titleLabel?.numberOfLines = 1 buttonBase.titleLabel?.lineBreakMode = .byTruncatingTail if let width { (buttonBase as? Button)?.width = .value(width) } } positionLayout.buttonPercentage = percentage collectionView.reloadData() } open override func reset() { super.reset() shouldUpdateView = false rowQuantityPhone = 0 rowQuantityTablet = 0 alignment = .center buttons.forEach { $0.reset() } shouldUpdateView = true setNeedsUpdate() } open override func layoutSubviews() { super.layoutSubviews() // Accounts for any collection size changes DispatchQueue.main.async { [weak self] in guard let self else { return } self.collectionView.collectionViewLayout.invalidateLayout() } } } extension ButtonGroup: UICollectionViewDataSource, UICollectionViewDelegate { public func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return buttons.count } open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let button = buttons[indexPath.row] guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "collectionViewCell", for: indexPath) as? ButtonGroupCollectionViewCell else { return UICollectionViewCell() } cell.contentView.subviews.forEach { $0.removeFromSuperview() } cell.contentView.addSubview(button) button.pinToSuperView() if hasDebugBorder { cell.addDebugBorder() } else { cell.removeDebugBorder() } return cell } public func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize { buttons[indexPath.row].intrinsicContentSize } public func collectionView(_ collectionView: UICollectionView, isButtonTypeForItemAtIndexPath indexPath: IndexPath) -> Bool { if let _ = buttons[indexPath.row] as? Button { return true } else { return false } } } extension ButtonGroup : ButtongGroupPositionLayoutDelegate { func collectionView(_ collectionView: UICollectionView, buttonBaseAtIndexPath indexPath: IndexPath) -> ButtonBase { buttons[indexPath.row] } }