/***************************************************************************** * ActionSheet.swift * * Copyright © 2018 VLC authors and VideoLAN * Copyright © 2018 Videolabs * * Authors: Soomin Lee * * Refer to the COPYING file of the official project for license. *****************************************************************************/ import Foundation import UIKit @objc(VLCActionSheetDataSource) protocol ActionSheetDataSource { @objc func numberOfRows() -> Int @objc func actionSheet(collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell } @objc(VLCActionSheetDelegate) protocol ActionSheetDelegate { @objc optional func headerViewTitle() -> String? @objc func itemAtIndexPath(_ indexPath: IndexPath) -> Any? @objc optional func actionSheet(collectionView: UICollectionView, didSelectItem item: Any, At indexPath: IndexPath) } // MARK: ActionSheet @objc(VLCActionSheet) class ActionSheet: UIViewController { private let cellHeight: CGFloat = 50 @objc weak var dataSource: ActionSheetDataSource? @objc weak var delegate: ActionSheetDelegate? var action: ((_ item: Any) -> Void)? lazy var backgroundView: UIView = { let backgroundView = UIView() backgroundView.alpha = 0 backgroundView.autoresizingMask = [.flexibleHeight, .flexibleWidth] backgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6) backgroundView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.removeActionSheet))) return backgroundView }() lazy var collectionViewLayout: UICollectionViewFlowLayout = { let collectionViewLayout = UICollectionViewFlowLayout() collectionViewLayout.minimumLineSpacing = 1 collectionViewLayout.minimumInteritemSpacing = 0 return collectionViewLayout }() @objc lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: UIScreen.main.bounds, collectionViewLayout: collectionViewLayout) collectionView.delegate = self collectionView.dataSource = self collectionView.backgroundColor = PresentationTheme.current.colors.background collectionView.alwaysBounceVertical = true collectionView.showsVerticalScrollIndicator = false collectionView.register(ActionSheetCell.self, forCellWithReuseIdentifier: ActionSheetCell.identifier) collectionView.translatesAutoresizingMaskIntoConstraints = false return collectionView }() lazy var headerView: ActionSheetSectionHeader = { let headerView = ActionSheetSectionHeader() headerView.title.text = delegate?.headerViewTitle?() ?? "Default header title" headerView.title.textColor = PresentationTheme.current.colors.cellTextColor headerView.backgroundColor = PresentationTheme.current.colors.background headerView.translatesAutoresizingMaskIntoConstraints = false return headerView }() lazy var bottomBackgroundView: UIView = { let bottomBackgroundView = UIView() bottomBackgroundView.backgroundColor = PresentationTheme.current.colors.background bottomBackgroundView.translatesAutoresizingMaskIntoConstraints = false return bottomBackgroundView }() lazy var mainStackView: UIStackView = { let mainStackView = UIStackView() mainStackView.spacing = 0 mainStackView.axis = .vertical mainStackView.alignment = .center mainStackView.translatesAutoresizingMaskIntoConstraints = false return mainStackView }() fileprivate lazy var maxCollectionViewHeightConstraint: NSLayoutConstraint = { let maxCollectionViewHeightConstraint = collectionView.heightAnchor.constraint( lessThanOrEqualToConstant: (view.bounds.height / 2) - cellHeight) return maxCollectionViewHeightConstraint }() fileprivate lazy var collectionViewHeightConstraint: NSLayoutConstraint = { guard let dataSource = dataSource else { preconditionFailure("VLCActionSheet: Data source not set correctly!") } let collectionViewHeightConstraint = collectionView.heightAnchor.constraint( equalToConstant: CGFloat(dataSource.numberOfRows()) * cellHeight) collectionViewHeightConstraint.priority = .required - 1 return collectionViewHeightConstraint }() fileprivate lazy var bottomBackgroundViewHeightConstraint: NSLayoutConstraint = { let bottomBackgroundViewHeightConstraint = bottomBackgroundView.heightAnchor.constraint(equalToConstant: 0) return bottomBackgroundViewHeightConstraint }() override func updateViewConstraints() { super.updateViewConstraints() if let presentingViewController = presentingViewController, let dataSource = dataSource { collectionViewHeightConstraint.constant = CGFloat(dataSource.numberOfRows()) * cellHeight maxCollectionViewHeightConstraint.constant = presentingViewController.view.frame.size.height / 2 collectionView.setNeedsLayout() collectionView.layoutIfNeeded() } } @available(iOS 11.0, *) override func viewSafeAreaInsetsDidChange() { bottomBackgroundViewHeightConstraint.constant = view.safeAreaInsets.bottom } // MARK: UIViewController override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .VLCThemeDidChangeNotification, object: nil) view.addSubview(backgroundView) view.addSubview(mainStackView) mainStackView.addArrangedSubview(headerView) mainStackView.addArrangedSubview(collectionView) mainStackView.addArrangedSubview(bottomBackgroundView) backgroundView.frame = UIScreen.main.bounds setupMainStackViewConstraints() setupCollectionViewConstraints() setuplHeaderViewConstraints() setupBottomBackgroundView() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) mainStackView.isHidden = true collectionView.reloadData() updateViewConstraints() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) setHeaderRoundedCorners() // This is to avoid a horrible visual glitch! mainStackView.isHidden = false let realMainStackView = mainStackView.frame mainStackView.frame.origin.y += mainStackView.frame.origin.y UIView.animate(withDuration: 0.65, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseOut, animations: { [mainStackView, backgroundView] in mainStackView.frame = realMainStackView backgroundView.alpha = 1 }) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { [weak self] _ in self?.maxCollectionViewHeightConstraint.constant = size.height / 2 self?.collectionView.layoutIfNeeded() self?.setHeaderRoundedCorners() }) } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() collectionView.collectionViewLayout.invalidateLayout() } @objc fileprivate func updateTheme() { collectionView.backgroundColor = PresentationTheme.current.colors.background headerView.backgroundColor = PresentationTheme.current.colors.background headerView.title.textColor = PresentationTheme.current.colors.cellTextColor bottomBackgroundView.backgroundColor = PresentationTheme.current.colors.background for cell in collectionView.visibleCells { if let cell = cell as? ActionSheetCell { cell.backgroundColor = PresentationTheme.current.colors.background cell.name.textColor = PresentationTheme.current.colors.cellTextColor } } collectionView.layoutIfNeeded() } } // MARK: Private setup methods private extension ActionSheet { private func setuplHeaderViewConstraints() { NSLayoutConstraint.activate([ headerView.heightAnchor.constraint(equalToConstant: cellHeight), headerView.widthAnchor.constraint(equalTo: view.widthAnchor), ]) } private func setupCollectionViewConstraints() { NSLayoutConstraint.activate([ collectionViewHeightConstraint, maxCollectionViewHeightConstraint, collectionView.widthAnchor.constraint(equalTo: view.widthAnchor), ]) } private func setupBottomBackgroundView() { NSLayoutConstraint.activate([ bottomBackgroundViewHeightConstraint, bottomBackgroundView.widthAnchor.constraint(equalTo: view.widthAnchor) ]) } private func setupMainStackViewConstraints() { NSLayoutConstraint.activate([ // Extra padding for spring animation mainStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 10), mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), mainStackView.widthAnchor.constraint(equalTo: view.widthAnchor), mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } } // MARK: Helpers private extension ActionSheet { private func setHeaderRoundedCorners() { let roundedCornerPath = UIBezierPath(roundedRect: headerView.bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 10, height: 10)) let maskLayer = CAShapeLayer() maskLayer.path = roundedCornerPath.cgPath headerView.layer.mask = maskLayer } } // MARK: Actions extension ActionSheet { @objc func setAction(closure action: @escaping (_ item: Any) -> Void) { self.action = action } @objc func removeActionSheet() { let realMainStackView = mainStackView.frame UIView.animate(withDuration: 0.55, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: .curveEaseIn, animations: { [mainStackView, backgroundView] in // Dismiss the mainStackView to the bottom of the screen mainStackView.frame.origin.y += mainStackView.frame.size.height backgroundView.alpha = 0 }, completion: { [unowned self, presentingViewController] finished in // When everything is complete, reset the frame for the re-use self.mainStackView.frame = realMainStackView presentingViewController?.dismiss(animated: false, completion: nil) }) } } // MARK: UICollectionViewDelegateFlowLayout extension ActionSheet: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: collectionView.frame.width, height: cellHeight) } } // MARK: UICollectionViewDelegate extension ActionSheet: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if let delegate = delegate, let item = delegate.itemAtIndexPath(indexPath) { delegate.actionSheet?(collectionView: collectionView, didSelectItem: item, At: indexPath) action?(item) } removeActionSheet() } } // MARK: UICollectionViewDataSource extension ActionSheet: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { if let dataSource = dataSource { return dataSource.numberOfRows() } preconditionFailure("VLCActionSheet: No data source") } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if let dataSource = dataSource { return dataSource.actionSheet(collectionView: collectionView, cellForItemAt: indexPath) } preconditionFailure("VLCActionSheet: No data source") } }