Skip to content
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

Interactive Animations #35

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
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
4 changes: 4 additions & 0 deletions Example/Stagehand.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
3DD79B712488D51600954004 /* SnapshotTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD79B702488D51600954004 /* SnapshotTestCase.swift */; };
3DD79B732488D6E600954004 /* CGAffineTransformInterpolationSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD79B722488D6E600954004 /* CGAffineTransformInterpolationSnapshotTests.swift */; };
3DEA0C9624837CAB00F1ECE7 /* TransformPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEA0C9524837CAB00F1ECE7 /* TransformPerformanceTests.swift */; };
3DDE7FF024C5123800999ABA /* InteractiveAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDE7FEF24C5123800999ABA /* InteractiveAnimationsViewController.swift */; };
3DEE440A231331DD0057D796 /* AnimationGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */; };
3DFE460424F4F0D000A460AC /* QuadrantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE460324F4F0D000A460AC /* QuadrantView.swift */; };
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; };
Expand Down Expand Up @@ -109,6 +110,7 @@
3DD79B702488D51600954004 /* SnapshotTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestCase.swift; sourceTree = "<group>"; };
3DD79B722488D6E600954004 /* CGAffineTransformInterpolationSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGAffineTransformInterpolationSnapshotTests.swift; sourceTree = "<group>"; };
3DEA0C9524837CAB00F1ECE7 /* TransformPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformPerformanceTests.swift; sourceTree = "<group>"; };
3DDE7FEF24C5123800999ABA /* InteractiveAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveAnimationsViewController.swift; sourceTree = "<group>"; };
3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationGroupViewController.swift; sourceTree = "<group>"; };
3DFE460324F4F0D000A460AC /* QuadrantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuadrantView.swift; sourceTree = "<group>"; };
3EEB62F0CC3F471BC6454D87 /* Pods-Stagehand_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand_Example.debug.xcconfig"; path = "Target Support Files/Pods-Stagehand_Example/Pods-Stagehand_Example.debug.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -225,6 +227,7 @@
3DA3997B230FCFAC00DE41A0 /* ExecutionBlockViewController.swift */,
3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */,
3D2712ED236A17C5001D3B4B /* AnimationQueueViewController.swift */,
3DDE7FEF24C5123800999ABA /* InteractiveAnimationsViewController.swift */,
);
name = "Feature Screens";
sourceTree = "<group>";
Expand Down Expand Up @@ -628,6 +631,7 @@
3DEE440A231331DD0057D796 /* AnimationGroupViewController.swift in Sources */,
3D1D304A22F007B7003E392C /* ChildAnimationsViewController.swift in Sources */,
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
3DDE7FF024C5123800999ABA /* InteractiveAnimationsViewController.swift in Sources */,
3D8E3D8422F2BC9300D70FCB /* RepeatingAnimationsViewController.swift in Sources */,
3D8E3D8022F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift in Sources */,
3DB3927D23249D680009E8B3 /* ColorAnimationsViewController.swift in Sources */,
Expand Down
181 changes: 181 additions & 0 deletions Example/Stagehand/InteractiveAnimationsViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//
// Copyright 2020 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Stagehand
import UIKit

final class InteractiveAnimationViewController: DemoViewController {

// MARK: - Life Cycle

override init() {
super.init()

contentView = mainView

animationRows = [
("Animate to Beginning (Linear)", { [unowned self] in
let animationInstance = self.createAnimationInstanceIfNeeded()
animationInstance.animateToBeginning()
}),
("Animate to End (Linear)", { [unowned self] in
let animationInstance = self.createAnimationInstanceIfNeeded()
animationInstance.animateToEnd()
}),
("Cancel (Halt)", { [unowned self] in
self.animationInstance?.cancel()
}),
("Use Linear Curve", { [unowned self] in
self.animationCurve = LinearAnimationCurve()
self.animationInstance?.cancel()
self.animationInstance = self.makeAnimation().performInteractive(on: self.mainView.animatableView)
}),
("Use Easing Curve", { [unowned self] in
self.animationCurve = CubicBezierAnimationCurve.easeInEaseOut
self.animationInstance?.cancel()
self.animationInstance = self.makeAnimation().performInteractive(on: self.mainView.animatableView)
}),
]

mainView.progressSlider.addTarget(self, action: #selector(progressUpdateDidBegin), for: .touchDown)
mainView.progressSlider.addTarget(self, action: #selector(progressValueChanged), for: .valueChanged)
mainView.progressSlider.addTarget(self, action: #selector(progressUpdateDidEnd), for: .touchUpInside)
mainView.progressSlider.addTarget(self, action: #selector(progressUpdateDidEnd), for: .touchUpOutside)
mainView.progressSlider.addTarget(self, action: #selector(progressUpdateDidEnd), for: .touchCancel)
}

// MARK: - Private Properties

private var mainView: View = .init()

private weak var animationInstance: InteractiveAnimationInstance?

private var animationCurve: AnimationCurve = LinearAnimationCurve()

// MARK: - Private Methods

private func makeAnimation() -> Animation<UIView> {
var animation = Animation<UIView>()
animation.addKeyframe(
for: \.transform,
at: 0,
value: .identity
)
animation.addKeyframe(
for: \.transform,
at: 0.5,
value: CGAffineTransform.identity
.translatedBy(x: (mainView.bounds.width - 100) / 2, y: 0)
.rotated(by: .pi / 4)
)
animation.addKeyframe(
for: \.transform,
at: 1,
value: CGAffineTransform.identity
.translatedBy(x: mainView.bounds.width - 100, y: 0)
.rotated(by: .pi / 2)
)
animation.implicitDuration = 2.5
animation.curve = animationCurve

animation.addPerFrameExecution { context in
self.mainView.progressSlider.value = Float(context.uncurvedProgress)
}

return animation
}

@discardableResult
private func createAnimationInstanceIfNeeded() -> InteractiveAnimationInstance {
if let existingInstance = animationInstance {
return existingInstance
}

let animationInstance = makeAnimation().performInteractive(on: mainView.animatableView)
self.animationInstance = animationInstance
return animationInstance
}

@objc private func progressUpdateDidBegin() {
createAnimationInstanceIfNeeded()
}

@objc private func progressValueChanged() {
let slider = mainView.progressSlider
let progress = Double((slider.value - slider.minimumValue) / (slider.maximumValue - slider.minimumValue))
animationInstance?.updateProgress(to: progress)
}

@objc private func progressUpdateDidEnd() {
let slider = mainView.progressSlider
let progress = Double((slider.value - slider.minimumValue) / (slider.maximumValue - slider.minimumValue))

if progress < 0.5 {
animationInstance?.animateToBeginning(using: CubicBezierAnimationCurve.easeInEaseOut)
} else {
animationInstance?.animateToEnd(using: CubicBezierAnimationCurve.easeInEaseOut)
}
}

}

// MARK: -

extension InteractiveAnimationViewController {

final class View: UIView {

// MARK: - Life Cycle

override init(frame: CGRect) {
super.init(frame: frame)

animatableView.backgroundColor = .red
addSubview(animatableView)

addSubview(progressSlider)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Public Properties

var animatableView: UIView = .init()

let progressSlider: UISlider = .init()

// MARK: - UIView

override func layoutSubviews() {
animatableView.bounds.size = .init(width: 50, height: 50)
animatableView.center = .init(
x: bounds.minX + 50,
y: bounds.minY + 60
)

progressSlider.bounds.size = progressSlider.sizeThatFits(bounds.insetBy(dx: 24, dy: 0).size)
progressSlider.frame.origin = .init(
x: 24,
y: animatableView.bounds.maxY + 60
)
}

}

}
1 change: 1 addition & 0 deletions Example/Stagehand/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class RootViewController: UITableViewController {
("Execution Blocks", { ExecutionBlockViewController() }),
("Animation Groups", { AnimationGroupViewController() }),
("Animation Queues", { AnimationQueueViewController() }),
("Interactive Animations", { InteractiveAnimationViewController() }),
]

/// Screens that are used for debugging specific functionality.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import Foundation
/// `perform(on:delay:duration:repeatStyle:completion:)` method to begin the animation. That method will return an
/// instance of this class. The `AnimationInstance` can then be used to track the `status` of the animation, or to
/// cancel it.
public final class AnimationInstance {
public class AnimationInstance {

// MARK: - Life Cycle

Expand Down
142 changes: 142 additions & 0 deletions Sources/Stagehand/AnimationInstance/InteractiveAnimationInstance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// Copyright 2020 Square Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

extension Animation {

/// Begins an interactive animation on the given `element`.
///
/// The animation will not be applied to the `element` until an action is taken with the returned
/// `InteractiveAnimationInstance`, either by setting the progress directly or starting progression to the beggining
/// or end of the animation.
///
/// - parameter element: The element to be animated.
/// - parameter duration: The end-to-end duration of the animation. If `nil`, the animation's `implicitDuration`
/// will be used as the end-to-end duration.
public func performInteractive(
on element: ElementType,
duration: TimeInterval? = nil
) -> InteractiveAnimationInstance {
return InteractiveAnimationInstance(
animation: self,
element: element,
duration: duration ?? self.implicitDuration
)
}

}

// MARK: -

public final class InteractiveAnimationInstance: AnimationInstance {

// MARK: - Life Cycle

internal init<ElementType: AnyObject>(
animation: Animation<ElementType>,
element: ElementType,
duration: TimeInterval
) {
let driver = InteractiveDriver(duration: duration)

self.interactiveDriver = driver

super.init(animation: animation, element: element, driver: driver)
}

// MARK: - Private Properties

private let interactiveDriver: InteractiveDriver

// MARK: - Public Methods

/// Updates the progress of the animation to the `relativeTimestamp` immediately.
///
/// If the animation is currently running automatically (from calling `animate(to:using:duration:)`), the animation
/// will be paused at the new `relativeTimestamp`.
///
/// - parameter relativeTimestamp: The target relative timestamp.
public func updateProgress(to relativeTimestamp: Double) {
switch status {
case .pending, .animating:
break

case .complete, .canceled:
// If the animation is already complete, or was canceled, we can't animate it again.
return
}

interactiveDriver.updateProgress(to: relativeTimestamp)
}

/// Begins animating a segment of the animation from the current relative timestamp to a specific point in the
/// animation.
///
/// The `curve` will be applied to the segment on top of any existing animation curve.
///
/// The duration of the animation segment will be determined based on the follow order of preference:
/// 1. The explicit segment duration, if specified
/// 2. A relative portion of the explicit end-to-end duration, if specified
/// 3. A relative portion of the animation's implicit duration
///
/// - parameter relativeTimestamp: The target relative timestamp.
/// - parameter curve: The curve to apply over the segment of the animation.
/// - parameter duration: The duration over which to perfom the specified segment of the animation.
public func animate(
to relativeTimestamp: Double,
using curve: AnimationCurve = LinearAnimationCurve(),
duration: TimeInterval? = nil
) {
switch status {
case .pending, .animating:
break

case .complete, .canceled:
// If the animation is already complete, or was canceled, we can't animate it again.
return
}

interactiveDriver.animate(to: relativeTimestamp, using: curve, duration: duration)
}

}

// MARK: -

extension InteractiveAnimationInstance {

/// Animate in reverse to the beginning of the animation.
///
/// The `curve` is applied on top of the animation's curve.
public func animateToBeginning(
using curve: AnimationCurve = LinearAnimationCurve(),
duration: TimeInterval? = nil
) {
animate(to: 0, using: curve, duration: duration)
}

/// Animate forward to the end of the animation.
///
/// The `curve` is applied on top of the animation's curve.
public func animateToEnd(
using curve: AnimationCurve = LinearAnimationCurve(),
duration: TimeInterval? = nil
) {
animate(to: 1, using: curve, duration: duration)
}

}
Loading