Skip to content

[Issue 58] Add Animation struct #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,60 @@ scrollView.registerKeyboardNotifications()
💡 Almost every full-screen view in your app that contains any text should be a vertical scroll view because of the vagaries of localization, Dynamic Type, potentially small screen sizes, and landscape mode support.
</aside>

### 5. Other

#### Elevation

`Elevation` is a model object to define shadows similarly to [W3C box-shadows](https://www.w3.org/TR/css-backgrounds-3/#box-shadow) and [Figma drop shadows](https://help.figma.com/hc/en-us/articles/360041488473-Apply-shadow-or-blur-effects#shadow). It has the following parameters that match how Figma (and web) define drop shadows:

* offset (x and y)
* blur
* spread
* color

`Elevation` has an `apply` method that then applies that shadow effect to a `CALayer`. Remember to call it every time your color mode changes to update the shadow color (a `CGColor`).

```swift
let button = UIButton()
let elevation = Elevation(
xOffset: 0,
yOffset: 2,
blur: 5,
spread: 0,
color: .black,
opacity: 0.5
)
elevation.apply(layer: button.layer, cornerRadius: 8)
```

#### Animation

`Animation` is a model object to define UIView animations. It has the following parameters:

* duration
* delay
* curve

`Animation.curve` is an enum with associated values that can be either `.regular` or `.spring`.

There is a `UIView` class override method for `animate` that takes an `Animation` object.

The advantage of adopting the `Animation` structure is that with a single method you can animate either a regular or spring animation. This allows us to build components where the user can customize the animations used without having our code be overly complex or fragile.

```swift
let button = UIButton()
button.alpha = 1
let animation = Animation(duration: 0.25, curve: .regular(options: .curveEaseOut))

UIView.animate(with: animation) {
// fade button out
button.alpha = 0
} completion: {
// remove it from the superview when done
button.removeFromSuperview()
}
```

Installation
----------

Expand Down
44 changes: 44 additions & 0 deletions Sources/YCoreUI/Extensions/UIKit/UIView+Animation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// UIView+Animation.swift
// YCoreUI
//
// Created by Mark Pospesel on 3/31/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import UIKit

/// Adds support for executing animations using properties from `Animation`
public extension UIView {
/// Executes an animation using the specified parameters
/// - Parameters:
/// - parameters: specifies duration, delay, curve type and options
/// - animations: the animation block to perform
/// - completion: the optional completion block to be called when the animation completes
class func animate(
with parameters: Animation,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
) {
switch parameters.curve {
case .regular(options: let options):
animate(
withDuration: parameters.duration,
delay: parameters.delay,
options: options,
animations: animations,
completion: completion
)
case .spring(damping: let damping, velocity: let velocity, let options):
animate(
withDuration: parameters.duration,
delay: parameters.delay,
usingSpringWithDamping: damping,
initialSpringVelocity: velocity,
options: options,
animations: animations,
completion: completion
)
}
}
}
46 changes: 46 additions & 0 deletions Sources/YCoreUI/Foundations/Animation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// Animation.swift
// YCoreUI
//
// Created by Mark Pospesel on 3/30/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import UIKit

/// Specifies the parameters to perform animations.
///
/// To be used with `UIView.animate(parameters:animations:completion:)`.
public struct Animation: Equatable {
/// Animation curve
public enum Curve: Equatable {
/// Regular animation curve
case regular(options: UIView.AnimationOptions)
/// Spring animation
case spring(damping: CGFloat, velocity: CGFloat, options: UIView.AnimationOptions = [])
}

/// Duration of the animation (in seconds). Defaults to `0.3`.
public var duration: TimeInterval

/// Delay of the animation (in seconds). Defaults to `0.0`.
public var delay: TimeInterval

/// Animation curve to apply. Defaults to `.regular(options: .curveEaseInOut)`.
public var curve: Curve

/// Creates animation parameters
/// - Parameters:
/// - duration: duration of the animation
/// - delay: delay of the animation
/// - curve: animation curve to apply
public init(
duration: TimeInterval = 0.3,
delay: TimeInterval = 0.0,
curve: Curve = .regular(options: .curveEaseInOut)
) {
self.duration = duration
self.delay = delay
self.curve = curve
}
}
127 changes: 127 additions & 0 deletions Tests/YCoreUITests/Extensions/UIKit/UIView+AnimationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//
// UIView+AnimationTests.swift
// YCoreUI
//
// Created by Mark Pospesel on 3/31/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import XCTest
@testable import YCoreUI

final class UIViewAnimationTests: XCTestCase {
func test_regular_deliversAnimation() throws {
defer { SpyView.reset() }
// Given
let duration = CGFloat(Int.random(in: 1...5)) / 10.0
let delay = CGFloat(Int.random(in: 1...5)) / 10.0
let options = try XCTUnwrap(getOptions().randomElement())
let sut = Animation(
duration: duration,
delay: delay,
curve: .regular(options: options)
)
var isAnimationBlockCalled = false
var isCompletionBlockCalled = false

// When
SpyView.animate(
with: sut
) {
isAnimationBlockCalled = true
} completion: { _ in
isCompletionBlockCalled = true
}

// Then
XCTAssertEqual(SpyView.lastAnimation, sut)
XCTAssertTrue(isAnimationBlockCalled)
XCTAssertTrue(isCompletionBlockCalled)
}

func test_spring_deliversAnimation() throws {
defer { SpyView.reset() }
// Given
let duration = CGFloat(Int.random(in: 1...5)) / 10.0
let delay = CGFloat(Int.random(in: 1...5)) / 10.0
let options = try XCTUnwrap(getOptions().randomElement())
let damping = CGFloat(Int.random(in: 1...10)) / 10.0
let velocity = CGFloat(Int.random(in: 1...6)) / 10.0
let sut = Animation(
duration: duration,
delay: delay,
curve: .spring(damping: damping, velocity: velocity, options: options)
)
var isAnimationBlockCalled = false
var isCompletionBlockCalled = false

// When
SpyView.animate(
with: sut
) {
isAnimationBlockCalled = true
} completion: { _ in
isCompletionBlockCalled = true
}

// Then
XCTAssertEqual(SpyView.lastAnimation, sut)
XCTAssertTrue(isAnimationBlockCalled)
XCTAssertTrue(isCompletionBlockCalled)
}
}

extension UIViewAnimationTests {
func getOptions() -> [UIView.AnimationOptions] {
[
[],
.curveEaseIn,
.curveEaseInOut,
.curveEaseOut,
.beginFromCurrentState
]
}
}

final class SpyView: UIView {
static var lastAnimation: Animation?

override class func animate(
withDuration duration: TimeInterval,
delay: TimeInterval,
options: UIView.AnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
) {
lastAnimation = Animation(
duration: duration,
delay: delay,
curve: .regular(options: options)
)
animations()
completion?(true)
}

override class func animate(
withDuration duration: TimeInterval,
delay: TimeInterval,
usingSpringWithDamping
dampingRatio: CGFloat,
initialSpringVelocity velocity: CGFloat,
options: UIView.AnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
) {
lastAnimation = Animation(
duration: duration,
delay: delay,
curve: .spring(damping: dampingRatio, velocity: velocity, options: options)
)
animations()
completion?(true)
}

class func reset() {
lastAnimation = nil
}
}
22 changes: 22 additions & 0 deletions Tests/YCoreUITests/Foundations/AnimationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// AnimationTests.swift
// YCoreUI
//
// Created by Mark Pospesel on 3/30/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import XCTest
@testable import YCoreUI

final class AnimationTests: XCTestCase {
func test_defaults() {
// Given
let sut = Animation()

// Then
XCTAssertEqual(sut.duration, 0.3)
XCTAssertEqual(sut.delay, 0.0)
XCTAssertEqual(sut.curve, .regular(options: .curveEaseInOut))
}
}