UIViewPropertyAnimator 的反弹效果

Posted

技术标签:

【中文标题】UIViewPropertyAnimator 的反弹效果【英文标题】:UIViewPropertyAnimator's bounce effect 【发布时间】:2020-01-10 15:37:48 【问题描述】:

假设我有一个动画师将视图从(0, 0) 移动到(-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
    

我和UIPanGestureRecognizer一起使用,这样我就可以随着手指的移动不断调整视图的大小。

当我想在动画开始或结束时添加某种弹跳效果时,问题就出现了。不仅仅是阻尼比,还有反弹效果。最简单的想象方法是UITableViewCell 的 Swipe-To-Delete 功能,您可以将“删除”按钮拖动到超出其实际宽度的位置,然后它会弹回。

实际上我想要实现的是在[0, 1] 段之外设置fractionComplete 属性的方法,因此当分数为1.2 时,偏移量变为144 而不是其最大值120。

现在fractionComplete 的最大值正好是1

以下是一些可视化此问题的示例:

我目前拥有的:

我想要实现的目标:


编辑(1 月 19 日):

抱歉,我的回复延迟了。以下是一些说明:

我不使用UIView.animate(...),而是使用UIViewPropertyAnimator,原因非常具体:它为我处理所有的时间、曲线和速度。

例如,您将视图拖到一半。这意味着剩余部分的持续时间应该是总持续时间的两倍。或者,如果您拖过 99% 的距离,它应该几乎立即完成剩余部分。

另外,UIViewPropertyAnimator 具有暂停(当用户再次开始拖动时)或反向(当用户开始向左拖动,但之后他改变主意并向右移动手指)等功能,我也从中受益。

所有这些都不适用于简单的 UIView 动画,或者充其量需要大量的努力。它只能进行简单的转换,而事实并非如此。

这就是为什么我必须使用某种动画师。

正如我在其发布者删除的答案中的 cmets 线程中所提到的,对我来说,这里最复杂的部分是模拟摩擦效果:拖得越远,视图实际移动的越少。就像您尝试将任何 UIScrollView 拖到其内容之外一样。

感谢您的努力,但我认为这两个答案中的任何一个都不相关。只要有时间,我将尝试使用UIDynamicAnimator 来实现此行为。可能在最近的一两周内。如果我有任何不错的结果,我会公布我的方法。


编辑(1 月 20 日):

我刚刚将一个演示项目上传到 GitHub,其中包括我在项目中拥有的所有转换。所以现在您实际上可以知道为什么我需要使用动画师以及我如何使用它们:https://github.com/demon9733/bouncingview-prototype

您真正感兴趣的唯一文件是MainViewController+Modes.swift。与过渡和动画相关的所有内容都包含在其中。

我需要做的是使用户能够将手柄区域拖动到“隐藏”按钮宽度之外并具有阻尼效果。向左滑动手柄区域会出现“隐藏”按钮。

附:我没有真正测试这个演示,所以它可能有我的主项目中没有的错误。所以你可以放心地忽略它们。

【问题讨论】:

这不是默认行为吗? @julian-silvestri 这是UITableViewCell 的默认行为。但我正在尝试使用常规UIView 实现这样的动画,使用UIViewPropertyAnimator 经过一番研究,我发现了这个框架:UIDynamicAnimator。但我仍然不确定它是否能够制作弹跳动画。 您能否通过打印声明确认当您滑动时,您实际上是在击中“滑动”的情况 @julian-silvestri 我不明白你的问题。第二张图片(我想要实现的)是UITableViewCell 滑动时的默认行为。第一张图片是我的常规UIView,我想在其中模仿这种反弹效果。问题是,我怎样才能在UIView 而不是UITableViewCell 中获得这种反弹效果? 【参考方案1】:

您需要允许平移手势到达所需的 x 位置,并且在平移结束时需要触发动画

一种方法是:

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
            )
        
    

1 月 20 日编辑

为了模拟阻尼效果,具体使用UIViewPropertyAnimator

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

【讨论】:

我同意这个答案,我提供了一个类似的答案,但写得不如这个,但 OP 不同意。 IMO 这是使用 OP 当前设置解决此问题的最佳方法。它可能需要 OP 进行更多自定义,但这非常好 @DharayMistry 关键的区别在于您仅在用户结束拖动后创建动画师。但是我从一开始就使用它,并且只在用户平移时更新它的fractionComplete。在您的情况下,使用 animator 绝对没有任何好处,并且确实可以用简单的 UIView 动画替换它。但这不是我想要达到的目标。【参考方案2】:

这是一种可能的方法(简化且有点粗糙 - 仅反弹,右侧没有按钮,因为它需要更多代码,实际上只是帧管理问题)

由于UIPanGestureRecognizer在结束时的延迟较长,我更喜欢使用UILongPressGestureRecognizer,因为它可以提供更快的反馈。

这是演示结果

ViewController 下面使用的情节提要只有灰色背景矩形容器视图,其他一切都在下面提供的代码中完成。

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)
    

【讨论】:

通过添加基于动画师的变体进行了更新(对于那些更喜欢那个动画的人,基于 UIView 动画的仍然评论) 关键区别在于您仅在用户结束拖动后创建动画师。但是我从一开始就使用它,并且只在用户平移时更新它的fractionComplete。在您的情况下,使用 animator 绝对没有任何好处,并且确实可以用简单的 UIView 动画替换它。但这不是我想要达到的目标。

以上是关于UIViewPropertyAnimator 的反弹效果的主要内容,如果未能解决你的问题,请参考以下文章

为啥在 UIViewPropertyAnimator 中使用“unowned”

UIViewPropertyAnimator 的反弹效果

完成块内的 UIViewPropertyAnimator 状态?

NSLayoutConstraints 的 UIViewPropertyAnimator 使视图消失

UIViewPropertyAnimator 开头有短动画片段

UIViewPropertyAnimator - 简单地停止所有动画,而不是特定的?