Skip to content

Improve Spring Animation example #4

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

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
14 changes: 8 additions & 6 deletions FluidInterfaces/FluidInterfaces.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
BF3FF11B211C42C300EF8DF4 /* UISpringTimingParameters+DesignParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3FF11A211C42C300EF8DF4 /* UISpringTimingParameters+DesignParams.swift */; };
D806B89020F8275600740219 /* UIColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D806B88F20F8275600740219 /* UIColorExtensions.swift */; };
D806B89220F82A3700740219 /* UIViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D806B89120F82A3700740219 /* UIViewExtensions.swift */; };
D8409D0A20FBDA8100C7DCD2 /* CGPointExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8409D0920FBDA8100C7DCD2 /* CGPointExtensions.swift */; };
Expand All @@ -29,6 +30,7 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
BF3FF11A211C42C300EF8DF4 /* UISpringTimingParameters+DesignParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISpringTimingParameters+DesignParams.swift"; sourceTree = "<group>"; };
D806B88F20F8275600740219 /* UIColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColorExtensions.swift; sourceTree = "<group>"; };
D806B89120F82A3700740219 /* UIViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = "<group>"; };
D8409D0920FBDA8100C7DCD2 /* CGPointExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPointExtensions.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -98,6 +100,7 @@
D806B89120F82A3700740219 /* UIViewExtensions.swift */,
D806B88F20F8275600740219 /* UIColorExtensions.swift */,
D8409D0920FBDA8100C7DCD2 /* CGPointExtensions.swift */,
BF3FF11A211C42C300EF8DF4 /* UISpringTimingParameters+DesignParams.swift */,
D8757E7520F124BD00D7EB4E /* Main.storyboard */,
D8757E7820F124BF00D7EB4E /* Assets.xcassets */,
D8757E7A20F124BF00D7EB4E /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -180,6 +183,7 @@
D8976AB420F2B3A400E148CB /* InterfaceViewController.swift in Sources */,
D806B89220F82A3700740219 /* UIViewExtensions.swift in Sources */,
D806B89020F8275600740219 /* UIColorExtensions.swift in Sources */,
BF3FF11B211C42C300EF8DF4 /* UISpringTimingParameters+DesignParams.swift in Sources */,
D8976ABC20F2D3DC00E148CB /* Spring.swift in Sources */,
D8757E7420F124BD00D7EB4E /* MenuViewController.swift in Sources */,
D8409D0C20FC010B00C7DCD2 /* GradientView.swift in Sources */,
Expand Down Expand Up @@ -269,7 +273,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
Expand Down Expand Up @@ -323,7 +327,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
Expand All @@ -337,9 +341,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PW5ET5HNB2;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = FluidInterfaces/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -356,9 +359,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PW5ET5HNB2;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = FluidInterfaces/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down
127 changes: 65 additions & 62 deletions FluidInterfaces/FluidInterfaces/Spring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ class SpringInterfaceViewController: InterfaceViewController {

private lazy var dampingSliderView: SliderView = {
let sliderView = SliderView()
sliderView.translatesAutoresizingMaskIntoConstraints = false
sliderView.title = "DAMPING (BOUNCINESS)"
sliderView.minValue = 0.1
sliderView.maxValue = 1
sliderView.valueFormatter = { String(format: "%i%%", Int($0 * 100)) }
sliderView.title = "DAMPING"
sliderView.range = 0.1...1
sliderView.value = dampingRatio
sliderView.sliderMovedAction = { self.dampingRatio = $0 }
sliderView.sliderFinishedMovingAction = { self.resetAnimation() }
Expand All @@ -32,10 +31,9 @@ class SpringInterfaceViewController: InterfaceViewController {

private lazy var frequencySliderView: SliderView = {
let sliderView = SliderView()
sliderView.translatesAutoresizingMaskIntoConstraints = false
sliderView.title = "RESPONSE (SPEED)"
sliderView.minValue = 0.1
sliderView.maxValue = 2
sliderView.valueFormatter = { String(format: "%.2fs", $0) }
sliderView.title = "RESPONSE"
sliderView.range = 0.1...2
sliderView.value = frequencyResponse
sliderView.sliderMovedAction = { self.frequencyResponse = $0 }
sliderView.sliderFinishedMovingAction = { self.resetAnimation() }
Expand All @@ -46,26 +44,37 @@ class SpringInterfaceViewController: InterfaceViewController {
private var frequencyResponse: CGFloat = 1

private let margin: CGFloat = 30

private var leadingAnchor, trailingAnchor : NSLayoutConstraint!

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(dampingSliderView)
dampingSliderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin).isActive = true
dampingSliderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin).isActive = true
dampingSliderView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 0).isActive = true
UIView.activate(constraints: [
dampingSliderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
dampingSliderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
dampingSliderView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 0)
])

view.addSubview(frequencySliderView)
frequencySliderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin).isActive = true
frequencySliderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin).isActive = true
frequencySliderView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 140).isActive = true
UIView.activate(constraints: [
frequencySliderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
frequencySliderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
frequencySliderView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 140)
])

view.addSubview(springView)
springView.heightAnchor.constraint(equalToConstant: 80).isActive = true
springView.widthAnchor.constraint(equalToConstant: 80).isActive = true
springView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin).isActive = true
springView.bottomAnchor.constraint(equalTo: dampingSliderView.topAnchor, constant: -80).isActive = true

UIView.activate(constraints: [
springView.heightAnchor.constraint(equalToConstant: 80),
springView.widthAnchor.constraint(equalToConstant: 80),
springView.bottomAnchor.constraint(equalTo: dampingSliderView.topAnchor, constant: -80)
])
self.leadingAnchor = springView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin)
self.leadingAnchor.isActive = true
self.trailingAnchor = springView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin)
self.trailingAnchor.isActive = false

animateView()

}
Expand All @@ -74,22 +83,28 @@ class SpringInterfaceViewController: InterfaceViewController {

/// Repeatedly animates the view using the current `dampingRatio` and `frequencyResponse`.
private func animateView() {
self.view.layoutIfNeeded()

// swap the constraints that align the springView to the left/right - deactivation has to be done in the correct order - when both layout constraints are active at the same time, this creates a layout warning
if self.leadingAnchor.isActive {
self.leadingAnchor.isActive = false
self.trailingAnchor.isActive = true
} else {
self.trailingAnchor.isActive = false
self.leadingAnchor.isActive = true
}

let timingParameters = UISpringTimingParameters(damping: dampingRatio, response: frequencyResponse)
animator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters)
animator.addAnimations {
let translation = self.view.bounds.width - 2 * self.margin - 80
self.springView.transform = CGAffineTransform(translationX: translation, y: 0)
}
animator.addCompletion { _ in
self.springView.transform = .identity
self.animateView()
self.view.layoutIfNeeded()
}
animator.addCompletion { _ in self.animateView() }
animator.startAnimation()
}

private func resetAnimation() {
animator.stopAnimation(true)
self.springView.transform = .identity
animateView()
}

Expand All @@ -110,22 +125,20 @@ class SliderView: UIView {
}
set {
slider.value = Float(newValue)
valueLabel.text = String(format: "%.2f", newValue)
}
}

public var minValue: CGFloat = 0 {
didSet {
slider.minimumValue = Float(minValue)
valueLabel.text = valueFormatter(newValue)
}
}

public var maxValue: CGFloat = 1 {
public var range: ClosedRange<Float> = 0...1 {
didSet {
slider.maximumValue = Float(maxValue)
slider.minimumValue = range.lowerBound
slider.maximumValue = range.upperBound
}
}

/// Code to format the value to a string for the valueLabel
public var valueFormatter: (CGFloat) -> (String) = { String(format: "%.2f", $0) }

/// Code that's executed when the slider moves.
public var sliderMovedAction: (CGFloat) -> () = { _ in }

Expand Down Expand Up @@ -169,41 +182,31 @@ class SliderView: UIView {
private func sharedInit() {

addSubview(titleLabel)
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
UIView.activate(constraints: [
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
titleLabel.topAnchor.constraint(equalTo: topAnchor)
])

addSubview(valueLabel)
valueLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
valueLabel.lastBaselineAnchor.constraint(equalTo: titleLabel.lastBaselineAnchor).isActive = true

UIView.activate(constraints: [
valueLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
valueLabel.lastBaselineAnchor.constraint(equalTo: titleLabel.lastBaselineAnchor)
])

addSubview(slider)
slider.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
slider.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
slider.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
slider.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20).isActive = true

UIView.activate(constraints: [
slider.leadingAnchor.constraint(equalTo: leadingAnchor),
slider.trailingAnchor.constraint(equalTo: trailingAnchor),
slider.bottomAnchor.constraint(equalTo: bottomAnchor),
slider.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20)
])

}

@objc private func sliderMoved(slider: UISlider, event: UIEvent) {
valueLabel.text = String(format: "%.2f", slider.value)
valueLabel.text = valueFormatter(CGFloat(slider.value))
sliderMovedAction(CGFloat(slider.value))
if event.allTouches?.first?.phase == .ended { sliderFinishedMovingAction() }
}

}

extension UISpringTimingParameters {

/// A design-friendly way to create a spring timing curve.
///
/// - Parameters:
/// - damping: The 'bounciness' of the animation. Value must be between 0 and 1.
/// - response: The 'speed' of the animation.
/// - initialVelocity: The vector describing the starting motion of the property. Optional, default is `.zero`.
public convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) {
let stiffness = pow(2 * .pi / response, 2)
let damp = 4 * .pi * damping / response
self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit

extension UISpringTimingParameters {

/// A design-friendly way to create a spring timing curve.
///
/// - Parameters:
/// - damping: The 'bounciness' of the animation. Value must be between 0 and 1.
/// - response: The 'speed' of the animation.
/// - initialVelocity: The vector describing the starting motion of the property. Optional, default is `.zero`.
public convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) {
let stiffness = pow(2 * .pi / response, 2)
let damp = 4 * .pi * damping / response
self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity)
}

}