diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 174e2101..2f938f80 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -43,10 +43,15 @@ EA4DB18528CA967F00103EE3 /* SelectorGroupHandlerBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4DB18428CA967F00103EE3 /* SelectorGroupHandlerBase.swift */; }; EA4DB2FD28D3D0CA00103EE3 /* AnyEquatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4DB2FC28D3D0CA00103EE3 /* AnyEquatable.swift */; }; EA4DB30228DCBCA500103EE3 /* Badge.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA4DB30128DCBCA500103EE3 /* Badge.swift */; }; + EA596ABD2A16B4EC00300C4B /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA596ABC2A16B4EC00300C4B /* Tab.swift */; }; + EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA596ABE2A16B4F500300C4B /* Tabs.swift */; }; EA5E304C294CBDD00082B959 /* TileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E304B294CBDD00082B959 /* TileContainer.swift */; }; EA5E30532950DDA60082B959 /* TitleLockup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E30522950DDA60082B959 /* TitleLockup.swift */; }; EA5E3058295105A40082B959 /* Tilelet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E3057295105A40082B959 /* Tilelet.swift */; }; EA5E305A29510F8B0082B959 /* EnumSubset.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E305929510F8B0082B959 /* EnumSubset.swift */; }; + EA5F86C82A1BD99100BC83E4 /* TabModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5F86C72A1BD99100BC83E4 /* TabModel.swift */; }; + EA5F86CC2A1D28B500BC83E4 /* ReleaseNotes.txt in Resources */ = {isa = PBXBuildFile; fileRef = EA5F86CB2A1D28B500BC83E4 /* ReleaseNotes.txt */; }; + EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5F86CF2A1F936100BC83E4 /* TabsContainer.swift */; }; EA81410B2A0E8E3C004F60D2 /* ButtonIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA81410A2A0E8E3C004F60D2 /* ButtonIcon.swift */; }; EA8141102A127066004F60D2 /* UIColor+VDSColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA81410F2A127066004F60D2 /* UIColor+VDSColor.swift */; }; EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */; }; @@ -164,10 +169,15 @@ EA4DB18428CA967F00103EE3 /* SelectorGroupHandlerBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectorGroupHandlerBase.swift; sourceTree = ""; }; EA4DB2FC28D3D0CA00103EE3 /* AnyEquatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyEquatable.swift; sourceTree = ""; }; EA4DB30128DCBCA500103EE3 /* Badge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Badge.swift; sourceTree = ""; }; + EA596ABC2A16B4EC00300C4B /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; }; + EA596ABE2A16B4F500300C4B /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; EA5E304B294CBDD00082B959 /* TileContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileContainer.swift; sourceTree = ""; }; EA5E30522950DDA60082B959 /* TitleLockup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleLockup.swift; sourceTree = ""; }; EA5E3057295105A40082B959 /* Tilelet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tilelet.swift; sourceTree = ""; }; EA5E305929510F8B0082B959 /* EnumSubset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumSubset.swift; sourceTree = ""; }; + EA5F86C72A1BD99100BC83E4 /* TabModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabModel.swift; sourceTree = ""; }; + EA5F86CB2A1D28B500BC83E4 /* ReleaseNotes.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ReleaseNotes.txt; sourceTree = ""; }; + EA5F86CF2A1F936100BC83E4 /* TabsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsContainer.swift; sourceTree = ""; }; EA81410A2A0E8E3C004F60D2 /* ButtonIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIcon.swift; sourceTree = ""; }; EA81410F2A127066004F60D2 /* UIColor+VDSColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+VDSColor.swift"; sourceTree = ""; }; EA89200328AECF4B006B9984 /* UITextField+Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Publisher.swift"; sourceTree = ""; }; @@ -384,6 +394,7 @@ EA89200B28B530F0006B9984 /* RadioBox */, EAF7F11428A1470D00B287F5 /* RadioButton */, EA1F265F28B945070033E859 /* RadioSwatch */, + EA596ABB2A16B4D500300C4B /* Tabs */, EAC925852911C9DE00091998 /* TextFields */, EA5E304A294CBDBB0082B959 /* TileContainer */, EA5E3056295105930082B959 /* Tilelet */, @@ -478,6 +489,7 @@ children = ( EA3361FF2891E14C0071C351 /* Fonts */, EAA5EEB828ECD24B003B3210 /* Icons.xcassets */, + EA5F86CB2A1D28B500BC83E4 /* ReleaseNotes.txt */, ); path = SupportingFiles; sourceTree = ""; @@ -511,6 +523,17 @@ path = Badge; sourceTree = ""; }; + EA596ABB2A16B4D500300C4B /* Tabs */ = { + isa = PBXGroup; + children = ( + EA596ABC2A16B4EC00300C4B /* Tab.swift */, + EA5F86C72A1BD99100BC83E4 /* TabModel.swift */, + EA596ABE2A16B4F500300C4B /* Tabs.swift */, + EA5F86CF2A1F936100BC83E4 /* TabsContainer.swift */, + ); + path = Tabs; + sourceTree = ""; + }; EA5E304A294CBDBB0082B959 /* TileContainer */ = { isa = PBXGroup; children = ( @@ -778,6 +801,7 @@ buildActionMask = 2147483647; files = ( EA3362042891E14D0071C351 /* VerizonNHGeTX-Bold.otf in Resources */, + EA5F86CC2A1D28B500BC83E4 /* ReleaseNotes.txt in Resources */, EA3362072891E14D0071C351 /* VerizonNHGeDS-Regular.otf in Resources */, EA3362062891E14D0071C351 /* VerizonNHGeTX-Regular.otf in Resources */, EA3362052891E14D0071C351 /* VerizonNHGeDS-Bold.otf in Resources */, @@ -805,6 +829,7 @@ EAF7F0A6289B0CE000B287F5 /* Resetable.swift in Sources */, EA985C2D296F03FE00F2FF2E /* TileletIconModels.swift in Sources */, EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */, + EA5F86C82A1BD99100BC83E4 /* TabModel.swift in Sources */, EA297A5729FB0A360031ED56 /* AppleGuidlinesTouchable.swift in Sources */, EA3361C328902D960071C351 /* Toggle.swift in Sources */, EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */, @@ -827,6 +852,7 @@ EAB5FEF5292D371F00998C17 /* ButtonBase.swift in Sources */, EA978EC5291D6AFE00ACC883 /* AnyLabelAttribute.swift in Sources */, EA33622C2891E73B0071C351 /* FontProtocol.swift in Sources */, + EA596ABD2A16B4EC00300C4B /* Tab.swift in Sources */, EAF7F11728A1475A00B287F5 /* RadioButton.swift in Sources */, EA985BEE2968A92400F2FF2E /* TitleLockupSubTitleModel.swift in Sources */, EA985BF22968B5BB00F2FF2E /* TitleLockupTextStyle.swift in Sources */, @@ -841,6 +867,7 @@ EA4DB30228DCBCA500103EE3 /* Badge.swift in Sources */, EA33624728931B050071C351 /* Initable.swift in Sources */, EAF7F0A4289B017C00B287F5 /* LabelAttributeModel.swift in Sources */, + EA5F86D02A1F936100BC83E4 /* TabsContainer.swift in Sources */, EAF7F0B1289B177F00B287F5 /* ColorLabelAttribute.swift in Sources */, EAC9258F2911C9DE00091998 /* EntryField.swift in Sources */, EAB1D2EA28AE84AA00DAE764 /* UIControlPublisher.swift in Sources */, @@ -893,6 +920,7 @@ EA3361A8288B23300071C351 /* UIColor.swift in Sources */, EAC9257D29119B5400091998 /* TextLink.swift in Sources */, EA1F266628B945070033E859 /* RadioSwatchGroup.swift in Sources */, + EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */, EA985BEC2968A91200F2FF2E /* TitleLockupTitleModel.swift in Sources */, 5FC35BE328D51405004EBEAC /* Button.swift in Sources */, ); @@ -1042,7 +1070,7 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 18; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1075,7 +1103,7 @@ buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 18; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/VDS/Classes/ColorConfiguration.swift b/VDS/Classes/ColorConfiguration.swift index 9c7c25bd..c52a793f 100644 --- a/VDS/Classes/ColorConfiguration.swift +++ b/VDS/Classes/ColorConfiguration.swift @@ -81,7 +81,7 @@ public class ControlColorConfiguration: KeyColorConfigurable { public typealias KeyType = UIControl.State public typealias ObjectType = Surfaceable & UIControl public var keyColors: [KeyColorConfiguration] = [] - + private var lastKeyColor: KeyColorConfiguration? public required init() { } public func setSurfaceColors(_ lightColor: UIColor, _ darkColor: UIColor, forState state: KeyType) { @@ -94,16 +94,23 @@ public class ControlColorConfiguration: KeyColorConfigurable { // find the exact match if let keyColor = keyColors.first(where: {$0.key == state }) { + lastKeyColor = keyColor return keyColor.surfaceConfig.getColor(surface) } else if state.contains(.disabled), let keyColor = keyColors.first(where: {$0.key == .disabled }) { + lastKeyColor = keyColor return keyColor.surfaceConfig.getColor(surface) } else if state.contains(.highlighted), let keyColor = keyColors.first(where: {$0.key == .highlighted }) { + lastKeyColor = keyColor return keyColor.surfaceConfig.getColor(surface) } else { - return .clear + if let lastKeyColor { + return lastKeyColor.surfaceConfig.getColor(surface) + } else { + return .clear + } } } } diff --git a/VDS/Components/Badge/Badge.swift b/VDS/Components/Badge/Badge.swift index c7fed36f..58562fd5 100644 --- a/VDS/Components/Badge/Badge.swift +++ b/VDS/Components/Badge/Badge.swift @@ -54,10 +54,13 @@ open class Badge: View { super.setup() accessibilityElements = [label] - layer.cornerRadius = VDSFormControls.borderradius + layer.cornerRadius = 2 addSubview(label) - label.pinToSuperView(.init(top: 2, left: 4, bottom: 2, right: 4)) + label.pinToSuperView(.init(top: 2, + left: VDSLayout.Spacing.space1X.value, + bottom: 2, + right: VDSLayout.Spacing.space1X.value)) maxWidthConstraint = label.widthAnchor.constraint(lessThanOrEqualToConstant: 100) minWidthConstraint = label.widthAnchor.constraint(greaterThanOrEqualToConstant: 23) diff --git a/VDS/Components/Tabs/Tab.swift b/VDS/Components/Tabs/Tab.swift new file mode 100644 index 00000000..2b9ff6bc --- /dev/null +++ b/VDS/Components/Tabs/Tab.swift @@ -0,0 +1,174 @@ +// +// Tab.swift +// VDS +// +// Created by Matt Bruce on 5/18/23. +// + +import Foundation +import VDSColorTokens +import Combine + +extension Tabs { + + @objc(VDSTab) + open class Tab: View { + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + ///position of the tab + open var index: Int = 0 + + ///label to write out the text + open var label: Label = Label() + + ///orientation of the tabs + open var orientation: Tabs.Orientation = .horizontal { didSet { setNeedsUpdate() } } + + ///Size for tab + open var size: Tabs.Size = .medium { didSet { setNeedsUpdate() } } + + ///Text position left or center + open var textPosition: TextPosition = .left { didSet { setNeedsUpdate() } } + + ///Sets the Position of the Selected/Hover Border Accent for All Tabs. + open var indicatorPosition: Tabs.IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } + + ///An optional callback that is called when this Tab is clicked. Passes parameters (tabIndex). + open var onClick: ((Int) -> Void)? { didSet { setNeedsUpdate() } } + + ///If provided, it will set fixed width for this Tab. + open var width: CGFloat? { didSet { setNeedsUpdate() } } + + ///If provided, it will set this Tab to the Active Tab on render. + open var selected: Bool = false { didSet { setNeedsUpdate() } } + + ///The text label of the tab. + open var text: String = "Tab" { didSet { setNeedsUpdate() } } + + ///Minimum width for the tab + open var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var labelWidthConstraint: NSLayoutConstraint? + private var labelLeadingConstraint: NSLayoutConstraint? + private var labelTopConstraint: NSLayoutConstraint? + private var labelBottomConstraint: NSLayoutConstraint? + + //-------------------------------------------------- + // MARK: - Configuration + //-------------------------------------------------- + private var textColorConfiguration: SurfaceColorConfiguration { selected ? textColorSelectedConfiguration : textColorNonSelectedConfiguration } + private var textColorNonSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight , VDSColor.elementsSecondaryOnlight) + private var textColorSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + private var indicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteRed, VDSColor.elementsPrimaryOndark) + private var indicatorWidth: CGFloat = 4.0 + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + public override init(frame: CGRect) { + super.init(frame: frame) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + required public convenience init() { + self.init(frame: .zero) + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func setup() { + super.setup() + addSubview(label) + backgroundColor = .clear + accessibilityTraits = .button + + label.backgroundColor = .clear + + label.translatesAutoresizingMaskIntoConstraints = false + label.pinTrailing() + + labelTopConstraint = label.topAnchor.constraint(equalTo: topAnchor) + labelTopConstraint?.isActive = true + + labelBottomConstraint = label.bottomAnchor.constraint(equalTo: bottomAnchor) + labelBottomConstraint?.isActive = true + + labelLeadingConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor) + labelLeadingConstraint?.isActive = true + + let layoutGuide = UILayoutGuide() + addLayoutGuide(layoutGuide) + + labelWidthConstraint = layoutGuide.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth) + labelWidthConstraint?.isActive = true + + //activate the constraints + NSLayoutConstraint.activate([layoutGuide.topAnchor.constraint(equalTo: topAnchor), + layoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + layoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + layoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor)]) + + publisher(for: UITapGestureRecognizer()) + .sink { [weak self] _ in + guard let self else { return } + self.onClick?(self.index) + }.store(in: &subscribers) + + } + + open override func updateView() { + label.text = text + label.textPosition = textPosition + label.textStyle = size.textStyle + label.textColor = textColorConfiguration.getColor(self) + labelWidthConstraint?.isActive = false + if let width, orientation == .vertical { + labelWidthConstraint = label.widthAnchor.constraint(equalToConstant: width) + } else { + labelWidthConstraint = label.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth) + } + labelWidthConstraint?.isActive = true + + var leadingSpace: CGFloat + if orientation == .horizontal { + leadingSpace = 0 + } else { + leadingSpace = size == .medium ? VDSLayout.Spacing.space4X.value : VDSLayout.Spacing.space6X.value + } + labelLeadingConstraint?.constant = leadingSpace + + var otherSpace: CGFloat + if orientation == .horizontal { + otherSpace = size == .medium ? VDSLayout.Spacing.space3X.value : VDSLayout.Spacing.space4X.value + } else { + otherSpace = VDSLayout.Spacing.space2X.value + } + labelTopConstraint?.constant = otherSpace + labelBottomConstraint?.constant = -otherSpace + + setNeedsLayout() + } + + open override func layoutSubviews() { + super.layoutSubviews() + + removeBorders() + + if selected { + var indicator: UIRectEdge = .left + if orientation == .horizontal { + indicator = indicatorPosition.value + } + addBorder(side: indicator, width: indicatorWidth, color: indicatorColorConfiguration.getColor(self), offset: 1) + } + } + } +} diff --git a/VDS/Components/Tabs/TabModel.swift b/VDS/Components/Tabs/TabModel.swift new file mode 100644 index 00000000..02f5bd43 --- /dev/null +++ b/VDS/Components/Tabs/TabModel.swift @@ -0,0 +1,28 @@ +// +// TabModel.swift +// VDS +// +// Created by Matt Bruce on 5/22/23. +// + +import Foundation + +extension Tabs { + public struct TabModel { + + ///Text that goes in the Tab + public var text: String + + ///Click event when you click on a tab + public var onClick: ((Int) -> Void)? + + ///Width of the tab + public var width: CGFloat? + + public init(text: String, onClick: ((Int) -> Void)? = nil, width: CGFloat? = nil) { + self.text = text + self.onClick = onClick + self.width = width + } + } +} diff --git a/VDS/Components/Tabs/Tabs.swift b/VDS/Components/Tabs/Tabs.swift new file mode 100644 index 00000000..f886e655 --- /dev/null +++ b/VDS/Components/Tabs/Tabs.swift @@ -0,0 +1,276 @@ +// +// Tabs.swift +// VDS +// +// Created by Matt Bruce on 5/18/23. +// + +import Foundation +import UIKit +import VDSColorTokens + +@objc(VDSTabs) +open class Tabs: View { + + //-------------------------------------------------- + // MARK: - Enums + //-------------------------------------------------- + public enum Orientation: String, CaseIterable{ + case vertical + case horizontal + } + + public enum IndicatorPosition: String, CaseIterable { + case top + case bottom + + var value: UIRectEdge { + if self == .top { + return .top + } else { + return .bottom + } + } + } + + public enum Overflow: String, CaseIterable { + case scroll + case none + } + + public enum Size: String, CaseIterable { + case medium + case large + + public var textStyle: TextStyle { + if self == .medium { + return .boldBodyLarge + } else { + //specs show that the font size shouldn't change however boldTitleSmall does + //change point size between iPad/iPhone. This is a "fix" so each device will + //load the correct pointSize + return UIDevice.isIPad ? .boldTitleSmall : .boldTitleMedium + } + } + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + ///An + /// callback that is called when the selectedIndex changes. Passes parameters (event, tabIndex). + open var onTabChange: ((Int) -> Void)? + + //Determines the layout of the Tabs, defaults to horizontal + open var orientation: Orientation = .horizontal { didSet { if oldValue != orientation { updateTabItems() } } } + + ///When true, Tabs will have border line. If false is passed then the border line won't be visible. + open var borderLine: Bool = true { didSet { setNeedsUpdate() } } + + ///It will fill the Tabs to the width of the compoent and all Tabs will be in equal width when orientation is horizontal. This is recommended when there are no more than 2-3 tabs. + open var fillContainer: Bool = false { didSet { updateTabItems() } } + + ///When true, Tabs will be sticky to top of page, when orientation is vertical. + open var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } } + + ///Sets the Position of the Selected/Hover Border Accent for All Tabs, only for Horizontal Orientation + open var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } + + ///Minimum Width for All Tabs, when orientation is horizontal. + open var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } } + + ///If set to 'scroll', Tabs can be overflow and scrollable. With 'none', tabs will not overflow and labels will be wrapped to multiple lines if the label text is long. + open var overflow: Overflow = .scroll { didSet { updateTabItems() } } + + ///The initial Selected Tab's index and is set once a Tab is clicked + open var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } } + + ///Determines the size of the Tabs TextStyle + open var size: Size = .medium { didSet { updateTabItems() } } + + ///When true, Tabs will be sticky to top of page, when orientation is vertical. + open var sticky: Bool = false { didSet { setNeedsUpdate() } } + + ///Model of the Tabs you are wanting to show. + open var tabModels: [TabModel] = [] { didSet { updateTabItems() } } + + open var tabSpacing: CGFloat { tabStackView.spacing } + + open var tabViews: [Tab] = [] + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var tabStackView: UIStackView! + private var scrollView: UIScrollView! + private var contentView: View! + private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark) + private var contentViewWidthConstraint: NSLayoutConstraint? + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + public override init(frame: CGRect) { + super.init(frame: frame) + } + + public convenience required init() { + self.init(frame: .zero) + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func setup() { + super.setup() + scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + addSubview(scrollView) + + contentView = View() + contentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + + tabStackView = UIStackView() + tabStackView.axis = .horizontal + tabStackView.distribution = .fill + tabStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(tabStackView) + + scrollView.pinToSuperView() + contentView.pinToSuperView() + tabStackView.pinToSuperView() + contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true + } + + private func updateTabItems() { + updateTabItems(with: tabModels) + } + + private func updateTabItems(with models: [TabModel]) { + // Clear existing tab items + for tabItem in tabViews { + tabItem.removeFromSuperview() + } + tabViews.removeAll() + // Create new tab items from the models + for model in models { + let tabItem = Tab() + tabItem.text = model.text + tabItem.onClick = model.onClick + tabItem.width = model.width + tabViews.append(tabItem) + tabStackView.addArrangedSubview(tabItem) + + tabItem + .publisher(for: UITapGestureRecognizer()) + .sink { [weak self] gesture in + guard let self, let tabItem = gesture.view as? Tab else { return } + if let selectedIndex = tabViews.firstIndex(of: tabItem) { + self.selectedIndex = selectedIndex + self.onTabChange?(selectedIndex) + } + }.store(in: &tabItem.subscribers) + } + setNeedsUpdate() + scrollToSelectedIndex(animated: false) + } + + private func scrollToSelectedIndex(animated: Bool) { + if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabViews.count { + let selectedTab = tabViews[selectedIndex] + scrollView.scrollRectToVisible(selectedTab.frame, animated: animated) + } + } + + open override func updateView() { + super.updateView() + + // Update the stackview properties + if orientation == .horizontal && fillContainer { + tabStackView.distribution = .fillEqually + } else { + tabStackView.distribution = orientation == .horizontal ? .fillProportionally : .fill + } + + tabStackView.axis = orientation == .horizontal ? .horizontal : .vertical + tabStackView.alignment = orientation == .horizontal ? .fill : .leading + tabStackView.spacing = orientation == .horizontal ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space4X.value + + // Update tab appearance based on properties + for (index, tabItem) in tabViews.enumerated() { + tabItem.selected = selectedIndex == index + tabItem.index = index + tabItem.minWidth = minWidth + tabItem.size = size + tabItem.textPosition = orientation == .horizontal && fillContainer ? .center : .left + tabItem.orientation = orientation + tabItem.surface = surface + tabItem.indicatorPosition = indicatorPosition + tabItem.accessibilityLabel = "\(tabItem.text) \(tabItem.selected ? "selected" : "unselected") \(index+1) of \(tabViews.count)" + } + + // Deactivate old constraint + contentViewWidthConstraint?.isActive = false + + // Apply width + if orientation == .vertical { + scrollView.isScrollEnabled = false + contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor) + } else { + // Apply overflow + if overflow == .scroll && !fillContainer { + let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width + contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: contentWidth) + + // Enable scrolling if necessary + scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) + } else { + contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor) + } + } + scrollView.isScrollEnabled = orientation == .horizontal && overflow == .scroll + + // Activate old constraint + contentViewWidthConstraint?.isActive = true + + setNeedsLayout() + layoutIfNeeded() + } + + open override func layoutSubviews() { + super.layoutSubviews() + + //scrollView Contentsize + if orientation == .horizontal && overflow == .scroll && !fillContainer { + let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width + scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) + } else { + scrollView.contentSize = bounds.size + } + + // Apply border line + layer.remove(layerName: "borderLineLayer") + if borderLine { + let borderLineLayer = CALayer() + borderLineLayer.name = "borderLineLayer" + borderLineLayer.backgroundColor = borderlineColorConfig.getColor(self).cgColor + + if orientation == .horizontal { + borderLineLayer.frame = CGRect(x: 0, y: bounds.height - 1, width: bounds.width, height: 1) + } else { + borderLineLayer.frame = CGRect(x: 0, y: 0, width: 1, height: bounds.height) + } + + layer.addSublayer(borderLineLayer) + } + + scrollToSelectedIndex(animated: true) + } +} diff --git a/VDS/Components/Tabs/TabsContainer.swift b/VDS/Components/Tabs/TabsContainer.swift new file mode 100644 index 00000000..ccb01c1c --- /dev/null +++ b/VDS/Components/Tabs/TabsContainer.swift @@ -0,0 +1,217 @@ +// +// TabsContainer.swift +// VDS +// +// Created by Matt Bruce on 5/25/23. +// + +import Foundation +import UIKit + +@objc(VDSTabsContainer) +open class TabsContainer: View { + //-------------------------------------------------- + // MARK: - Enums + //-------------------------------------------------- + public enum Width { + case percentage(CGFloat) + case value(CGFloat) + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + open var tabMenu = Tabs() + + ///An optional callback that is called when the selectedIndex changes. Passes parameters (event, tabIndex). + open var onTabChange: ((Int) -> Void)? + + ///Determines the layout of the Tabs, defaults to horizontal + open var orientation: Tabs.Orientation = .horizontal { didSet { setNeedsUpdate() } } + + ///When true, Tabs will have border line. If false is passed then the border line won't be visible. + open var borderLine: Bool = true { didSet { setNeedsUpdate() } } + + ///It will fill the Tabs to the width of the compoent and all Tabs will be in equal width when orientation is horizontal. This is recommended when there are no more than 2-3 tabs. + open var fillContainer: Bool = false { didSet { setNeedsUpdate() } } + + ///When true, Tabs will be sticky to top of page, when orientation is vertical. + open var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } } + + ///Sets the Position of the Selected/Hover Border Accent for All Tabs, only for Horizontal Orientation + open var indicatorPosition: Tabs.IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } + + ///Minimum Width for All Tabs, when orientation is horizontal. + open var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } } + + ///If set to 'scroll', Tabs can be overflow and scrollable. With 'none', tabs will not overflow and labels will be wrapped to multiple lines if the label text is long. + open var overflow: Tabs.Overflow = .scroll { didSet { setNeedsUpdate() } } + + ///The initial Selected Tab's index and is set once a Tab is clicked + open var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } } + + ///Determines the size of the Tabs TextStyle + open var size: Tabs.Size = .medium { didSet { setNeedsUpdate() } } + + ///Space between the Tabs and Contentl. + open var space: CGFloat = 5.0 { didSet { setNeedsUpdate() } } + + ///When true, Tabs will be sticky to top of page, when orientation is vertical. + open var sticky: Bool = false { didSet { setNeedsUpdate() } } + + ///rules for width + private var _width: Width = .percentage(0.25) + + ///Width of all Tabs when orientation is vertical, defaults to 25%. + open var width: Width { + get { + return _width + } + set { + switch newValue { + case .percentage(let percentage): + if percentage >= 0 && percentage <= 1 { + _width = newValue + setNeedsUpdate() + } else { + print("Invalid percentage value. It should be between 0 and 1.") + } + case .value(let value): + if value >= minWidth { + _width = newValue + setNeedsUpdate() + } else { + print("Invalid value. It should be greater than or equal to \(minWidth).") + } + } + } + } + + ///Model of the Tabs you are wanting to show. + open var tabModels: [TabModel] = [] { + didSet { + tabMenu.tabModels = tabModels.compactMap{ $0.model } + contentView.arrangedSubviews.forEach{ $0.removeFromSuperview() } + tabModels.forEach { + let view = $0.view + view.isHidden = true + contentView.addArrangedSubview(view) + } + setNeedsUpdate() + } + } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var contentViewWidthConstraint: NSLayoutConstraint? + + private var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + } + + private var contentView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fillProportionally + $0.axis = .vertical + $0.spacing = 10 + } + + private var tabMenuLayoutGuide = UILayoutGuide() + + open override func setup() { + super.setup() + + tabMenu.addLayoutGuide(tabMenuLayoutGuide) + addSubview(stackView) + stackView.pinToSuperView() + stackView.addArrangedSubview(tabMenu) + stackView.addArrangedSubview(contentView) + + NSLayoutConstraint.activate([ + tabMenuLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + tabMenuLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + tabMenuLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + tabMenuLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + + open override func updateView() { + super.updateView() + + stackView.alignment = orientation == .horizontal ? .fill : .top + stackView.axis = orientation == .horizontal ? .vertical : .horizontal + stackView.spacing = space + + tabMenu.onTabChange = { [weak self] index in + guard let self else { return } + self.tabClicked(index: index) + } + + //update tabs width constraints + contentViewWidthConstraint?.isActive = false + if orientation == .vertical { + switch width { + case .percentage(let amount): + contentViewWidthConstraint = tabMenu.widthAnchor.constraint(equalTo: tabMenuLayoutGuide.widthAnchor, multiplier: amount) + case .value(let amount): + contentViewWidthConstraint = tabMenu.widthAnchor.constraint(equalToConstant: amount) + } + } else { + contentViewWidthConstraint = tabMenu.widthAnchor.constraint(equalTo: widthAnchor) + } + contentViewWidthConstraint?.isActive = true + + tabMenu.surface = surface + tabMenu.disabled = disabled + tabMenu.orientation = orientation + tabMenu.borderLine = borderLine + tabMenu.fillContainer = fillContainer + tabMenu.indicatorFillTab = indicatorFillTab + tabMenu.indicatorPosition = indicatorPosition + tabMenu.minWidth = minWidth + tabMenu.overflow = overflow + tabMenu.selectedIndex = selectedIndex + tabMenu.size = size + tabMenu.sticky = sticky + setSelected(index: selectedIndex) + + tabModels.forEach { + var view = $0.view + view.surface = surface + } + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + private func tabClicked(index: Int) { + onTabChange?(index) + setSelected(index: index) + } + + private func setSelected(index: Int) { + for (modelIndex, model) in tabModels.enumerated() { + let view = model.view + let shouldShow = index == modelIndex + view.isHidden = !shouldShow + } + } +} + +extension TabsContainer { + public struct TabModel { + public typealias AnySurfaceableView = UIView & Surfaceable + public var model: Tabs.TabModel + public var view: AnySurfaceableView + + public init(model: Tabs.TabModel, view: AnySurfaceableView) { + self.model = model + self.view = view + } + } +} diff --git a/VDS/Components/Tooltip/TooltipAlertViewController.swift b/VDS/Components/Tooltip/TooltipAlertViewController.swift index b289c118..5de31cd0 100644 --- a/VDS/Components/Tooltip/TooltipAlertViewController.swift +++ b/VDS/Components/Tooltip/TooltipAlertViewController.swift @@ -47,7 +47,8 @@ open class TooltipAlertViewController: UIViewController, Surfaceable { open var surface: Surface = .light { didSet { updateView() }} open var titleText: String = "" { didSet { updateView() }} open var titleLabel = Label().with { label in - label.textStyle = .boldTitleMedium + //use the same font/pointsize for both Title upsizes font in iPad + label.textStyle = UIDevice.isIPad ? .boldTitleSmall : .boldTitleMedium } open var contentText: String = "" { didSet { updateView() }} diff --git a/VDS/Extensions/UIView.swift b/VDS/Extensions/UIView.swift index 5b5c909b..327849fc 100644 --- a/VDS/Extensions/UIView.swift +++ b/VDS/Extensions/UIView.swift @@ -176,10 +176,66 @@ extension UIView { extension CALayer { func remove(layerName: String) { - sublayers?.forEach({ layer in + guard let sublayers = sublayers else { + return + } + + sublayers.forEach({ layer in if layer.name?.hasPrefix(layerName) ?? false { layer.removeFromSuperlayer() } }) } } + + +extension UIView { + + public func addBorder(side: UIRectEdge, width: CGFloat, color: UIColor, offset: CGFloat = 0) { + let layerName = borderLayerName(for: side) + layer.remove(layerName: layerName) + + let borderLayer = CALayer() + borderLayer.backgroundColor = color.cgColor + borderLayer.name = layerName + + switch side { + case .left: + borderLayer.frame = CGRect(x: 0, y: 0, width: width, height: frame.height) + case .right: + borderLayer.frame = CGRect(x: frame.width - width - offset, y: 0, width: width, height: frame.height) + case .top: + borderLayer.frame = CGRect(x: 0, y: 0, width: frame.width, height: width) + case .bottom: + borderLayer.frame = CGRect(x: 0, y: frame.height - width - offset, width: frame.width, height: width) + default: + break + } + + layer.addSublayer(borderLayer) + } + + public func removeBorders() { + layer.borderWidth = 0 + layer.borderColor = nil + layer.remove(layerName: borderLayerName(for: .top)) + layer.remove(layerName: borderLayerName(for: .left)) + layer.remove(layerName: borderLayerName(for: .right)) + layer.remove(layerName: borderLayerName(for: .bottom)) + } + + private func borderLayerName(for side: UIRectEdge) -> String { + switch side { + case .left: + return "leftBorderLayer" + case .right: + return "rightBorderLayer" + case .top: + return "topBorderLayer" + case .bottom: + return "bottomBorderLayer" + default: + return "" + } + } +} diff --git a/VDS/SupportingFiles/ReleaseNotes.txt b/VDS/SupportingFiles/ReleaseNotes.txt new file mode 100644 index 00000000..64ba426f --- /dev/null +++ b/VDS/SupportingFiles/ReleaseNotes.txt @@ -0,0 +1,14 @@ +1.0.18 +======= +- CXTDT-412383 - Badge Corner Radius / Color issue (resolved in previous build) +- Completed Tabs +- Started ButtonIcon + +1.0.17 +======= +- Color Tokens Update +- Changes to Badge Colors +- updated trailing tooltip label +- updated entry field colors, tooltip +- update toggle size +- added touchable protocol to button diff --git a/VDS/Typography/Typography.swift b/VDS/Typography/Typography.swift index a680ebc0..0df38e84 100644 --- a/VDS/Typography/Typography.swift +++ b/VDS/Typography/Typography.swift @@ -329,3 +329,9 @@ extension TextStyle { } } + +extension TextStyle: CustomDebugStringConvertible { + public var debugDescription: String { + "Name: \(self.rawValue) FontFace: \(font.fontName) FontWeight: \(self.rawValue.hasPrefix("bold") ? "bold" : "normal") PointSize: \(font.pointSize) LetterSpacing: \(letterSpacing) LineHeight: \(lineHeight)" + } +}