// // Tabs.swift // VDS // // Created by Matt Bruce on 5/18/23. // import Foundation import UIKit import VDSColorTokens public struct TabItemModel { public var text: String public var onClick: (() -> Void)? public var width: CGFloat? public init(text: String, onClick: (() -> Void)? = nil, width: CGFloat? = nil) { self.text = text self.onClick = onClick self.width = width } } public class Tabs: View { public enum Orientation { case vertical case horizontal } public enum IndicatorPosition { case top case bottom } public enum Overflow { case scroll case none } public enum Size { case medium case large public var textStyle: TextStyle { if self == .medium { return .boldBodyLarge } else { return .boldTitleSmall } } } public enum Width { case percentage(CGFloat) case value(CGFloat) } public var orientation: Orientation = .horizontal { didSet { setNeedsLayout() } } public var borderLine: Bool = true { didSet { setNeedsLayout() } } public var fillContainer: Bool = true { didSet { setNeedsLayout() } } public var indicatorFillTab: Bool = false { didSet { setNeedsLayout() } } public var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsLayout() } } public var minWidth: CGFloat = 44.0 { didSet { setNeedsLayout() } } public var onTabChange: ((Int) -> Void)? public var overflow: Overflow = .none { didSet { setNeedsLayout() } } public var selectedIndex: Int = 0 { didSet { setNeedsLayout() } } public var size: Size = .medium { didSet { setNeedsLayout() } } public var sticky: Bool = false { didSet { setNeedsLayout() } } public var width: Width = .percentage(0.25) { didSet { setNeedsLayout() } } private var tabItems: [TabItem] = [] private var tabStackView: UIStackView! private var scrollView: UIScrollView! private var contentView: View! private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark) public override init(frame: CGRect) { super.init(frame: frame) commonInit() } public convenience required init() { self.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { 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 } public func updateTabItems(with models: [TabItemModel]) { // Clear existing tab items for tabItem in tabItems { tabItem.removeFromSuperview() } tabItems.removeAll() // Create new tab items from the models for model in models { let tabItem = TabItem() tabItem.text = model.text tabItem.onClick = model.onClick tabItem.width = model.width tabItem.textStyle = size.textStyle tabItems.append(tabItem) tabStackView.addArrangedSubview(tabItem) // Add tap gesture recognizer to handle tab selection let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tabItemTapped(_:))) tabItem.isUserInteractionEnabled = true tabItem.addGestureRecognizer(tapGesture) } setNeedsLayout() } @objc private func tabItemTapped(_ gesture: UITapGestureRecognizer) { guard let tabItem = gesture.view as? TabItem else { return } if let selectedIndex = tabItems.firstIndex(of: tabItem) { self.selectedIndex = selectedIndex onTabChange?(selectedIndex) } } public override func layoutSubviews() { super.layoutSubviews() // Update tab appearance based on properties for (index, tabItem) in tabItems.enumerated() { if selectedIndex == index { // Apply selected style to the current tab if orientation == .vertical { tabItem.indicatorPosition = .left } else { if indicatorPosition == .top { tabItem.indicatorPosition = .top } else { tabItem.indicatorPosition = .bottom } } tabItem.selected = true } else { // Apply default style to other tabs tabItem.indicatorPosition = nil tabItem.selected = false } if orientation == .horizontal { tabItem.textPosition = .center } else { tabItem.textPosition = .left } tabItem.surface = surface } // 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: bounds.width - 1, y: 0, width: 1, height: bounds.height) } layer.addSublayer(borderLineLayer) } // Apply fill container tabStackView.alignment = fillContainer && orientation == .horizontal ? .fill : .leading // Apply indicator fill tab if orientation == .vertical { if indicatorFillTab { tabItems.forEach { $0.label.textAlignment = .left } } else { tabItems.forEach { $0.label.textAlignment = .center } } } // Apply indicator position if orientation == .horizontal { if indicatorPosition == .top { tabStackView.alignment = .top } else if indicatorPosition == .bottom { tabStackView.alignment = .bottom } } // Apply sticky if sticky && orientation == .vertical { scrollView.pinTop() } else { scrollView.pinTop(layoutMargins.top) } // Apply width if orientation == .vertical { switch width { case .percentage(let amount): contentView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: amount).isActive = true case .value(let amount): contentView.widthAnchor.constraint(equalToConstant: amount).isActive = true } } // Apply overflow if orientation == .horizontal && overflow == .scroll { let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width contentView.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true } else { contentView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true } // Enable scrolling if necessary let contentWidth = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height) } } public class TabItem: View { public var label: Label = Label() public var onClick: (() -> Void)? { didSet { setNeedsUpdate() } } public var width: CGFloat? { didSet { setNeedsUpdate() } } public var selected: Bool = false { didSet { setNeedsUpdate() } } public var text: String? { didSet { setNeedsUpdate() } } public var textStyle: TextStyle = .bodyMedium public var textPosition: TextPosition = .center { didSet { setNeedsUpdate() } } public var indicatorPosition: UIRectEdge? { didSet { setNeedsUpdate() } } private var labelMinWidthConstraint: NSLayoutConstraint? private var labelWidthConstraint: NSLayoutConstraint? private var textColorConfiguration: SurfaceColorConfiguration { selected ? textColorSelectedConfiguration : textColorNonSelectedConfiguration } private var textColorNonSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight , VDSColor.elementsSecondaryOnlight) private var textColorSelectedConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) public override init(frame: CGRect) { super.init(frame: frame) addSubview(label) backgroundColor = .clear label.backgroundColor = .clear label.translatesAutoresizingMaskIntoConstraints = false label .pinTop(5) .pinLeading(5) .pinTrailing(5) .pinBottom(6) labelMinWidthConstraint = label.widthAnchor.constraint(greaterThanOrEqualToConstant: 44.0) labelMinWidthConstraint?.isActive = true labelWidthConstraint = label.widthAnchor.constraint(equalToConstant: 44.0) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tabItemTapped)) addGestureRecognizer(tapGesture) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required public convenience init() { self.init(frame: .zero) } @objc private func tabItemTapped() { onClick?() } public override func updateView() { label.textPosition = textPosition label.text = text label.textStyle = textStyle label.textColor = textColorConfiguration.getColor(self) if let width { labelMinWidthConstraint?.isActive = false labelWidthConstraint?.constant = width labelWidthConstraint?.isActive = true } else { labelWidthConstraint?.isActive = false labelMinWidthConstraint?.isActive = true } if let indicatorPosition { addBorder(side: indicatorPosition, width: 4.0, color: .red) } else { removeBorders() } } }