From d9d37059279d5fd8e2023158ebcc5c3a5614b248 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 24 May 2023 19:18:06 -0500 Subject: [PATCH] initial tabscontainer Signed-off-by: Matt Bruce --- VDSSample.xcodeproj/project.pbxproj | 4 + .../ViewControllers/MenuViewController.swift | 3 +- .../TabsContainerViewController.swift | 399 ++++++++++++++++++ 3 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 VDSSample/ViewControllers/TabsContainerViewController.swift diff --git a/VDSSample.xcodeproj/project.pbxproj b/VDSSample.xcodeproj/project.pbxproj index 8d320bd..abcbff8 100644 --- a/VDSSample.xcodeproj/project.pbxproj +++ b/VDSSample.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ EA5E3050294D11540082B959 /* TileContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E304F294D11540082B959 /* TileContainerViewController.swift */; }; EA5E30552950EA6E0082B959 /* TitleLockupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E30542950EA6E0082B959 /* TitleLockupViewController.swift */; }; EA5E305C295111050082B959 /* TileletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5E305B295111050082B959 /* TileletViewController.swift */; }; + EA5F86CE2A1E863F00BC83E4 /* TabsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5F86CD2A1E863F00BC83E4 /* TabsContainerViewController.swift */; }; EA81410E2A0ED8DC004F60D2 /* ButtonIconViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA81410D2A0ED8DC004F60D2 /* ButtonIconViewController.swift */; }; EA84F76228BE4AE500D67ABC /* RadioSwatchGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA84F76128BE4AE500D67ABC /* RadioSwatchGroupViewController.swift */; }; EA89201928B56DF5006B9984 /* RadioBoxGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89201828B56DF5006B9984 /* RadioBoxGroupViewController.swift */; }; @@ -138,6 +139,7 @@ EA5E304F294D11540082B959 /* TileContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileContainerViewController.swift; sourceTree = ""; }; EA5E30542950EA6E0082B959 /* TitleLockupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleLockupViewController.swift; sourceTree = ""; }; EA5E305B295111050082B959 /* TileletViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileletViewController.swift; sourceTree = ""; }; + EA5F86CD2A1E863F00BC83E4 /* TabsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsContainerViewController.swift; sourceTree = ""; }; EA81410D2A0ED8DC004F60D2 /* ButtonIconViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconViewController.swift; sourceTree = ""; }; EA84F76128BE4AE500D67ABC /* RadioSwatchGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioSwatchGroupViewController.swift; sourceTree = ""; }; EA89201828B56DF5006B9984 /* RadioBoxGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioBoxGroupViewController.swift; sourceTree = ""; }; @@ -305,6 +307,7 @@ EA89201828B56DF5006B9984 /* RadioBoxGroupViewController.swift */, EAF7F11928A14A0E00B287F5 /* RadioButtonViewController.swift */, EA84F76128BE4AE500D67ABC /* RadioSwatchGroupViewController.swift */, + EA5F86CD2A1E863F00BC83E4 /* TabsContainerViewController.swift */, EA596AB92A16B2ED00300C4B /* TabsViewController.swift */, EA0FC2C02912DC5500DF80B4 /* TextLinkCaretViewController.swift */, EA985C24296E06EA00F2FF2E /* TextAreaViewController.swift */, @@ -495,6 +498,7 @@ EA81410E2A0ED8DC004F60D2 /* ButtonIconViewController.swift in Sources */, EA985C20296DECF600F2FF2E /* IconName.swift in Sources */, EA89204928B66CE2006B9984 /* KeyboardFrameChangeListening.swift in Sources */, + EA5F86CE2A1E863F00BC83E4 /* TabsContainerViewController.swift in Sources */, EA985C01296CC21C00F2FF2E /* IconViewController.swift in Sources */, EAC9258029119FC400091998 /* TextLinkViewController.swift in Sources */, EAB1D2D428AC409F00DAE764 /* LabelViewController.swift in Sources */, diff --git a/VDSSample/ViewControllers/MenuViewController.swift b/VDSSample/ViewControllers/MenuViewController.swift index 7e153c7..e437e08 100644 --- a/VDSSample/ViewControllers/MenuViewController.swift +++ b/VDSSample/ViewControllers/MenuViewController.swift @@ -101,7 +101,8 @@ class MenuViewController: UITableViewController, TooltipLaunchable { MenuComponent(title: "RadioBoxGroup", completed: true, viewController: RadioBoxGroupViewController.self), MenuComponent(title: "RadioButtonGroup", completed: true, viewController: RadioButtonViewController.self), MenuComponent(title: "RadioSwatchGroup", completed: true, viewController: RadioSwatchGroupViewController.self), - MenuComponent(title: "Tabs", completed: false, viewController: TabsViewController.self), + MenuComponent(title: "TabsContainer", completed: false, viewController: TabsContainerViewController.self), + MenuComponent(title: "Tabs", completed: true, viewController: TabsViewController.self), MenuComponent(title: "TextArea", completed: false, viewController: TextAreaViewController.self), MenuComponent(title: "TextLink", completed: true, viewController: TextLinkViewController.self), MenuComponent(title: "TextLinkCaret", completed: true, viewController: TextLinkCaretViewController.self), diff --git a/VDSSample/ViewControllers/TabsContainerViewController.swift b/VDSSample/ViewControllers/TabsContainerViewController.swift new file mode 100644 index 0000000..38ec7a4 --- /dev/null +++ b/VDSSample/ViewControllers/TabsContainerViewController.swift @@ -0,0 +1,399 @@ +// +// TabViewContainerViewController.swift +// VDSSample +// +// Created by Matt Bruce on 5/24/23. +// + +import Foundation +import UIKit +import VDS +import Combine +import VDSColorTokens + +class TabsContainerViewController: BaseViewController { + + var disabledSwitch = Toggle() + var borderlineSwitch = Toggle() + var fillContainerSwitch = Toggle() + var sampleSwitch = Toggle() + var widthValueTextField = NumericField() + var widthPercentageTextField = NumericField() + + var verticalOrientationFormStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fill + $0.axis = .vertical + $0.spacing = 10 + $0.isHidden = true + } + }() + + var horizontalOrientationFormStackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.alignment = .fill + $0.distribution = .fill + $0.axis = .vertical + $0.spacing = 10 + } + }() + + override func allTextFields() -> [TextField]? { [widthValueTextField, widthPercentageTextField] } + + lazy var orientationPickerSelectorView = { + PickerSelectorView(title: "", + picker: self.picker, + items: Tabs.Orientation.allCases) + }() + + lazy var indicatorPositionPickerSelectorView = { + PickerSelectorView(title: "", + picker: self.picker, + items: Tabs.IndicatorPosition.allCases) + }() + + lazy var sizePickerSelectorView = { + PickerSelectorView(title: "", + picker: self.picker, + items: Tabs.Size.allCases) + }() + + lazy var overflowPickerSelectorView = { + PickerSelectorView(title: "", + picker: self.picker, + items: Tabs.Overflow.allCases) + }() + + override func viewDidLoad() { + super.viewDidLoad() + addContentTopView(view: component) + setupPicker() + setupModel() + } + + override func setupForm(){ + super.setupForm() + addFormRow(label: "Large Sample", view: .makeWrapper(for: sampleSwitch)) + addFormRow(label: "Disabled", view: .makeWrapper(for: disabledSwitch)) + addFormRow(label: "Show Borderline", view: .makeWrapper(for: borderlineSwitch)) + addFormRow(label: "Surface", view: surfacePickerSelectorView) + addFormRow(label: "Size", view: sizePickerSelectorView) + + if UIDevice.isIPad { + addFormRow(label: "Orientation", view: orientationPickerSelectorView) + //only in vertical mode + addFormRow(label: "% Width (0.25 -> 1.0)", view: widthPercentageTextField, stackView: verticalOrientationFormStackView) + addFormRow(label: "# Width", view: widthValueTextField, stackView: verticalOrientationFormStackView) + } + + //only in horizontal mode + addFormRow(label: "Fill Container", view: .makeWrapper(for: fillContainerSwitch), stackView: horizontalOrientationFormStackView) + addFormRow(label: "Indicator Position", view: indicatorPositionPickerSelectorView, stackView: horizontalOrientationFormStackView) + addFormRow(label: "Overflow", view: overflowPickerSelectorView, stackView: horizontalOrientationFormStackView) + + formStackView.addArrangedSubview(verticalOrientationFormStackView) + formStackView.addArrangedSubview(horizontalOrientationFormStackView) + + disabledSwitch.onChange = { [weak self] sender in + self?.component.disabled = sender.isOn + } + + borderlineSwitch.onChange = { [weak self] sender in + self?.component.borderLine = sender.isOn + } + + fillContainerSwitch.onChange = { [weak self] sender in + self?.component.fillContainer = sender.isOn + } + + sampleSwitch.onChange = { [weak self] sender in + guard let self else { return } + self.component.selectedIndex = 0 + self.component.tabModels = sender.isOn ? self.getAllTabs() : self.getSomeTabs() + } + + widthValueTextField.textPublisher.sink { [weak self] text in + if let value = Double(text) { + self?.component.width = .value(value) + self?.widthPercentageTextField.text = "" + + } + }.store(in: &subscribers) + + widthPercentageTextField.textPublisher.sink { [weak self] text in + if let value = Double(text) { + self?.component.width = .percentage(value) + self?.widthValueTextField.text = "" + } + }.store(in: &subscribers) + + } + + func getTabs(texts:[String]) -> [TabsContainer.TabModel] { + texts.compactMap { + let label = Label() + label.text = "This is an example of the \($0) Tab. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + return TabsContainer.TabModel(model: .init(text: $0), view: label) + } + } + + func getAllTabs() -> [TabsContainer.TabModel] { + getTabs(texts: ["Accessories", "Internet and TV", "Customer Service", "Contact Us"]) + } + + func getSomeTabs() -> [TabsContainer.TabModel] { + getTabs(texts: ["Accessories", "Internet and TV"]) + } + + func setupModel() { + //set to the large sample + component.tabModels = getAllTabs() + + //setup UI + surfacePickerSelectorView.text = component.surface.rawValue + sizePickerSelectorView.text = component.size.rawValue + orientationPickerSelectorView.text = component.orientation.rawValue + indicatorPositionPickerSelectorView.text = component.indicatorPosition.rawValue + overflowPickerSelectorView.text = component.overflow.rawValue + disabledSwitch.isOn = component.disabled + borderlineSwitch.isOn = component.borderLine + fillContainerSwitch.isOn = component.fillContainer + sampleSwitch.isOn = true + updateWidth() + } + + func setupPicker(){ + surfacePickerSelectorView.onPickerDidSelect = { [weak self] item in + self?.component.surface = item + self?.contentTopView.backgroundColor = item.color + } + + sizePickerSelectorView.onPickerDidSelect = { [weak self] item in + self?.component.size = item + } + + orientationPickerSelectorView.onPickerDidSelect = { [weak self] item in + self?.component.orientation = item + self?.verticalOrientationFormStackView.isHidden = item == .horizontal + self?.horizontalOrientationFormStackView.isHidden = item == .vertical + } + + indicatorPositionPickerSelectorView.onPickerDidSelect = { [weak self] item in + self?.component.indicatorPosition = item + } + + overflowPickerSelectorView.onPickerDidSelect = { [weak self] item in + self?.component.overflow = item + } + } + + func updateWidth() { + switch component.width { + case .percentage(let percentage): + widthPercentageTextField.text = "\(percentage)" + case .value(let value): + widthValueTextField.text = "\(value)" + widthPercentageTextField.text = "" + @unknown default: + print("") + } + } + +} + +open class TabsContainer: View { + + //-------------------------------------------------- + // 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() } } + + ///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: Tabs.Width = .percentage(0.25) + + ///Width of all Tabs when orientation is vertical, defaults to 25%. + open var width: Tabs.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).") + } + @unknown default: + print("new value passed.") + } + } + } + + ///Model of the Tabs you are wanting to show. + open var tabModels: [TabModel] = [] { + didSet { + tabMenu.tabModels = tabModels.compactMap{ $0.model } + bottomView.arrangedSubviews.forEach{ $0.removeFromSuperview() } + tabModels.forEach { + var view = $0.view + view.isHidden = true + bottomView.addArrangedSubview($0.view) + } + setNeedsUpdate() + } + } + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var contentViewWidthConstraint: NSLayoutConstraint? + + private var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + $0.spacing = 5 + $0.alignment = .fill + $0.distribution = .fill + } + + private var bottomView = 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(bottomView) + + 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 + + tabMenu.onTabChange = { [weak self] index in + guard let self else { return } + self.tabClicked(index: index) + } + + 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) + @unknown default: break + } + 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 + } + } + + private func tabClicked(index: Int) { + onTabChange?(index) + setSelected(index: index) + } + + private func setSelected(index: Int) { + for (modelIndex, model) in tabModels.enumerated() { + var 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 + } + } +}