iOS 中实现水底连续下落效果

几天之前在 dribbble 上看到一个效果甚是喜欢 让我联想到了一些美妙的东西 身为手操创造利器的程序员 怎能不将它实现出来

image

设计作品来自dribbble 的 Vadim Gromov。想看最终实现效果图可以直接拉到文末。

看起来好像很复杂,没关系,慢慢来。首先是将这个复杂的动画分解一些,实际上可以将它看成是多个水滴下落动画的叠加:

image

由7个单独的滴水动画叠加而成

下面开始逐滴发射实现

顶端的滴水分析

1.上帝说,要有水

顶端滴水动画分解如下:

image

水滴效果的实现,非贝塞尔曲线莫属。这里可以使用我在之前发布的文章《iOS 下水滴动画之实现》里面提到过的原理,再贴一次原理图:

image

整体思路是:用一个圆形的 View 表示下落的水滴,然后在水滴下落的过程中根据水滴的位置重新用贝塞尔曲线绘制水滴的形状。先初始化一些成员:

- (void)setupTopWater
{ 
    //水
    self.topWaterLayer = [CAShapeLayer layer];
    self.topWaterLayer.frame = CGRectMake(0, 0, self.frame.size.width, 0);
    self.topWaterLayer.fillColor = [UIColor whiteColor].CGColor;
    self.topWaterLayer.strokeColor = [UIColor clearColor].CGColor;
    [self.layer addSublayer:self.topWaterLayer];
    
    //水滴
    self.topWaterDrop = [[UIView alloc] initWithFrame:CGRectMake(0, 0, TOPWATERDROP_W, TOPWATERDROP_W)];
    self.topWaterDrop.backgroundColor = [UIColor whiteColor];
    self.topWaterDrop.layer.cornerRadius = TOPWATERDROP_W / 2.0f;
    self.topWaterDrop.clipsToBounds = YES;
    self.topWaterDrop.center = CGPointMake(self.frame.size.width / 2.0f, -TOPWATERDROP_W / 2.0f);
    [self addSubview:self.topWaterDrop];
    }

编写根据水滴的位置来绘制贝塞尔曲线的代码:

- (void)drawTopWater
{
    UIBezierPath *path = [self getBezierPathFromPoint1:CGPointMake(self.frame.size.width / 2.0f, 0) radius1:TOPWATER_W / 2.0f Point2:self.topWaterDrop.center radius2:TOPWATERDROP_W / 2.0f];
    self.topWaterLayer.path = path.CGPath;
    }
- (UIBezierPath *)getBezierPathFromPoint1:(CGPoint)point1 radius1:(CGFloat)r1 Point2:(CGPoint)point2 radius2:(CGFloat)r2
{
    CGFloat x1 = point1.x;
    CGFloat y1 = point1.y;
    CGFloat x2 = point2.x;
    CGFloat y2 = point2.y;
    
    CGFloat distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
    
    CGFloat sinDegree = (x2 - x1) / distance;
    CGFloat cosDegree = (y2 - y1) / distance;
    
    CGPoint pointA = CGPointMake(x1 - r1 * cosDegree, y1 + r1 * sinDegree);
    CGPoint pointB = CGPointMake(x1 + r1 * cosDegree, y1 - r1 * sinDegree);
    CGPoint pointC = CGPointMake(x2 + r2 * cosDegree, y2 - r2 * sinDegree);
    CGPoint pointD = CGPointMake(x2 - r2 * cosDegree, y2 + r2 * sinDegree);
    CGPoint pointN = CGPointMake(pointB.x + (distance / 2) * sinDegree, pointB.y + (distance / 2) * cosDegree);
    CGPoint pointM = CGPointMake(pointA.x + (distance / 2) * sinDegree, pointA.y + (distance / 2) * cosDegree);
    
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path moveToPoint:pointA];
    [path addLineToPoint:pointB];
    [path addQuadCurveToPoint:pointC controlPoint:pointN];
    [path addLineToPoint:pointD];
    [path addQuadCurveToPoint:pointA controlPoint:pointM];
    
    return path;
    
    }

2.让它下落! 水滴的下落不是线性的,它受重力作用。要实现它有两种思路,一种是根据物理上位移与时间的二次函数关系,计算出 60 个关键点位置(duration 不超过 1s 的话)作为动画的关键帧,第二种是直接利用 UIKit Dynamic 来为水滴模拟重力效果。这里我用第二种方法,先做一些物理世界的设置:

- (void)setupDynamic
{
    self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self];
    
    //gravity
    self.gravityBehavior = [[UIGravityBehavior alloc] init];
    [self.animator addBehavior:self.gravityBehavior];
    
    //collision
    self.collisionBehavior = [[UICollisionBehavior alloc] init];
    self.collisionBehavior.collisionMode = UICollisionBehaviorModeEverything;
    [self.collisionBehavior addBoundaryWithIdentifier:@"floor" fromPoint:CGPointMake(0, self.frame.size.height) toPoint:CGPointMake(self.frame.size.width, self.frame.size.height)];
    [self.animator addBehavior:self.collisionBehavior];
    }

在刚刚的 setupTopWater 函数末尾添加代码:

- (void)setupTopWater
{
    ...
    
    [self.collisionBehavior addItem:self.topWaterDrop];
    
    self.topWaterDropBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.topWaterDrop]];
    self.topWaterDropBehavior.elasticity = 0;
    self.topWaterDropBehavior.allowsRotation = NO;
    [self.animator addBehavior:self.topWaterDropBehavior];
    }

下落!

- (void)play
{
    [self.gravityBehavior addItem:self.topWaterDrop];
    }

关于 UIKit Dynamic 可以在我这篇翻译博文使用 UIKit Dynamics 来模拟物理效果里面看到更多。在 rootViewController 中引入这个 view,运行,可以看到:

image

噢,我们好像忘记绘制贝塞尔曲线了。接下来我们需要使用 CADisplayLink。可以在 kitten 的这篇博文里了解更多。首先设置 displayLink : obj-c

image

可以看到小水滴跟绘制的贝塞尔曲线之间有缝隙。这个缝隙的产生是由于 displayLink 的调用跟不上小水滴下落位置变化。要解决这个问题也不难,但可能不是最好的方法,一共两个步骤:

  1. 绘制贝塞尔路径时,在低端绘制多一个半圆,替代原来的水滴;
  2. 原来的水滴 alpha 值设为透明,将它当做一个辅助的 view;

代码就不贴了,可以到文末下载 demo 查看更多,修改之后效果如下:

image

3.当断应断 下落到一定距离之后,应该让水滴断开,并且让上面的水“收回去”。要实现水滴收回去的效果,这里我用了一个辅助 view:

image

上面看见的那颗黑色的,小小颗的,鼻屎一样的东西,就是用来作为辅助的 view。代码就不上了,没什么特别的:

image

我们的白色粘稠液体水滴终于有点样子了。关于上面的滴水就到这里了。值得一提的是,原效果图中水滴断开之后,回弹部分用得是一条圆滑的4次贝塞尔曲线,但是 iOS 里面只能画到三次,所以采用了一种折衷的方法:

image

左边为原设计图,右边为我的实现

如果有你更好的实现方法,我将不胜感激。~(≧▽≦)/~

底部弹水分析

image

实现方法步骤如下:

1.增加一条撞击边界,位于“底部水平面下方”,偏移一个水滴的高度 image

增加一条撞击边界

2.水滴碰到边界之后,自然会回弹起来,这时候采用跟上面水滴下落时类似的方法,来实现水滴的断开与水面平复效果。检测水滴与边界的碰撞可用系统系统的 delegate,需要注意,当水滴与这个边界碰撞时,需要将边界移除掉,好让水滴能够撞击地板,我们再执行下一步。

这里有个小问题花了一点时间。我代码中绘图不是在 drawInRect 中完成的,而是利用 CAShapeLayer 中的 path 属性。因此在绘制路径时要注意,坐标得以这个 layer 的frame为坐标系来设置。

image

水滴回弹

3.最后水滴会碰撞到地板,这里可以做一些复位处理以便开始新一次的滴水。

代码就不上了,最后实现的效果如下:

image

着重提一点,下面的水面平复时,使用的辅助 view 也需要用 UIView Dynamic 来模拟它受重力的效果。

最后来一幅密集恐惧症:

image

总结

做这个动效确实不难,仅仅是有些些繁琐而已。主要是 贝塞尔曲线 以及 UIKit Dynamic 的运用,然后细心一点做一下运动过程的分段,无他。

最后还是要特别感谢@Kitten Yang,在他的博客里学到很多 iOS 绘图方面的知识。

What’s more?

虽然效果是出来了,但是很多细节还不是很圆滑,可以尝试给贝塞尔路径设置不同的控制点,来让效果更加圆滑一点。

整个项目工程代码在Github

iOS动效