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

import SignalServiceKit

public extension UIViewPropertyAnimator {

    convenience init(
        duration: TimeInterval,
        springDamping: CGFloat,
        springResponse: CGFloat,
        initialVelocity velocity: CGVector = .zero,
    ) {
        let stiffness = pow(2 * .pi / springResponse, 2)
        let damping = 4 * .pi * springDamping / springResponse
        let timingParameters = UISpringTimingParameters(
            mass: 1,
            stiffness: stiffness,
            damping: damping,
            initialVelocity: velocity,
        )
        self.init(duration: duration, timingParameters: timingParameters)
        isUserInteractionEnabled = true
    }
}

public extension UIView {

    func animateDecelerationToVerticalEdge(
        withDuration duration: TimeInterval,
        velocity: CGPoint,
        velocityThreshold: CGFloat = 500,
        boundingRect: CGRect,
        completion: ((Bool) -> Void)? = nil,
    ) {
        var velocity = velocity
        if abs(velocity.x) < velocityThreshold { velocity.x = 0 }
        if abs(velocity.y) < velocityThreshold { velocity.y = 0 }

        let currentPosition = frame.origin

        let referencePoint: CGPoint
        if velocity != .zero {
            // Calculate the time until we intersect with each edge with
            // a constant velocity.

            // time = (end position - start position) / velocity

            let timeUntilVerticalEdge: CGFloat
            if velocity.x > 0 {
                timeUntilVerticalEdge = ((boundingRect.maxX - width) - currentPosition.x) / velocity.x
            } else if velocity.x < 0 {
                timeUntilVerticalEdge = (boundingRect.minX - currentPosition.x) / velocity.x
            } else {
                timeUntilVerticalEdge = .greatestFiniteMagnitude
            }

            let timeUntilHorizontalEdge: CGFloat
            if velocity.y > 0 {
                timeUntilHorizontalEdge = ((boundingRect.maxY - height) - currentPosition.y) / velocity.y
            } else if velocity.y < 0 {
                timeUntilHorizontalEdge = (boundingRect.minY - currentPosition.y) / velocity.y
            } else {
                timeUntilHorizontalEdge = .greatestFiniteMagnitude
            }

            // See which edge we intersect with first and calculate the position
            // on the other axis when we reach that intersection point.

            // end position = (time * velocity) + start position

            let intersectPoint: CGPoint
            if timeUntilHorizontalEdge > timeUntilVerticalEdge {
                intersectPoint = CGPoint(
                    x: velocity.x > 0 ? (boundingRect.maxX - width) : boundingRect.minX,
                    y: (timeUntilVerticalEdge * velocity.y) + currentPosition.y,
                )
            } else {
                intersectPoint = CGPoint(
                    x: (timeUntilHorizontalEdge * velocity.x) + currentPosition.x,
                    y: velocity.y > 0 ? (boundingRect.maxY - height) : boundingRect.minY,
                )
            }

            referencePoint = intersectPoint
        } else {
            referencePoint = currentPosition
        }

        let destinationFrame = CGRect(origin: referencePoint, size: frame.size).pinnedToVerticalEdge(of: boundingRect)
        let distance = destinationFrame.origin.distance(currentPosition)

        UIView.animate(
            withDuration: duration,
            delay: 0,
            usingSpringWithDamping: 1,
            initialSpringVelocity: abs(velocity.length / distance),
            options: .curveEaseOut,
            animations: { self.frame = destinationFrame },
            completion: completion,
        )
    }

    func setIsHidden(_ isHidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) {
        setIsHidden(isHidden, withAnimationDuration: animated ? 0.2 : 0, completion: completion)
    }

    func setIsHidden(_ isHidden: Bool, withAnimationDuration duration: TimeInterval, completion: ((Bool) -> Void)? = nil) {
        guard duration > 0, isHidden != self.isHidden else {
            self.isHidden = isHidden
            completion?(true)
            return
        }

        let initialAlpha = alpha
        if !isHidden, initialAlpha > 0 {
            UIView.performWithoutAnimation {
                self.alpha = 0
                self.isHidden = false
            }
        }

        UIView.animate(
            withDuration: duration,
            animations: {
                self.alpha = isHidden ? 0 : initialAlpha
            },
            completion: { finished in
                guard finished else {
                    completion?(false)
                    return
                }
                self.isHidden = isHidden
                self.alpha = initialAlpha
                completion?(true)
            },
        )
    }
}

public extension UIView.AnimationCurve {

    var asAnimationOptions: UIView.AnimationOptions {
        switch self {
        case .easeInOut:
            return .curveEaseInOut
        case .easeIn:
            return .curveEaseIn
        case .easeOut:
            return .curveEaseOut
        case .linear:
            return .curveLinear
        @unknown default:
            return .curveEaseInOut
        }
    }
}

public extension Optional where Wrapped == UIView.AnimationCurve {

    var asAnimationOptions: UIView.AnimationOptions {
        return (self ?? .easeInOut).asAnimationOptions
    }
}

// MARK: - Corners

public extension UIView {

    static func uiRectCorner(forOWSDirectionalRectCorner corner: OWSDirectionalRectCorner) -> UIRectCorner {
        if corner == .allCorners {
            return .allCorners
        }

        var result: UIRectCorner = []
        let isRTL = CurrentAppContext().isRTL

        if corner.contains(.topLeading) {
            result.insert(isRTL ? .topRight : .topLeft)
        }
        if corner.contains(.topTrailing) {
            result.insert(isRTL ? .topLeft : .topRight)
        }
        if corner.contains(.bottomTrailing) {
            result.insert(isRTL ? .bottomLeft : .bottomRight)
        }
        if corner.contains(.bottomLeading) {
            result.insert(isRTL ? .bottomRight : .bottomLeft)
        }
        return result
    }
}

public extension UIBezierPath {
    /// Create a roundedRect path with two different corner radii.
    ///
    /// - Parameters:
    ///   - rect: The outer bounds of the roundedRect.
    ///   - sharpCorners: The corners that should use `sharpCornerRadius`. The
    ///     other corners will use `wideCornerRadius`.
    ///   - sharpCornerRadius: The corner radius of `sharpCorners`.
    ///   - wideCornerRadius: The corner radius of non-`sharpCorners`.
    ///
    static func roundedRect(
        _ rect: CGRect,
        sharpCorners: UIRectCorner,
        sharpCornerRadius: CGFloat,
        wideCornerRadius: CGFloat,
    ) -> UIBezierPath {

        return roundedRect(
            rect,
            sharpCorners: sharpCorners,
            sharpCornerRadius: sharpCornerRadius,
            wideCorners: .allCorners.subtracting(sharpCorners),
            wideCornerRadius: wideCornerRadius,
        )
    }

    /// Create a roundedRect path with two different corner radii.
    ///
    /// The behavior is undefined if `sharpCorners` and `wideCorners` overlap.
    ///
    /// - Parameters:
    ///   - rect: The outer bounds of the roundedRect.
    ///   - sharpCorners: The corners that should use `sharpCornerRadius`.
    ///   - sharpCornerRadius: The corner radius of `sharpCorners`.
    ///   - wideCorners: The corners that should use `wideCornerRadius`.
    ///   - wideCornerRadius: The corner radius of `wideCorners`.
    ///
    static func roundedRect(
        _ rect: CGRect,
        sharpCorners: UIRectCorner,
        sharpCornerRadius: CGFloat,
        wideCorners: UIRectCorner,
        wideCornerRadius: CGFloat,
    ) -> UIBezierPath {

        assert(sharpCorners.isDisjoint(with: wideCorners))

        func cornerRounding(forCorner corner: UIRectCorner) -> CGFloat {
            if sharpCorners.contains(corner) {
                return sharpCornerRadius
            }
            if wideCorners.contains(corner) {
                return wideCornerRadius
            }
            return 0
        }

        return UIBezierPath.roundedRect(
            rect,
            topLeftRounding: cornerRounding(forCorner: .topLeft),
            topRightRounding: cornerRounding(forCorner: .topRight),
            bottomRightRounding: cornerRounding(forCorner: .bottomRight),
            bottomLeftRounding: cornerRounding(forCorner: .bottomLeft),
        )
    }

    static func roundedRect(
        _ rect: CGRect,
        topLeftRounding: CGFloat,
        topRightRounding: CGFloat,
        bottomRightRounding: CGFloat,
        bottomLeftRounding: CGFloat,
    ) -> UIBezierPath {

        let topAngle = CGFloat.halfPi * 3
        let rightAngle = CGFloat.halfPi * 0
        let bottomAngle = CGFloat.halfPi * 1
        let leftAngle = CGFloat.halfPi * 2

        let bubbleLeft = rect.minX
        let bubbleTop = rect.minY
        let bubbleRight = rect.maxX
        let bubbleBottom = rect.maxY

        let bezierPath = UIBezierPath()

        // starting just to the right of the top left corner and working clockwise
        bezierPath.move(to: CGPoint(x: bubbleLeft + topLeftRounding, y: bubbleTop))

        // top right corner
        bezierPath.addArc(
            withCenter: CGPoint(
                x: bubbleRight - topRightRounding,
                y: bubbleTop + topRightRounding,
            ),
            radius: topRightRounding,
            startAngle: topAngle,
            endAngle: rightAngle,
            clockwise: true,
        )

        // bottom right corner
        bezierPath.addArc(
            withCenter: CGPoint(
                x: bubbleRight - bottomRightRounding,
                y: bubbleBottom - bottomRightRounding,
            ),
            radius: bottomRightRounding,
            startAngle: rightAngle,
            endAngle: bottomAngle,
            clockwise: true,
        )

        // bottom left corner
        bezierPath.addArc(
            withCenter: CGPoint(
                x: bubbleLeft + bottomLeftRounding,
                y: bubbleBottom - bottomLeftRounding,
            ),
            radius: bottomLeftRounding,
            startAngle: bottomAngle,
            endAngle: leftAngle,
            clockwise: true,
        )

        // top left corner
        bezierPath.addArc(
            withCenter: CGPoint(
                x: bubbleLeft + topLeftRounding,
                y: bubbleTop + topLeftRounding,
            ),
            radius: topLeftRounding,
            startAngle: leftAngle,
            endAngle: topAngle,
            clockwise: true,
        )

        return bezierPath
    }
}

// MARK: CoreAnimation

private class CALayerDelegateNoAnimations: NSObject, CALayerDelegate {
    /* If defined, called by the default implementation of the
     * -actionForKey: method. Should return an object implementing the
     * CAAction protocol. May return 'nil' if the delegate doesn't specify
     * a behavior for the current event. Returning the null object (i.e.
     * '[NSNull null]') explicitly forces no further search. (I.e. the
     * +defaultActionForKey: method will not be called.) */
    func action(for layer: CALayer, forKey event: String) -> CAAction? {
        NSNull()
    }
}

extension CALayer {

    private static let delegateNoAnimations = CALayerDelegateNoAnimations()

    public func disableAnimationsWithDelegate() {
        owsAssertDebug(self.delegate == nil)

        self.delegate = Self.delegateNoAnimations
    }
}

public extension CGAffineTransform {
    static func translate(_ point: CGPoint) -> CGAffineTransform {
        CGAffineTransform(translationX: point.x, y: point.y)
    }

    static func scale(_ scaling: CGFloat) -> CGAffineTransform {
        CGAffineTransform(scaleX: scaling, y: scaling)
    }

    static func rotate(_ angleRadians: CGFloat) -> CGAffineTransform {
        CGAffineTransform(rotationAngle: angleRadians)
    }

    func translate(_ point: CGPoint) -> CGAffineTransform {
        translatedBy(x: point.x, y: point.y)
    }

    func scale(_ scaling: CGFloat) -> CGAffineTransform {
        scaledBy(x: scaling, y: scaling)
    }

    func rotate(_ angleRadians: CGFloat) -> CGAffineTransform {
        rotated(by: angleRadians)
    }
}

public extension CACornerMask {
    static let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
    static let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
    static let left: CACornerMask = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
    static let right: CACornerMask = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]

    static let all: CACornerMask = top.union(bottom)
}