I am using UIViewPropertyAnimator to make small contentOffset animations.
To keep my view controller lean I have the animation code in the UIScrollView subclass which is to be animated. I originally did my animations in a UIView.animate block, but I noticed that when the view disappears (i.e. another view is pushed on top of the view) the animation jumps to the end, so I am trying to implement a pause in the animation. In addition I wanted to reduce the CPU load through unnecessary animation calls.
var animator: UIViewPropertyAnimator?
...
func showAnimation() {
...
if animator == nil {
animator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: { [unowned self] in
self.contentOffset = endPoint
})
animator?.addCompletion { [unowned self] (position) in
if position == .end {
self.afterAnimation()
print("completion called")
}
}
}
}
func pauseAnimation() {
if animator?.state == .active {
animator?.pauseAnimation()
}
print("paused animation")
}
The method afterAnimation() just determines if the showAnimation() is to be called again or not.
In my view controller I basically have the following:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
myScrollView.showAnimation()
}
override func viewWillDisappear(_ animated: Bool) {
viewWillDisappear(animated)
myScrollView.pauseAnimation()
}
Now this works well as long as the user does not stay on another screen too long.
For example if the user calls up the settings screen (which gets pushed onto the
navigation stack) the pause method is called - just as expected. If the user stays
in that screen too long, then the animator's completion method is called, even though
the animation is not completed. Once the user dismisses the settings screen again the
animation is frozen and does not continue.
I also tried working with
animator = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: duration, delay: 0, options: .curveLinear, animations: { [unowned self] in
self.contentOffset = endPoint
print("starting animation")
}) { [unowned self] _ in
self.afterAnimation()
print("completion called")
}
but the results were the same. After a while the completion block gets called.
How can I best solve this issue? Thanks!
EDIT: Through different print statements I am slowly having the idea that even when the animator gets paused, the animation's internal counter continues and calls the completion block when the animation should be finished.
Related
I'm animating a view to move as the user pans the screen. I have kept a threshold after which the view will animate to a default position.
The problem currently is that the completion handler of the animate method which resets the view to a position is called before the duration. The animation seems to be happening abruptly instead of over a duration of time.
// Pan gesture selector method
#objc func panAction(for panGesture: UIPanGestureRecognizer) {
switch panGesture.state {
case .began:
//Began code
case changed:
if (condition) {
print("IF")
//Change constraint constant of customView
animate(view: customView)
} else if (condition) {
print("ELSE IF")
//Change constraint constant of customView
animate(view: customView)
} else {
//Change constraint constant of customView
print("ELSE")
view.layoutIfNeeded()
}
case .ended:
//Ended code
default:
break
}
}
The animate method:
func animate(view: UIView) {
UIView.animate(withDuration: 3, delay: 0, options: .curveEaseOut, animations: {
view.layoutIfNeeded()
}, completion: { (finished) in
if finished {
flag = false
}
})
}
The flag is being set immediately rather than after 3 seconds.
The o/p i get while panning and crossing threshold.
ELSE
ELSE
ELSE
ELSE
IF
Edit: I am an idiot. I did not call layoutIfNeeded() on the superView.
Your gesture sends several events while the gesture is happening, and as you call your UIView.animate() code multiple times the new value supersedes the previous one.
Try adding the animation option .beginFromCurrentState:
UIView.animate(withDuration: 0.5, delay: 0, options: [.beginFromCurrentState,.allowAnimatedContent,.allowUserInteraction], animations: {
view.layoutIfNeeded()
}) { (completed) in
...
}
And expect your completed to be called multiple times with the completed == false as the gesture is progressing.
Edit: Your issue may also be related to calling layoutIfNeeded() on the wrong view, possibly try to call this on the viewController.view ?
I solved this. I was not calling layoutIfNeeded on the superview.
I currently have the problem that the completion of the animation function ends before the animation itself does.
The array progressBar[] includes multiple UIProgressViews. When one is finished animating I want the next one to start animating and so on. But right now they all start at once.
How can I fix this?
#objc func updateProgress() {
if self.index < self.images.count {
progressBar[index].setProgress(0.01, animated: false)
group.enter()
DispatchQueue.main.async {
UIView.animate(withDuration: 5, delay: 0.0, options: .curveLinear, animations: {
self.progressBar[self.index].setProgress(1.0, animated: true)
}, completion: { (finished: Bool) in
if finished == true {
self.group.leave()
}
})
}
group.notify(queue: .main) {
self.index += 1
self.updateProgress()
}
}
}
The problem is that UIView.animate() can only be used on animatable properties, and progress is not an animatable property. "Animatable" here means "externally animatable by Core Animation." UIProgressView does its own internal animations, and that conflicts with external animations. This is UIProgressView being a bit over-smart, but we can work around it.
UIProgressView does use Core Animation, and so will fire CATransaction completion blocks. It does not, however, honor the duration of the current CATransaction, which I find confusing since it does honor the duration of the current UIView animation. I'm not actually certain how both of these are true (I would think that the UIView animation duration would be implemented on the transaction), but it seems to be the case.
Given that, the way to do what you're trying looks like this:
func updateProgress() {
if self.index < self.images.count {
progressBar[index].setProgress(0.01, animated: false)
CATransaction.begin()
CATransaction.setCompletionBlock {
self.index += 1
self.updateProgress()
}
UIView.animate(withDuration: 5, delay: 0, options: .curveLinear,
animations: {
self.progressBar[self.index].setProgress(1.0, animated: true)
})
CATransaction.commit()
}
}
I'm creating a nested transaction here (with begin/commit) just in case there is some other completion block created during this transaction. That's pretty unlikely, and the code "works" without calling begin/commit, but this way is a little safer than messing with the default transaction.
This sounded simple, but I haven't been able to figure it out.
Context: a VC tells a view to animate itself and the VC waits the animation to be completed before.
I thought about doing something like this:
In ViewController:
loadingView.animate()
In LoadingView (UIView subclass):
animate() -> Bool {
UIView.animate(withDuration: 1.0, animations: {
self.imageViewCenterYConstraint.constant -= 20
self.layoutIfNeeded()
}, completion {
return true // This line obviously doesn't work.
})
}
I do not want to include the rest of the code inside the completion block. The rest of the code is in the VC.
I suspect that all this should rather be done with an additional completion handler to add to the animate func.
PS: Just in case you know of a better solution/best practices, here is more context: I display a loading animation and remove it once I have retrieved data from the network. I always want to wait for the animation to complete before removing, even if the network data was already downloaded. Don't want to stop the animation at half of it.
You can't return from a function once an async task is complete. (Ok, it might be possible, but it is a VERY BAD IDEA. You'd have to block the main thread waiting for the async function to complete, which would freeze your UI and cause the system springboard process to terminate your app if it takes too long.)
You need to write your function to take a completion block and invoke it once the animation is complete. Something like this:
animate( completion: (Bool) -> Void) {
UIView.animate(withDuration: 1.0, animations: {
self.imageViewCenterYConstraint.constant -= 20
self.layoutIfNeeded()
}, completion {
completion(true)
})
}
(I think that's right but I still do not have closure syntax perfect so it might need adjustment.)
For your problem use escaping closures to return it once animation is done.
Write function for the same and then your code will look like this.
func animate( completion: #escaping (Bool) -> Void) {
UIView.animate(withDuration: 1.0, animations: {
//Do your animation stuff here
//self.imageViewCenterYConstraint.constant -= 20
//self.layoutIfNeeded()
}, completion: {(true) in
completion(true)
})
}
You can call this method like this
animate { (isDone) in
// Animation is completed do your own stuff here
}
I have a UIScrollview with buttons that I use for paging right and left:
#IBAction func leftPressed(sender: AnyObject) {
self.scrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
}
I'd like to perform an action after the scrollview has finished the paging animation. Something like:
#IBAction func leftPressed(sender: AnyObject) {
self.scrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
secondFunction()
}
The above code doesn't work because the second function runs before the scrollview is finished animating the offset. My initial reaction was to use a completion handler but I'm not sure how to apply one to the setContentOffset function. I've tried:
func animatePaging(completion: () -> Void) {
self.mainScrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
completion()
}
with the call
animatePaging(completion: self.secondFunction())
But I get the error "Cannot invoke 'animatePaging' with an argument list of type '(completion())'. Any thoughts?
The problem is that you need a completion handler for the scrolling animation itself. But setContentOffset(_:animated:) does not have a completion handler.
One solution would be that you animate the scrolling yourself using UIView's static function animateWithDuration(_:animations:completion:). That function has a completion handler that you can use:
UIView.animateWithDuration(0.5, animations: { () -> Void in
self.scrollView.contentOffset = CGPointMake(0, 0)
}) { (finished) -> Void in
self.secondFunction()
}
Update from joern answer - Swift 4.2
UIView.animate(withDuration: 0.5, animations: { [unowned self] in
self.scrollView.contentOffset = .zero
}) { [unowned self] _ in
self.secondFunction()
}
I have created an interactive transition. My func animateTransition(transitionContext: UIViewControllerContextTransitioning) is quite normal, I get the container UIView, I add the two UIViewControllers and then I do the animation changes in a UIView.animateWithDuration(duration, animations, completion).
I add a UIScreenEdgePanGestureRecognizer to my from UIViewController. It works well except when I do a very quick pan.
In that last scenario, the app is not responsive, still on the same UIViewController (the transition seems not to have worked) but the background tasks run. When I run the Debug View Hierarchy, I see the new UIViewController instead of the previous one, and the previous one (at least its UIView) stands where it is supposed to stand at the end of the transition.
I did some print out and check points and from that I can say that when the problem occurs, the animation's completion (the one in my animateTransition method) is not reached, so I cannot call the transitionContext.completeTransition method to complete or not the transition.
I could see as well that the pan goes sometimes from UIGestureRecognizerState.Began straight to UIGestureRecognizerState.Ended without going through UIGestureRecognizerState.Changed.
When it goes through UIGestureRecognizerState.Changed, both the translation and the velocity stay the same for every UIGestureRecognizerState.Changed states.
EDIT :
Here is the code:
animateTransition method
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let containerView = transitionContext.containerView()
let screens: (from: UIViewController, to: UIViewController) = (transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!, transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!)
let parentViewController = presenting ? screens.from : screens.to
let childViewController = presenting ? screens.to : screens.from
let parentView = parentViewController.view
let childView = childViewController.view
// positionning the "to" viewController's view for the animation
if presenting {
offStageChildViewController(childView)
}
containerView.addSubview(parentView)
containerView.addSubview(childView)
let duration = transitionDuration(transitionContext)
UIView.animateWithDuration(duration, animations: {
if self.presenting {
self.onStageViewController(childView)
self.offStageParentViewController(parentView)
} else {
self.onStageViewController(parentView)
self.offStageChildViewController(childView)
}}, completion: { finished in
if transitionContext.transitionWasCancelled() {
transitionContext.completeTransition(false)
} else {
transitionContext.completeTransition(true)
}
})
}
Gesture and gesture handler:
weak var fromViewController: UIViewController! {
didSet {
let screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: "presentingViewController:")
screenEdgePanRecognizer.edges = edge
fromViewController.view.addGestureRecognizer(screenEdgePanRecognizer)
}
}
func presentingViewController(pan: UIPanGestureRecognizer) {
let percentage = getPercentage(pan)
switch pan.state {
case UIGestureRecognizerState.Began:
interactive = true
presentViewController(pan)
case UIGestureRecognizerState.Changed:
updateInteractiveTransition(percentage)
case UIGestureRecognizerState.Ended:
interactive = false
if finishPresenting(pan, percentage: percentage) {
finishInteractiveTransition()
} else {
cancelInteractiveTransition()
}
default:
break
}
}
Any idea what might happen?
EDIT 2:
Here are the undisclosed methods:
override func getPercentage(pan: UIPanGestureRecognizer) -> CGFloat {
let translation = pan.translationInView(pan.view!)
return abs(translation.x / pan.view!.bounds.width)
}
override func onStageViewController(view: UIView) {
view.transform = CGAffineTransformIdentity
}
override func offStageParentViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(-view.bounds.width / 2, 0)
}
override func offStageChildViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(view.bounds.width, 0)
}
override func presentViewController(pan: UIPanGestureRecognizer) {
let location = pan.locationInView((fromViewController as! MainViewController).tableView)
let indexPath = (fromViewController as! MainViewController).tableView.indexPathForRowAtPoint(location)
if indexPath == nil {
pan.state = .Failed
return
}
fromViewController.performSegueWithIdentifier("chartSegue", sender: pan)
}
I remove the "over" adding lines => didn't fix it
I added updateInteractiveTransition in .Began, in .Ended, in both => didn't fix it
I turned on shouldRasterize on the layer of the view of my toViewController and let it on all the time => didn't fix it
But the question is why, when doing a fast interactive gesture, is it not responding quickly enough
It actually works with a fast interactive gesture as long as I leave my finger long enough. For example, if I pan very fast on more than (let say) 1cm, it's ok. It's not ok if I pan very fast on a small surface (let say again) less than 1cm
Possible candidates include the views being animated are too complicated (or have complicated effects like shading)
I thought about a complicated view as well but I don't think my view is really complicated. There are a bunch of buttons and labels, a custom UIControl acting as a segmented segment, a chart (that is loaded once the controller appeared) and a xib is loaded inside the viewController.
Ok I just created a project with the MINIMUM classes and objects in order to trigger the problem. So to trigger it, you just do a fast and brief swipe from the right to the left.
What I noticed is that it works pretty easily the first time but if you drag the view controller normally the first time, then it get much harder to trigger it (even impossible?). While in my full project, it doesn't really matter.
When I was diagnosing this problem, I noticed that the gesture's change and ended state events were taking place before animateTransition even ran. So the animation was canceled/finished before it even started!
I tried using GCD animation synchronization queue to ensure that the updating of the UIPercentDrivenInterativeTransition doesn't happen until after `animate:
private let animationSynchronizationQueue = dispatch_queue_create("com.domain.app.animationsynchronization", DISPATCH_QUEUE_SERIAL)
I then had a utility method to use this queue:
func dispatchToMainFromSynchronizationQueue(block: dispatch_block_t) {
dispatch_async(animationSynchronizationQueue) {
dispatch_sync(dispatch_get_main_queue(), block)
}
}
And then my gesture handler made sure that changes and ended states were routed through that queue:
func handlePan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .Began:
dispatch_suspend(animationSynchronizationQueue)
fromViewController.performSegueWithIdentifier("segueID", sender: gesture)
case .Changed:
dispatchToMainFromSynchronizationQueue() {
self.updateInteractiveTransition(percentage)
}
case .Ended:
dispatchToMainFromSynchronizationQueue() {
if isOkToFinish {
self.finishInteractiveTransition()
} else {
self.cancelInteractiveTransition()
}
}
default:
break
}
}
So, I have the gesture recognizer's .Began state suspend that queue, and I have the animation controller resume that queue in animationTransition (ensuring that the queue starts again only after that method runs before the gesture proceeds to try to update the UIPercentDrivenInteractiveTransition object.
Have the same issue, tried to use serialQueue.suspend()/resume(), does not work.
This issue is because when pan gesture is too fast, end state is earlier than animateTransition starts, then context.completeTransition can not get run, the whole animation is messed up.
My solution is forcing to run context.completeTransition when this situation happened.
For example, I have two classes:
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if !swipeInteractor.interactionInProgress {
DispatchQueue.main.asyncAfter(deadline: .now()+transitionDuration) {
if context.transitionWasCancelled {
toView?.removeFromSuperview()
} else {
fromView?.removeFromSuperview()
}
context.completeTransition(!context.transitionWasCancelled)
}
}
...
}
...
}
interactionInProgress is set to true when gesture began, set to false when gesture ends.
I had a similar problem, but with programmatic animation triggers not triggering the animation completion block. My solution was like Sam's, except instead of dispatching after a small delay, manually call finish on the UIPercentDrivenInteractiveTransition instance.
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
private var swipeInteractor: SwipeInteractor
..
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
...
if !swipeInteractor.interactionInProgress {
swipeInteractor.finish()
}
...
UIView.animateWithDuration(...)
}
}