Slowly panning in UIPercentDrivenInteractiveTransition results in glitch
Asked Answered
H

2

6

In my app I'm dismissing a viewController using a UIPercentDrivenInteractiveTransition triggered by a pan gesture. I'm expecting my viewController to be dragged to the right as I'm panning it. However when I slowly pan I get a glitch: the viewController quickly jumps from left to right a bit. Here's the code for the transition:

class FilterHideTransition: UIPercentDrivenInteractiveTransition {

    let viewController: FilterViewController
    var enabled = false

    private let panGesture = UIPanGestureRecognizer()
    private let tapGesture = UITapGestureRecognizer()

    init(viewController: FilterViewController) {
        self.viewController = viewController
        super.init()
        panGesture.addTarget(self, action: #selector(didPan(with:)))
        panGesture.cancelsTouchesInView = false
        panGesture.delegate = self

        tapGesture.addTarget(self, action: #selector(didTap(with:)))
        tapGesture.cancelsTouchesInView = false
        tapGesture.delegate = self

        viewController.view.addGestureRecognizer(panGesture)
        viewController.view.addGestureRecognizer(tapGesture)
    }
}

//MARK: - Actions
private extension FilterHideTransition {

    @objc func didPan(with recognizer: UIPanGestureRecognizer) {
        let translation = recognizer.translation(in: viewController.view)
        let percentage = translation.x / viewController.view.frame.size.width

        print(percentage)

        switch recognizer.state {
        case .began:
            enabled = true
            viewController.dismiss(animated: true, completion: nil)
            break
        case .changed:
            update(percentage)
            break
        case .ended:
            completionSpeed = 0.3
            if percentage > 0.5 {
                finish()
            } else {
                cancel()
            }
            enabled = false
            break
        case .cancelled:
            cancel()
            enabled = false
            break
        default:
            cancel()
            enabled = false
            break
        }
    }

    @objc func didTap(with recognizer: UITapGestureRecognizer) {
        viewController.dismiss(animated: true, completion: nil)
    }

    func isTouch(touch: UITouch, in view: UIView) -> Bool {
        let touchPoint = touch.location(in: view)
        return view.hitTest(touchPoint, with: nil) != nil
    }
}

extension FilterHideTransition: UIGestureRecognizerDelegate {

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        if gestureRecognizer == tapGesture {
            return !isTouch(touch: touch, in: viewController.panel)
        } else if gestureRecognizer == panGesture {
            return  !isTouch(touch: touch, in: viewController.heightSlider) &&
                !isTouch(touch: touch, in: viewController.widthSlider) &&
                !isTouch(touch: touch, in: viewController.priceSlider)
        } else {
            return true
        }
    }
}

Here's the code for the animator:

class FilterHideAnimator: NSObject, UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.25
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
              let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)  as? OverlayTabBarController
              else { return }

        let startFrame = fromVC.view.frame
        let endFrame = CGRect(x: startFrame.size.width, y: 0, width: startFrame.size.width, height: startFrame.size.height)

        UIView.animate(withDuration: transitionDuration(using: transitionContext),
                   delay: 0.0,
                   options: .curveEaseIn,
                   animations: {
                        fromVC.view.frame = endFrame
                        toVC.overlay.alpha = 0
                    },
                   completion: {
                        _ in
                        if transitionContext.transitionWasCancelled {
                            transitionContext.completeTransition(false)
                        } else {
                            transitionContext.completeTransition(true)
                        }
                    })
    }
}

My question: How can I prevent this glitch from happening?

Heavierthanair answered 12/1, 2018 at 10:26 Comment(4)
Can you add also the animator implementation?Stultify
Sure, coming up! -> AddedHeavierthanair
Did you ever figure this out? Is UIPercentDrivenInteractiveTransition just broken?Fordo
@AlexMedearis I stopped caring when I found out UIViewPropertyAnimator did the trick for me. this actually worked fine together with UIPercentDrivenInteractiveTransition so that definitely ain't broken. I guess UIView.Animate is not that great when used in an interaction.Heavierthanair
A
12

I tested your minimal working example and the same issue reappears. I wasn't able to fix it using UIView.animate API, but the issue does not appear if you use UIViewPropertyAnimator - only drawback is that UIViewPropertyAnimator is available only from iOS 10+.

iOS 10+ SOLUTION

First refactor HideAnimator to implement interruptibleAnimator(using:) to return a UIViewPropertyAnimator object that performs the transition animator (note that as per documentation we are supposed to return the same animator object for ongoing transition):

class HideAnimator: NSObject, UIViewControllerAnimatedTransitioning {

    fileprivate var propertyAnimator: UIViewPropertyAnimator?

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.25
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // use animator to implement animateTransition
        let animator = interruptibleAnimator(using: transitionContext)
        animator.startAnimation()
    }

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
        // as per documentation, we need to return existing animator
        // for ongoing transition
        if let propertyAnimator = propertyAnimator {
            return propertyAnimator
        }

        guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
            else { fatalError() }

        let startFrame = fromVC.view.frame
        let endFrame = CGRect(x: startFrame.size.width, y: 0, width: startFrame.size.width, height: startFrame.size.height)

        let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: UICubicTimingParameters(animationCurve: .easeInOut))
        animator.addAnimations {
            fromVC.view.frame = endFrame
        }
        animator.addCompletion { (_) in
            if transitionContext.transitionWasCancelled {
                transitionContext.completeTransition(false)
            } else {
                transitionContext.completeTransition(true)
            }
            // reset animator because the current transition ended
            self.propertyAnimator = nil
        }
        self.propertyAnimator = animator
        return animator
    }
}

One last thing to make it work, in didPan(with:) remove following line:

completionSpeed = 0.3

This will use the default speed (which is 1.0, or you can set it explicitly). When using interruptibleAnimator(using:) the completion speed is automatically calculated based on the fractionComplete of the animator.

Aforetime answered 16/1, 2018 at 17:41 Comment(9)
Thanks for the answer, but no luck in either of the suggestions unfortunately :(Heavierthanair
@Heavierthanair can you maybe provide a minimal working example to play around with?Stultify
Sure, will make sure there's one up later today.Heavierthanair
@Heavierthanair which iOS versions are you targeting? is iOS10+ solution OK?Stultify
Nosál I was targeting 11 but 10 might have the same issue too.Heavierthanair
@Heavierthanair ok, that's cool then - check my updated answer.. it is working for me correctlyStultify
So using this process gave me a much cleaner transitioning experience (especially with canceling and completing the animation) but it still jumps ~5 pixels every time it starts. With UIView.animate... it jumps it ~30% of the time with UIViewPropertyAnimator it jumps 100% of the time.Monge
@Monge now I am not at my computer, and it is just a hypothesis - but are you sure it is the result of an animator, and not of the gesture recognizer? Eg, if using pan gesture recognizer, it takes some time to recognize the gesture, so when the gesture gets fired, the translation is usually > 0, which means that right at the beginning the animation progress is > 0. I would start by making sure this is not happeningStultify
Yes, I print out the offsets as they come in and see that they are all very small (< 1 pixel) yet the first movement jumps 5-7 pixels. Also when mid pan if I move it to the top up and down slightly the 5-7 pixel jump is very apparent.Monge
E
3

So the issue is actually that when you initiate an interactive transition, the animation tries to run in its entirety. If you put a breakpoint in the gestures change state, you can see the entire animation run, and when you resume, it picks back up tracking your finger. I tried a bunch of hacks around setting the interactive transition's progress to 0 but nothing seemed to work.

The solution involves setting the transition context's container view's layer speed to 0 during the transition and setting it back to 1 when the transition is ready to complete. I abstracted this code into a simple subclass of UIPercentDrivenInteractiveTransition. The code looks something like:

@implementation InteractiveTransition {
  id<UIViewControllerContextTransitioning> _transitionContext;
}

- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
  _transitionContext = transitionContext;
  [_transitionContext containerView].layer.speed = 0;
  [super startInteractiveTransition:transitionContext];
}

- (void)finishInteractiveTransition {
  [_transitionContext containerView].layer.speed = 1;
  [super finishInteractiveTransition];
}

- (void)cancelInteractiveTransition {
  [_transitionContext containerView].layer.speed = 1;
  [super cancelInteractiveTransition];
}

@end

This will pause the animation until you're ready to finish or cancel the interactive transition.

Ebneter answered 18/9, 2018 at 20:21 Comment(1)
IMHO this looks a bit cleaner than the solution above. I just tested and i works fine. Thanks!Shred

© 2022 - 2024 — McMap. All rights reserved.