diff --git a/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/Tabs.swift b/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/Tabs.swift index e6689f3d..26c4cc3f 100644 --- a/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/Tabs.swift +++ b/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/Tabs.swift @@ -8,379 +8,64 @@ import UIKit import VDSColorTokens +import VDS @objc public protocol TabsDelegate { func shouldSelectItem(_ indexPath: IndexPath, tabs: Tabs) -> Bool func didSelectItem(_ indexPath: IndexPath, tabs: Tabs) } -@objcMembers open class Tabs: View, MVMCoreUIViewConstrainingProtocol, MFButtonProtocol { +@objcMembers open class Tabs: VDS.Tabs, VDSMoleculeViewProtocol, MVMCoreUIViewConstrainingProtocol, MFButtonProtocol { - public var tabsModel: TabsModel? { - get { return model as? TabsModel } - } - - var delegateObject: MVMCoreUIDelegateObject? - var additionalData: [AnyHashable: Any]? - - let layout = UICollectionViewFlowLayout() - public var collectionView: CollectionView? - - let bottomScrollView = UIScrollView(frame: .zero) - let bottomContentView = View() - let bottomLine = Line() - let selectionLine = View() - var selectionLineLeftConstraint: NSLayoutConstraint? - var selectionLineWidthConstraint: NSLayoutConstraint? - - private var widthLabel = Label() + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + open var viewModel: TabsModel! + open var delegateObject: MVMCoreUIDelegateObject? + open var additionalData: [AnyHashable : Any]? //delegate weak public var delegate: TabsDelegate? - - //control var - public var selectedIndex: Int = 0 - public var paddingBeforeFirstTab: Bool = true - - //constant - let TabCellId = "TabCell" - public let itemSpacing: CGFloat = 20.0 - public let cellHeight: CGFloat = 28.0 - public let selectionLineHeight: CGFloat = 4.0 - public let minimumItemWidth: CGFloat = 32.0 - public let selectionLineMovingTime: TimeInterval = 0.2 - + //------------------------------------------------- // MARK: - Layout Views //------------------------------------------------- - open override func reset() { - super.reset() - selectedIndex = 0 - paddingBeforeFirstTab = true - } - - open override func updateView(_ size: CGFloat) { - super.updateView(size) - collectionView?.updateView(size) - } - - open override func setupView() { - super.setupView() - backgroundColor = VDSColor.backgroundPrimaryLight - addSubview(bottomLine) - setupCollectionView() - setupSelectionLine() - setupConstraints() - } - - func setupCollectionView () { - layout.scrollDirection = .horizontal - layout.minimumLineSpacing = 0 + open func updateView(_ size: CGFloat) {} - let collectionView = CollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.register(TabItemCell.self, forCellWithReuseIdentifier: TabCellId) - collectionView.backgroundColor = .clear - collectionView.showsVerticalScrollIndicator = false - collectionView.showsHorizontalScrollIndicator = false - collectionView.dataSource = self - collectionView.delegate = self - addSubview(collectionView) - self.collectionView = collectionView - } - - func setupSelectionLine() { - bottomScrollView.translatesAutoresizingMaskIntoConstraints = false - bottomScrollView.delegate = self - addSubview(bottomScrollView) - bottomScrollView.addSubview(bottomContentView) - selectionLine.backgroundColor = VDSColor.paletteRed - bottomContentView.addSubview(selectionLine) - bringSubviewToFront(bottomScrollView) - } - - func setupConstraints() { - //collection view - NSLayoutConstraint.constraintPinSubview(collectionView, pinTop: true, pinBottom: false, pinLeft: true, pinRight: true) - collectionView?.heightAnchor.constraint(equalToConstant: cellHeight).isActive = true - - //selection line - bottomScrollView.topAnchor.constraint(equalTo: collectionView!.bottomAnchor).isActive = true; - NSLayoutConstraint.constraintPinSubview(bottomScrollView, pinTop: false, pinBottom: false, pinLeft: true, pinRight: true) - bottomScrollView.heightAnchor.constraint(equalToConstant: selectionLineHeight).isActive = true - NSLayoutConstraint.constraintPinSubview(selectionLine, pinTop: true, pinBottom: true, pinLeft: false, pinRight: false) - selectionLine.heightAnchor.constraint(equalToConstant: selectionLineHeight).isActive = true - selectionLineLeftConstraint = selectionLine.leftAnchor.constraint(equalTo: bottomContentView.leftAnchor) - selectionLineLeftConstraint?.isActive = true - selectionLineWidthConstraint = selectionLine.widthAnchor.constraint(equalToConstant: minimumItemWidth) - selectionLineWidthConstraint?.isActive = true - NSLayoutConstraint.constraintPinSubview(toSuperview: bottomContentView) - - //bottom line - bottomLine.topAnchor.constraint(equalTo: bottomScrollView.bottomAnchor).isActive = true; - NSLayoutConstraint.constraintPinSubview(bottomLine, pinTop: false, pinBottom: true, pinLeft: true, pinRight: true) - } - - open override func layoutSubviews() { - super.layoutSubviews() - // Accounts for any collection size changes - DispatchQueue.main.async { - self.layoutCollection() - } - } - - /// Invalidates the layout and ensures we are paged to the correct cell. - open func layoutCollection() { - collectionView?.collectionViewLayout.invalidateLayout() - - // Go to current cell. layoutIfNeeded is needed otherwise cellForItem returns nil for peaking logic. The dispatch is a sad way to ensure the collection view is ready to be scrolled. - DispatchQueue.main.async { - self.collectionView?.scrollToItem(at: IndexPath(row: self.selectedIndex, section: 0), at: .left, animated: false) - self.collectionView?.layoutIfNeeded() - } - } - //------------------------------------------------- // MARK: - Control Methods //------------------------------------------------- public func selectIndex(_ index: Int, animated: Bool) { - guard let _ = collectionView, tabsModel?.tabs.count ?? 0 > 0 else { - selectedIndex = index - tabsModel?.selectedIndex = index - return - } - MVMCoreDispatchUtility.performBlock(onMainThread: { - let currentIndex = self.selectedIndex - self.selectedIndex = index - self.tabsModel?.selectedIndex = index - self.deselect(indexPath: IndexPath(row: currentIndex, section: 0)) - self.selectItem(atIndexPath: IndexPath(row: index, section: 0), animated: animated) - }) + self.selectedIndex = index } - public func reloadData() { - collectionView?.reloadData() - } + public func reloadData() { setNeedsUpdate() } //------------------------------------------------- // MARK: - Molecule Setup //------------------------------------------------- - override open func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable : Any]?) { - super.set(with: model, delegateObject, additionalData) - self.delegateObject = delegateObject - self.additionalData = additionalData - selectedIndex = tabsModel?.selectedIndex ?? 0 - selectionLine.backgroundColor = tabsModel?.selectedBarColor.uiColor - let lineModel = bottomLine.viewModel ?? LineModel(type: .secondary) - lineModel.inverted = tabsModel?.style == .dark - bottomLine.set(with: lineModel, delegateObject, additionalData) - reloadData() - } -} - -//------------------------------------------------- -// MARK: - Collection View Methods -//------------------------------------------------- - -extension Tabs: UICollectionViewDataSource { - public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return tabsModel?.tabs.count ?? 0 - } - - public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabCellId, for: indexPath) as? TabItemCell else { - return UICollectionViewCell() - } - cell.updateCell(indexPath: indexPath, delegateObject: delegateObject, additionalData: additionalData, selected: indexPath.row == selectedIndex, tabsModel: tabsModel) - updateView(collectionView.bounds.width) - return cell - } -} - -extension Tabs: UICollectionViewDelegateFlowLayout { - - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - guard self.collectionView(collectionView, numberOfItemsInSection: indexPath.section) != 2 else { - // If two tabs, take up the screen - let insets = self.collectionView(collectionView, layout: collectionViewLayout, insetForSectionAt: indexPath.section) - let width = (collectionView.bounds.width / 2.0) - insets.left - insets.right - return CGSize(width: width, height: cellHeight) - } - guard let labelModel = tabsModel?.tabs[indexPath.row].label else { - return .zero - } - return CGSize(width: max(minimumItemWidth, getLabelWidth(labelModel).width), height: cellHeight) - } - - //pre calculate the width of the collection cell - //when user select tabs, it will reload related collectionview, if we use autosize, it would relayout the width, need to keep the cell width constant. - func getLabelWidth(_ labelModel: LabelModel?) -> CGSize { - guard let labelModel = labelModel else { return .zero} - widthLabel.set(with: labelModel, nil, nil) - let cgSize = widthLabel.intrinsicContentSize - widthLabel.reset() - return cgSize - } - - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - guard section == 0 else { - return UIEdgeInsets(top: 0, left: itemSpacing, bottom: 0, right: 0) - } - guard paddingBeforeFirstTab else { - return .zero - } - return UIEdgeInsets(top: 0, left: Padding.Component.horizontalPaddingForApplicationWidth, bottom: 0, right: 0) - } - - public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - // If two tabs, take up the screen, no space between items - guard self.collectionView(collectionView, numberOfItemsInSection: section) != 2 else { - return 0 - } - return itemSpacing - } - - public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - return delegate?.shouldSelectItem(indexPath, tabs: self) ?? true - } - - public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectIndex(indexPath.row, animated: true) - } - - public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - guard let tabCell = cell as? TabItemCell else { return } - if indexPath.row == selectedIndex { - DispatchQueue.main.async { - self.moveSelectionLine(toIndex: indexPath, animated: false, cell: tabCell) + open func viewModelDidUpdate() { + orientation = viewModel.orientation + indicatorPosition = viewModel.indicatorPosition + overflow = viewModel.overflow + size = viewModel.size + selectedIndex = viewModel.selectedIndex + surface = viewModel.style ?? .light + tabModels = viewModel.tabs.compactMap { TabModel(text: $0.label.text) } + + if let delegate { + onTabChange = { [weak self] index in + guard let self else { return } + delegate.didSelectItem(.init(row: index, section: 0), tabs: self) + } + + onTabShouldSelect = { [weak self] index in + guard let self else { return true } + return delegate.shouldSelectItem(.init(row: index, section: 0), tabs: self) } } } - - func deselect(indexPath:IndexPath) { - collectionView?.deselectItem(at: indexPath, animated: false) - collectionView?.reloadItems(at: [indexPath]) - } - - func selectItem(atIndexPath indexPath: IndexPath, animated: Bool) { - - guard let collect = collectionView, tabsModel?.tabs.count ?? 0 > 0 else { return } - - collect.selectItem(at: indexPath, animated: animated, scrollPosition: .centeredHorizontally) - guard let tabCell = collect.cellForItem(at: indexPath) as? TabItemCell, let tabsModel = tabsModel else { return } - moveSelectionLine(toIndex: indexPath, animated: animated, cell: tabCell) - tabCell.label.textColor = tabsModel.selectedColor.uiColor - tabCell.updateAccessibility(indexPath: indexPath, selected: true, tabsModel: tabsModel) - tabCell.setNeedsDisplay() - tabCell.setNeedsLayout() - tabCell.layoutIfNeeded() - if let delegate = delegate { - delegate.didSelectItem(indexPath, tabs: self) - } else if let action = tabsModel.tabs[selectedIndex].action { - MVMCoreUIActionHandler.performActionUnstructured(with: action, sourceModel: tabsModel, additionalData: nil, delegateObject: delegateObject) - } - if UIAccessibility.isVoiceOverRunning { - UIAccessibility.post(notification: .layoutChanged, argument: tabCell) - } - } } - - -extension Tabs: UIScrollViewDelegate { - public func scrollViewDidScroll(_ scrollView: UIScrollView) { - /*bottomScrollview is subview of self, it's not belongs to collectionview. - When collectionview is scrolling, bottomScrollView will stay without moving - Adding collectionview's offset to bottomScrollView, will make the bottomScrollview looks like scrolling with the selected tab item. - */ - guard let offsetX = collectionView?.contentOffset.x else { return } - bottomScrollView.setContentOffset(CGPoint(x: offsetX, y: bottomScrollView.contentOffset.y), animated: false) - } -} - - -//------------------------------------------------- -// MARK: - Bottom Line Methods -//------------------------------------------------- -extension Tabs { - func moveSelectionLine(toIndex indexPath: IndexPath, animated: Bool, cell: TabItemCell) { - guard let collect = collectionView else {return} - - let size = collectionView(collect, layout: layout, sizeForItemAt: indexPath) - let animationBlock = { - [weak self] in - self?.selectionLineWidthConstraint?.constant = size.width - self?.selectionLineLeftConstraint?.constant = cell.frame.origin.x - self?.bottomContentView.layoutIfNeeded() - } - if animated { - UIView.animate(withDuration: selectionLineMovingTime, animations: animationBlock) - } else { - animationBlock() - } - } - - /// Adjust the line based on the percentage - func progress(from index: Int, toIndex: Int, percentage: CGFloat) { - let fromIndexPath = IndexPath(row: index, section: 0) - let toIndexPath = IndexPath(row: toIndex, section: 0) - guard let collection = collectionView, - let fromCell = collection.cellForItem(at: fromIndexPath), - let toCell = collection.cellForItem(at: toIndexPath) else { return } - - // setting the width for percentage - selectionLineWidthConstraint?.constant = (toCell.bounds.width - fromCell.bounds.width) * percentage + fromCell.bounds.width - - // setting the x for percentage - let originalX = fromCell.frame.origin.x - let toX = toCell.frame.origin.x - let xDifference = toX - originalX - let finalX = (xDifference * percentage) + originalX - selectionLineLeftConstraint?.constant = finalX - - bottomContentView.layoutIfNeeded() - } -} - -@objcMembers public class TabItemCell: CollectionViewCell { - public let label = Label() - - public override func setupView() { - super.setupView() - contentView.addSubview(label) - NSLayoutConstraint.constraintPinSubview(label, pinTop: false, topConstant: 0, pinBottom: true, bottomConstant: 6, pinLeft: true, leftConstant: 0, pinRight: true, rightConstant: 0) - label.baselineAdjustment = .alignCenters - } - - public override func updateView(_ size: CGFloat) { - super.updateView(size) - label.updateView(size) - } - - public func updateCell(indexPath: IndexPath, delegateObject: MVMCoreUIDelegateObject?, additionalData: [AnyHashable: Any]?, selected: Bool, tabsModel: TabsModel?) { - guard let tabsModel = tabsModel else { return } - label.reset() - label.set(with: tabsModel.tabs[indexPath.row].label, delegateObject, additionalData) - if selected { - label.textColor = tabsModel.selectedColor.uiColor - } else { - label.textColor = tabsModel.unselectedColor.uiColor - } - updateAccessibility(indexPath: indexPath, selected: selected, tabsModel: tabsModel) - } - - public func updateAccessibility(indexPath: IndexPath, selected: Bool, tabsModel: TabsModel?) { - //Accessibility - isAccessibilityElement = false - contentView.isAccessibilityElement = true - let accKey = selected ? "toptabbar_tab_selected" : "AccTab" - let accLabel = "\(label.text ?? "") \(MVMCoreUIUtility.hardcodedString(withKey: accKey) ?? "")" - let accOrder = String(format: MVMCoreUIUtility.hardcodedString(withKey: "AccTabIndex") ?? "", indexPath.row + 1, tabsModel?.tabs.count ?? 0) - contentView.accessibilityLabel = "\(accLabel) \(accOrder)" - contentView.accessibilityHint = selected ? nil : MVMCoreUIUtility.hardcodedString(withKey: "AccTabHint") - } -} - - diff --git a/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/TabsModel.swift b/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/TabsModel.swift index 3671e5e1..648357b3 100644 --- a/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/TabsModel.swift +++ b/MVMCoreUI/Atomic/Molecules/HorizontalCombinationViews/TabsModel.swift @@ -8,67 +8,22 @@ import UIKit import VDSColorTokens - +import VDS open class TabsModel: MoleculeModelProtocol { + public static var identifier: String = "tabs" public var id: String = UUID().uuidString open var tabs: [TabItemModel] - open var style: NavigationItemStyle? - - private var _backgroundColor: Color? - open var backgroundColor: Color? { - get { - if let backgroundColor = _backgroundColor { return backgroundColor } - if let style = style, - style == .dark { return Color(uiColor: VDSColor.backgroundPrimaryDark) } - return Color(uiColor: VDSColor.backgroundPrimaryLight) - } - set { - _backgroundColor = newValue - } - } - - private var _selectedColor: Color? - open var selectedColor: Color { - get { - if let selectedColor = _selectedColor { return selectedColor } - if let style = style, - style == .dark { return Color(uiColor: VDSColor.elementsPrimaryOndark) } - return Color(uiColor: VDSColor.elementsPrimaryOnlight) - } - set { - _selectedColor = newValue - } - } - - private var _unselectedColor: Color? - open var unselectedColor: Color { - get { - if let unselectedColor = _unselectedColor { return unselectedColor } - if let style = style, - style == .dark { return Color(uiColor: VDSColor.elementsSecondaryOndark) } - return Color(uiColor: VDSColor.elementsSecondaryOnlight) - } - set { - _unselectedColor = newValue - } - } - - private var _selectedBarColor: Color? - open var selectedBarColor: Color { - get { - if let selectedBarColor = _selectedBarColor { return selectedBarColor } - if let style = style, - style == .dark { return Color(uiColor: VDSColor.elementsPrimaryOndark) } - return Color(uiColor: VDSColor.paletteRed) - } - set { - _selectedBarColor = newValue - } - } - + open var style: Surface? + + open var orientation: Tabs.Orientation = .horizontal + open var indicatorPosition: Tabs.IndicatorPosition = .bottom + open var overflow: Tabs.Overflow = .scroll + open var size: Tabs.Size = .medium + public var backgroundColor: Color? + // Must be capped to 0...(tabs.count - 1) open var selectedIndex: Int = 0 @@ -77,10 +32,11 @@ open class TabsModel: MoleculeModelProtocol { case moleculeName case tabs case backgroundColor - case selectedColor - case unselectedColor - case selectedBarColor case selectedIndex + case orientation + case indicatorPosition + case overflow + case size case style } @@ -92,14 +48,26 @@ open class TabsModel: MoleculeModelProtocol { let typeContainer = try decoder.container(keyedBy: CodingKeys.self) id = try typeContainer.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString tabs = try typeContainer.decode([TabItemModel].self, forKey: .tabs) - backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor) - _selectedColor = try typeContainer.decodeIfPresent(Color.self, forKey: .selectedColor) - _unselectedColor = try typeContainer.decodeIfPresent(Color.self, forKey: .unselectedColor) - _selectedBarColor = try typeContainer.decodeIfPresent(Color.self, forKey: .selectedBarColor) - style = try typeContainer.decodeIfPresent(NavigationItemStyle.self, forKey: .style) + style = try typeContainer.decodeIfPresent(Surface.self, forKey: .style) if let index = try typeContainer.decodeIfPresent(Int.self, forKey: .selectedIndex) { selectedIndex = index } + + if let orientation = try typeContainer.decodeIfPresent(VDS.Tabs.Orientation.self, forKey: .orientation) { + self.orientation = orientation + } + + if let indicatorPosition = try typeContainer.decodeIfPresent(VDS.Tabs.IndicatorPosition.self, forKey: .indicatorPosition) { + self.indicatorPosition = indicatorPosition + } + + if let overflow = try typeContainer.decodeIfPresent(VDS.Tabs.Overflow.self, forKey: .overflow) { + self.overflow = overflow + } + + if let size = try typeContainer.decodeIfPresent(VDS.Tabs.Size.self, forKey: .orientation) { + self.size = size + } } open func encode(to encoder: Encoder) throws { @@ -107,12 +75,13 @@ open class TabsModel: MoleculeModelProtocol { try container.encode(id, forKey: .id) try container.encode(moleculeName, forKey: .moleculeName) try container.encode(tabs, forKey: .tabs) - try container.encodeIfPresent(_backgroundColor, forKey: .backgroundColor) - try container.encodeIfPresent(_selectedColor, forKey: .selectedColor) - try container.encodeIfPresent(_unselectedColor, forKey: .unselectedColor) - try container.encodeIfPresent(_selectedBarColor, forKey: .selectedBarColor) try container.encode(selectedIndex, forKey: .selectedIndex) try container.encodeIfPresent(style, forKey: .style) + try container.encode(orientation, forKey: .orientation) + try container.encode(overflow, forKey: .overflow) + try container.encode(size, forKey: .size) + try container.encode(indicatorPosition, forKey: .indicatorPosition) + } }