重制Skype应用中Action Sheet的动画效果

suiling 2014-07-18 11:51:55 8029

(原文:Recreating Skype's Action Sheet Animation

Skype最近发了一个新的iOS版本,整体体验更为流畅,也弹性十足。在新版本中,一个很明显的视觉改变是应用内多个地方增加了弹性的动画效果,比如导航、添加联系人、界面转换、菜单以及Action Sheet等等。这篇文章中我们将会复现Skype应用中Action Sheet的动画效果。

53.gif

方法

由于没有越狱手机,所以不能查看应用的层级视图。所以我们必须想出自己的动画。第一眼看去,似乎可以把三个“spring(弹簧)”连接到包含 UIBezierPath的视图中,从而实现Skype Action Sheet的动画效果,这也是我们选择的方法:

Springs

我们可以使用 -animateWithDuration:delay:usingSpringWithDamping: initialSpringVelocity:options:animations:completion:来动画这些“弹簧”。我们将使用两个辅助视图(即 sideHelperView  centerHelperView)来观察动画的过程。我们不需要第三个视图,因为旁边的“弹簧”都是一样的,以下是该部分内容中视图控制器的源码:

class ViewController: UIViewController {

    @IBOutlet var sideHelperView: UIView!
    @IBOutlet var centerHelperView: UIView!
    
    // all constraints are between the view's top and the bottom layout guide
    @IBOutlet var sideHelperTopConstraint: NSLayoutConstraint!
    @IBOutlet var centerHelperTopConstraint: NSLayoutConstraint!
    
    let animationDuration = 0.5
    
    @IBAction func toggleVisibility(sender: UIButton) {
        let actionSheetHeight: Float = 240 // will be changed later
        let hiddenTopMargin: Float = 0
        let showedTopMargin: Float = -actionSheetHeight
        let newTopMargin: Float = abs(centerHelperTopConstraint.constant - hiddenTopMargin) < 1 ? showedTopMargin : hiddenTopMargin
        let options: UIViewAnimationOptions = .BeginFromCurrentState | .AllowUserInteraction
       
        // Spring Type 1
        sideHelperTopConstraint.constant = newTopMargin
        UIView.animateWithDuration(animationDuration,
            delay: 0,
            usingSpringWithDamping: 0.75,
            initialSpringVelocity: 0.8,
            options: options,
            animations: {
                self.sideHelperView.layoutIfNeeded()
            }, completion:nil
        )
       
        // Spring Type 2
        centerHelperTopConstraint.constant = newTopMargin
        UIView.animateWithDuration(animationDuration,
            delay: 0,
            usingSpringWithDamping: 0.9,
            initialSpringVelocity: 0.9,
            options: options,
            animations: {
                self.centerHelperView.layoutIfNeeded()
            }, completion:nil
        )
    }
}

操作过程

54.gif

展示链接

现在说一个不是很明显的地方:我们如何使用辅助视图的位置来驱动 UIBezierPath 绘制。我们需要回调动画的每个 frame 才能知道何时重绘路径。CAAnimation 没有提供这种便利,但可以用以下几种方法添加:

1.我们可以利用这一事实--动画的每一帧都要调用 -drawInContext:;可参阅 Core animation progress callback 学习更多内容。

2.我们可以使用 CADisplayLink,它是一个特殊类型的timer,描述如下:

 A CADisplayLink object is a timer object that allows your application to synchronize its drawing to the refresh rate of the display.

 使用 CADisplayLink 的方法看起来很简单,所以这也是我们要继续的,我们将为 ViewController 添加两个属性:

var displayLink: CADisplayLink?
var animationCount = 0

并创建两个方法来创建和阻止 displayLink ,这样它就不会无限地运行下去:animationWillStart 会在动画之前调用,animationDidComplete 处于动画的完成块中。动画允许用户交互,所以我们需要跟踪运行了多少个动画,并在动画完成后解除 displayLink 的有效性:

func animationWillStart() {
    if !displayLink {
        displayLink = CADisplayLink(target: self, selector: "tick:")
        displayLink!.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
    }
    animationCount++
}

func animationDidComplete() {
    animationCount--
    if animationCount == 0 {
        displayLink!.invalidate()
        displayLink = nil
    }
}

贝塞尔路径

现在我们已准备在storyboard添加了弹性视图,并添加outlet进行连接到它和它的顶部约束中:

@IBOutlet var bouncyView: BouncyView 
@IBOutlet var bouncyViewTopConstraint: NSLayoutConstraint

下一步,我们看到 -addQuadCurveToPoint:controlPoint: 似乎是绘制所需曲线最简单的方法。文档甚至包含了一个示例,精确展示我们试图达到的目标:

使用 -drawRect: 的绘制会自动附在视图的 bounds上,所以我们需要偏移A和C的位置,为高于这两点的 Control Point 留出空间。不需要知道Control Point的准确位置,所以我们可以进使用位置的三角区域范围(sideToCenterDelta)。最终 BouncyView 的实现就变得非常简便。

class BouncyView: UIView {
    
    var sideToCenterDelta: Float = 0.0
    let fillColor = UIColor(red: 0, green: 0.722, blue: 1, alpha: 1) // blue
    
    override func drawRect(rect: CGRect) {
        let yOffset: Float = 20.0
        let width = CGRectGetWidth(rect)
        let height = CGRectGetHeight(rect)
        
        let path = UIBezierPath()
        path.moveToPoint(CGPoint(x: 0.0, y: yOffset))
        path.addQuadCurveToPoint(CGPoint(x: width, y: yOffset),
            controlPoint:CGPoint(x: width / 2.0, y: yOffset + sideToCenterDelta))
        path.addLineToPoint(CGPoint(x: width, y: height))
        path.addLineToPoint(CGPoint(x: 0.0, y: height))
        path.closePath()
        
        let context = UIGraphicsGetCurrentContext()
        CGContextAddPath(context, path.CGPath)
        fillColor.set()
        CGContextFillPath(context)
    }
}

连接各个部分

最终填充了-tick: 方法,它将会驱动 bouncyView 的动画。我们不能直接访问辅助视图的 frames,因为它们没有在动画期间展示当前的值,相反我们需要方位它们呈现层的 frames.下一步,我们将会更新 bouncyViewTopConstraint ,这样 bouncyView 的垂直位置就和 centerHelperView 一样了,并基于辅助视图的位置正确设置 sideToCenterDelta

func tick(displayLink: CADisplayLink) {
    let sideHelperPresentationLayer = sideHelperView.layer.presentationLayer() as CALayer
    let centerHelperPresentationLayer = centerHelperView.layer.presentationLayer() as CALayer
    let newBouncyViewTopConstraint = CGRectGetMinY(sideHelperPresentationLayer.frame) - CGRectGetMaxY(view.frame)
    
    bouncyViewTopConstraint.constant = newBouncyViewTopConstraint
    bouncyView.layoutIfNeeded()
    
    bouncyView.sideToCenterDelta = CGRectGetMinY(sideHelperPresentationLayer.frame) - CGRectGetMinY(centerHelperPresentationLayer.frame)
    bouncyView.setNeedsDisplay()
}

总结

下边是最终的结果,并不完全和Skype应用中的效果一样,不过我认为已经以非标准的方法达到了很不错的效果。你可以在gitHub上查看该项目,并通过改变 spring 的参数来呈现不一样的结果。

如果你还对其他类似实现方法感兴趣,可以看看BRFlabbyTable.

我写了一个后续的文章,并发布了一个开源库AHKBendableView

54.gif