// // MoleculeStackView.swift // MVMCoreUI // // Created by Scott Pfeil on 2/11/19. // Copyright © 2019 Verizon Wireless. All rights reserved. // import UIKit public class StackItem { var view: UIView var spacing: CGFloat? var percentage: Int? var verticalAlignment: UIStackView.Alignment? var horizontalAlignment: UIStackView.Alignment? init(with view: UIView) { self.view = view } init(with view: UIView, json: [AnyHashable: Any]) { self.view = view update(with: json) } func update(with json: [AnyHashable: Any]) { spacing = json.optionalCGFloatForKey("spacing") percentage = json["percent"] as? Int if let alignment = json.optionalStringForKey("verticalAlignment") { verticalAlignment = ViewConstrainingView.getAlignmentFor(alignment, defaultAlignment: .fill) } if let alignment = json.optionalStringForKey("horizontalAlignment") { horizontalAlignment = ViewConstrainingView.getAlignmentFor(alignment, defaultAlignment: .fill) } } } public class MoleculeStackView: ViewConstrainingView { var contentView: UIView = MVMCoreUICommonViewsUtility.commonView() var items: [StackItem] = [] var useStackSpacingBeforeFirstItem = false private var moleculesShouldSetHorizontalMargins = true private var moleculesShouldSetVerticalMargins = false /// For setting the direction of the stack var axis: NSLayoutConstraint.Axis = .vertical { didSet { moleculesShouldSetHorizontalMargins = (moleculesShouldSetHorizontalMargins && axis == .vertical) if axis != oldValue { restack() } } } /// The spacing to use between each item in the stack. var spacing: CGFloat = 16 { didSet { if spacing != oldValue { restack() } } } // MARK: - Helpers public func setAxisWithJSON(_ json: [AnyHashable: Any]?) { switch json?.optionalStringForKey("axis") { case "horizontal": axis = .horizontal default: axis = .vertical } } public 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. func restack() { MVMCoreUIStackableViewController.remove(contentView.subviews) let stackItems = items items.removeAll() for (index, item) in stackItems.enumerated() { addStackItem(item, lastItem: index == stackItems.count - 1) } } // MARK: - Inits public override init(frame: CGRect) { super.init(frame: frame) } public init(withJSON json: [AnyHashable: Any]?, delegateObject: MVMCoreUIDelegateObject?, additionalData: [AnyHashable : Any]?) { super.init(frame: CGRect.zero) setWithJSON(json, delegateObject: delegateObject, additionalData: additionalData) } public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - MFViewProtocol public override func setupView() { super.setupView() guard contentView.superview == nil else { return } translatesAutoresizingMaskIntoConstraints = false backgroundColor = .clear addSubview(contentView) pinView(toSuperView: contentView) contentView.setContentHuggingPriority(.defaultHigh, for: .vertical) contentView.setContentHuggingPriority(.defaultHigh, for: .horizontal) } public override func updateView(_ size: CGFloat) { super.updateView(size) for item in items { (item.view as? MVMCoreViewProtocol)?.updateView(size) } } // MARK: - MVMCoreUIMoleculeViewProtocol public override func setAsMolecule() { updateViewHorizontalDefaults = false } public override func reset() { backgroundColor = .clear for item in items { if let view = item.view as? MVMCoreUIMoleculeViewProtocol { view.reset?() } } } public override func shouldSetHorizontalMargins(_ shouldSet: Bool) { super.shouldSetHorizontalMargins(shouldSet) moleculesShouldSetHorizontalMargins = (shouldSet && axis == .vertical) for item in items { (item.view as? MVMCoreUIViewConstrainingProtocol)?.shouldSetHorizontalMargins?(moleculesShouldSetHorizontalMargins) } } public override func shouldSetVerticalMargins(_ shouldSet: Bool) { super.shouldSetVerticalMargins(shouldSet) moleculesShouldSetVerticalMargins = false for item in items { (item.view as? MVMCoreUIViewConstrainingProtocol)?.shouldSetVerticalMargins?(moleculesShouldSetVerticalMargins) } } open override func setWithJSON(_ json: [AnyHashable: Any]?, delegateObject: MVMCoreUIDelegateObject?, additionalData: [AnyHashable: Any]?) { let previousJSON = self.json super.setWithJSON(json, delegateObject: delegateObject, additionalData: additionalData) MVMCoreUIStackableViewController.remove(contentView.subviews) // If the items in the stack are the same, just update previous items instead of re-allocating. var items: [StackItem]? if MoleculeStackView.name(forReuse: previousJSON, delegateObject: delegateObject) == MoleculeStackView.name(forReuse: json, delegateObject: delegateObject) { items = self.items } self.items = [] guard let molecules = json?.arrayForKey(KeyMolecules) as? [[String: Any]] else { return } // Sets the stack attributes setAxisWithJSON(json) spacing = json?.optionalCGFloatForKey("spacing") ?? 16 // Set the alignment for the stack in the containing view. The json driven value is for the axis direction alignment. if axis == .vertical { alignHorizontal(.fill) alignVertical(ViewConstrainingView.getAlignmentFor(json?.optionalStringForKey("alignment"), defaultAlignment: .fill)) } else { alignHorizontal(ViewConstrainingView.getAlignmentFor(json?.optionalStringForKey("alignment"), defaultAlignment: .fill)) alignVertical(.fill) } // Adds the molecules and sets the json. for (index, map) in molecules.enumerated() { if let moleculeJSON = map.optionalDictionaryForKey(KeyMolecule) { var view: UIView? if let item = items?[index] { item.update(with: map) view = item.view (view as? MVMCoreUIMoleculeViewProtocol)?.setWithJSON(moleculeJSON, delegateObject: delegateObject, additionalData: nil) addStackItem(item, lastItem: index == molecules.count - 1) } else if let molecule = MVMCoreUIMoleculeMappingObject.shared()?.createMolecule(forJSON: moleculeJSON, delegateObject: delegateObject, constrainIfNeeded: true) { view = molecule addStackItem(StackItem(with: molecule, json: map), lastItem: index == molecules.count - 1) } (view as? MVMCoreUIViewConstrainingProtocol)?.shouldSetHorizontalMargins?(moleculesShouldSetHorizontalMargins) (view as? MVMCoreUIViewConstrainingProtocol)?.shouldSetVerticalMargins?(moleculesShouldSetVerticalMargins) } } } public override static func name(forReuse molecule: [AnyHashable : Any]?, delegateObject: MVMCoreUIDelegateObject?) -> String? { // This will aggregate names of molecules to make an id. guard let molecules = molecule?.optionalArrayForKey(KeyMolecules) else { return "stack<>" } var name = "stack<" for case let item as [AnyHashable: AnyHashable] in molecules { if let molecule = item.optionalDictionaryForKey(KeyMolecule), let moleculeName = MVMCoreUIMoleculeMappingObject.shared()?.getMoleculeClass(withJSON: molecule)?.name?(forReuse: molecule, delegateObject: delegateObject) ?? molecule.optionalStringForKey(KeyMoleculeName) { name.append(moleculeName + ",") } } name.append(">") return name } public override static func estimatedHeight(forRow json: [AnyHashable : Any]?, delegateObject: MVMCoreUIDelegateObject?) -> CGFloat { guard let items = json?.optionalArrayForKey(KeyMolecules) else { return 0 } let horizontal = json?.optionalStringForKey("axis") == "horizontal" var estimatedHeight: CGFloat = 0 for case let item as [AnyHashable: AnyHashable] in items { if let molecule = item.optionalDictionaryForKey(KeyMolecule) { let height = MVMCoreUIMoleculeMappingObject.shared()?.getMoleculeClass(withJSON: molecule)?.estimatedHeight?(forRow: molecule, delegateObject: delegateObject) if !horizontal { // Vertical stack aggregates the items let spacing = item.optionalCGFloatForKey("spacing") ?? (estimatedHeight != 0 ? (json?.optionalCGFloatForKey("spacing") ?? 16) : 0) estimatedHeight += ((height ?? 0) + spacing) } else if let height = height { // Horizontal stack takes the tallest item. estimatedHeight = max(estimatedHeight, height) } } } return estimatedHeight } public override static func requiredModules(_ json: [AnyHashable : Any]?, delegateObject: MVMCoreUIDelegateObject?, error: AutoreleasingUnsafeMutablePointer?) -> [String]? { guard let items = json?.optionalArrayForKey(KeyMolecules) else { return nil } var modules: [String] = [] for case let item as [AnyHashable: AnyHashable] in items { if let molecule = item.optionalDictionaryForKey(KeyMolecule), let modulesForMolecule = MVMCoreUIMoleculeMappingObject.shared()?.getMoleculeClass(withJSON: molecule)?.requiredModules?(molecule, delegateObject: delegateObject, error: error) { modules += modulesForMolecule } } return modules.count > 0 ? modules : nil } // MARK: - Adding to stack /// Adds the view to the stack. func addView(_ view: UIView, lastItem: Bool) { addStackItem(StackItem(with: view), lastItem: lastItem) } /// Adds the stack item to the stack. func addStackItem(_ stackItem: StackItem, lastItem: Bool) { let view = stackItem.view contentView.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false let spacing = stackItem.spacing ?? self.spacing if let view = view as? MVMCoreUIViewConstrainingProtocol { let verticalAlignment = stackItem.verticalAlignment ?? (stackItem.percentage == nil && axis == .vertical ? .fill : (axis == .vertical ? .leading : .center)) let horizontalAlignment = stackItem.horizontalAlignment ?? view.alignment?() ?? (axis == .vertical || stackItem.percentage == nil ? .fill : .leading) view.alignHorizontal?(horizontalAlignment) view.alignVertical?(verticalAlignment) } if axis == .vertical { if items.count == 0 { pinView(view, toView: contentView, attribute: .top, relation: .equal, priority: .required, constant: useStackSpacingBeforeFirstItem ? spacing : stackItem.spacing ?? 0) } else if let previousView = items.last?.view { _ = NSLayoutConstraint(pinFirstView: previousView, toSecondView: view, withConstant: spacing, directionVertical: 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 = stackItem.percentage { view.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: CGFloat(percent)/100.0).isActive = true } if lastItem { pinView(contentView, toView: view, attribute: .bottom, relation: .equal, priority: .required, constant: 0) } } else { if items.count == 0 { // First horizontal item has no spacing by default unless told otherwise. pinView(view, toView: contentView, attribute: .leading, relation: .equal, priority: .required, constant: useStackSpacingBeforeFirstItem ? spacing : stackItem.spacing ?? 0) } else if let previousView = items.last?.view { _ = NSLayoutConstraint(pinFirstView: previousView, toSecondView: view, withConstant: spacing, directionVertical: false) } 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 = stackItem.percentage { view.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: CGFloat(percent)/100.0).isActive = true } } if lastItem { pinView(contentView, toView: view, attribute: .right, relation: .equal, priority: .required, constant: 0) } items.append(stackItem) } }