From b974e639314432eb916322bf58fc786791701cdc Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 9 Oct 2024 08:20:13 -0500 Subject: [PATCH] initial work Signed-off-by: Matt Bruce --- VDS.xcodeproj/project.pbxproj | 16 ++ .../Tilelet/TileletGroup/TileletGroup.swift | 165 ++++++++++++ .../TileletGroupPositionLayout.swift | 248 ++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 VDS/Components/Tilelet/TileletGroup/TileletGroup.swift create mode 100644 VDS/Components/Tilelet/TileletGroup/TileletGroupPositionLayout.swift diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index bb3a0e44..2cc99ec2 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -210,6 +210,8 @@ EAF7F11728A1475A00B287F5 /* RadioButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F11528A1475A00B287F5 /* RadioButtonItem.swift */; }; EAF7F13328A2A16500B287F5 /* AttachmentLabelAttributeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F13228A2A16500B287F5 /* AttachmentLabelAttributeModel.swift */; }; EAF978212A99035B00C2FEA9 /* Enabling.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF978202A99035B00C2FEA9 /* Enabling.swift */; }; + EAFD5AA02CB5CA5300C87DE1 /* TileletGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFD5A9F2CB5CA5300C87DE1 /* TileletGroup.swift */; }; + EAFD5AA22CB5CA7900C87DE1 /* TileletGroupPositionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFD5AA12CB5CA7900C87DE1 /* TileletGroupPositionLayout.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -459,6 +461,8 @@ EAF7F11528A1475A00B287F5 /* RadioButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioButtonItem.swift; sourceTree = ""; }; EAF7F13228A2A16500B287F5 /* AttachmentLabelAttributeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentLabelAttributeModel.swift; sourceTree = ""; }; EAF978202A99035B00C2FEA9 /* Enabling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Enabling.swift; sourceTree = ""; }; + EAFD5A9F2CB5CA5300C87DE1 /* TileletGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileletGroup.swift; sourceTree = ""; }; + EAFD5AA12CB5CA7900C87DE1 /* TileletGroupPositionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileletGroupPositionLayout.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -957,6 +961,7 @@ EA5E3056295105930082B959 /* Tilelet */ = { isa = PBXGroup; children = ( + EAFD5A9E2CB5C9ED00C87DE1 /* TileletGroup */, EA5E3057295105A40082B959 /* Tilelet.swift */, EA985BE529688F6A00F2FF2E /* TileletBadgeModel.swift */, 71ACE89D2BA1CC1700FB6ADC /* TiletEyebrowModel.swift */, @@ -1171,6 +1176,15 @@ path = RadioButton; sourceTree = ""; }; + EAFD5A9E2CB5C9ED00C87DE1 /* TileletGroup */ = { + isa = PBXGroup; + children = ( + EAFD5A9F2CB5CA5300C87DE1 /* TileletGroup.swift */, + EAFD5AA12CB5CA7900C87DE1 /* TileletGroupPositionLayout.swift */, + ); + path = TileletGroup; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1382,6 +1396,7 @@ EAB1D2CD28ABE76100DAE764 /* Withable.swift in Sources */, 71FC86DC2B96F4C800700965 /* PaginationCellItem.swift in Sources */, EAC846F3294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift in Sources */, + EAFD5AA02CB5CA5300C87DE1 /* TileletGroup.swift in Sources */, EAF7F0952899861000B287F5 /* CheckboxItem.swift in Sources */, EA985BE82968951C00F2FF2E /* TileletTitleModel.swift in Sources */, 71FC86DE2B9738B900700965 /* SurfaceConfigurationValue.swift in Sources */, @@ -1460,6 +1475,7 @@ EA336171288B19200071C351 /* VDS.docc in Sources */, EA985BF02968A93600F2FF2E /* TitleLockupEyebrowModel.swift in Sources */, EA5E30532950DDA60082B959 /* TitleLockup.swift in Sources */, + EAFD5AA22CB5CA7900C87DE1 /* TileletGroupPositionLayout.swift in Sources */, EAD062B02A3B873E0015965D /* BadgeIndicator.swift in Sources */, 183B16F32C78CF7C00BA6A10 /* CarouselSlotCell.swift in Sources */, 44A952DD2BE3DA820009F874 /* TableFlowLayout.swift in Sources */, diff --git a/VDS/Components/Tilelet/TileletGroup/TileletGroup.swift b/VDS/Components/Tilelet/TileletGroup/TileletGroup.swift new file mode 100644 index 00000000..b4fc2ab2 --- /dev/null +++ b/VDS/Components/Tilelet/TileletGroup/TileletGroup.swift @@ -0,0 +1,165 @@ +// +// TileletGroup.swift +// VDS +// +// Created by Matt Bruce on 10/8/24. +// + +import Foundation +import UIKit +import VDSCoreTokens +import Combine + +open class TileletGroup: 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: - 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 } + + /// Array of Buttonable Views that are shown in the group. + open var tilelets: [Tilelet] = [] { didSet { setNeedsUpdate() } } + + /// Whether this object is enabled or not + override open var isEnabled: Bool { + didSet { + tilelets.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 { + tilelets.forEach { $0.surface = surface } + } + } + + open var contentSizePublisher: AnyPublisher { collectionView.contentSizePublisher } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + + fileprivate lazy var positionLayout = TileletGroupPositionLayout().with { + $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(UICollectionViewCell.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.rowQuantity = rowQuantity + + var width: CGFloat? + tilelets.forEach { tilelet in + if let width { + tilelet.width = width + } + } + collectionView.reloadData() + } + + open override func setDefaults() { + super.setDefaults() + rowQuantityPhone = 0 + rowQuantityTablet = 0 + tilelets = [] + } + + open override func reset() { + tilelets.forEach { $0.reset() } + super.reset() + } + + 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 TileletGroup: UICollectionViewDataSource, UICollectionViewDelegate { + public func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return tilelets.count + } + + open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let tilelet = tilelets[indexPath.row] + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "collectionViewCell", for: indexPath) + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + cell.contentView.addSubview(tilelet) + tilelet.pinToSuperView() + if hasDebugBorder { + cell.addDebugBorder() + } else { + cell.removeDebugBorder() + } + return cell + } + + public func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize { + tilelets[indexPath.row].intrinsicContentSize + } +} + +extension TileletGroup : TileletGroupPositionLayoutDelegate { + func collectionView(_ collectionView: UICollectionView, tileletAtIndexPath indexPath: IndexPath) -> Tilelet { + tilelets[indexPath.row] + } +} diff --git a/VDS/Components/Tilelet/TileletGroup/TileletGroupPositionLayout.swift b/VDS/Components/Tilelet/TileletGroup/TileletGroupPositionLayout.swift new file mode 100644 index 00000000..00088ed3 --- /dev/null +++ b/VDS/Components/Tilelet/TileletGroup/TileletGroupPositionLayout.swift @@ -0,0 +1,248 @@ +// +// TileletGroupPositionLayout.swift +// VDS +// +// Created by Matt Bruce on 10/8/24. +// + +import Foundation +import UIKit + +class TileletCollectionViewRow { + var attributes = [TileletLayoutAttributes]() + + init() {} + + func add(attribute: TileletLayoutAttributes) { + attributes.append(attribute) + } + + 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(with collectionViewWidth: CGFloat){ + var offset = 0.0 + let height = rowHeight + + attributes.last?.spacing = 0 + + 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 TileletGroupPositionLayoutDelegate: AnyObject { + func collectionView(_ collectionView: UICollectionView, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize + func collectionView(_ collectionView: UICollectionView, tileletAtIndexPath indexPath: IndexPath) -> Tilelet +} + +class TileletLayoutAttributes: UICollectionViewLayoutAttributes{ + var spacing: CGFloat = 0 + + var tilelet: Tilelet? + + convenience init(spacing: CGFloat, + tilelet: Tilelet, + forCellWith indexPath: IndexPath) { + self.init(forCellWith: indexPath) + self.spacing = spacing + self.tilelet = tilelet + } +} + +class TileletGroupPositionLayout: UICollectionViewLayout { + + weak var delegate: TileletGroupPositionLayoutDelegate? + var verticalSpacer: ((TileletCollectionViewRow, TileletCollectionViewRow?) -> CGFloat)? + var axisSpacer: ((NSLayoutConstraint.Axis, Tilelet, Tilelet) -> CGFloat)? + + // Total height of the content. Will be used to configure the scrollview content + var layoutHeight: CGFloat = 0.0 + var rowQuantity: Int = 0 + var tileletPercentage: CGFloat? + + private var itemCache: [TileletLayoutAttributes] = [] + + 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 TileletGroup + 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 tilelets + let totalItems = collectionView.numberOfItems(inSection: section) + + //create rows + var rows = [TileletCollectionViewRow]() + rows.append(TileletCollectionViewRow()) + + 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(TileletCollectionViewRow()) + } + + // get the tilelet + let itemTilelet = delegate.collectionView(collectionView, tileletAtIndexPath: indexPath) + + // see if there is another item in the array + let nextItem = item + 1 + + // if so, get the tilelet + // and get the spacing based of the + // current tilelet and the next tilelet + if nextItem < totalItems { + + //get the next tilelet + let neighbor = delegate.collectionView(collectionView, tileletAtIndexPath: IndexPath(item: nextItem, section: section)) + + // get the spacing to go between the current and next tilelet + itemSpacing = getAxisSpacing(for: .horizontal, with: itemTilelet, neighboring: neighbor) + } + + // create the custom layout attribute + let attributes = TileletLayoutAttributes(spacing: itemSpacing, tilelet: itemTilelet, 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.layout(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: Tilelet, neighboring: Tilelet) -> CGFloat { + 20 + } + + private func getVerticalSpacing(for row: TileletCollectionViewRow, neighboringRow: TileletCollectionViewRow?) -> CGFloat { + 20 + } +} + + +