284 lines
9.8 KiB
Swift
284 lines
9.8 KiB
Swift
//
|
|
// TabBarView.swift
|
|
// App
|
|
//
|
|
// Created by James Magahern on 10/28/20.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class TabView: UIControl
|
|
{
|
|
var active: Bool = false { didSet { setNeedsLayout() } }
|
|
var collapsed: Bool = false
|
|
var identifier: UUID?
|
|
|
|
let label = UILabel(frame: .zero)
|
|
let closeButton = UIButton(frame: .zero)
|
|
let imageView = UIImageView(image: nil)
|
|
|
|
private let leftSeparator = UIView(frame: .zero)
|
|
private let rightSeparator = UIView(frame: .zero)
|
|
|
|
convenience init() {
|
|
self.init(frame: .zero)
|
|
|
|
addSubview(label)
|
|
label.text = "Tab View"
|
|
label.font = .boldSystemFont(ofSize: 11.0)
|
|
|
|
addSubview(closeButton)
|
|
closeButton.setImage(UIImage(systemName: "xmark.square.fill"), for: .normal)
|
|
closeButton.imageView?.contentMode = .center
|
|
closeButton.tintColor = .label
|
|
|
|
addSubview(leftSeparator)
|
|
addSubview(rightSeparator)
|
|
leftSeparator.backgroundColor = .secondarySystemFill
|
|
rightSeparator.backgroundColor = .secondarySystemFill
|
|
|
|
// Try just one for now
|
|
leftSeparator.isHidden = true
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
let insetBounds = bounds.inset(by: layoutMargins)
|
|
|
|
let closeButtonPadding = CGFloat(5.0)
|
|
let closeButtonSize = CGSize(width: bounds.height, height: bounds.height)
|
|
closeButton.frame = CGRect(
|
|
x: insetBounds.width - closeButtonSize.height, y: 0.0,
|
|
width: closeButtonSize.width, height: closeButtonSize.height
|
|
)
|
|
|
|
label.frame = CGRect(
|
|
x: insetBounds.minX, y: insetBounds.minY,
|
|
width: closeButton.frame.minX - closeButtonPadding, height: insetBounds.height
|
|
)
|
|
|
|
let separatorWidth = CGFloat(1.0)
|
|
leftSeparator.frame = CGRect(
|
|
x: 0.0, y: 0.0,
|
|
width: separatorWidth, height: bounds.height
|
|
)
|
|
|
|
rightSeparator.frame = CGRect(
|
|
x: bounds.width - separatorWidth, y: 0.0,
|
|
width: separatorWidth, height: bounds.height
|
|
)
|
|
|
|
if isTracking {
|
|
backgroundColor = .systemFill
|
|
} else if active {
|
|
backgroundColor = .init(dynamicProvider: { (traitCollection) -> UIColor in
|
|
if traitCollection.userInterfaceStyle == .light {
|
|
return .secondarySystemGroupedBackground
|
|
} else {
|
|
return .secondarySystemFill
|
|
}
|
|
})
|
|
} else {
|
|
backgroundColor = .init(dynamicProvider: { (traitCollection) -> UIColor in
|
|
if traitCollection.userInterfaceStyle == .light {
|
|
return .secondarySystemFill
|
|
} else {
|
|
return .secondarySystemGroupedBackground
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
|
super.beginTracking(touch, with: event)
|
|
setNeedsLayout()
|
|
return true
|
|
}
|
|
|
|
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
|
super.endTracking(touch, with: event)
|
|
setNeedsLayout()
|
|
}
|
|
}
|
|
|
|
protocol TabBarViewDataSource: AnyObject {
|
|
func numberOfTabs(forTabBarView: TabBarView) -> Int
|
|
func tabBarView(_ tabBarView: TabBarView, titleForTabAtIndex: Int) -> String
|
|
func tabBarView(_ tabBarView: TabBarView, imageForTabAtIndex: Int) -> UIImage?
|
|
func tabBarView(_ tabBarView: TabBarView, uniqueIdentifierForTabAtIndex: Int) -> UUID
|
|
}
|
|
|
|
protocol TabBarViewDelegate: AnyObject {
|
|
func tabBarView(_ tabBarView: TabBarView, didClickToActivateTabAtIndex: Int)
|
|
func tabBarView(_ tabBarView: TabBarView, didClickToCloseTabAtIndex: Int)
|
|
}
|
|
|
|
class TabBarView: UIView
|
|
{
|
|
static let preferredHeight: CGFloat = 30.0
|
|
|
|
public var delegate: TabBarViewDelegate?
|
|
public var dataSource: TabBarViewDataSource?
|
|
|
|
private var tabViews: [TabView] = []
|
|
private var activeTabIndex: Int = 0
|
|
private var tabContainerView = UIScrollView(frame: .zero)
|
|
|
|
private let bottomSeparatorView = UIView(frame: .zero)
|
|
|
|
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
CGSize(width: size.width, height: Self.preferredHeight)
|
|
}
|
|
|
|
convenience init() {
|
|
self.init(frame: .zero)
|
|
|
|
addSubview(tabContainerView)
|
|
backgroundColor = .secondarySystemGroupedBackground
|
|
tabContainerView.showsHorizontalScrollIndicator = false
|
|
tabContainerView.showsVerticalScrollIndicator = false
|
|
|
|
addSubview(bottomSeparatorView)
|
|
bottomSeparatorView.backgroundColor = .systemFill
|
|
}
|
|
|
|
public func reloadTabs(animated: Bool = true) {
|
|
guard let dataSource = self.dataSource else { return }
|
|
|
|
var tabViewsRemoved = Set<TabView>(tabViews)
|
|
|
|
let numberOfTabs = dataSource.numberOfTabs(forTabBarView: self)
|
|
for i in 0..<numberOfTabs {
|
|
let identifier = dataSource.tabBarView(self, uniqueIdentifierForTabAtIndex: i)
|
|
if let tabView = tabViewsRemoved.first(where: { $0.identifier == identifier }) {
|
|
tabViewsRemoved.remove(tabView)
|
|
self.reloadTab(atIndex: i)
|
|
} else {
|
|
let newTabView = makeTabView(withIdentifier: identifier)
|
|
if animated { newTabView.collapsed = true }
|
|
tabViews.append(newTabView)
|
|
}
|
|
}
|
|
|
|
layoutSubviews()
|
|
|
|
if animated {
|
|
// Incoming tabs uncollapse
|
|
tabViews.forEach { $0.collapsed = false }
|
|
|
|
// Outgoing tabs collapse
|
|
tabViewsRemoved.forEach { $0.collapsed = true }
|
|
|
|
UIView.animate(withDuration: 0.22, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 1.0, options: [], animations: { [unowned self] in
|
|
layoutSubviews()
|
|
}, completion: { [unowned self] finished in
|
|
tabViewsRemoved.forEach { $0.removeFromSuperview() }
|
|
tabViews.removeAll { tabViewsRemoved.contains($0) }
|
|
})
|
|
}
|
|
|
|
// Adjust scroll offset
|
|
let contentEnd = CGPoint(x: tabContainerView.contentSize.width - tabContainerView.bounds.width, y: 0.0)
|
|
tabContainerView.setContentOffset(contentEnd, animated: animated)
|
|
}
|
|
|
|
public func reloadTab(atIndex index: Int) {
|
|
guard let dataSource = self.dataSource else { return }
|
|
if index > tabViews.count - 1 { return }
|
|
|
|
let title = dataSource.tabBarView(self, titleForTabAtIndex: index)
|
|
let image = dataSource.tabBarView(self, imageForTabAtIndex: index)
|
|
|
|
let tabView = tabViews[index]
|
|
tabView.label.text = title
|
|
tabView.imageView.image = image
|
|
}
|
|
|
|
public func activateTab(atIndex index: Int) {
|
|
self.activeTabIndex = index
|
|
setNeedsLayout()
|
|
}
|
|
|
|
private func makeTabView(withIdentifier identifier: UUID) -> TabView {
|
|
let tabView = TabView()
|
|
tabView.identifier = identifier
|
|
|
|
tabView.addAction(UIAction(handler: { [unowned self, tabView] _ in
|
|
guard let delegate = self.delegate else { return }
|
|
guard let tabIndex = self.tabViews.firstIndex(of: tabView) else { return }
|
|
|
|
delegate.tabBarView(self, didClickToActivateTabAtIndex: tabIndex)
|
|
}), for: .touchUpInside)
|
|
|
|
tabView.closeButton.addAction(UIAction(handler: { [unowned self, tabView] _ in
|
|
guard let delegate = self.delegate else { return }
|
|
guard let tabIndex = self.tabViews.firstIndex(of: tabView) else { return }
|
|
|
|
delegate.tabBarView(self, didClickToCloseTabAtIndex: tabIndex)
|
|
}), for: .touchUpInside)
|
|
|
|
return tabView
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
let tabContainerBounds = bounds
|
|
tabContainerView.frame = tabContainerBounds
|
|
|
|
let separatorHeight = CGFloat(1.0)
|
|
bottomSeparatorView.frame = CGRect(
|
|
x: 0.0, y: bounds.height - separatorHeight,
|
|
width: bounds.width, height: separatorHeight
|
|
)
|
|
|
|
let minimumTabWidth = { (traitCollection: UITraitCollection) -> CGFloat in
|
|
if traitCollection.horizontalSizeClass == .compact {
|
|
return (tabContainerBounds.width / 3.0) + 15.0
|
|
} else {
|
|
return 140.0
|
|
}
|
|
}(traitCollection)
|
|
|
|
let maximumTabWidth = tabContainerBounds.width
|
|
|
|
let visibleTabCount = tabViews.filter({ $0.collapsed == false }).count
|
|
|
|
var xOffset = CGFloat(0.0)
|
|
var tabWidth: CGFloat = (tabContainerBounds.width / CGFloat(visibleTabCount))
|
|
if tabWidth < minimumTabWidth {
|
|
tabWidth = minimumTabWidth
|
|
} else if tabWidth > maximumTabWidth {
|
|
tabWidth = maximumTabWidth
|
|
}
|
|
|
|
for (i, tabView) in tabViews.enumerated() {
|
|
tabContainerView.addSubview(tabView)
|
|
|
|
tabView.alpha = tabView.collapsed ? 0.0 : 1.0
|
|
|
|
tabView.frame = CGRect(
|
|
x: xOffset,
|
|
y: tabContainerBounds.minY,
|
|
width: tabView.collapsed ? 1.0 : tabWidth,
|
|
height: tabContainerBounds.height
|
|
)
|
|
|
|
xOffset += tabView.frame.width
|
|
|
|
if i == activeTabIndex {
|
|
tabView.active = true
|
|
} else {
|
|
tabView.active = false
|
|
}
|
|
|
|
tabView.layoutIfNeeded()
|
|
}
|
|
|
|
tabContainerView.contentSize = CGSize(
|
|
width: xOffset, height: tabContainerBounds.height
|
|
)
|
|
}
|
|
}
|