5

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: enter image description here

What I want to achieve: enter image description here


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.

demon9733
  • 1,054
  • 3
  • 13
  • 35
  • is this not a defualt behviour? – Julian Silvestri Jan 13 '20 at 16:51
  • @julian-silvestri This is a default behavior for `UITableViewCell`. But I'm trying to achieve such animation with a regular `UIView`, using `UIViewPropertyAnimator`. – demon9733 Jan 13 '20 at 18:59
  • After some research, I found this framework: `UIDynamicAnimator`. But I'm still not sure it's capable of bounce animations. – demon9733 Jan 13 '20 at 19:01
  • Can you confirm through a print statement that when you swipe , you are actullay hitting the "swipe" case – Julian Silvestri Jan 13 '20 at 19:15
  • @julian-silvestri I don't understand your question. The second image (what I want to achieve) is the default behavior of the `UITableViewCell` when swiping it. And the first image is my regular `UIView`, where I want to mimic this bounce effect. The question is, how can I get this bounce effect in `UIView`, not `UITableViewCell`? – demon9733 Jan 13 '20 at 20:51
  • in your code you have a switch statement and case .swiped is set. When you "Swipe" does your swipe case actually get called? – Julian Silvestri Jan 13 '20 at 20:53
  • Of course it gets called. Otherwise there would be no animation at all. – demon9733 Jan 13 '20 at 20:54
  • @user3344236 Your comment has absolutely nothing to do with what I'm asking. The bounce effect happens when the user drags the view with his finger, using `UIPanGestureRecognizer`. It's not about the animation completion block. – demon9733 Jan 14 '20 at 10:13
  • And what frame origin x do you get, btw. Start from this. – ares777 Jan 14 '20 at 10:58
  • @user3344236 Ok, I will simplify the question. Assume you want to be able to drag your custom `UIView` to the left to reveal "Hide" button. How would you add a bounce effect to it? So the animation should just mimic swipe-to-delete gesture of `UITableView`. – demon9733 Jan 14 '20 at 12:00
  • perhaps I am confused in your code.. case .swiped: view.frame.origin.x = -120, this is the animation we are seeing in your question ? IE .. this is not your attempt to bounce ? – Julian Silvestri Jan 15 '20 at 13:15
  • https://stackoverflow.com/questions/21892105/how-to-create-a-uiview-bounce-animation , maybe this will help ? – Julian Silvestri Jan 15 '20 at 13:18
  • @JulianSilvestri Yes, this is exactly the animation you see in the first image. And that's how `UIViewPropertyAnimator` works: it takes view's starting positing and ending position (new value of `origin.x`), and then calculates the actual frame values for any `fractionComplete` from 0 to 1. I don't have any attempt to bounce yet, because I have no clue how to do that :) – demon9733 Jan 15 '20 at 13:21
  • Yes, I was thinking of `UIDynamicAnimator`, as I mentioned before in this comments thread. However, I'm not sure how exactly to mimic the friction effect (it goes less than your finger when you drag the view outside of its possible frame range). If you have any experience with this, please advise. Any code example of `UIView` replicating `UITableView`'s swipe gesture would be just awesome. – demon9733 Jan 15 '20 at 13:25
  • Maybe you can decrease the view size while you are animating, and set the animation velocity based on that amount of size, so when view are bigger the velocity is high, and when view gets smaller, velocity becomes smaller too, perhaps you'll have problems with layout, but I thing you can dead with that. – Pedro Ortiz Jan 15 '20 at 15:15

2 Answers2

3

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()
        }
    }
}
Dharay
  • 195
  • 1
  • 7
  • I agree with this answer, I provided a similar answer but not as well written as this one but the OP does not agree. IMO this is a the best approach to solving this question with the OP current setup. It may require more customization from the OP but this is excellent – Julian Silvestri Jan 16 '20 at 15:02
  • @DharayMistry The key difference is that you create the animator only after user ended dragging. But I use it from the very beginning, and only update its `fractionComplete` while user is panning. In your case you have absolutely no benefits from using animator, and indeed can replace it with a simple `UIView` animation. But that's not what I'm trying to achieve. – demon9733 Jan 20 '20 at 10:04
0

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

enter image description here

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)
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • updated by adding variant based on animator (UIView animation-based remains commented for those how prefer that one) – Asperi Jan 19 '20 at 14:52
  • The key difference is that you create the animator only after user ended dragging. But I use it from the very beginning, and only update its `fractionComplete` while user is panning. In your case you have absolutely no benefits from using animator, and indeed can replace it with a simple UIView animation. But that's not what I'm trying to achieve. – demon9733 Jan 19 '20 at 18:38