updated tabs to work better
Signed-off-by: Matt Bruce <matt.bruce@verizon.com>
This commit is contained in:
parent
aba5490a10
commit
c75e5670df
@ -9,19 +9,11 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
import VDSColorTokens
|
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 class Tabs: View {
|
||||||
|
|
||||||
|
//--------------------------------------------------
|
||||||
|
// MARK: - Enums
|
||||||
|
//--------------------------------------------------
|
||||||
public enum Orientation: String, CaseIterable{
|
public enum Orientation: String, CaseIterable{
|
||||||
case vertical
|
case vertical
|
||||||
case horizontal
|
case horizontal
|
||||||
@ -63,26 +55,61 @@ public class Tabs: View {
|
|||||||
case value(CGFloat)
|
case value(CGFloat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//--------------------------------------------------
|
||||||
|
// MARK: - Public Properties
|
||||||
|
//--------------------------------------------------
|
||||||
public var onTabChange: ((Int) -> Void)?
|
public var onTabChange: ((Int) -> Void)?
|
||||||
public var orientation: Orientation = .horizontal { didSet { setNeedsUpdate() } }
|
public var orientation: Orientation = .horizontal { didSet { updateTabItems() } }
|
||||||
public var borderLine: Bool = true { didSet { setNeedsUpdate() } }
|
public var borderLine: Bool = true { didSet { setNeedsUpdate() } }
|
||||||
public var fillContainer: Bool = false { didSet { setNeedsUpdate() } }
|
public var fillContainer: Bool = false { didSet { updateTabItems() } }
|
||||||
public var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } }
|
public var indicatorFillTab: Bool = false { didSet { setNeedsUpdate() } }
|
||||||
public var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } }
|
public var indicatorPosition: IndicatorPosition = .bottom { didSet { setNeedsUpdate() } }
|
||||||
public var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } }
|
public var minWidth: CGFloat = 44.0 { didSet { setNeedsUpdate() } }
|
||||||
public var overflow: Overflow = .scroll { didSet { setNeedsUpdate() } }
|
public var overflow: Overflow = .scroll { didSet { updateTabItems() } }
|
||||||
public var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } }
|
public var selectedIndex: Int = 0 { didSet { setNeedsUpdate() } }
|
||||||
public var size: Size = .medium { didSet { setNeedsUpdate() } }
|
public var size: Size = .medium { didSet { setNeedsUpdate() } }
|
||||||
public var sticky: Bool = false { didSet { setNeedsUpdate() } }
|
public var sticky: Bool = false { didSet { setNeedsUpdate() } }
|
||||||
public var width: Width = .percentage(0.25) { didSet { setNeedsUpdate() } }
|
public var tabModels: [TabModel] = [] { didSet { updateTabItems() } }
|
||||||
public var tabModels: [TabModel] = [] { didSet { updateTabItems(with: tabModels) } }
|
|
||||||
|
|
||||||
|
//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 tabItems: [TabItem] = []
|
||||||
private var tabStackView: UIStackView!
|
private var tabStackView: UIStackView!
|
||||||
private var scrollView: UIScrollView!
|
private var scrollView: UIScrollView!
|
||||||
private var contentView: View!
|
private var contentView: View!
|
||||||
private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark)
|
private var borderlineColorConfig = SurfaceColorConfiguration(VDSColor.elementsLowcontrastOnlight, VDSColor.elementsLowcontrastOndark)
|
||||||
|
private var contentViewWidthConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
//--------------------------------------------------
|
||||||
|
// MARK: - Initializers
|
||||||
|
//--------------------------------------------------
|
||||||
public override init(frame: CGRect) {
|
public override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
}
|
}
|
||||||
@ -95,6 +122,9 @@ public class Tabs: View {
|
|||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//--------------------------------------------------
|
||||||
|
// MARK: - Overrides
|
||||||
|
//--------------------------------------------------
|
||||||
open override func setup() {
|
open override func setup() {
|
||||||
super.setup()
|
super.setup()
|
||||||
scrollView = UIScrollView()
|
scrollView = UIScrollView()
|
||||||
@ -116,10 +146,13 @@ public class Tabs: View {
|
|||||||
scrollView.pinToSuperView()
|
scrollView.pinToSuperView()
|
||||||
contentView.pinToSuperView()
|
contentView.pinToSuperView()
|
||||||
tabStackView.pinToSuperView()
|
tabStackView.pinToSuperView()
|
||||||
|
|
||||||
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
|
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateTabItems() {
|
||||||
|
updateTabItems(with: tabModels)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateTabItems(with models: [TabModel]) {
|
private func updateTabItems(with models: [TabModel]) {
|
||||||
// Clear existing tab items
|
// Clear existing tab items
|
||||||
for tabItem in tabItems {
|
for tabItem in tabItems {
|
||||||
@ -136,56 +169,95 @@ public class Tabs: View {
|
|||||||
tabItems.append(tabItem)
|
tabItems.append(tabItem)
|
||||||
tabStackView.addArrangedSubview(tabItem)
|
tabStackView.addArrangedSubview(tabItem)
|
||||||
|
|
||||||
// Add tap gesture recognizer to handle tab selection
|
tabItem
|
||||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tabItemTapped(_:)))
|
.publisher(for: UITapGestureRecognizer())
|
||||||
tabItem.isUserInteractionEnabled = true
|
.sink { [weak self] gesture in
|
||||||
tabItem.addGestureRecognizer(tapGesture)
|
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()
|
setNeedsUpdate()
|
||||||
|
scrollToSelectedIndex(animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func tabItemTapped(_ gesture: UITapGestureRecognizer) {
|
private func scrollToSelectedIndex(animated: Bool) {
|
||||||
guard let tabItem = gesture.view as? TabItem else { return }
|
if orientation == .horizontal && self.overflow == .scroll, selectedIndex < tabItems.count {
|
||||||
|
let selectedTab = tabItems[selectedIndex]
|
||||||
if let selectedIndex = tabItems.firstIndex(of: tabItem) {
|
scrollView.scrollRectToVisible(selectedTab.frame, animated: animated)
|
||||||
self.selectedIndex = selectedIndex
|
|
||||||
onTabChange?(selectedIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func updateView() {
|
open override func updateView() {
|
||||||
super.updateView()
|
super.updateView()
|
||||||
if fillContainer {
|
if orientation == .horizontal && fillContainer {
|
||||||
tabStackView.distribution = .fillEqually
|
tabStackView.distribution = .fillEqually
|
||||||
} else {
|
} else {
|
||||||
tabStackView.distribution = .fill
|
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
|
// Update tab appearance based on properties
|
||||||
for (index, tabItem) in tabItems.enumerated() {
|
for (index, tabItem) in tabItems.enumerated() {
|
||||||
if selectedIndex == index {
|
tabItem.selected = selectedIndex == index
|
||||||
tabItem.selected = true
|
|
||||||
} else {
|
|
||||||
tabItem.selected = false
|
|
||||||
}
|
|
||||||
tabItem.size = size
|
tabItem.size = size
|
||||||
tabItem.orientation = orientation
|
tabItem.orientation = orientation
|
||||||
tabItem.surface = surface
|
tabItem.surface = surface
|
||||||
tabItem.indicatorPosition = indicatorPosition
|
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 {
|
if borderLine {
|
||||||
let borderLineLayer = CALayer()
|
let borderLineLayer = CALayer()
|
||||||
borderLineLayer.name = "borderLineLayer"
|
borderLineLayer.name = "borderLineLayer"
|
||||||
@ -200,149 +272,23 @@ public class Tabs: View {
|
|||||||
layer.addSublayer(borderLineLayer)
|
layer.addSublayer(borderLineLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply width
|
scrollToSelectedIndex(animated: true)
|
||||||
if orientation == .vertical {
|
}
|
||||||
switch width {
|
|
||||||
case .percentage(let amount):
|
//--------------------------------------------------
|
||||||
contentView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: amount).isActive = true
|
// MARK: - Private Methods
|
||||||
case .value(let amount):
|
//--------------------------------------------------
|
||||||
contentView.widthAnchor.constraint(equalToConstant: amount).isActive = true
|
private func tabWidth(for item: TabItem) -> CGFloat? {
|
||||||
}
|
guard orientation == .vertical else { return item.width }
|
||||||
} else {
|
var calculated: CGFloat
|
||||||
// Apply overflow
|
switch width {
|
||||||
if orientation == .horizontal && overflow == .scroll {
|
case .percentage(let percent):
|
||||||
let contentWidth = tabStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width
|
calculated = (bounds.width * percent) - tabStackView.spacing
|
||||||
contentView.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true
|
case .value(let value):
|
||||||
} else {
|
calculated = value - tabStackView.spacing
|
||||||
contentView.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable scrolling if necessary
|
return calculated > minWidth ? calculated : minWidth
|
||||||
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 indicatorPosition: Tabs.IndicatorPosition = .bottom { 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 labelTopConstraint: NSLayoutConstraint?
|
|
||||||
private var labelBottomConstraint: 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.pinTrailing()
|
|
||||||
|
|
||||||
labelTopConstraint = label.topAnchor.constraint(equalTo: topAnchor, constant: 0)
|
|
||||||
labelTopConstraint?.isActive = true
|
|
||||||
|
|
||||||
labelBottomConstraint = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0)
|
|
||||||
labelBottomConstraint?.isActive = true
|
|
||||||
|
|
||||||
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)
|
|
||||||
setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func setNeedsLayout() {
|
|
||||||
super.setNeedsLayout()
|
|
||||||
|
|
||||||
if let width {
|
|
||||||
labelMinWidthConstraint?.isActive = false
|
|
||||||
labelWidthConstraint?.constant = width
|
|
||||||
labelWidthConstraint?.isActive = true
|
|
||||||
} else {
|
|
||||||
labelWidthConstraint?.isActive = false
|
|
||||||
labelMinWidthConstraint?.isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var leadingSpace: CGFloat
|
|
||||||
if orientation == .horizontal {
|
|
||||||
leadingSpace = 0
|
|
||||||
} else {
|
|
||||||
leadingSpace = size == .medium ? VDSLayout.Spacing.space4X.value : VDSLayout.Spacing.space6X.value
|
|
||||||
}
|
|
||||||
labelLeadingConstraint?.constant = leadingSpace
|
|
||||||
|
|
||||||
var otherSpace: CGFloat
|
|
||||||
if orientation == .horizontal {
|
|
||||||
otherSpace = size == .medium ? VDSLayout.Spacing.space3X.value : VDSLayout.Spacing.space4X.value
|
|
||||||
} else {
|
|
||||||
otherSpace = VDSLayout.Spacing.space2X.value
|
|
||||||
}
|
|
||||||
labelTopConstraint?.constant = otherSpace
|
|
||||||
labelBottomConstraint?.constant = -otherSpace
|
|
||||||
|
|
||||||
if selected {
|
|
||||||
var indicator: UIRectEdge = .left
|
|
||||||
if orientation == .horizontal {
|
|
||||||
indicator = indicatorPosition.value
|
|
||||||
}
|
|
||||||
addBorder(side: indicator, width: indicatorWidth, color: indicatorColorConfiguration.getColor(self))
|
|
||||||
} else {
|
|
||||||
removeBorders()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override func draw(_ rect: CGRect) {
|
|
||||||
super.draw(rect)
|
|
||||||
|
|
||||||
addDebugBorder(color: .green)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user