vds_ios/VDS/Components/Tabs/Tabs.swift
Matt Bruce c75e5670df updated tabs to work better
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
2023-05-22 16:06:45 -05:00

295 lines
10 KiB
Swift

//
// Tabs.swift
// VDS
//
// Created by Matt Bruce on 5/18/23.
//
import Foundation
import UIKit
import VDSColorTokens
public class Tabs: View {
//--------------------------------------------------
// MARK: - Enums
//--------------------------------------------------
public enum Orientation: String, CaseIterable{
case vertical
case horizontal
}
public enum IndicatorPosition: String, CaseIterable {
case top
case bottom
var value: UIRectEdge {
if self == .top {
return .top
} else {
return .bottom
}
}
}
public enum Overflow: String, CaseIterable {
case scroll
case none
}
public enum Size: String, CaseIterable {
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)
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
public var onTabChange: ((Int) -> Void)?
public var orientation: Orientation = .horizontal { didSet { updateTabItems() } }
public var borderLine: Bool = true { didSet { setNeedsUpdate() } }
public var fillContainer: Bool = false { didSet { updateTabItems() } }
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 { updateTabItems() } }
public var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } }
public var size: Size = .medium { didSet { setNeedsUpdate() } }
public var sticky: Bool = false { didSet { setNeedsUpdate() } }
public var tabModels: [TabModel] = [] { didSet { updateTabItems() } }
//rules for width
private var _width: Width = .percentage(0.25)
public var width: 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).")
}
}
}
}
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
private var tabItems: [TabItem] = []
private var tabStackView: UIStackView!
private var scrollView: UIScrollView!
private var contentView: View!
private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark)
private var contentViewWidthConstraint: NSLayoutConstraint?
//--------------------------------------------------
// 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(tabStackView)
scrollView.pinToSuperView()
contentView.pinToSuperView()
tabStackView.pinToSuperView()
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
}
private func updateTabItems() {
updateTabItems(with: tabModels)
}
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)
tabItem
.publisher(for: UITapGestureRecognizer())
.sink { [weak self] gesture in
guard let self, let tabItem = gesture.view as? TabItem else { return }
if let selectedIndex = tabItems.firstIndex(of: tabItem) {
self.selectedIndex = selectedIndex
self.onTabChange?(selectedIndex)
}
}.store(in: &tabItem.subscribers)
}
setNeedsUpdate()
scrollToSelectedIndex(animated: false)
}
private func scrollToSelectedIndex(animated: Bool) {
if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabItems.count {
let selectedTab = tabItems[selectedIndex]
scrollView.scrollRectToVisible(selectedTab.frame, animated: animated)
}
}
open override func updateView() {
super.updateView()
if orientation == .horizontal && fillContainer {
tabStackView.distribution = .fillEqually
} else {
tabStackView.distribution = .fill
}
// Update tab appearance based on properties
for (index, tabItem) in tabItems.enumerated() {
tabItem.selected = selectedIndex == index
tabItem.size = size
tabItem.orientation = orientation
tabItem.surface = surface
tabItem.indicatorPosition = indicatorPosition
tabItem.width = tabWidth(for: tabItem)
}
tabStackView.axis = orientation == .horizontal ? .horizontal : .vertical
tabStackView.alignment = orientation == .horizontal ? .fill : .leading
tabStackView.spacing = orientation == .horizontal ? VDSLayout.Spacing.space6X.value : VDSLayout.Spacing.space4X.value
// Deactivate old constraint
contentViewWidthConstraint?.isActive = false
// Apply width
if orientation == .vertical {
scrollView.isScrollEnabled = false
switch width {
case .percentage(let amount):
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: amount)
case .value(let amount):
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: amount)
}
} else {
// Apply overflow
if overflow == .scroll && !fillContainer {
let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalToConstant: contentWidth)
// Enable scrolling if necessary
scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height)
} else {
contentViewWidthConstraint = contentView.widthAnchor.constraint(equalTo: widthAnchor)
}
}
scrollView.isScrollEnabled = orientation == .horizontal && overflow == .scroll
// Activate old constraint
contentViewWidthConstraint?.isActive = true
setNeedsLayout()
layoutIfNeeded()
}
open override func layoutSubviews() {
super.layoutSubviews()
//scrollView Contentsize
if orientation == .horizontal && overflow == .scroll && !fillContainer {
let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width
scrollView.contentSize = CGSize(width: contentWidth, height: scrollView.bounds.height)
} else {
scrollView.contentSize = bounds.size
}
// 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: 0, y: 0, width: 1, height: bounds.height)
}
layer.addSublayer(borderLineLayer)
}
scrollToSelectedIndex(animated: true)
}
//--------------------------------------------------
// MARK: - Private Methods
//--------------------------------------------------
private func tabWidth(for item: TabItem) -> CGFloat? {
guard orientation == .vertical else { return item.width }
var calculated: CGFloat
switch width {
case .percentage(let percent):
calculated = (bounds.width * percent) - tabStackView.spacing
case .value(let value):
calculated = value - tabStackView.spacing
}
return calculated > minWidth ? calculated : minWidth
}
}