Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ViewControllers/SheetViewController.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit

open class SheetViewController: UIViewController {

    public var dismissHandler: ((SheetViewController) -> Void)?

    public let contentView: UIView = UIView()

    private let sheetView: SheetView = SheetView()
    private let handleView: UIView = UIView()

    public var isHandleHidden: Bool {
        get { handleView.isHidden }
        set { handleView.isHidden = newValue }
    }

    deinit {
        Logger.verbose("")
    }

    override public init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        self.transitioningDelegate = self
        self.modalPresentationStyle = .overCurrentContext
    }

    public required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: View LifeCycle

    var sheetViewVerticalConstraint: NSLayoutConstraint?

    override public func loadView() {
        self.view = UIView()

        sheetView.preservesSuperviewLayoutMargins = true

        sheetView.addSubview(contentView)
        contentView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
        contentView.autoPinEdge(toSuperviewMargin: .bottom)

        view.addSubview(sheetView)
        sheetView.autoPinWidthToSuperview()
        sheetView.setContentHuggingVerticalHigh()
        sheetView.setCompressionResistanceHigh()
        self.sheetViewVerticalConstraint = sheetView.autoPinEdge(.top, to: .bottom, of: self.view)

        handleView.backgroundColor = Theme.isDarkThemeEnabled ? UIColor.ows_white : UIColor.ows_gray05
        let kHandleViewHeight: CGFloat = 5
        handleView.autoSetDimensions(to: CGSize(width: 40, height: kHandleViewHeight))
        handleView.layer.cornerRadius = kHandleViewHeight / 2
        view.addSubview(handleView)
        handleView.autoAlignAxis(.vertical, toSameAxisOf: sheetView)
        handleView.autoPinEdge(.bottom, to: .top, of: sheetView, withOffset: -6)

        // Gestures
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBackground))
        self.view.addGestureRecognizer(tapGesture)

        let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeDown))
        swipeDownGesture.direction = .down
        self.view.addGestureRecognizer(swipeDownGesture)
    }

    // MARK: Present / Dismiss animations

    fileprivate func animatePresentation(completion: @escaping (Bool) -> Void) {
        guard let sheetViewVerticalConstraint = self.sheetViewVerticalConstraint else {
            owsFailDebug("sheetViewVerticalConstraint was unexpectedly nil")
            return
        }

        let backgroundDuration: TimeInterval = 0.1
        UIView.animate(withDuration: backgroundDuration) {
            self.view.backgroundColor = .Signal.backdrop
        }

        self.sheetView.superview?.layoutIfNeeded()

        NSLayoutConstraint.deactivate([sheetViewVerticalConstraint])
        self.sheetViewVerticalConstraint = self.sheetView.autoPinEdge(toSuperviewEdge: .bottom)
        UIView.animate(
            withDuration: 0.2,
            delay: backgroundDuration,
            options: .curveEaseOut,
            animations: {
                self.sheetView.superview?.layoutIfNeeded()
            },
            completion: completion,
        )
    }

    fileprivate func animateDismiss(completion: @escaping (Bool) -> Void) {
        guard let sheetViewVerticalConstraint = self.sheetViewVerticalConstraint else {
            owsFailDebug("sheetVerticalConstraint was unexpectedly nil")
            return
        }

        self.sheetView.superview?.layoutIfNeeded()
        NSLayoutConstraint.deactivate([sheetViewVerticalConstraint])

        let dismissDuration: TimeInterval = 0.2
        self.sheetViewVerticalConstraint = self.sheetView.autoPinEdge(.top, to: .bottom, of: self.view)
        UIView.animate(
            withDuration: dismissDuration,
            delay: 0,
            options: .curveEaseOut,
            animations: {
                self.view.backgroundColor = UIColor.clear
                self.sheetView.superview?.layoutIfNeeded()
            },
            completion: completion,
        )
    }

    // MARK: Actions

    @objc
    private func didTapBackground() {
        dismissHandler?(self)
    }

    @objc
    private func didSwipeDown() {
        dismissHandler?(self)
    }
}

extension SheetViewController: UIViewControllerTransitioningDelegate {
    public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return SheetViewPresentationController(sheetViewController: self)
    }

    public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return SheetViewDismissalController(sheetViewController: self)
    }
}

private class SheetViewPresentationController: NSObject, UIViewControllerAnimatedTransitioning {

    let sheetViewController: SheetViewController
    init(sheetViewController: SheetViewController) {
        self.sheetViewController = sheetViewController
    }

    // This is used for percent driven interactive transitions, as well as for
    // container controllers that have companion animations that might need to
    // synchronize with the main animation.
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }

    // This method can only be a nop if the transition is interactive and not a percentDriven interactive transition.
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        Logger.debug("")
        transitionContext.containerView.addSubview(sheetViewController.view)
        sheetViewController.view.autoPinEdgesToSuperviewEdges()
        sheetViewController.animatePresentation { didComplete in
            Logger.debug("completed: \(didComplete)")
            transitionContext.completeTransition(didComplete)
        }
    }
}

private class SheetViewDismissalController: NSObject, UIViewControllerAnimatedTransitioning {

    let sheetViewController: SheetViewController
    init(sheetViewController: SheetViewController) {
        self.sheetViewController = sheetViewController
    }

    // This is used for percent driven interactive transitions, as well as for
    // container controllers that have companion animations that might need to
    // synchronize with the main animation.
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }

    // This method can only be a nop if the transition is interactive and not a percentDriven interactive transition.
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        Logger.debug("")
        sheetViewController.animateDismiss { didComplete in
            Logger.debug("completed: \(didComplete)")
            transitionContext.completeTransition(didComplete)
        }
    }
}

private class SheetView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = Theme.isDarkThemeEnabled ? UIColor.ows_gray90
            : UIColor.ows_gray05
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var bounds: CGRect {
        didSet {
            updateMask()
        }
    }

    private func updateMask() {
        let cornerRadius: CGFloat = 16
        let path: UIBezierPath = UIBezierPath(
            roundedRect: bounds,
            byRoundingCorners: [.topLeft, .topRight],
            cornerRadii: CGSize(square: cornerRadius),
        )
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        self.layer.mask = mask
    }
}