334 lines
13 KiB
Swift
334 lines
13 KiB
Swift
//
|
||
// Tabs.swift
|
||
// VDS
|
||
//
|
||
// Created by Matt Bruce on 5/18/23.
|
||
//
|
||
|
||
import Foundation
|
||
import UIKit
|
||
import VDSColorTokens
|
||
|
||
/// 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.
|
||
@objc(VDSTabs)
|
||
open class Tabs: View {
|
||
|
||
//--------------------------------------------------
|
||
// MARK: - Enums
|
||
//--------------------------------------------------
|
||
|
||
/// Layout Axis of the Tabs
|
||
public enum Orientation: String, CaseIterable{
|
||
case vertical
|
||
case horizontal
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
|
||
//Type of behavior for Scrolling
|
||
public enum Overflow: String, CaseIterable {
|
||
case scroll
|
||
case none
|
||
}
|
||
|
||
/// Available size for tabs
|
||
public enum Size: String, CaseIterable {
|
||
case medium
|
||
case large
|
||
}
|
||
|
||
//--------------------------------------------------
|
||
// 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 tabViews: [Tab] = []
|
||
|
||
//--------------------------------------------------
|
||
// 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: - 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 orientation == .horizontal ? .fillProportionally : .fill
|
||
}
|
||
}
|
||
|
||
private var stackViewSpacing: CGFloat {
|
||
switch orientation {
|
||
case .vertical:
|
||
return size == .medium ? VDSLayout.Spacing.space4X.value : VDSLayout.Spacing.space6X.value
|
||
case .horizontal:
|
||
return size == .medium ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space8X.value
|
||
}
|
||
}
|
||
|
||
private var scrollIsEnabled: Bool {
|
||
orientation == .horizontal && overflow == .scroll
|
||
}
|
||
|
||
private var textPosition: TextPosition {
|
||
orientation == .horizontal && fillContainer ? .center : .left
|
||
}
|
||
|
||
//--------------------------------------------------
|
||
// 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(borderlineView)
|
||
contentView.addSubview(tabStackView)
|
||
|
||
scrollView.pinToSuperView()
|
||
contentView.pinToSuperView()
|
||
tabStackView.pinToSuperView()
|
||
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
|
||
|
||
borderlineViewWidthConstraint = borderlineView.widthAnchor.constraint(equalToConstant: 0)
|
||
borderlineViewHeightConstraint = borderlineView.heightAnchor.constraint(equalToConstant: 0)
|
||
|
||
borderlineViewLeadingConstraint = borderlineView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
|
||
borderlineViewTrailingConstraint = borderlineView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||
borderlineViewTopConstraint = borderlineView.topAnchor.constraint(equalTo: contentView.topAnchor)
|
||
borderlineViewBottomConstraint = borderlineView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||
|
||
}
|
||
|
||
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.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 }
|
||
model.onClick?(tab.index)
|
||
self.selectedIndex = tab.index
|
||
self.onTabChange?(tab.index)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// Function used to make changes to the View based off a change events or from local properties.
|
||
open override func updateView() {
|
||
super.updateView()
|
||
|
||
// Update the stackview properties
|
||
tabStackView.distribution = stackViewDistribution
|
||
tabStackView.axis = stackViewAxis
|
||
tabStackView.alignment = stackViewAlignment
|
||
tabStackView.spacing = stackViewSpacing
|
||
|
||
// Update tab appearance based on properties
|
||
for (index, tabItem) in tabViews.enumerated() {
|
||
tabItem.size = size
|
||
tabItem.isSelected = selectedIndex == index
|
||
tabItem.index = index
|
||
tabItem.minWidth = minWidth
|
||
tabItem.textPosition = textPosition
|
||
tabItem.orientation = orientation
|
||
tabItem.surface = surface
|
||
tabItem.indicatorPosition = indicatorPosition
|
||
tabItem.accessibilityValue = "\(index+1) of \(tabViews.count) Tabs"
|
||
}
|
||
|
||
//update the width based on rules
|
||
updateContentView()
|
||
|
||
setNeedsLayout()
|
||
layoutIfNeeded()
|
||
}
|
||
|
||
private func updateContentView() {
|
||
// Deactivate old constraint
|
||
contentViewWidthConstraint?.isActive = false
|
||
|
||
// Apply overflow
|
||
if orientation == .horizontal && overflow == .scroll && !fillContainer {
|
||
let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width
|
||
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: contentWidth)
|
||
scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height)
|
||
} else {
|
||
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor)
|
||
scrollView.contentSize = bounds.size
|
||
}
|
||
|
||
scrollView.isScrollEnabled = scrollIsEnabled
|
||
|
||
// Activate old constraint
|
||
contentViewWidthConstraint?.isActive = true
|
||
|
||
scrollToSelectedIndex(animated: true)
|
||
}
|
||
|
||
open override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
|
||
//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
|
||
}
|
||
}
|
||
}
|