// // ThreeLayerCollectionViewController.swift // MVMCoreUI // // Created by Scott Pfeil on 4/6/20. // Copyright © 2020 Verizon Wireless. All rights reserved. // import Foundation /// A view controller that has three main layers, a header, collection rows, and a footer. The header is added as a supplement header to the first section, and the footer is added as a supplement footer to the last section. This view controller allows for flexible space between the three layers to fit the screeen. @objc open class ThreeLayerCollectionViewController: ProgrammaticCollectionViewController, UICollectionViewDelegateFlowLayout { private var topView: UIView? private var bottomView: UIView? private var headerView: ContainerCollectionReusableView? private var footerView: ContainerCollectionReusableView? private let headerID = "header" private let footerID = "footer" public var bottomViewOutsideOfScrollArea: Bool = false public var topViewOutsideOfScrollArea: Bool = false open override func updateViewConstraints() { // Update the spacing on constraint update updateFlexibleSpace() super.updateViewConstraints() } /// Updates the padding for flexible space (header or footer) private func updateFlexibleSpace() { guard let collectionView = collectionView else { return } let minimumSpace: CGFloat = minimumFillSpace() var currentSpace: CGFloat = 0 var totalMinimumSpace: CGFloat = 0 var fillTop = false if spaceBelowTopView() == nil, headerView != nil { fillTop = true currentSpace += headerView?.bottomConstraint?.constant ?? 0 totalMinimumSpace += minimumSpace } var fillBottom = false if spaceAboveBottomView() == nil, footerView != nil { fillBottom = true currentSpace += footerView?.topConstraint?.constant ?? 0 totalMinimumSpace += minimumSpace } guard fillTop || fillBottom else { return } let newSpace = MVMCoreUIUtility.getVariableConstraintHeight(currentSpace, in: collectionView, minimumHeight: totalMinimumSpace) if !MVMCoreGetterUtility.cgfequalwiththreshold(newSpace, currentSpace, 1) { if fillTop && fillBottom { // space both let half = newSpace / 2 headerView?.bottomConstraint?.constant = half footerView?.topConstraint?.constant = half collectionView.collectionViewLayout.invalidateLayout() } else if fillTop { // Only top is spaced. headerView?.bottomConstraint?.constant = newSpace collectionView.collectionViewLayout.invalidateLayout() } else if fillBottom { // Only bottom is spaced. footerView?.topConstraint?.constant = newSpace collectionView.collectionViewLayout.invalidateLayout() } } } func addTopViewOutside() { guard let collectionView = collectionView, let topView = topView else { return } view.addSubview(topView) topView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true topView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: topView.rightAnchor).isActive = true collectionView.topAnchor.constraint(equalTo: topView.bottomAnchor).isActive = true } func addBottomViewOutside() { guard let collectionView = collectionView, let bottomView = bottomView else { return } view.addSubview(bottomView) bottomView.topAnchor.constraint(equalTo: collectionView.bottomAnchor).isActive = true bottomView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor).isActive = true view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: bottomView.rightAnchor).isActive = true view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor).isActive = true } //MARK: - ViewController open override func updateViews() { super.updateViews() // Needed due to dispatch in reloadCollectionData. DispatchQueue.main.async { let width = self.view.bounds.width if let topView = self.topView as? MVMCoreViewProtocol { topView.updateView(width) } if let bottomView = self.bottomView as? MVMCoreViewProtocol { bottomView.updateView(width) } if let cells = self.collectionView?.visibleCells { for cell in cells { self.update(cell: cell, size: width) } } self.invalidateCollectionLayout() } } open override func handleNewData() { super.handleNewData() topView?.removeFromSuperview() bottomView?.removeFromSuperview() topView = viewForTop() bottomView = viewForBottom() if topViewOutsideOfScrollArea { topConstraint?.isActive = false addTopViewOutside() } else { topConstraint?.isActive = true } if bottomViewOutsideOfScrollArea { bottomConstraint?.isActive = false addBottomViewOutside() } else { bottomConstraint?.isActive = true } reloadCollectionData() } override open func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. collectionView?.frameChangeAction = { [weak self] in self?.invalidateCollectionLayout() } } //MARK: - Spacing // If both are subclassed to return a value, then the buttons will not be pinned towards the bottom because neither spacing would try to fill the screen. /// Space between the top view and the collection rows, nil to fill. 0 default open func spaceBelowTopView() -> CGFloat? { return 0 } /// Space between the bottom view and the collection rows, nil to fill. nil default open func spaceAboveBottomView() -> CGFloat? { return nil } /// can override to return a minimum fill space. 0 default open func minimumFillSpace() -> CGFloat { return 0 } //MARK: - Functions to subclass /// Subclass for a top view. open func viewForTop() -> UIView? { return nil } /// Subclass for a bottom view. open func viewForBottom() -> UIView? { return nil } //MARK: - Collection /// Should be used to refresh the layout of the collection view. Updates flexible padding. open func invalidateCollectionLayout() { self.collectionView?.collectionViewLayout.invalidateLayout() // TODO: Improve this workaround (autolayout for cells happens async so contentSize isn't accurate immediately) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { self.updateFlexibleSpace() }) } /// Should be used to reload the data of the collection view. Updates flexible padding. open func reloadCollectionData() { collectionView?.reloadData() // TODO: Improve this workaround (autolayout for cells happens async so contentSize isn't accurate immediately) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { self.updateFlexibleSpace() }) } /// Called in updateView, updates the cell. open func update(cell: UICollectionViewCell, size: CGFloat) { (cell as? MVMCoreViewProtocol)?.updateView(size) } open override func registerCells() { super.registerCells() collectionView?.register(ContainerCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: headerID) collectionView?.register(ContainerCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: footerID) } open override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 0 } open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { guard section == 0 else { return .zero } // Calculate the height of the header since apple doesn't support autolayout. Width is fixed, height is tall as content. let header = headerView ?? self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: IndexPath(row: 0, section: section)) return header.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) } open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { guard section == numberOfSections(in: collectionView) - 1 else { return .zero } // Calculate the height of the footr since apple doesn't support autolayout. Width is fixed, height is tall as content. let footer = footerView ?? self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: IndexPath(row: 0, section: section)) return footer.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) } open func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { if kind == UICollectionView.elementKindSectionFooter, let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: footerID, for: indexPath) as? ContainerCollectionReusableView { let bottomView = (bottomViewOutsideOfScrollArea ? nil : self.bottomView) ?? MVMCoreUICommonViewsUtility.getView(with: 0.5) footerView.addAndContain(view: bottomView) footerView.topConstraint?.constant = spaceAboveBottomView() ?? 0 self.footerView = footerView return footerView } else if kind == UICollectionView.elementKindSectionHeader, let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: headerID, for: indexPath) as? ContainerCollectionReusableView { let topView = (topViewOutsideOfScrollArea ? nil : self.topView) ?? MVMCoreUICommonViewsUtility.getView(with: 0.5) headerView.addAndContain(view: topView) headerView.bottomConstraint?.constant = spaceBelowTopView() ?? 0 self.headerView = headerView return headerView } fatalError() } }