SWIFT - OSX
I have a bunch of imageViews set in my Main.storyboard. I am trying to make them spin when the app starts and i would like them to indefinitely. I came across the roateByAngle(angle: CGFloat), but this doesn't animate it, instead it just jumps to the new angle.
I would like to make two functions, spinClockwise() and spinAntiClockwise() so I can just call them in the viewDidLoad and they will just keep turning.
Ive been playing with CATransform3DMakeRotation but cannot seem to get my desired results
let width = myImg.frame.width / 2
let height = myImg.frame.height / 2
myImg.layer?.transform = CATransform3DMakeRotation(180, width, height, 1)
Let me know if i can be more specific.
Thanks
You could add an extension of UIView or UIImageView like this:
extension UIView {
///The less is the timeToRotate, the more fast the animation is !
func spinClockwise(timeToRotate: Double) {
startRotate(CGFloat(M_PI_2), timeToRotate: timeToRotate)
}
///The less is the timeToRotate, the more fast the animation is !
func spinAntiClockwise(timeToRotate: Double) {
startRotate(CGFloat(-M_PI_2), timeToRotate: timeToRotate)
}
func startRotate(angle: CGFloat, timeToRotate: Double) {
UIView.animateWithDuration(timeToRotate, delay: 0.0, options:[UIViewAnimationOptions.CurveLinear, UIViewAnimationOptions.Repeat], animations: {
self.transform = CGAffineTransformMakeRotation(angle)
}, completion: nil)
print("Start rotating")
}
func stopAnimations() {
self.layer.removeAllAnimations()
print("Stop rotating")
}
}
So when you want to rotate your myImg, you just have to call:
myImg.spinClockwise(3)
And when you want to stop it:
myImg.stopAnimations()
NOTE:
I added a playground just so you can test it out ;)
Cheers!
EDIT:
My bad, Here is the example for NSView:
extension NSView {
///The less is the timeToRotate, the more fast the animation is !
func spinClockwise(timeToRotate: Double) {
startRotate(CGFloat(-1 * M_PI * 2.0), timeToRotate: timeToRotate)
}
///The less is the timeToRotate, the more fast the animation is !
func spinAntiClockwise(timeToRotate: Double) {
startRotate(CGFloat(M_PI * 2.0), timeToRotate: timeToRotate)
}
func startRotate(angle: CGFloat, timeToRotate: Double) {
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = angle
rotateAnimation.duration = timeToRotate
rotateAnimation.repeatCount = .infinity
self.layer?.addAnimation(rotateAnimation, forKey: nil)
Swift.print("Start rotating")
}
func stopAnimations() {
self.layer?.removeAllAnimations()
Swift.print("Stop rotating")
}
}
Important note: Now, after my tests, I noticed that you must set the anchor point of your NSView in the middle so that it can rotate around its center:
view.layer?.anchorPoint = CGPointMake(0.5, 0.5)
I added a new playground with the OSX example
For me, I could not change the anchor point. It was spinning around (0,0) which is bottom left. I moved the anchor point to (0.5, 0.5), but still no luck. Then I came accross with this answer. I modified my code like below, and it begins to rotate around itself. I observed a drawback though, the place of the view somehow shifted, but it can be fixed by trial and error, trying to get it to the right place.
extension NSView {
func startRotating(duration: Double = 1) {
let kAnimationKey = "rotation"
//self.wantsLayer = true
if layer?.animation(forKey: kAnimationKey) == nil {
var oldFrame = self.frame
self.layer?.anchorPoint = CGPoint(x: 1, y: 1)
self.frame = oldFrame
let animate = CABasicAnimation(keyPath: "transform.rotation")
animate.duration = duration
animate.repeatCount = Float.infinity
animate.fromValue = 0.0
animate.toValue = Double.pi * 2.0
self.layer?.add(animate, forKey: kAnimationKey)
oldFrame = self.frame
self.layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
self.frame = oldFrame
}
}
}
Related
I'm currently playing with CALayers a bit. For demo purposes I'm creating a custom UIView that renders a fuel gauge. The view has two sub-layers:
one for the background
one for the hand
The layer that represents the hand is then simple rotated accordingly to point at the correct value. So far, so good. Now I want the view to resize its layers whenever the size of the view is changed. To achieve this, I created an override of the layoutSubviews method like this:
public override func layoutSubviews()
{
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds)
{
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
As the method is being called many times, I'm using previousBounds to make sure I only perform the update on the layers when the size has actually changed.
At first, I had just the following code in the updateLayers method to set the frames of the sub-layers:
backgroundLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
That worked fine - until the handLayer was rotated. In that case some weird things happen to its size. I suppose it is because the frame gets applied after the rotation and of course, the rotated layer doesn't actually fit the bounds and is thus resized to fit.
My current solution is to temporarily create a new CATransaction that suppresses animations, reverting the transformation back to identity, setting the frame and then re-applying the transformation like this:
CATransaction.begin()
CATransaction.setDisableActions(true)
let oldTransform = scaleLayer.transform
handLayer.transform = CATransform3DIdentity
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.transform = oldTransform
CATransaction.commit()
I already tried omitting the CATransaction and instead applying the handLayer.affineTransform to the bounds I'm setting, but that didn't yield the expected results - maybe I did it wrong (side question: How to rotate a given CGRect around its center without doing all the maths myself)?
My question is simply: Is there a recommended was of setting the frame of a transformed layer or is the solution I found already "the" way to do it?
EDIT
Kevvv provided some sample code, which I've modified to demonstrate my problem:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
customView.backgroundColor = UIColor.blue
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
handLayer.backgroundColor = UIColor.red.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
handLayer.transform = CATransform3DMakeRotation(5, 0, 0, 1)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
customView.frame = customView.frame.insetBy(dx:10, dy:10)
/*let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)*/
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
If you add this to a playground, then run and tap the control, you'll see what I mean. Watch the red "square".
Do you mind explaining what the "weird things happening to the size" means? I tried to replicate it, but couldn't find the unexpected effects:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
Edit
I think the issue is that the red box is resized with a frame. Since a frame is always upright even if it's rotated, if you were to do an inset from a frame, it'd look like this:
However, if you were to resize the red box with bounds:
sublayer.bounds = bounds.insetBy(dx: 5, dy: 5)
sublayer.position = self.convert(self.center, from: self.superview)
instead of:
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
You'll probably have to re-center the handPath and everything else in it accordingly as well.
Let's say I have an animator that moves a view from (0, 0) to (-120, 0):
let frameAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: 0.8)
animator.addAnimations {
switch state:
case .normal: view.frame.origin.x = 0
case .swiped: view.frame.origin.x = -120
}
}
I use it together with UIPanGestureRecognizer, so that I can resize the view continuously along with the finger movements.
The issue comes when I want to add some sort of bouncing effect at the start or at the end of the animation. NOT just the damping ratio, but the bounce effect. The easiest way to imagine this is Swipe-To-Delete feature of UITableViewCell, where you can drag "Delete" button beyond its actual width, and then it bounces back.
Effectively what I want to achieve, is the way to set fractionComplete property outside of [0, 1] segment, so when the fraction is 1.2, the offset becomes 144 instead of its 120 maximum.
And right now the maximum value for fractionComplete is exactly 1.
Below are some examples to have this issue visualized:
What I currently have:
What I want to achieve:
EDIT (19 January):
Sorry for my delayed reply. Here are some clarifications:
I don't use UIView.animate(...), and use UIViewPropertyAnimator instead for a very specific reason: it handles for me all the timings, curves and velocities.
For example, you dragged the view halfway through. This means that duration of the remaining part should be two times less than total duration. Or if you dragged though the 99% of the distance, it should complete the remaining part almost instantly.
As an addition, UIViewPropertyAnimator has such features as pause (when user starts dragging once again), or reverse (when user started dragging to the left, but after that he changed his mind and moved the finger to the right), that I also benefit from.
All this is not available for simple UIView animations, or requires TONS of effort at best. It is only capable of simple transitions, and this is not the case.
That's why I have to use some sort of animator.
And as I mentioned in the comments thread in the answer that was removed by its publisher, the most complex part for me here is to simulate the friction effect: the further you drag, the less the view actually moves. Just as when you're trying to drag any UIScrollView outside of it's content.
Thanks for your effort guys, but I don't think any of these 2 answers is relevant. I will try to implement this behaviour using UIDynamicAnimator whenever I have time. Probably in the nearest week or two. I will publish my approach in case I have any decent results.
EDIT (20 January):
I just uploaded a demo project to the GitHub, which includes all the transitions that I have in my project. So now you can actually have an idea why do I need to use animators and how I use them: https://github.com/demon9733/bouncingview-prototype
The only file you are actually interested in is MainViewController+Modes.swift. Everything related to transitions and animations is contained there.
What I need to do is to enable user to drag the handle area beyond "Hide" button width with a damping effect. "Hide" button will appear on swiping the handle area to the left.
P.S. I didn't really test this demo, so it can have bugs that I don't have in my main project. So you can safely ignore them.
you need to allow pan gesture to get to needed x position and at the end of pan an animation is needed to be triggered
one way to do this would be:
var initial = CGRect.zero
override func viewDidLayoutSubviews() {
initial = animatedView.frame
}
#IBAction func pan(_ sender: UIPanGestureRecognizer) {
let closed = initial
let open = initial.offsetBy(dx: -120, dy: 0)
// 1 manage panning along x direction
sender.view?.center = CGPoint(x: (sender.view?.center.x)! + sender.translation(in: sender.view).x, y: (sender.view?.center.y)! )
sender.setTranslation(CGPoint.zero, in: self.view)
// 2 animate to needed position once pan ends
if sender.state == .ended {
if (sender.view?.frame.origin.x)! > initialOrigin.origin.x {
UIView.animate(withDuration: 1 , animations: {
sender.view?.frame = closed
})
} else {
UIView.animate(withDuration: 1 , animations: {
sender.view?.frame = open
})
}
}
}
Edit 20 Jan
For simulating dampening effect and make use of UIViewPropertyAnimator specifically,
var initialOrigin = CGRect.zero
override func viewDidLayoutSubviews() {
initialOrigin = animatedView.frame
}
#IBAction func pan(_ sender: UIPanGestureRecognizer) {
let closed = initialOrigin
let open = initialOrigin.offsetBy(dx: -120, dy: 0)
// 1. to simulate dampening
var multiplier: CGFloat = 1.0
if animatedView?.frame.origin.x ?? CGFloat(0) > closed.origin.x || animatedView?.frame.origin.x ?? CGFloat(0) < open.origin.x {
multiplier = 0.2
} else {
multiplier = 1
}
// 2. animate panning
sender.view?.center = CGPoint(x: (sender.view?.center.x)! + sender.translation(in: sender.view).x * multiplier, y: (sender.view?.center.y)! )
sender.setTranslation(CGPoint.zero, in: self.view)
// 3. animate to needed position once pan ends
if sender.state == .ended {
if (sender.view?.frame.origin.x)! > initialOrigin.origin.x {
let animate = UIViewPropertyAnimator(duration: 0.3, curve: .easeOut, animations: {
self.animatedView.frame.origin.x = closed.origin.x
})
animate.startAnimation()
} else {
let animate = UIViewPropertyAnimator(duration: 0.3, curve: .easeOut, animations: {
self.animatedView.frame.origin.x = open.origin.x
})
animate.startAnimation()
}
}
}
Here is possible approach (simplified & a bit scratchy - only bounce, w/o button at right, because it would much more code and actually only a matter of frames management)
Due to long delay of UIPanGestureRecognizer at ending, I prefer to use UILongPressGestureRecognizer, as it gives faster feedback.
Here is demo result
The Storyboard of used below ViewController has only gray-background-rect-container view, everything else is done in code provided below.
class ViewController: UIViewController {
#IBOutlet weak var container: UIView!
let imageView = UIImageView()
var initial: CGFloat = .zero
var dropped = false
private func excedesLimit() -> Bool {
// < set here desired bounce limits
return imageView.frame.minX < -180 || imageView.frame.minX > 80
}
#IBAction func pressHandler(_ sender: UILongPressGestureRecognizer) {
let location = sender.location(in: imageView.superview).x
if sender.state == .began {
dropped = false
initial = location - imageView.center.x
}
else if !dropped {
if (sender.state == .changed) {
imageView.center = CGPoint(x: location - initial, y: imageView.center.y)
dropped = excedesLimit()
}
if sender.state == .ended || dropped {
initial = .zero
// variant with animator
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) {
let stickTo: CGFloat = self.imageView.frame.minX < -100 ? -100 : 0 // place for button at right
self.imageView.frame = CGRect(origin: CGPoint(x: stickTo, y: self.imageView.frame.origin.y), size: self.imageView.frame.size)
}
animator.isInterruptible = true
animator.startAnimation()
// uncomment below - variant with UIViewAnimation
// UIView.beginAnimations("bounce", context: nil)
// UIView.setAnimationDuration(0.2)
// UIView.setAnimationTransition(.none, for: imageView, cache: true)
// UIView.setAnimationBeginsFromCurrentState(true)
//
// let stickTo: CGFloat = imageView.frame.minX < -100 ? -100 : 0 // place for button at right
// imageView.frame = CGRect(origin: CGPoint(x: stickTo, y: imageView.frame.origin.y), size: imageView.frame.size)
// UIView.setAnimationDelegate(self)
// UIView.setAnimationDidStop(#selector(makeBounce))
// UIView.commitAnimations()
}
}
}
// #objc func makeBounce() {
// let bounceAnimation = CABasicAnimation(keyPath: "position.x")
// bounceAnimation.duration = 0.1
// bounceAnimation.repeatCount = 0
// bounceAnimation.autoreverses = true
// bounceAnimation.fillMode = kCAFillModeBackwards
// bounceAnimation.isRemovedOnCompletion = true
// bounceAnimation.isAdditive = false
// bounceAnimation.timingFunction = CAMediaTimingFunction(name: "easeOut")
// imageView.layer.add(bounceAnimation, forKey:"bounceAnimation");
// }
override func viewDidLoad() {
super.viewDidLoad()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = UIImage(named: "cat")
imageView.contentMode = .scaleAspectFill
imageView.layer.borderColor = UIColor.red.cgColor
imageView.layer.borderWidth = 1.0
imageView.clipsToBounds = true
imageView.isUserInteractionEnabled = true
container.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: container.centerYAnchor).isActive = true
imageView.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 1).isActive = true
imageView.heightAnchor.constraint(equalTo: container.heightAnchor, multiplier: 1).isActive = true
let pressGesture = UILongPressGestureRecognizer(target: self, action: #selector(pressHandler(_:)))
pressGesture.minimumPressDuration = 0
pressGesture.allowableMovement = .infinity
imageView.addGestureRecognizer(pressGesture)
}
}
To have a similar effect to Snapchat's HUD movement, I have created a movement of the HUD elements based on UIScollView's contentOffset. Edit: Link to the Github project.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.view.layoutIfNeeded()
let factor = scrollView.contentOffset.y / self.view.frame.height
self.transformElements(self.playButton,
0.45 + 0.55 * factor, // 0.45 = desired scale + 0.55 = 1.0 == original scale
Roots.screenSize.height - 280, // 280 == original Y
Roots.screenSize.height - 84, // 84 == minimum desired Y
factor)
}
func transformElements(_ element: UIView?,
_ scale: CGFloat,
_ originY: CGFloat,
_ desiredY: CGFloat,
_ factor: CGFloat) {
if let e = element {
e.transform = CGAffineTransform(scaleX: scale, y: scale) // this line lagging
let resultY = desiredY + (originY - desiredY) * factor
var frame = e.frame
frame.origin.y = resultY
e.frame = frame
}
}
With this code implemented the scroll as well as the transition appeared to be "laggy"/not smooth. (Physical iPhone 6S+ and 7+).
Deleting the following line: e.transform = CGAffineTransform(scaleX: scale, y: scale) erased the issue. The scroll as well as the Y-movement of the UIView object is smooth again.
What's the best approach to transform the scale of an object?
There are no Layout Constraints.
func setupPlayButton() {
let rect = CGRect(x: Roots.screenSize.width / 2 - 60,
y: Roots.screenSize.height - 280,
width: 120,
height: 120)
self.playButton = UIButton(frame: rect)
self.playButton.setImage(UIImage(named: "playBtn")?.withRenderingMode(.alwaysTemplate), for: .normal)
self.playButton.tintColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
self.view.addSubview(playButton)
}
This is happening because you are applying both: transform and frame. It will be smoother, if you apply only transform. Update your transformElements function as below:
func transformElements(_ element: UIView?,
_ scale: CGFloat,
_ originY: CGFloat,
_ desiredY: CGFloat,
_ factor: CGFloat) {
if let e = element {
e.transform = CGAffineTransform(scaleX: scale, y: scale).translatedBy(x: 0, y: desiredY * (1 - factor))
}
}
You can make these kinds of animation smoother by creating an animation then setting the speed of the layer to 0 and then changing the timeOffset of the layer.
first add the animation in the setupPlayButton method
let animation = CABasicAnimation.init(keyPath: "transform.scale")
animation.fromValue = 1.0
animation.toValue = 0.45
animation.duration = 1.0
//Set the speed of the layer to 0 so it doesn't animate until we tell it to
self.playButton.layer.speed = 0.0;
self.playButton.layer.add(animation, forKey: "transform");
next in the scrollViewDidScroll change the timeOffset of the layer and move the center of the button.
if let btn = self.playButton{
var factor:CGFloat = 1.0
if isVertically {
factor = scrollView.contentOffset.y / self.view.frame.height
} else {
factor = scrollView.contentOffset.x / Roots.screenSize.width
var transformedFractionalPage: CGFloat = 0
if factor > 1 {
transformedFractionalPage = 2 - factor
} else {
transformedFractionalPage = factor
}
factor = transformedFractionalPage;
}
//This will change the size
let timeOffset = CFTimeInterval(1-factor)
btn.layer.timeOffset = timeOffset
//now change the positions. only use center - not frame - so you don't mess up the animation. These numbers aren't right I don't know why
let desiredY = Roots.screenSize.height - (280-60);
let originY = Roots.screenSize.height - (84-60);
let resultY = desiredY + (originY - desiredY) * (1-factor)
btn.center = CGPoint.init(x: btn.center.x, y: resultY);
}
I couldn't quite get the position of the button correct - so something is wrong with my math there, but I trust you can fix it.
If you want more info about this technique see here: http://ronnqvi.st/controlling-animation-timing/
I've implemented a Navigation controller to incorporate an rotating-disc type of layout (imagine each VC laid out in a circle, that rotates as a whole, into view sequentially. The navigation controller is configured to use a custom transition class, as below :-
import UIKit
class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning {
let isPresenting :Bool
let duration :TimeInterval = 0.5
let animationDuration: TimeInterval = 0.7
let delay: TimeInterval = 0
let damping: CGFloat = 1.4
let spring: CGFloat = 6.0
init(isPresenting: Bool) {
self.isPresenting = isPresenting
super.init()
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
//Get references to the view hierarchy
let fromViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
let toViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
let sourceRect: CGRect = transitionContext.initialFrame(for: fromViewController)
let containerView: UIView = transitionContext.containerView
if self.isPresenting { // Push
//1. Settings for the fromVC ............................
// fromViewController.view.frame = sourceRect
fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);
//2. Setup toVC view...........................
containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view)
toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
toViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
//3. Perform the animation...............................
UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
fromViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
toViewController.view.transform = CGAffineTransform(rotationAngle: 0);
}, completion: {
(animated: Bool) -> () in transitionContext.completeTransition(true)
})
} else { // Pop
//1. Settings for the fromVC ............................
fromViewController.view.frame = sourceRect
fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);
//2. Setup toVC view...........................
// toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
toViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view)
//3. Perform the animation...............................
UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
fromViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
toViewController.view.transform = CGAffineTransform(rotationAngle: 0);
}, completion: {
//When the animation is completed call completeTransition
(animated: Bool) -> () in transitionContext.completeTransition(true)
})
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration;
}
}
A representation of how the views move is show in the illustration below... The two red areas are the problems, as explained later.
The presenting (push) translation works fine - 2 moves to 1 and 3 moves to 2. However, the dismissing (pop) translation does not, whereby the dismissing VC moves out of view seemingly correctly (2 moving to 3), but the presenting (previous) VC arrives either in the wrong place, or with an incorrectly sized frame...
With the class as-is, the translation results in 2 moving to 3 (correctly) but 1 then moves to 4, the view is correctly sized, yet seems offset, by a seemingly arbitrary distance, from the intended location. I have since tried a variety of different solutions.
In the pop section I tried adding the following line (commented in the code) :-
toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
...but the VC now ends up being shrunk (1 moves to 5). I hope someone can see the likely stupid error I'm making. I tried simply duplicating the push section to the pop section (and reversing everything), but it just doesn't work!
FYI... Those needing to know how to hookup the transition to a UINavigationController - Add the UINavigationControllerDelegate to your nav controller, along with the following function...
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let transition: SwingTransition = SwingTransition.init(isPresenting: ( operation == .push ? true : false ))
return transition;
}
The diagram below shows how all views would share the same originating point (for the translation). The objective is to give the illusion of a revolver barrel moving each VC into view. The top centre view represents the viewing window, showing the third view in the stack. Apologies for the poor visuals...
The problem is that one of the properties in the restored view controller's view isn't getting reset properly. I'd suggest resetting it when the animation is done (you probably don't want to keep the non-standard transform and anchorPoint in case you do other animations later that presume the view is not transformed). So, in the completion block of the animation, reset the position, anchorPoint and transform of the views.
class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning {
let isPresenting: Bool
let duration: TimeInterval = 0.5
let delay: TimeInterval = 0
let damping: CGFloat = 1.4
let spring: CGFloat = 6
init(isPresenting: Bool) {
self.isPresenting = isPresenting
super.init()
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let from = transitionContext.viewController(forKey: .from)!
let to = transitionContext.viewController(forKey: .to)!
let frame = transitionContext.initialFrame(for: from)
let height = frame.size.height
let width = frame.size.width
let angle: CGFloat = 15.0 * .pi / 180.0
let rotationCenterOffset: CGFloat = width / 2 / tan(angle / 2) / height + 1 // use fixed value, e.g. 3, if you want, or use this to ensure that the corners of the two views just touch, but don't overlap
let rightTransform = CATransform3DMakeRotation(angle, 0, 0, 1)
let leftTransform = CATransform3DMakeRotation(-angle, 0, 0, 1)
transitionContext.containerView.insertSubview(to.view, aboveSubview: from.view)
// save the anchor and position
let anchorPoint = from.view.layer.anchorPoint
let position = from.view.layer.position
// prepare `to` layer for rotation
to.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
to.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)
to.view.layer.transform = self.isPresenting ? rightTransform : leftTransform
//to.view.layer.opacity = 0
// prepare `from` layer for rotation
from.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
from.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)
// rotate
UIView.animate(withDuration: duration, delay: delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, animations: {
from.view.layer.transform = self.isPresenting ? leftTransform : rightTransform
to.view.layer.transform = CATransform3DIdentity
//to.view.layer.opacity = 1
//from.view.layer.opacity = 0
}, completion: { finished in
// restore the layers to their default configuration
for view in [to.view, from.view] {
view?.layer.transform = CATransform3DIdentity
view?.layer.anchorPoint = anchorPoint
view?.layer.position = position
//view?.layer.opacity = 1
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
}
I did a few other sundry changes, while I was here:
eliminated the semicolons;
eliminated one of the duration properties;
fixed the name of the parameter of the completion closure of animate method to finished rather than animated to more accurately reflect what it's real purpose is ... you could use _, too;
set the completeTransition based upon whether the animation was canceled or not (because if you ever make this interactive/cancelable, you don't want to always use true);
use .pi rather than M_PI;
I commented out my adjustments of opacity, but I generally do that to give the effect a touch more polish and to ensure that if you tweak angles so the views overlap, you don't get any weird artifacts of the other view just as the animation starts or just as its finishing; I've actually calculated the parameters so there's no overlapping, regardless of screen dimensions, so that wasn't necessary and I commented out the opacity lines, but you might consider using them, depending upon the desired effect.
Previously I showed how to simplify the process a bit, but the resulting effect wasn't exactly what you were looking for, but see the previous rendition of this answer if you're interested.
Your problem is a common one that happens when you do custom view controller transitions. I know this because I've done it a lot :)
You're looking for a problem in the pop transition, but the actual problem is in the push. If you inspect the view of the first controller in the stack after the transition, you'll see that it has an unusual frame, because you've messed about with its transform and anchor point and layer position and so forth. Really, you need to clean all that up before you end the transition, otherwise it bites you later on, as you're seeing in the pop.
A much simpler and safer way to do your custom transitions is to add a "canvas" view, then to that canvas add snapshots of your outgoing and incoming views instead and manipulate those. This means you have no cleanup at the end of the transition - just remove the canvas view. I've written about this technique here. For your case, I added the following convenience method:
extension UIView {
func snapshot(view: UIView, afterUpdates: Bool) -> UIView? {
guard let snapshot = view.snapshotView(afterScreenUpdates: afterUpdates) else { return nil }
self.addSubview(snapshot)
snapshot.frame = convert(view.bounds, from: view)
return snapshot
}
}
Then updated your transition code to move the snapshots around on a canvas view instead:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
//Get references to the view hierarchy
let fromViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
let toViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
let sourceRect: CGRect = transitionContext.initialFrame(for: fromViewController)
let containerView: UIView = transitionContext.containerView
// The canvas is used for all animation and discarded at the end
let canvas = UIView(frame: containerView.bounds)
containerView.addSubview(canvas)
let fromView = transitionContext.view(forKey: .from)!
let toView = transitionContext.view(forKey: .to)!
toView.frame = transitionContext.finalFrame(for: toViewController)
toView.layoutIfNeeded()
let toSnap = canvas.snapshot(view: toView, afterUpdates: true)!
if self.isPresenting { // Push
//1. Settings for the fromVC ............................
// fromViewController.view.frame = sourceRect
let fromSnap = canvas.snapshot(view: fromView, afterUpdates: false)!
fromView.removeFromSuperview()
fromSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
fromSnap.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);
//2. Setup toVC view...........................
toSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
toSnap.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
toSnap.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
//3. Perform the animation...............................
UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
fromSnap.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
toSnap.transform = CGAffineTransform(rotationAngle: 0);
}, completion: {
(animated: Bool) -> () in
containerView.insertSubview(toViewController.view, belowSubview:canvas)
canvas.removeFromSuperview()
transitionContext.completeTransition(true)
})
} else { // Pop
//1. Settings for the fromVC ............................
fromViewController.view.frame = sourceRect
fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);
//2. Setup toVC view...........................
let toSnap = canvas.snapshot(view: toView, afterUpdates: true)!
toSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
toSnap.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
toSnap.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
//3. Perform the animation...............................
UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
fromViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
toSnap.transform = CGAffineTransform(rotationAngle: 0);
}, completion: {
//When the animation is completed call completeTransition
(animated: Bool) -> () in
containerView.insertSubview(toViewController.view, belowSubview: canvas)
canvas.removeFromSuperview()
transitionContext.completeTransition(true)
})
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration;
}
}
This particular transition is pretty simple so it's not too difficult to reset the properties of the view frames, but if you do anything more complex then the canvas and snapshot approach works really well, so I tend to just use it everywhere.
On UIView you can change the backgroundColour animated. And on a UISlideView you can change the value animated.
Can you add a custom property to your own UIView subclass so that it can be animated?
If I have a CGPath within my UIView then I can animate the drawing of it by changing the percentage drawn of the path.
Can I encapsulate that animation into the subclass.
i.e. I have a UIView with a CGPath that creates a circle.
If the circle is not there it represents 0%. If the circle is full it represents 100%. I can draw any value by changing the percentage drawn of the path. I can also animate the change (within the UIView subclass) by animating the percentage of the CGPath and redrawing the path.
Can I set some property (i.e. percentage) on the UIView so that I can stick the change into a UIView animateWithDuration block and it animate the change of the percentage of the path?
I hope I have explained what I would like to do well.
Essentially, all I want to do is something like...
[UIView animateWithDuration:1.0
animations:^{
myCircleView.percentage = 0.7;
}
completion:nil];
and the circle path animate to the given percentage.
If you extend CALayer and implement your custom
- (void) drawInContext:(CGContextRef) context
You can make an animatable property by overriding needsDisplayForKey (in your custom CALayer class) like this:
+ (BOOL) needsDisplayForKey:(NSString *) key {
if ([key isEqualToString:#"percentage"]) {
return YES;
}
return [super needsDisplayForKey:key];
}
Of course, you also need to have a #property called percentage. From now on you can animate the percentage property using core animation. I did not check whether it works using the [UIView animateWithDuration...] call as well. It might work. But this worked for me:
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"percentage"];
animation.duration = 1.0;
animation.fromValue = [NSNumber numberWithDouble:0];
animation.toValue = [NSNumber numberWithDouble:100];
[myCustomLayer addAnimation:animation forKey:#"animatePercentage"];
Oh and to use yourCustomLayer with myCircleView, do this:
[myCircleView.layer addSublayer:myCustomLayer];
Complete Swift 3 example:
public class CircularProgressView: UIView {
public dynamic var progress: CGFloat = 0 {
didSet {
progressLayer.progress = progress
}
}
fileprivate var progressLayer: CircularProgressLayer {
return layer as! CircularProgressLayer
}
override public class var layerClass: AnyClass {
return CircularProgressLayer.self
}
override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == #keyPath(CircularProgressLayer.progress),
let action = action(for: layer, forKey: #keyPath(backgroundColor)) as? CAAnimation,
let animation: CABasicAnimation = (action.copy() as? CABasicAnimation) {
animation.keyPath = #keyPath(CircularProgressLayer.progress)
animation.fromValue = progressLayer.progress
animation.toValue = progress
self.layer.add(animation, forKey: #keyPath(CircularProgressLayer.progress))
return animation
}
return super.action(for: layer, forKey: event)
}
}
/*
* Concepts taken from:
* https://stackoverflow.com/a/37470079
*/
fileprivate class CircularProgressLayer: CALayer {
#NSManaged var progress: CGFloat
let startAngle: CGFloat = 1.5 * .pi
let twoPi: CGFloat = 2 * .pi
let halfPi: CGFloat = .pi / 2
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(progress) {
return true
}
return super.needsDisplay(forKey: key)
}
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
UIGraphicsPushContext(ctx)
//Light Grey
UIColor.lightGray.setStroke()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let strokeWidth: CGFloat = 4
let radius = (bounds.size.width / 2) - strokeWidth
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: twoPi, clockwise: true)
path.lineWidth = strokeWidth
path.stroke()
//Red
UIColor.red.setStroke()
let endAngle = (twoPi * progress) - halfPi
let pathProgress = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle , clockwise: true)
pathProgress.lineWidth = strokeWidth
pathProgress.lineCapStyle = .round
pathProgress.stroke()
UIGraphicsPopContext()
}
}
let circularProgress = CircularProgressView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut, animations: {
circularProgress.progress = 0.76
}, completion: nil)
There is a great objc article here, which goes into details about how this works
As well as a objc project that uses the same concepts here:
Essentially action(for layer:) will be called when an object is being animated from an animation block, we can start our own animations with the same properties (stolen from the backgroundColor property) and animate the changes.
For the ones who needs more details on that like I did:
there is a cool example from Apple covering this question.
E.g. thanks to it I found that you don't actually need to add your custom layer as sublayer (as #Tom van Zummeren suggests). Instead it's enough to add a class method to your View class:
+ (Class)layerClass
{
return [CustomLayer class];
}
Hope it helps somebody.
you will have to implement the percentage part yourself. you can override layer drawing code to draw your cgpath accroding to the set percentage value. checkout the core animation programming guide and animation types and timing guide
#David Rees answer get me on the right track, but there is one issue. In my case
completion of animation always returns false, right after animation has began.
UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut, animations: {
circularProgress.progress = 0.76
}, completion: { finished in
// finished - always false
})
This is the way it've worked for me - action of animation is handled inside of CALayer.
I have also included small example how to make layer with additional properties like "color".
In this case, without initializer that copies the values, changing the color would take affect only on non-animating view. During animation it would be visble with "default setting".
public class CircularProgressView: UIView {
#objc public dynamic var progress: CGFloat {
get {
return progressLayer.progress
}
set {
progressLayer.progress = newValue
}
}
fileprivate var progressLayer: CircularProgressLayer {
return layer as! CircularProgressLayer
}
override public class var layerClass: AnyClass {
return CircularProgressLayer.self
}
}
/*
* Concepts taken from:
* https://stackoverflow.com/a/37470079
*/
fileprivate class CircularProgressLayer: CALayer {
#NSManaged var progress: CGFloat
let startAngle: CGFloat = 1.5 * .pi
let twoPi: CGFloat = 2 * .pi
let halfPi: CGFloat = .pi / 2
var color: UIColor = .red
// preserve layer properties
// without this specyfic init, if color was changed to sth else
// animation would still use .red
override init(layer: Any) {
super.init(layer: layer)
if let layer = layer as? CircularProgressLayer {
self.color = layer.color
self.progress = layer.progress
}
}
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(progress) {
return true
}
return super.needsDisplay(forKey: key)
}
override func action(forKey event: String) -> CAAction? {
if event == #keyPath(CircularProgressLayer.progress) {
guard let animation = action(forKey: #keyPath(backgroundColor)) as? CABasicAnimation else {
setNeedsDisplay()
return nil
}
if let presentation = presentation() {
animation.keyPath = event
animation.fromValue = presentation.value(forKeyPath: event)
animation.toValue = nil
} else {
return nil
}
return animation
}
return super.action(forKey: event)
}
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
UIGraphicsPushContext(ctx)
//Light Gray
UIColor.lightGray.setStroke()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let strokeWidth: CGFloat = 4
let radius = (bounds.size.width / 2) - strokeWidth
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: twoPi, clockwise: true)
path.lineWidth = strokeWidth
path.stroke()
// Red - default
self.color.setStroke()
let endAngle = (twoPi * progress) - halfPi
let pathProgress = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle , clockwise: true)
pathProgress.lineWidth = strokeWidth
pathProgress.lineCapStyle = .round
pathProgress.stroke()
UIGraphicsPopContext()
}
}
The way to handle animations differently and copy layer properties I have found in this article:
https://medium.com/better-programming/make-apis-like-apple-animatable-view-properties-in-swift-4349b2244cea