// // Tabs.swift // VDS // // Created by Matt Bruce on 5/18/23. // import Foundation import UIKit import VDSCoreTokens /// Tabs are organizational components that group content and allow customers to navigate its display. Use them to separate content when the content is related but doesn’t need to be compared. @objcMembers @objc(VDSTabs) open class Tabs: View, ParentViewProtocol { //-------------------------------------------------- // 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 layout axis of the tabs public enum Orientation: String, CaseIterable{ case vertical case horizontal } /// Enum used to describe the position for the Hover Border Accent for a Tab. public enum IndicatorPosition: String, CaseIterable { case top case bottom var value: UIRectEdge { if self == .top { return .top } else { return .bottom } } } /// Enum used to describe the type of behavior for Scrolling public enum Overflow: String, CaseIterable { case scroll case none } /// Enum used to describe the available size for tabs public enum Size: String, CaseIterable { case medium case large } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private var tabStackView = UIStackView() private var scrollView = UIScrollView() private var contentView = View() private var borderlineView = View() private let borderlineSize = 1.0 private var borderlineColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark) private var borderlineViewLeadingConstraint: NSLayoutConstraint? private var borderlineViewTrailingConstraint: NSLayoutConstraint? private var borderlineViewTopConstraint: NSLayoutConstraint? private var borderlineViewBottomConstraint: NSLayoutConstraint? private var borderlineViewHeightConstraint: NSLayoutConstraint? private var borderlineViewWidthConstraint: NSLayoutConstraint? private var contentViewWidthConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- open var children: [any ViewProtocol] { tabViews } /// A callback when the selectedIndex changes. Passes parameters (tabIndex). open var onTabDidSelect: ((Int) -> Void)? /// A callback when the Tab determine if a item should be selected. open var onTabShouldSelect:((Int) -> Bool)? /// Determines the layout of the Tabs, defaults to horizontal open var orientation: Orientation = .horizontal { didSet { if oldValue != orientation { 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: 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 { 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: 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() } } /// Array of ``TabModel`` you are wanting to show. open var tabModels: [TabModel] = [] { didSet { updateTabItems() } } /// Array of ``Tab`` views for the Tabs. open var tabViews: [Tab] = [] //-------------------------------------------------- // MARK: - Configuration Properties //-------------------------------------------------- private var stackViewAxis: NSLayoutConstraint.Axis { orientation == .horizontal ? .horizontal : .vertical } private var stackViewAlignment: UIStackView.Alignment { orientation == .horizontal ? .fill : .leading } private var stackViewDistribution: UIStackView.Distribution{ if orientation == .horizontal && fillContainer { return .fillEqually } else { return .fill //orientation == .horizontal ? .fillProportionally : .fill } } private var stackViewSpacing: CGFloat { switch orientation { case .vertical: return size == .medium ? VDSLayout.space4X : VDSLayout.space6X case .horizontal: return size == .medium ? VDSLayout.space6X : VDSLayout.space8X } } private var scrollIsEnabled: Bool { orientation == .horizontal && overflow == .scroll } private var textAlignment: TextAlignment { orientation == .horizontal && fillContainer ? .center : .left } private var applyOverflow: Bool { orientation == .horizontal && overflow == .scroll && !fillContainer } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// 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() isAccessibilityElement = false 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(borderlineView) contentView.addSubview(tabStackView) scrollView.pinToSuperView() contentView.pinToSuperView() tabStackView.pinToSuperView() contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true borderlineViewWidthConstraint = borderlineView.width(constant: 0) borderlineViewHeightConstraint = borderlineView.height(constant: 0) borderlineViewLeadingConstraint = borderlineView.pinLeading(anchor: contentView.leadingAnchor) borderlineViewTrailingConstraint = borderlineView.pinTrailing(anchor: contentView.trailingAnchor) borderlineViewTopConstraint = borderlineView.pinTop(anchor: contentView.topAnchor) borderlineViewBottomConstraint = borderlineView.pinBottom(anchor: contentView.bottomAnchor) } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() updateStackView() updateTabs() updateContentView() updateBorderline() } open override func layoutSubviews() { super.layoutSubviews() updateContentView() } open override func setDefaults() { super.setDefaults() onTabDidSelect = nil onTabShouldSelect = nil orientation = .horizontal borderLine = true fillContainer = false indicatorFillTab = false indicatorPosition = .bottom minWidth = 44.0 overflow = .scroll selectedIndex = 0 size = .medium sticky = false tabModels = [] } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- /// Removes all of the Tab Views and creates new ones from the Tab Models property. private func updateTabItems() { // Clear existing tab items for tabItem in tabViews { tabItem.removeFromSuperview() } tabViews.removeAll() // Create new tab items from the models for model in tabModels { let tabItem = Tab() tabItem.size = size tabItem.text = model.text tabItem.width = model.width tabViews.append(tabItem) tabStackView.addArrangedSubview(tabItem) tabItem.onClick = { [weak self] tab in guard let self else { return } if self.onTabShouldSelect?(tab.index) ?? true { model.onClick?(tab.index) self.selectedIndex = tab.index self.onTabDidSelect?(tab.index) } } } accessibilityElements = tabViews setNeedsUpdate() scrollToSelectedIndex(animated: false) } /// Scrolls to the selected Tab by the selectedIndex. /// - Parameter animated: If there is animation of the scrolling to the selectedIndex. private func scrollToSelectedIndex(animated: Bool) { if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabViews.count { let selectedTab = tabViews[selectedIndex] scrollView.scrollRectToVisible(selectedTab.frame, animated: animated) } } /// Updates the StackView from local properties. private func updateStackView() { tabStackView.distribution = stackViewDistribution tabStackView.axis = stackViewAxis tabStackView.alignment = stackViewAlignment tabStackView.spacing = stackViewSpacing } /// Updates the Tab individual views from local properties. private func updateTabs() { let numberOfLines = applyOverflow ? 1 : 0 for (index, tabItem) in tabViews.enumerated() { tabItem.numberOfLines = numberOfLines tabItem.size = size tabItem.isSelected = selectedIndex == index tabItem.index = index tabItem.minWidth = minWidth tabItem.textAlignment = textAlignment tabItem.orientation = orientation tabItem.surface = surface tabItem.indicatorPosition = indicatorPosition tabItem.bridge_accessibilityValueBlock = { [weak self] in guard let self else { return "" } return "\(index+1) of \(tabViews.count) Tabs" } } } // Update the ContentView and ScrollView ContentSize from local properties. private func updateContentView() { // Deactivate old constraint contentViewWidthConstraint?.isActive = false // Apply overflow if applyOverflow { contentViewWidthConstraint = nil scrollView.contentSize = CGSize(width: tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width, height: scrollView.bounds.height) } else { contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) scrollView.contentSize = bounds.size } scrollView.isScrollEnabled = scrollIsEnabled // Activate old constraint contentViewWidthConstraint?.isActive = true scrollToSelectedIndex(animated: true) } //update layout for borderline private func updateBorderline() { //borderLine if borderLine { var edge: UIRectEdge = .bottom if orientation == .vertical { edge = .left } else if indicatorPosition == .top { edge = .top } borderlineViewLeadingConstraint?.isActive = false borderlineViewTrailingConstraint?.isActive = false borderlineViewTopConstraint?.isActive = false borderlineViewBottomConstraint?.isActive = false borderlineViewHeightConstraint?.isActive = false borderlineViewWidthConstraint?.isActive = false if edge == .left { borderlineViewWidthConstraint?.constant = borderlineSize borderlineViewWidthConstraint?.isActive = true borderlineViewTopConstraint?.isActive = true borderlineViewLeadingConstraint?.isActive = true borderlineViewBottomConstraint?.isActive = true } else if edge == .top { borderlineViewHeightConstraint?.constant = borderlineSize borderlineViewHeightConstraint?.isActive = true borderlineViewTopConstraint?.isActive = true borderlineViewLeadingConstraint?.isActive = true borderlineViewTrailingConstraint?.isActive = true } else { borderlineViewHeightConstraint?.constant = borderlineSize borderlineViewHeightConstraint?.isActive = true borderlineViewLeadingConstraint?.isActive = true borderlineViewTrailingConstraint?.isActive = true borderlineViewBottomConstraint?.isActive = true } borderlineView.backgroundColor = borderlineColorConfiguration.getColor(self) borderlineView.isHidden = false } else { borderlineView.isHidden = true } } }