Animation ends with error in custom interactive transitioning animation? - ios

I am setting up a custom transitioning animation. Everything works fine except the error at the end of the animation:
As you see, after the interactive animation, the animation just excutes again.
This is the method i use to handle the the interactive gesture in the class the conforms to UIPercentDrivenInteractiveTransition and UIViewControllerAnimatedTransitioning:
func handlePan(recognizer: UIPanGestureRecognizer) {
let maxMov:CGFloat = -300
let translation = recognizer.translationInView(recognizer.view!.superview!)
if(translation.y < 0){
let value = (translation.y < maxMov) ? maxMov : translation.y
let progress = value / maxMov
switch recognizer.state {
case .Changed:
updateInteractiveTransition(progress)
case .Cancelled, .Ended:
if progress < 0.5 {
self.cancelInteractiveTransition()
} else {
self.finishInteractiveTransition()
}
default:
break
}
}
}

Custom transitioning animation is based on CAAnimation whose timing is controlled by CAMediaTiming. In the handlePan method, you should reset the currentTiming of the container view in the transitioning.
Firstly, define a stored context which will be later assigned in the animateTransitionmethod.
var storedContext: UIViewControllerContextTransitioning?
then get it in :
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
storedContext = transitionContext
....}
Then in your handlePan:
case .Cancelled, .Ended:
let transitionLayer = storedContext!.containerView().layer
transitionLayer.beginTime = CACurrentMediaTime()
....
This ensures the containerLayer's begin time equals to the time you cancel or end the gesture! Without this process, the duplicated animation would occurs.

Related

Interactive View Controller Transition - Weird behavior (stuck in the transition)

I'm trying to implement a custom pan gesture to interactively transition to a new view controller. The way it works is that I have a button (labeled "Template Editor", see below) on which you can start a pan to move the current view controller to the right, revealing the new view controller next to it (I've recorded my problem, see below).
Everything is working but there is a bug that I don't understand at all:
Sometimes, when I just swipe over the button (triggering a pan gesture) then lift my finger again (touch down -> fast, short swipe to the right -> touch up) the interactive transition glitches out. It starts to very slowly complete the transition and afterwards, I cannot dismiss the presented view controller, nor can I present anything on that presented view controller.
I have no idea why. Here's my code:
First, the UIViewControllerAnimatedTransitioning class. It's implemented using UIViewPropertyAnimator and just adds the animation using transform:
class MovingTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
enum Direction {
case left, right
}
// MARK: - Properties
// ========== PROPERTIES ==========
private var animator: UIViewImplicitlyAnimating?
var duration = 0.6
var presenting = true
var shouldAnimateInteractively: Bool = false
public var direction: Direction = .left
private var movingMultiplicator: CGFloat {
return direction == .left ? -1 : 1
}
// ====================
// MARK: - Initializers
// ========== INITIALIZERS ==========
// ====================
// MARK: - Overrides
// ========== OVERRIDES ==========
// ====================
// MARK: - Functions
// ========== FUNCTIONS ==========
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let animator = interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
// If the animator already exists, return it (important, see documentation!)
if let animator = self.animator {
return animator
}
// Otherwise, create the animator
let containerView = transitionContext.containerView
let fromView = transitionContext.view(forKey: .from)!
let toView = transitionContext.view(forKey: .to)!
if presenting {
toView.frame = containerView.frame
toView.transform = CGAffineTransform(translationX: movingMultiplicator * toView.frame.width, y: 0)
} else {
toView.frame = containerView.frame
toView.transform = CGAffineTransform(translationX: -movingMultiplicator * toView.frame.width, y: 0)
}
containerView.addSubview(toView)
let animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 0.9, animations: nil)
animator.addAnimations {
if self.presenting {
toView.transform = .identity
fromView.transform = CGAffineTransform(translationX: -self.movingMultiplicator * toView.frame.width, y: 0)
} else {
toView.transform = .identity
fromView.transform = CGAffineTransform(translationX: self.movingMultiplicator * toView.frame.width, y: 0)
}
}
animator.addCompletion { (position) in
// Important to set frame above (device rotation will otherwise mess things up)
toView.transform = .identity
fromView.transform = .identity
if !transitionContext.transitionWasCancelled {
self.shouldAnimateInteractively = false
}
self.animator = nil
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
self.animator = animator
return animator
}
// ====================
}
Here's the part that adds the interactivity. It's a method that's being called by a UIPanGestureRecognizer I added to the button.
public lazy var transitionAnimator: MovingTransitionAnimator = MovingTransitionAnimator()
public lazy var interactionController = UIPercentDrivenInteractiveTransition()
...
#objc private func handlePan(pan: UIPanGestureRecognizer) {
let translation = pan.translation(in: utilityView)
var progress = (translation.x / utilityView.frame.width)
progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
switch pan.state {
case .began:
// This is a flag that helps me distinguish between when a user taps on the button and when he starts a pan
transitionAnimator.shouldAnimateInteractively = true
// Just a dummy view controller that's dismissing as soon as its been presented (the problem occurs with every view controller I use here)
let vc = UIViewController()
vc.view.backgroundColor = .red
vc.transitioningDelegate = self
present(vc, animated: true, completion: {
self.transitionAnimator.shouldAnimateInteractively = false
vc.dismiss(animated: true, completion: nil)
})
case .changed:
interactionController.update(progress)
case .cancelled:
interactionController.cancel()
case .ended:
if progress > 0.55 || pan.velocity(in: utilityView).x > 600
interactionController.completionSpeed = 0.8
interactionController.finish()
} else {
interactionController.completionSpeed = 0.8
interactionController.cancel()
}
default:
break
}
}
I also implemented all the necessary delegate methods:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionAnimator.presenting = true
return transitionAnimator
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionAnimator.presenting = false
return transitionAnimator
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? MovingTransitionAnimator, animator.shouldAnimateInteractively else { return nil }
return interactionController
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? MovingTransitionAnimator, animator.shouldAnimateInteractively else { return nil }
return interactionController
}
That's it. There's no more logic behind it (I think; if you need more information, please tell me), but it still has this bug. Here's a recording of the bug. You can't really see my touch but all I'm doing is touching down -> fast, shortly swiping to the right -> touching up. And after this really slow transition has finished, I can't dismiss the red view controller. It's stuck there:
Here's what's even stranger:
Neither interactionController.finish() nor interactionController.cancel() is being called when this occurs (at least not from within my handlePan(_:)method).
I checked the view hierarchy in Xcode after this bug occurred and I got this:
First, it's seemingly stuck in the transition (everything is still inside UITransitionView).
Second, on the left hand side you see the views of the first view controller(the one I start the transition from). However, on the image there only is the red view controller visible, the one that was about to be presented.
Do you have any idea what's going on? I've been trying to figure this out for the past 3 hours but I can't get get it to work properly. I'd appreciate any help
Thank you!
EDIT
Okay, I found a way to reproduce it 100% of the time. I also created an isolated project demonstrating the problem (it's a little differently structured because I tried many things but the result is still exactly the same)
Here's the project: https://github.com/d3mueller/InteractiveTransitionDemo2
How to reproduce the problem:
Swipe from right to left and then quickly from left to right. This will trigger the bug.
Also, a similar bug will appear, when you swipe from right to left very fast multiple times. Then it will actually run the transition and finish it correctly (but it shouldn't even start because moving from right to left keeps the progress at 0.0)
You might try setting:
/// Set this to NO in order to start an interruptible transition non
/// interactively. By default this is YES, which is consistent with the behavior
/// before 10.0.
#property (nonatomic) BOOL wantsInteractiveStart NS_AVAILABLE_IOS(10_0);
to NO on your interactionController
Good luck and curious to hear if you figure it out.

Hero animation start delayed

I am trying to implement a transition between two controllers with Hero inside a navigation controller.
I have this method to handle a pan gesture:
#objc func pannedCell(gesture: UIPanGestureRecognizer) {
guard let animatedView = gesture.view as? HotelListingCollectionViewCell else {
return
}
let translation = gesture.translation(in: nil)
let screenHeight = self.rootView.bounds.height
let progress = ((-1 * translation.y) / screenHeight) * 2
print(progress)
switch gesture.state {
case .began:
self.initialViewPostion = animatedView.center
Hero.shared.defaultAnimation = .fade
let vc = ListingSearchViewController()
vc.viewModel.hotels.value = self.viewModel.hotels.value
self.navigationController?.pushViewController(vc, animated: true)
case .changed:
Hero.shared.update(progress)
let pos = CGPoint(
x: animatedView.center.x + translation.x,
y: self.rootView.hotelResultsCollectionView.center.y + translation.y
)
Hero.shared.apply(modifiers: [
.position(pos),
.useGlobalCoordinateSpace,
.useOptimizedSnapshot
], to: animatedView)
default:
if progress > 0.8 {
Hero.shared.finish()
} else {
Hero.shared.cancel()
}
}
}
The problem here is the pushViewController method takes ~ 1 second to execute so when I start dragging my view, it moves under my finger approx. 1 second after I started moving the finger on the screen.
I am doing it wrong ?
Thanks
Ok, I think I found something !
The issue seems to be related to operation I did in .began case of the gesture handler.
I moved the instantiation of the new view controller out of this call back to avoid overloading the UI thread during the animation and everything seems to work as expected.

Handling scroll views with (custom, interactive) view controller presentation and dismissal

I have been experimenting with custom interactive view controller presentation and dismissal (using a combination of UIPresentationController, UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning, and UIViewControllerTransitioningDelegate) and have mostly gotten things working well for my needs.
However, there is one common scenario that I've yet to find addressed in any of the tutorials or documentation that I've read, leading me to the following question:
...
What is the proper way of handling custom interactive view controller dismissal, via a pan gesture, when the dismissed view contains a UIScrollView (ie. UITableView, UICollectionView, WKWebView, etc)?
...
Basically, what I'd like is for the following:
View controllers are interactively dismissible by panning them down. This is common UX in many apps.
If the dismissed view controller contains a (vertically-scrolling) scroll view, panning down scrolls that view as expected until the user reaches the top, after which the scrolling ceases and the pan-to-dismiss occurs.
Scroll views should otherwise behave as normal.
I know that this is technically possible - I've seen it in other apps, such as Overcast and Apple's own Music app - but I've not been able to find the key to coordinating the behavior of my pan gesture with that of the scroll view(s).
Most of my own attempts center on trying to conditionally enable/disable the scrollview (or its associated pan gesture recognizer) based on its contentOffset.y while scrolling and having the view controller dismissal's pan gesture recognizer take over from there, but this has been fraught with problems and I fear that I am overthinking it.
I feel like the secret mostly lies in the following pan gesture recognizer delegate method:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// ...
}
I have created a reduced sample project which should demonstrate the scenario more clearly. Any code suggestions are highly welcome!
https://github.com/Darchmare/SlidePanel-iOS
Solution
Make scrollView stop scrolling after it reached top by using UIScrollView's bounces property and scrollViewDidScroll(_:) method.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollView.bounces = (scrollView.contentOffset.y > 10);
}
Don't forget to set scrollView.delegate = self
Only handle panGestureRecognizer when scrollView reached top - It means when scrollView.contentOffset.y == 0 by using a protocol.
protocol PanelAnimationControllerDelegate {
func shouldHandlePanelInteractionGesture() -> Bool
}
ViewController
func shouldHandlePanelInteractionGesture() -> Bool {
return (scrollView.contentOffset.y == 0);
}
PanelInteractionController
class PanelInteractionController: ... {
var startY:CGFloat = 0
private weak var viewController: (UIViewController & PanelAnimationControllerDelegate)?
#objc func handlePanGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
break
case .changed:
let translation = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
let velocity = gestureRecognizer.velocity(in: gestureRecognizer.view!.superview)
let state = gestureRecognizer.state
// Don't do anything when |scrollView| is scrolling
if !(viewController?.shouldHandlePanelInteractionGesture())! && percentComplete == 0 {
return;
}
var rawProgress = CGFloat(0.0)
rawProgress = ((translation.y - startTransitionY) / gestureRecognizer.view!.bounds.size.height)
let progress = CGFloat(fminf(fmaxf(Float(rawProgress), 0.0), 1.0))
if abs(velocity.x) > abs(velocity.y) && state == .began {
// If the user attempts a pan and it looks like it's going to be mostly horizontal, bail - we don't want it... - JAC
return
}
if !self.interactionInProgress {
// Start to pan |viewController| down
self.interactionInProgress = true
startTransitionY = translation.y;
self.viewController?.dismiss(animated: true, completion: nil)
} else {
// If the user gets to a certain point within the dismissal and releases the panel, allow the dismissal to complete... - JAC
self.shouldCompleteTransition = progress > 0.2
update(progress)
}
case .cancelled:
self.interactionInProgress = false
startTransitionY = 0
cancel()
case .ended:
self.interactionInProgress = false
startTransitionY = 0
if self.shouldCompleteTransition == false {
cancel()
} else {
finish()
}
case .failed:
self.interactionInProgress = false
startTransitionY = 0
cancel()
default:
break;
}
}
}
Result
For more detail, you can take a look at my sample project
For me, this little bit of code answered a lot of my issues and greatly helped my custom transitions in scrollviews, it will hold a negative scrollview offset from moving while trying to start a transition or showing an activity indicator on the top. My guess is that this will solve at least some of your transition/animation hiccups:
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y < -75 {
scrollView.contentInset.top = -scrollView.contentOffset.y
}
// Do animation or transition
}
I believe you don't need an additional pan gesture recognizer to implement this. You can simply hook onto the different delegate methods of the scroll view to achieve the "pan to dismiss" effect. Here is how I went about it
// Set the dragging property to true
func scrollViewWillBeginDragging(_: UIScrollView) {
isDragging = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// If not dragging, we could make an early exit
guard isDragging else {
return
}
let topOffset = scrollView.contentOffset.y + statusBarHeight
// If The dismissal has not already started and the user has scrolled to the top and they are currently scrolling, then initiate the interactive dismissal
if !isDismissing && topOffset <= 0 && scrollView.isTracking {
startInteractiveTransition()
return
}
// If its already being dismissed, then calculate the progress and update the interactive dismissal animator
if isDismissing {
updateInteractiveTransitionProgress()
}
}
// Once the scroll ends, check for a few things
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// Early return
if !isDismissing {
return
}
// Optional check to dismiss the controller, if swiped from the top
checkForFastSwipes()
// If dragged enough, dismiss the controller, otherwise cancel the
transition
if interactor?.shouldFinish ?? false {
interactor?.finish()
} else {
interactor?.cancel()
}
// Finally reset the transition properties
resetTransitionProperties()
}
private func checkForFastSwipes() {
let velocity = scrollView.panGestureRecognizer.velocity(in: view)
let velDiff = velocity.y - velocity.x
if velDiff > 0 && velDiff >= 75 {
interactor?.hasStarted = false
self.dismiss()
interactor?.shouldFinish = true
}
}
private func startInteractiveTransition() {
isDismissing = true
interactor?.hasStarted = true
dismiss()
}
private func updateInteractiveTransitionProgress() {
progress = max(
0.0,
min(1.0, ((-scrollView.contentOffset.y) - statusBarHeight) / 90.0)
)
interactor?.shouldFinish = progress > 0.5
interactor?.update(progress)
}
private func resetTransitionProperties() {
isDismissing = false
isDragging = false
}
The interactor property used for synchronizing the animation with gesture
var interactor: Interactor?
class Interactor: UIPercentDrivenInteractiveTransition {
var hasStarted = false
var shouldFinish = false
}
Inspired by the following Kodeco tutorial
https://www.kodeco.com/books/ios-animations-by-tutorials/v6.0/chapters/25-uiviewpropertyanimator-view-controller-transitions
(Look for the Interactive view controller transitions section)
Edit
After implementing the solution below, I realized that it only works if you have sufficient content to scroll, however, if you have dynamic content wherein the contents are not guaranteed to be scrollable as was my case, you'd be better off adding a pan gesture as mentioned by #trungduc. However, there are a few improvements that we could make to their answer like detecting an upwards scroll and not letting it interfere with our gesture.
Under the changed state add the following code
let isUpwardsScroll = self.velocity(in: target).y < 0
/*
If the user is normally scrolling the view, ignore it. However,
once the interaction starts allow such gestures as they could be
dragging the interactable view back
*/
if isUpwardScroll && !interactor.hasStarted {
return
}

UIViewPropertyAnimator issue with Autolayout

Here is the code of what I tried to repeat according to Apple WWDC but with autolayout:
extension AugmentedReallityViewController {
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
// // hide languages and units anyway
// moveUnitView(show: false)
// moveLanguageView(show: false)
//
// let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
// let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded
//
// switch recognizer.state {
// case .began:
// startInteractiveTransition(state: newState, duration: 1)
// isLastPanelUpdateToReachTheNewState = true // just in case, but we should change this property later
// case .changed:
// let translation = recognizer.translation(in: viewSettings)
// let fractionComplete = translation.y / viewSettings.frame.size.height
//
// // we will use this property when interaction ends
// if fractionComplete != 0 { // if it's == 0 , we need to use prev data
// isLastPanelUpdateToReachTheNewState = (newState == SettingsPanelState.expanded && fractionComplete < 0) || (newState == SettingsPanelState.collapsed && fractionComplete > 0)
// }
//
// updateInteractiveTransition(fractionComplete: fractionComplete)
// case .ended:
// continueInteractiveTransition(cancel: !isLastPanelUpdateToReachTheNewState)
// default:
// break
// }
}
#objc func handleSettingsTap() {
// hide languages and units anyway
moveUnitView(show: false)
moveLanguageView(show: false)
let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded
animateOrReverseRunningTransition(state: newState, duration: 10)
}
// perform all animations with animators if not already running
private func animateTransitionIfNeeded(state: SettingsPanelState, duration: TimeInterval) {
if runningAnimators.isEmpty {
// // define constraint for frame animation
// // update constraints
// switch state {
// case .expanded:
// constraint_settingsView_bottom.constant = 0
// case .collapsed:
// constraint_settingsView_bottom.constant = -constraint_height_settingViewWhitePart.constant
// }
// animate that
let frameAnimator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: { [weak self] in
if let strongSelf = self {
// define constraint for frame animation
// update constraints
switch state {
case .expanded:
strongSelf.constraint_settingsView_bottom.constant = 0
case .collapsed:
strongSelf.constraint_settingsView_bottom.constant = -(strongSelf.constraint_height_settingViewWhitePart.constant)
}
}
self?.view.layoutIfNeeded()
})
frameAnimator.startAnimation()
runningAnimators.append(frameAnimator)
frameAnimator.addCompletion({ [weak self] (position) in
if position == UIViewAnimatingPosition.end { // need to remove this animator from array
if let index = self?.runningAnimators.index(of: frameAnimator) {
print("removed animator because of completion")
self?.runningAnimators.remove(at: index)
// we can change state to a new one
self?.settingsPanelState = state
}
else {
print("animator completion with state = \(position)")
}
}
})
}
}
// starts transition if neccessary or reverses it on tap
private func animateOrReverseRunningTransition(state: SettingsPanelState, duration: TimeInterval) {
if runningAnimators.isEmpty { // start transition from start to end
animateTransitionIfNeeded(state: state, duration: duration)
}
else { // reverse all animators
for animator in runningAnimators {
animator.stopAnimation(true)
animator.isReversed = !animator.isReversed
// test
print("tried to reverse")
}
}
}
// called only on pan .begin
// starts transition if neccessary and pauses (on pan .begin)
private func startInteractiveTransition(state: SettingsPanelState, duration: TimeInterval) {
animateTransitionIfNeeded(state: state, duration: duration)
for animator in runningAnimators {
animator.pauseAnimation()
// save progress of any item
progressWhenInterrupted = animator.fractionComplete
}
}
// scrubs transition on pan .changed
private func updateInteractiveTransition(fractionComplete: CGFloat) {
for animator in runningAnimators {
animator.fractionComplete = fractionComplete + progressWhenInterrupted
}
}
// continue or reverse transition on pan .ended
private func continueInteractiveTransition(cancel: Bool) {
for animator in runningAnimators {
// need to continue or reverse
if !cancel {
let timing = UICubicTimingParameters(animationCurve: .easeOut)
animator.continueAnimation(withTimingParameters: timing, durationFactor: 0)
}
else {
animator.isReversed = true
}
}
}
private func addPanGustureRecognizerToSettings() {
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handlePan(recognizer:)))
// panGestureRecognizer.cancelsTouchesInView = false
viewSettings.addGestureRecognizer(panGestureRecognizer)
}
private func addTapGestureRecognizerToSettings() {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handleSettingsTap))
tapGestureRecognizer.cancelsTouchesInView = false
viewSettingsTopTriangle.addGestureRecognizer(tapGestureRecognizer)
}
}
Right now I'm just testing tap gestures. And there are 2 main issues:
1) Tap recognizer doesn't work properly during animation. But in apple WWDC they changed frames (not constraints like in my case) and tap recognizers worked perfectly
2) If I change reverse property it changes constraints really very bad. I have extra strips and so on
3) I tried both ways to put changing constraint before animation block and inside. It doesn't really matter, works the same
Any help how to do that with autolayout? Or at least how to do it with frames but my view controller is based on autolayout, so anyway I will have constraints to this bottom view.
When you are using autolayout for animations, you do it as follows:
Make sure autolayout is done:
self.view.layoutIfNeeded()
Then you change the constraints BEFORE the animation block. So for example:
someConstraint.constant = 0
Then after changing the constraint, you tell the autolayout that constraints have been changed:
self.view.setNeedsLayout()
And then you add an animation block with simply calling layoutIfNeeded():
UIView.animate(withDuration: 1, animations: {
self.view.layoutIfNeeded()
})
The same applies when you use UIViewPropertyAnimator - change the constraints in the animation block. E.g.:
self.view.layoutIfNeeded()
someConstraint.constant = 0
self.view.setNeedsLayout()
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeInOut) {
self.view.layoutIfNeeded()
}
animator.startAnimation()
This happens because layoutIfNeeded() does the actual layout - it calculates the frames of the affected views. So if you are setting frames directly, you set them in the animation block. However, Autolayout sets the frames for you - therefore what you need is to tell the autolayout to set them in the animation block (as you would do, if you would set them directly). The layoutIfNeeded() call does exactly that - it tells the autolayout engine to calculate and set the new frames.
About reversal:
While I don't have enough experience to be 100% sure, I would expect that simply setting the animator to reverse would not suffice. Since you apply the constraints before starting the animation, and then you just tell the autolayout to update frames according to the constraints - I would assume that when you reverse the animator, you would also need to reverse also the constraints that are driving the animation.
Animator just animates views into new frames. However, reversed or not, the new constraints still hold regardless of whether you reversed the animator or not. Therefore after the animator finishes, if later autolayout again lays out views, I would expect the views to go into places set by currently active constraints. Simply said: The animator animates frame changes, but not constraints themselves. That means reversing animator reverses frames, but it does not reverse constraints - as soon as autolayout does another layout cycle, they will be again applied.
The important thing to set self.view.layoutIfNeeded() animation to happen
private func animateCard(with topOffset: CGFloat) {
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut)
animator.addAnimations {
self.topCardConstraint?.constant = topOffset
self.view.layoutIfNeeded()
}
animator.startAnimation()
}

UIPercentDrivenInteractiveTransition doesn't get to animation's completion on fast gesture

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(...)
}
}

Resources