From 0f1627ca6f47cfb506cfde896717c90b888843af Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 18 Nov 2022 10:01:00 -0600 Subject: [PATCH] added button group Signed-off-by: Matt Bruce --- VDS.xcodeproj/project.pbxproj | 4 + .../Buttons/ButtonGroup/ButtonGroup.swift | 232 ++++++++++++++++++ .../ButtonGroupPositionLayout.swift | 167 +++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift create mode 100644 VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index d17ca572..519ed7eb 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ EAB1D2E628AE842000DAE764 /* Publisher+Bind.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2E328AE842000DAE764 /* Publisher+Bind.swift */; }; EAB1D2EA28AE84AA00DAE764 /* UIControlPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */; }; EAB5FED429267EB300998C17 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FED329267EB300998C17 /* UIView.swift */; }; + EAB5FEED2927E1B200998C17 /* ButtonGroupPositionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB5FEEC2927E1B200998C17 /* ButtonGroupPositionLayout.swift */; }; EAC9257D29119B5400091998 /* TextLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC9257C29119B5400091998 /* TextLink.swift */; }; EAC925832911B35400091998 /* TextLinkCaret.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC925822911B35300091998 /* TextLinkCaret.swift */; }; EAC925842911C63100091998 /* Colorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA5EEDF28F49DB3003B3210 /* Colorable.swift */; }; @@ -149,6 +150,7 @@ EAB1D2E328AE842000DAE764 /* Publisher+Bind.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+Bind.swift"; sourceTree = ""; }; EAB1D2E928AE84AA00DAE764 /* UIControlPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControlPublisher.swift; sourceTree = ""; }; EAB5FED329267EB300998C17 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; + EAB5FEEC2927E1B200998C17 /* ButtonGroupPositionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupPositionLayout.swift; sourceTree = ""; }; EAC9257C29119B5400091998 /* TextLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLink.swift; sourceTree = ""; }; EAC925822911B35300091998 /* TextLinkCaret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkCaret.swift; sourceTree = ""; }; EAC925872911C9DE00091998 /* TextEntryField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextEntryField.swift; sourceTree = ""; }; @@ -218,6 +220,7 @@ isa = PBXGroup; children = ( EA0FC2C52914222900DF80B4 /* ButtonGroup.swift */, + EAB5FEEC2927E1B200998C17 /* ButtonGroupPositionLayout.swift */, ); path = ButtonGroup; sourceTree = ""; @@ -654,6 +657,7 @@ EAB1D2CD28ABE76100DAE764 /* Withable.swift in Sources */, EAF7F0952899861000B287F5 /* Checkbox.swift in Sources */, EA3361C9289054C50071C351 /* Surfaceable.swift in Sources */, + EAB5FEED2927E1B200998C17 /* ButtonGroupPositionLayout.swift in Sources */, EA4DB30228DCBCA500103EE3 /* Badge.swift in Sources */, EA33624728931B050071C351 /* Initable.swift in Sources */, EAF7F0A4289B017C00B287F5 /* LabelAttributeModel.swift in Sources */, diff --git a/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift b/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift new file mode 100644 index 00000000..10bcb417 --- /dev/null +++ b/VDS/Components/Buttons/ButtonGroup/ButtonGroup.swift @@ -0,0 +1,232 @@ +// +// 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 textPosition: TextPosition = .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 collectionView: SelfSizingCollectionView = { + let positionLayout = ButtonGroupPositionLayout().with { + $0.position = .center + $0.delegate = self + } + + 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() + 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 + } +} diff --git a/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift b/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift new file mode 100644 index 00000000..3abb7155 --- /dev/null +++ b/VDS/Components/Buttons/ButtonGroup/ButtonGroupPositionLayout.swift @@ -0,0 +1,167 @@ +// +// ButtonGroupPositionLayout.swift +// VDS +// +// Created by Matt Bruce on 11/18/22. +// + +import Foundation +import UIKit + +class ButtonCollectionViewRow { + var attributes = [UICollectionViewLayoutAttributes]() + var spacing: CGFloat = 0 + + init(spacing: CGFloat) { + self.spacing = spacing + } + + func add(attribute: UICollectionViewLayoutAttributes) { + attributes.append(attribute) + } + + var rowWidth: CGFloat { + return attributes.reduce(0, { result, attribute -> CGFloat in + return result + attribute.frame.width + }) + CGFloat(attributes.count - 1) * spacing + } + + func layout(for position: ButtonViewPosition, with collectionViewWidth: CGFloat){ + var offset = 0.0 + + 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 + offset += attribute.frame.width + spacing + } + } +} + +public enum ButtonViewPosition: String, CaseIterable { + case left, center, right +} + +protocol ButtongGroupPositionLayoutDelegate: AnyObject { + func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize + func collectionView(_ collectionView: UICollectionView, insetsForItemsInSection section: Int) -> UIEdgeInsets + func collectionView(_ collectionView: UICollectionView, itemSpacingInSection section: Int) -> CGFloat +} + +class ButtonGroupPositionLayout: UICollectionViewLayout { + + weak var delegate: ButtongGroupPositionLayoutDelegate? + + // Total height of the content. Will be used to configure the scrollview content + var layoutHeight: CGFloat = 0.0 + var position: ButtonViewPosition = .left + private var itemCache: [UICollectionViewLayoutAttributes] = [] + + override func prepare() { + super.prepare() + + itemCache.removeAll() + + guard let collectionView = collectionView else { + return + } + var itemSpacing = 0.0 + // Variable to track the width of the layout at the current state when the item is being drawn + var layoutWidthIterator: CGFloat = 0.0 + + for section in 0.. collectionView.frame.width { + // 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 + layoutHeight += itemSize.height + interItemSpacing + } + + let frame = CGRect(x: layoutWidthIterator + insets.left, y: layoutHeight, width: itemSize.width, height: itemSize.height) + let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) + attributes.frame = frame + itemCache.append(attributes) + layoutWidthIterator = layoutWidthIterator + frame.width + interItemSpacing + } + + layoutHeight += itemSize.height + insets.bottom + layoutWidthIterator = 0.0 + } + + //Turn into rows and re-calculate + var rows = [ButtonCollectionViewRow]() + var currentRowY: CGFloat = -1 + + for attribute in itemCache { + if currentRowY != attribute.frame.midY { + currentRowY = attribute.frame.midY + rows.append(ButtonCollectionViewRow(spacing: itemSpacing)) + } + rows.last?.add(attribute: attribute) + } + + //recalculate rows based off of positions + rows.forEach { $0.layout(for: position, with: collectionView.frame.width) } + let rowAttributes = rows.flatMap { $0.attributes } + itemCache = rowAttributes + + } + + override func layoutAttributesForElements(in rect: CGRect)-> [UICollectionViewLayoutAttributes]? { + super.layoutAttributesForElements(in: rect) + + var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = [] + + for attributes in itemCache { + if attributes.frame.intersects(rect) { + visibleLayoutAttributes.append(attributes) + } + } + + return visibleLayoutAttributes + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + super.layoutAttributesForItem(at: indexPath) + 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 + } + let insets = collectionView.contentInset + return collectionView.bounds.width - (insets.left + insets.right) + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + layoutHeight = 0.0 + return true + } +} +