jjochen/JJFloatingActionButton

View on GitHub
Sources/AnimationConfiguration.swift

Summary

Maintainability
A
2 hrs
Test Coverage
//
//  AnimationConfiguration.swift
//
//  Copyright (c) 2017-Present Jochen Pfeiffer
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
//

import UIKit

// MARK: - JJAnimationSettings

/// General animation configuration settings
///
@objc public class JJAnimationSettings: NSObject {
    /// Duration of the animation.
    /// Default is `0.3`
    ///
    @objc public var duration: TimeInterval = 0.3

    /// Damping ratio of the animation.
    /// Default is `0.55`
    ///
    /// - Remark: Not used for transitions.
    ///
    @objc public var dampingRatio: CGFloat = 0.55

    /// Initial velocity of the animation.
    /// Default is `0.3`
    ///
    /// - Remark: Not used for transitions.
    ///
    @objc public var initialVelocity: CGFloat = 0.3

    /// Delay in between two item animations.
    /// Default is `0.1`
    ///
    /// - Remark: Only used for item animations.
    ///
    @objc public var interItemDelay: TimeInterval = 0.1

    /// Initializes and returns a newly allocated animation settings object with specified parameters.
    ///
    /// - Parameter duration: Duration of the animation. Default is `0.3`.
    /// - Parameter dampingRatio: Damping ratio of the animation. Default is `0.55`
    /// - Parameter initialVelocity: Initial velocity of the animation. Default is `0.3`
    /// - Parameter interItemDelay: Delay in between two item animations. Default is `0.1`
    ///
    /// - Returns: An initialized animation settings object.
    ///
    @objc public convenience init(duration: TimeInterval = 0.3,
                                  dampingRatio: CGFloat = 0.55,
                                  initialVelocity: CGFloat = 0.3,
                                  interItemDelay: TimeInterval = 0.1) {
        self.init()
        self.duration = duration
        self.dampingRatio = dampingRatio
        self.initialVelocity = initialVelocity
        self.interItemDelay = interItemDelay
    }
}

// MARK: - JJButtonAnimationConfiguration

///
/// A typealias representing a closure that calculates the angle for each item in a floating action button.
///
/// - Parameters:
///     - index: The index of the item.
///     - numberOfItems: The total number of items.
///     - actionButton: The floating action button.
///
/// - Returns: The angle in radians for the specified item.
///
public typealias JJItemAngle = (_ index: Int, _ numberOfItems: Int, _ actionButton: JJFloatingActionButton) -> CGFloat

/// Button animation configuration
///
@objc public class JJButtonAnimationConfiguration: NSObject {
    /// Initializes and returns a newly allocated button animation configuration object with the specified style.
    ///
    /// - Parameter style: The animation style.
    ///
    /// - Returns: An initialized button animation configuration object.
    ///
    @objc public init(withStyle style: JJButtonAnimationStyle) {
        self.style = style
    }

    /// Button animation style
    ///
    @objc public enum JJButtonAnimationStyle: Int {
        /// Rotate button image to given angle.
        ///
        case rotation

        /// Transition to given image.
        ///
        case transition
    }

    /// Button animation style
    /// Possible values:
    ///   - `.rotation`
    ///   - `.transition`
    ///
    @objc public let style: JJButtonAnimationStyle

    /// The angle in radian the button will rotate to when opening.
    ///
    /// - Remark: Is ignored for style `.rotation`
    ///
    @objc public var angle: CGFloat = 0

    /// The image button will transition to when opening.
    ///
    /// - Remark: Is ignored for style `.transition`
    ///
    @objc public var image: UIImage?

    /// Animation settings for opening animation.
    /// Default values are:
    ///   - `duration = 0.3`
    ///   - `dampingRatio = 0.55`
    ///   - `initialVelocity = 0.3`
    ///
    @objc public lazy var opening = JJAnimationSettings(duration: 0.3, dampingRatio: 0.55, initialVelocity: 0.3)

    /// Animation settings for closing animation.
    /// Default values are:
    ///   - `duration = 0.3`
    ///   - `dampingRatio = 0.6`
    ///   - `initialVelocity = 0.8`
    ///
    @objc public lazy var closing = JJAnimationSettings(duration: 0.3, dampingRatio: 0.6, initialVelocity: 0.8)
}

@objc public extension JJButtonAnimationConfiguration {
    /// Returns a button animation configuration that rotates the button image by given angle.
    ///
    /// - Parameter angle: The angle in radian the button will rotate to when opening.
    ///
    /// - Returns: A button animation configuration object.
    ///
    static func rotation(toAngle angle: CGFloat = -.pi / 4) -> JJButtonAnimationConfiguration {
        let configuration = JJButtonAnimationConfiguration(withStyle: .rotation)
        configuration.angle = angle
        return configuration
    }

    /// Returns a button animation configuration that transitions to a given image.
    ///
    /// - Parameter image: The image button will transition to when opening.
    ///
    /// - Returns: A button animation configuration object.
    ///
    static func transition(toImage image: UIImage) -> JJButtonAnimationConfiguration {
        let configuration = JJButtonAnimationConfiguration(withStyle: .transition)
        configuration.image = image
        return configuration
    }
}

// MARK: - JJItemAnimationConfiguration

/// Item animation configuration
///
@objc public class JJItemAnimationConfiguration: NSObject {
    /// Animation settings for opening animation.
    /// Default values are:
    ///   - `duration = 0.3`
    ///   - `dampingRatio = 0.55`
    ///   - `initialVelocity = 0.3`
    ///   - `interItemDelay = 0.1`
    ///
    @objc public lazy var opening = JJAnimationSettings(duration: 0.3, dampingRatio: 0.55, initialVelocity: 0.3, interItemDelay: 0.1)

    /// Animation settings for closing animation.
    /// Default values are:
    ///   - `duration = 0.3`
    ///   - `dampingRatio = 0.6`
    ///   - `initialVelocity = 0.8`
    ///   - `interItemDelay = 0.1`
    ///
    @objc public lazy var closing = JJAnimationSettings(duration: 0.15, dampingRatio: 0.6, initialVelocity: 0.8, interItemDelay: 0.1)

    /// Defines the layout of the acton items when opened.
    /// Default is a layout in a vertical line with 12 points inter item spacing
    ///
    @objc public var itemLayout: JJItemLayout = .verticalLine()

    /// Configures the items before opening. The change from open to closed state is animated.
    /// Default is a scale by factor `0.4` and `item.alpha = 0`.
    ///
    @objc public var closedState: JJItemPreparation = .scale()

    /// Configures the items for open state. The change from open to closed state is animated.
    /// Default is `item.transform = .identity` and `item.alpha = 1`.
    ///
    @objc public var openState: JJItemPreparation = .identity()
}

@objc public extension JJItemAnimationConfiguration {
    /// Returns an item animation configuration with
    ///   - `itemLayout = .verticalLine()`
    ///   - `closedState = .scale()`
    ///
    /// - Parameter interItemSpacing: The distance between two adjacent items. Default is `12`.
    /// - Parameter firstItemSpacing: The distance between the action button and the first action item.
    ///                               When `firstItemSpacing` is `0` or less `interItemSpacing` is used instead.
    ///                               Default is `0`.
    ///
    /// - Returns: An item animation configuration object.
    ///
    static func popUp(withInterItemSpacing interItemSpacing: CGFloat = 12,
                      firstItemSpacing: CGFloat = 0) -> JJItemAnimationConfiguration {
        let configuration = JJItemAnimationConfiguration()
        configuration.itemLayout = .verticalLine(withInterItemSpacing: interItemSpacing, firstItemSpacing: firstItemSpacing)
        configuration.closedState = .scale()
        return configuration
    }

    /// Returns an item animation configuration with
    ///   - `itemLayout = .verticalLine()`
    ///   - `closedState = .horizontalOffset()`
    ///
    /// - Parameter interItemSpacing: The distance between two adjacent items. Default is `12`.
    /// - Parameter firstItemSpacing: The distance between the action button and the first action item.
    ///                               When `firstItemSpacing` is `0` or less `interItemSpacing` is used instead.
    ///                               Default is `0`.
    ///
    /// - Returns: An item animation configuration object.
    ///
    static func slideIn(withInterItemSpacing interItemSpacing: CGFloat = 12,
                        firstItemSpacing: CGFloat = 0) -> JJItemAnimationConfiguration {
        let configuration = JJItemAnimationConfiguration()
        configuration.itemLayout = .verticalLine(withInterItemSpacing: interItemSpacing, firstItemSpacing: firstItemSpacing)
        configuration.closedState = .horizontalOffset()
        return configuration
    }

    /// Returns an item animation configuration with
    ///   - `itemLayout = .circular()`
    ///   - `closedState = .scale()`
    ///
    /// - Parameter radius: The distance between the center of an item and the center of the button itself.
    /// - Parameter angleForItem: A closure that calculates the angle for each item in a floating action button.
    ///                           Default is `JJItemAnimationConfiguration.angleForItem`.
    ///
    /// - Returns: An item animation configuration object.
    ///
    static func circularPopUp(withRadius radius: CGFloat = 100,
                              angleForItem: @escaping JJItemAngle = JJItemAnimationConfiguration.angleForItem)
        -> JJItemAnimationConfiguration {
        let configuration = JJItemAnimationConfiguration()
        configuration.itemLayout = .circular(withRadius: radius, angleForItem: angleForItem)
        configuration.closedState = .scale()
        configuration.opening.interItemDelay = 0.05
        configuration.closing.interItemDelay = 0.05
        return configuration
    }

    /// Returns an item animation configuration with
    ///   - `itemLayout = .circular()`
    ///   - `closedState = .circularOffset()`
    ///
    /// - Parameter radius: The distance between the center of an item and the center of the button itself.
    /// - Parameter angleForItem: A closure that calculates the angle for each item in a floating action button.
    ///                           Default is `JJItemAnimationConfiguration.angleForItem`.
    ///
    /// - Returns: An item animation configuration object.
    ///
    static func circularSlideIn(withRadius radius: CGFloat = 100,
                                angleForItem: @escaping JJItemAngle = JJItemAnimationConfiguration.angleForItem)
        -> JJItemAnimationConfiguration {
        let configuration = JJItemAnimationConfiguration()
        configuration.itemLayout = .circular(withRadius: radius, angleForItem: angleForItem)
        configuration.closedState = .circularOffset(distance: radius * 0.75, angleForItem: angleForItem)
        return configuration
    }
}

// MARK: - JJItemLayout

/// Item layout
///
@objc public class JJItemLayout: NSObject {
    /// A closure that defines the layout of given action items relative to an action button.
    ///
    @objc public var layout: (_ items: [JJActionItem], _ actionButton: JJFloatingActionButton) -> Void

    /// Initializes and returns a newly allocated item layout object with given layout closure.
    ///
    /// - Parameter layout: A closure that defines the the layout of given action items relative to an action button.
    ///
    /// - Returns: An initialized item layout object.
    ///
    @objc public init(layout: @escaping (_ items: [JJActionItem], _ actionButton: JJFloatingActionButton) -> Void) {
        self.layout = layout
    }

    /// Returns an item layout object that places the items in a vertical line with given inter item spacing.
    ///
    /// - Parameter interItemSpacing: The distance between two adjacent items.
    /// - Parameter firstItemSpacing: The distance between the action button and the first action item.
    ///                               When `firstItemSpacing` is 0 or less `interItemSpacing` is used instead.
    ///                               Default is 0.
    ///
    /// - Returns: An item layout object.
    ///
    @objc public static func verticalLine(withInterItemSpacing interItemSpacing: CGFloat = 12,
                                          firstItemSpacing: CGFloat = 0) -> JJItemLayout {
        return JJItemLayout { items, actionButton in
            var previousItem: JJActionItem?
            for item in items {
                let previousView = previousItem ?? actionButton
                let isFirstItem = (previousItem == nil)
                let spacing = selectSpacing(forFirstItem: isFirstItem, defaultSpacing: interItemSpacing, firstItemSpacing: firstItemSpacing)
                item.bottomAnchor.constraint(equalTo: previousView.topAnchor, constant: -spacing).isActive = true
                item.circleView.centerXAnchor.constraint(equalTo: actionButton.centerXAnchor).isActive = true
                previousItem = item
            }
        }
    }

    /// Returns an item layout object that places the items in a circle around the action button with given radius.
    ///
    /// - Parameter radius: The distance between the center of an item and the center of the button itself.
    /// - Parameter angleForItem: A closure that calculates the angle for each item in a floating action button.
    ///                           Default is `JJItemAnimationConfiguration.angleForItem`.
    ///
    /// - Returns: An item layout object.
    ///
    @objc public static func circular(withRadius radius: CGFloat = 100,
                                      angleForItem: @escaping JJItemAngle = JJItemAnimationConfiguration.angleForItem)
        -> JJItemLayout {
        return JJItemLayout { items, actionButton in
            let numberOfItems = items.count
            for (index, item) in items.enumerated() {
                let angle = angleForItem(index, numberOfItems, actionButton)
                let horizontalDistance = radius * cos(angle)
                let verticalDistance = radius * sin(angle)

                item.circleView.centerXAnchor.constraint(equalTo: actionButton.centerXAnchor, constant: horizontalDistance).isActive = true
                item.circleView.centerYAnchor.constraint(equalTo: actionButton.centerYAnchor, constant: verticalDistance).isActive = true
            }
        }
    }
}

// MARK: - JJItemPreparation

/// Item preparation
///
@objc public class JJItemPreparation: NSObject {
    /// A closure that prepares a given action item for animation.
    ///
    @objc public var prepare: (_ item: JJActionItem, _ index: Int, _ numberOfItems: Int, _ actionButton: JJFloatingActionButton) -> Void

    /// Initializes and returns a newly allocated item preparation object with given prepare closure.
    ///
    /// - Parameter layout: A closure that defines the the layout of given action items relative to an action button.
    ///
    /// - Returns: An initialized item layout object.
    ///
    @objc public init(prepare: @escaping (_ item: JJActionItem,
                                          _ index: Int,
                                          _ numberOfItems: Int,
                                          _ actionButton: JJFloatingActionButton) -> Void) {
        self.prepare = prepare
    }

    /// Returns an item preparation object that
    ///   - sets `item.alpha` to `1` and
    ///   - `item.transform` to `identity`.
    ///
    /// - Returns: An item preparation object.
    ///
    @objc public static func identity() -> JJItemPreparation {
        return JJItemPreparation { item, _, _, _ in
            item.transform = .identity
            item.alpha = 1
        }
    }

    /// Returns an item preparation object that
    ///   - sets `item.alpha` to `0` and
    ///   - scales the item by given ratio.
    ///
    /// - Parameter ratio: The factor by which the item is scaled
    ///
    /// - Returns: An item preparation object.
    ///
    @objc public static func scale(by ratio: CGFloat = 0.4) -> JJItemPreparation {
        return JJItemPreparation { item, _, _, _ in
            item.scale(by: ratio)
            item.alpha = 0
        }
    }

    /// Returns an item preparation object that
    ///   - sets `item.alpha` to `0`,
    ///   - offsets the item by given values and
    ///   - scales the item by given ratio.
    ///
    /// - Parameter translationX: The value in points by which the item is offsetted horizontally
    /// - Parameter translationY: The value in points by which the item is offsetted vertically
    /// - Parameter scale: The factor by which the item is scaled
    ///
    /// - Returns: An item preparation object.
    ///
    @objc public static func offset(translationX: CGFloat, translationY: CGFloat, scale: CGFloat = 0.4) -> JJItemPreparation {
        return JJItemPreparation { item, _, _, _ in
            item.scale(by: scale, translationX: translationX, translationY: translationY)
            item.alpha = 0
        }
    }

    /// Returns an item preparation object that
    ///   - sets `item.alpha` to `0`,
    ///   - offsets the item horizontally by given values.
    ///
    /// - Parameter distance: The value in points by which the item is offsetted horizontally
    ///                       towards the closest vertical edge of the screen.
    /// - Parameter scale: The factor by which the item is scaled
    ///
    /// - Remark: The item is offsetted towards the closest vertical edge of the screen.
    ///
    /// - Returns: An item preparation object.
    ///
    @objc public static func horizontalOffset(distance: CGFloat = 50, scale: CGFloat = 0.4) -> JJItemPreparation {
        return JJItemPreparation { item, _, _, actionButton in
            let translationX = actionButton.isOnLeftSideOfScreen ? -distance : distance
            item.scale(by: scale, translationX: translationX)
            item.alpha = 0
        }
    }

    /// Returns an item preparation object that
    ///   - sets `item.alpha` to `0`,
    ///   - offsets the item horizontally by given values.
    ///
    /// - Parameter distance: The value in points by which the item is offsetted
    ///                       towards the action button.
    /// - Parameter scale: The factor by which the item is scaled
    /// - Parameter angleForItem: A closure that calculates the angle for each item in a floating action button.
    ///                           Default is `JJItemAnimationConfiguration.angleForItem`.
    ///
    /// - Remark: The item is offsetted towards the action button.
    ///
    /// - Returns: An item preparation object.
    ///
    @objc public static func circularOffset(distance: CGFloat = 50,
                                            scale: CGFloat = 0.4,
                                            angleForItem: @escaping JJItemAngle = JJItemAnimationConfiguration.angleForItem)
        -> JJItemPreparation {
        return JJItemPreparation { item, index, numberOfItems, actionButton in
            let itemAngle = angleForItem(index, numberOfItems, actionButton)
            let transitionAngle = itemAngle + CGFloat.pi
            let translationX = distance * cos(transitionAngle)
            let translationY = distance * sin(transitionAngle)
            item.scale(by: scale, translationX: translationX, translationY: translationY)
            item.alpha = 0
        }
    }
}

// MARK: - Helper

public extension JJItemAnimationConfiguration {
    /// Calculates the angle for the item at the specified index in the floating action button.
    ///
    /// - Parameters:
    ///   - index: The index of the item.
    ///   - numberOfItems: The total number of items in the floating action button.
    ///   - actionButton: The floating action button.
    ///
    /// - Returns: The angle in radians for the item at the specified index.
    ///
    @objc static func angleForItem(at index: Int, numberOfItems: Int, actionButton: JJFloatingActionButton) -> CGFloat {
        precondition(numberOfItems > 0)
        precondition(index >= 0)
        precondition(index < numberOfItems)

        let startAngle: CGFloat = actionButton.isOnLeftSideOfScreen ? 2 * .pi : .pi
        let endAngle: CGFloat = 1.5 * .pi

        switch (numberOfItems, index) {
        case (1, _):
            return (startAngle + endAngle) / 2
        case (2, 0):
            return startAngle + 0.1 * (endAngle - startAngle)
        case (2, 1):
            return endAngle - 0.1 * (endAngle - startAngle)
        default:
            return startAngle + CGFloat(index) * (endAngle - startAngle) / (CGFloat(numberOfItems) - 1)
        }
    }
}

private extension JJItemLayout {
    static func selectSpacing(forFirstItem isFirstItem: Bool, defaultSpacing: CGFloat, firstItemSpacing: CGFloat) -> CGFloat {
        if isFirstItem && firstItemSpacing > 0 {
            return firstItemSpacing
        }
        return defaultSpacing
    }
}

private extension JJActionItem {
    func scale(by factor: CGFloat, translationX: CGFloat = 0, translationY: CGFloat = 0) {
        let scale = scaleTransformation(factor: factor)
        let translation = CGAffineTransform(translationX: translationX, y: translationY)
        transform = scale.concatenating(translation)
    }
}

private extension JJActionItem {
    func scaleTransformation(factor: CGFloat) -> CGAffineTransform {
        let scale = CGAffineTransform(scaleX: factor, y: factor)

        let center = circleView.center
        let circleCenterScaled = point(center, transformed: scale)
        let translationX = center.x - circleCenterScaled.x
        let translationY = center.y - circleCenterScaled.y
        let translation = CGAffineTransform(translationX: translationX, y: translationY)
        return scale.concatenating(translation)
    }

    func point(_ point: CGPoint, transformed transform: CGAffineTransform) -> CGPoint {
        let anchorPoint = CGPoint(x: bounds.width * layer.anchorPoint.x, y: bounds.height * layer.anchorPoint.y)
        let relativePoint = CGPoint(x: point.x - anchorPoint.x, y: point.y - anchorPoint.y)
        let transformedPoint = relativePoint.applying(transform)
        let result = CGPoint(x: anchorPoint.x + transformedPoint.x, y: anchorPoint.y + transformedPoint.y)
        return result
    }
}

extension UIView {
    var isOnLeftSideOfScreen: Bool {
        return isOnLeftSide(ofView: UIWindow.key)
    }

    func isOnLeftSide(ofView superview: UIView?) -> Bool {
        guard let superview = superview else {
            return false
        }
        let point = convert(center, to: superview)
        return point.x < superview.bounds.size.width / 2
    }
}