// // Stack.swift // MVMCoreUI // // Created by Scott Pfeil on 1/16/20. // Copyright © 2020 Verizon Wireless. All rights reserved. // import Foundation open class Stack: Container where T: (StackModelProtocol & MoleculeModelProtocol) { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- open var contentView: UIView = MVMCoreUICommonViewsUtility.commonView() open var stackModel: T? { get { return model as? T } } open var stackItems: [UIView] = [] //-------------------------------------------------- // MARK: - Helpers //-------------------------------------------------- open func pinView(_ view: UIView, toView: UIView, attribute: NSLayoutConstraint.Attribute, relation: NSLayoutConstraint.Relation, priority: UILayoutPriority, constant: CGFloat) { let constraint = NSLayoutConstraint(item: view, attribute: attribute, relatedBy: relation, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant) constraint.priority = priority constraint.isActive = true } /// Restacks the existing items. open func restack() { removeAllItemViews() guard let stackModel = stackModel else { return } let stackItems = self.stackItems self.stackItems = [] let lastItemIndex = stackModel.molecules.lastIndex(where: { (item) -> Bool in return !item.gone }) // Adds the views let totalSpace = getTotalSpace() for (index, view) in stackItems.enumerated() { addView(view, stackModel.molecules[index], totalSpacing: totalSpace, lastItem: lastItemIndex == index) } } /// Removes all stack items views from the view. open func removeAllItemViews() { for item in stackItems { item.removeFromSuperview() } } //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- public override init(frame: CGRect) { super.init(frame: frame) } /// The main initializer for model driven public init(with model: T, delegateObject: MVMCoreUIDelegateObject?, additionalData: [AnyHashable: Any]?) { super.init(frame: .zero) setOptional(with: model, delegateObject, additionalData) } /// The main initializer for hardcode driven public init(with model: T, stackItems: [UIView]) { super.init(frame: CGRect.zero) self.model = model self.stackItems = stackItems } public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// Returns a Stack created with a StackModel and StackItems containing the passed in views. public static func createStack(with views: [UIView], axis: NSLayoutConstraint.Axis? = nil, spacing: CGFloat? = nil) -> Stack { var items: [StackItem] = [] var models: [StackItemModel] = [] for view in views { items.append(StackItem(andContain: view)) models.append(StackItemModel()) } let model = StackModel(molecules: models, axis: axis, spacing: spacing) return Stack(with: model, stackItems: items) } /// Returns a Stack created with a StackModel containing the passed in views and using the passed in stackitems. public static func createStack(with viewModels:[(view: UIView, model: StackItemModel)], axis: NSLayoutConstraint.Axis? = nil, spacing: CGFloat? = nil) -> Stack { var stackItems: [StackItem] = [] var models: [StackItemModel] = [] for item in viewModels { stackItems.append(StackItem(andContain: item.view)) models.append(item.model) } let model = StackModel(molecules: models, axis: axis, spacing: spacing) return Stack(with: model, stackItems: stackItems) } //-------------------------------------------------- // MARK: - MFViewProtocol //-------------------------------------------------- open override func setupView() { super.setupView() guard contentView.superview == nil else { return } MVMCoreUIUtility.setMarginsFor(contentView, leading: 0, top: 0, trailing: 0, bottom: 0) translatesAutoresizingMaskIntoConstraints = false backgroundColor = .clear addSubview(contentView) containerHelper.constrainView(contentView) contentView.setContentHuggingPriority(.defaultHigh, for: .vertical) contentView.setContentHuggingPriority(.defaultHigh, for: .horizontal) } open override func updateView(_ size: CGFloat) { super.updateView(size) for item in stackItems { (item as? MVMCoreViewProtocol)?.updateView(size) } } //-------------------------------------------------- // MARK: - MVMCoreUIMoleculeViewProtocol //-------------------------------------------------- open override func reset() { super.reset() backgroundColor = .clear for item in stackItems { (item as? MVMCoreUIMoleculeViewProtocol)?.reset?() } } open override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { let previousModel = self.model super.set(with: model, delegateObject, additionalData) removeAllItemViews() // If the items in the stack are different, clear them, create new ones. if (previousModel == nil) || Self.nameForReuse(with: previousModel!, delegateObject) != Self.nameForReuse(with: model, delegateObject) { stackItems = [] createStackItemsFromModel(model, delegateObject, additionalData) } else { setStackItemsFromModel(model, delegateObject, additionalData) } restack() } open override class func nameForReuse(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?) -> String? { // This will aggregate names of molecules to make an id. guard let model = model as? T else { return "stack<>" } var name = "stack<" for case let item in model.molecules { if let moleculeClass = MVMCoreUIMoleculeMappingObject.shared()?.moleculeMapping[item.moleculeName] as? ModelMoleculeViewProtocol.Type, let nameForReuse = moleculeClass.nameForReuse(with: item, delegateObject) { name.append(nameForReuse + ",") } else { name.append(item.moleculeName + ",") } } name.append(">") return name } // Need to update to take into account first spacing flag open override class func estimatedHeight(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?) -> CGFloat? { guard let model = model as? T else { return 0 } let horizontal = model.axis == .horizontal var estimatedHeight: CGFloat = 0 for case let item in model.molecules { if item.gone { continue } let height = (MVMCoreUIMoleculeMappingObject.shared()?.getMoleculeClass(item) as? ModelMoleculeViewProtocol.Type)?.estimatedHeight(with: item, delegateObject) ?? 0 if !horizontal { // Vertical stack aggregates the items let spacing = item.spacing ?? model.spacing estimatedHeight += (height + spacing) } else { // Horizontal stack takes the tallest item. estimatedHeight = max(estimatedHeight, height) } } return estimatedHeight } open override class func requiredModules(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, error: AutoreleasingUnsafeMutablePointer?) -> [String]? { guard let model = model as? T else { return nil } var modules: [String] = [] for case let item in model.molecules { if let modulesForMolecule = (MVMCoreUIMoleculeMappingObject.shared()?.getMoleculeClass(item) as? ModelMoleculeViewProtocol.Type)?.requiredModules(with: item, delegateObject, error: error) { modules += modulesForMolecule } } return modules.count > 0 ? modules : nil } //-------------------------------------------------- // MARK: - Subclassables //-------------------------------------------------- /// Can be subclassed to create views when we get stack item models and have no views yet open func createStackItemsFromModel(_ model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { } /// Can be subclassed to set stack items with model when we already have views open func setStackItemsFromModel(_ model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { guard let models = stackModel?.molecules else { return } for (index, element) in models.enumerated() { (stackItems[index] as? ModelMoleculeViewProtocol)?.set(with: element, delegateObject, additionalData) } } //-------------------------------------------------- // MARK: - Adding to stack //-------------------------------------------------- /// Sets the stack with StackItems containing the passed in views and creates a StackModel with StackItems. open func setAndCreateModel(with views: [UIView]) { var stackItems: [StackItem] = [] var models: [StackItemModel] = [] for view in views { stackItems.append(StackItem(andContain: view)) models.append(StackItemModel()) } self.stackItems = stackItems model = StackModel(molecules: models) } /// Sets the stack with StackItems containing the passed in views and sets the StackModel with models. open func set(with viewModels:[(view: UIView, model: T.AnyStackItemModel)]) { guard var stackModel = self.stackModel else { return } var stackItems: [StackItem] = [] var models: [T.AnyStackItemModel] = [] for item in viewModels { stackItems.append(StackItem(andContain: item.view)) models.append(item.model) } stackModel.molecules = models self.stackItems = stackItems } /// Gets the percent modifier. This value is used to help properly calculate percent for stack items when spacing is involved. private func getTotalSpace() -> CGFloat { guard let stackModel = stackModel else { return 0.0 } var totalSpace: CGFloat = 0.0 var firstMoleculeFound = false for stackItemModel in stackModel.molecules { guard !stackItemModel.gone else { continue } let spacing = stackItemModel.spacing ?? stackModel.spacing if firstMoleculeFound { totalSpace += spacing } else { firstMoleculeFound = true totalSpace += (stackModel.useStackSpacingBeforeFirstItem ? spacing : stackItemModel.spacing ?? 0) } } return totalSpace } /// Adds the stack item view private func addView(_ view: UIView,_ model: StackItemModelProtocol, totalSpacing: CGFloat, lastItem: Bool) { guard let stackModel = self.stackModel else { return } guard !model.gone else { // Gone views do not show stackItems.append(view) return } contentView.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false let spacing = model.spacing ?? stackModel.spacing if let container = view as? ContainerProtocol { let verticalAlignment = (model as? ContainerModelProtocol)?.verticalAlignment ?? (container.view as? MVMCoreUIViewConstrainingProtocol)?.verticalAlignment?() ?? (model.percent == nil && stackModel.axis == .vertical ? .fill : (stackModel.axis == .vertical ? .leading : .center)) let horizontalAlignment = (model as? ContainerModelProtocol)?.horizontalAlignment ?? (container.view as? MVMCoreUIViewConstrainingProtocol)?.horizontalAlignment?() ?? (stackModel.axis == .vertical || model.percent == nil ? .fill : .leading) container.alignHorizontal(horizontalAlignment) container.alignVertical(verticalAlignment) } let first = contentView.subviews.count == 1 if stackModel.axis == .vertical { if first { pinView(view, toView: contentView, attribute: .top, relation: .equal, priority: .required, constant: stackModel.useStackSpacingBeforeFirstItem ? spacing : model.spacing ?? 0) } else if let previousView = stackItems.last(where: { item in return item.superview != nil }) { view.topAnchor.constraint(equalTo: previousView.bottomAnchor, constant: spacing).isActive = true } pinView(view, toView: contentView, attribute: .leading, relation: .equal, priority: .required, constant: 0) pinView(contentView, toView: view, attribute: .trailing, relation: .equal, priority: .required, constant: 0) if let percent = model.percent { let multiplier = CGFloat(percent) / 100.0 let constant = multiplier * totalSpacing view.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: multiplier, constant: -constant).isActive = true } if lastItem { pinView(contentView, toView: view, attribute: .bottom, relation: .equal, priority: .required, constant: 0) } } else { if first { // First horizontal item has no spacing by default unless told otherwise. pinView(view, toView: contentView, attribute: .leading, relation: .equal, priority: .required, constant: stackModel.useStackSpacingBeforeFirstItem ? spacing : model.spacing ?? 0) } else if let previousView = stackItems.last(where: { item in return item.superview != nil }) { view.leftAnchor.constraint(equalTo: previousView.rightAnchor, constant: spacing).isActive = true } pinView(view, toView: contentView, attribute: .top, relation: .equal, priority: .required, constant: 0) pinView(contentView, toView: view, attribute: .bottom, relation: .equal, priority: .required, constant: 0) if let percent = model.percent { let multiplier = CGFloat(percent) / 100.0 let constant = multiplier * totalSpacing view.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: multiplier, constant: -constant).isActive = true } if lastItem { pinView(contentView, toView: view, attribute: .right, relation: .equal, priority: .required, constant: 0) } } stackItems.append(view) } }