// // Tabs.swift // VDS // // Created by Matt Bruce on 5/18/23. // import Foundation import UIKit import VDSColorTokens public struct TabModel { 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 onTabChange: ((Int) -> Void)? public var orientation: Orientation = .vertical { didSet { setNeedsUpdate() } } public var borderLine: Bool = true { didSet { setNeedsUpdate() } } public var fillContainer: Bool = false { didSet { setNeedsUpdate() } } public var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } } public var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } } public var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } } public var overflow: Overflow = .scroll { didSet { setNeedsUpdate() } } public var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } } public var size: Size = .medium { didSet { setNeedsUpdate() } } public var sticky: Bool = false { didSet { setNeedsUpdate() } } public var width: Width = .percentage(0.25) { didSet { setNeedsUpdate() } } public var tabModels: [TabModel] = [] { didSet { updateTabItems(with: tabModels) } } 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) } public convenience required init() { self.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } 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(with models: [TabModel]) { // 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 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) } setNeedsUpdate() } @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 updateView() { super.updateView() if fillContainer { tabStackView.distribution = .fillEqually } else { tabStackView.distribution = .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 setNeedsLayout() } public override func layoutSubviews() { super.layoutSubviews() // Apply border line layer.remove(layerName: "borderLineLayer") // Update tab appearance based on properties for (index, tabItem) in tabItems.enumerated() { if selectedIndex == index { tabItem.selected = true } else { tabItem.selected = false } tabItem.size = size tabItem.orientation = orientation tabItem.surface = surface } 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 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 } } else { // 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) //addDebugBorder(color: .blue) } } public class TabItem: View { public var orientation: Tabs.Orientation = .horizontal { didSet { setNeedsUpdate() } } public var size: Tabs.Size = .medium { didSet { setNeedsUpdate() } } 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() } } private var labelMinWidthConstraint: NSLayoutConstraint? private var labelWidthConstraint: NSLayoutConstraint? private var labelLeadingConstraint: NSLayoutConstraint? 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 public override init(frame: CGRect) { super.init(frame: frame) addSubview(label) backgroundColor = .clear label.backgroundColor = .clear label.translatesAutoresizingMaskIntoConstraints = false label .pinTop(5) .pinTrailing(5) .pinBottom(6) labelLeadingConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0) labelLeadingConstraint?.isActive = true 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() { if orientation == .horizontal { label.textPosition = .center } else { label.textPosition = .left } label.text = text label.textStyle = size.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 selected { var indicatorPosition: UIRectEdge = .top if orientation == .vertical { indicatorPosition = .left } else { if indicatorPosition == .top { indicatorPosition = .top } else { indicatorPosition = .bottom } } addBorder(side: indicatorPosition, width: indicatorWidth, color: indicatorColorConfiguration.getColor(self)) } else { removeBorders() } setNeedsDisplay() } // public override func draw(_ rect: CGRect) { // super.draw(rect) // // addDebugBorder(color: .green) // } }