废话不多说先上图,看看这个酷炫的下拉刷新动画:
然后自己动手研究了一下,下面讲讲实现原理。
水波动画的关键点就是正余弦函数
正弦型函数解析式:y=Asin(ωx+φ)+h
各常数值对函数图像的影响:
φ(初相位):决定波形与X轴位置关系或横向移动距离(左加右减)
ω:决定周期(最小正周期T=2π/|ω|)
A:决定峰值(即纵向拉伸压缩的倍数)
h:表示波形在Y轴的位置关系或纵向移动距离(上加下减)
拆解和分析
我们来拆解一下这个动画吧。两个波浪是两个正弦函数的效果叠加。首先我们看看该如何绘制一个波的曲线,如下图
这里写图片描述
如果要绘制上面这个曲线,可以观察:波的峰值是1,周期是2π,初相位是0,h位移也是0。那么计算各个点的坐标公式就是y = sin(x);获得各个点的坐标之后,使用CGPathAddLineToPoint这个函数,把这些点逐一连成线,就可以得到最后的路径。
接下来问题来了,我们已经绘制了一条静态的曲线,如何让它形成一个流动的波呢?
这就需要设置上面公式中的φ常量(初相位),假如φ是π/2,那么y=sin(x+φ)在x=0位置的时候,y的值就不在是0,而是1,就得到一条变化的曲线。通过上面的分析,我们知道,需要建立一个时间和φ的函数。
我们可以创建一个定时器(当然做动画我们肯定不会使用计时器,这里举个例子,下面详解),假设每秒让φ自增π/2,这样第4s的时候,φ等于2π(一个周期),y=sin(x+2π)和y=sin(x)等效,又回到了初初始状态,这样就完成了一个波动周期,往下继续加下去,不停的往复这个波动周期动画。
如果我们希望波动的非常剧烈,也就是波流速很快,那么我们可以让初相位随着时间的函数波动更快,就可以实现了。
代码实现:
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 100, self.view.frame.size.width, 200)];
view.backgroundColor = [UIColor whiteColor];
[self.view addSubview:view];
CAShapeLayer *firstWaveLayer = [CAShapeLayer layer];
firstWaveLayer.fillColor = [UIColor lightGrayColor].CGColor;
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGPathMoveToPoint(path, nil, 0, y);
CGFloat waveWidth = self.view.frame.size.width;
CGFloat cycle = 6 * M_PI / self.view.frame.size.width;
CGFloat offsetX = 0;
for (float x = 0.0f; x <= waveWidth ; x++) {
y = 8 * sin(cycle * x + 0) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
firstWaveLayer.path = path;
CGPathRelease(path);
[view.layer addSublayer:firstWaveLayer];
当然仅仅只有一条正弦曲线是模拟不出来波浪的效果的,还需要一条余弦曲线才可以合成波浪曲线效果:
CAShapeLayer *secondWaveLayer = [CAShapeLayer layer];
secondWaveLayer.fillColor = [UIColor redColor].CGColor;
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGPathMoveToPoint(path, nil, 0, y);
for (float x = 0.0f; x <= waveWidth ; x++) {
y = 8 * cos(cycle * x + offsetX) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
secondWaveLayer.path = path;
CGPathRelease(path);
[view.layer addSublayer:secondWaveLayer];
然后我们可以看见效果是这样的:
从图中可以看出,相同参数下的正弦曲线和余弦曲线并不能很好的合成一个对称的曲线,我们想要的效果是正弦曲线的波峰对应余弦曲线的波谷,所以需要将余弦函数的水平便宜做一个调整。
标准的余弦函数需要在水平方向上向左偏移四分之一周期的距离才能够跟同参数的正弦函数对称。
CGFloat offsetX = M_PI/cycle/2; // also equal 2*M_PI/_cycle/4;
现在波浪有了,要想让波浪动起来,需要有定时器每次触发的时候都产生两条新的曲线(path),然后替换现有曲线,快速替换达到动态的效果。
先创建定时器,然后给定时器绑定上事件:
displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkTric)];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
为了形成动态效果,我们需要每次产生曲线的时候都有一个水平方向的偏移量,让产生的曲线每次都比上次偏移一点:
- (void)displayLinkTric {
static CGFloat offsetX = 0;
offsetX += 0.07;
CGFloat waveWidth = self.view.frame.size.width;
CGFloat cycle = 6 * M_PI / self.view.frame.size.width;
{
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGPathMoveToPoint(path, nil, 0, y);
for (float x = 0.0f; x <= waveWidth ; x++) {
y = 8 * sin(cycle * x + offsetX) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
firstWaveLayer.path = path;
CGPathRelease(path);
}
{
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGFloat forword = M_PI/cycle/2; // also equal 2*M_PI/_cycle/4
CGPathMoveToPoint(path, nil, 0, y);
for (float x = 0.0f; x <= waveWidth ; x++) {
y = 8 * cos(cycle * x + offsetX + forword) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
secondWaveLayer.path = path;
CGPathRelease(path);
}
}
最终可以看到流动的波浪产生了:
仔细观察,发现波浪还是不够逼真,因为真实的播放不仅是前进的,还是浮动的,所以我们的这个波浪缺少了浮动的感觉,前面在正弦函数的部分提起过,要改变正弦函数的波动,需要改变它的振幅,所以需要一个算法来动态产生一个振幅:
- (void)displayLinkTric {
static CGFloat offsetX = 0;
offsetX += 0.05;
static CGFloat amplitude = 8;
static BOOL increase = YES;
if (increase) {
amplitude += 0.04;
} else {
amplitude -= 0.04;
}
if (amplitude >= 12) {
increase = NO;
}
if (amplitude <= 4) {
increase = YES;
}
CGFloat waveWidth = self.view.frame.size.width;
CGFloat cycle = 2 * M_PI / self.view.frame.size.width;
{
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGPathMoveToPoint(path, nil, 0, y);
for (float x = 0.0f; x <= waveWidth ; x++) {
y = amplitude * sin(cycle * x + offsetX) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
firstWaveLayer.path = path;
CGPathRelease(path);
}
{
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = 50;
CGFloat forword = M_PI/cycle/2; // also equal 2*M_PI/_cycle/4
CGPathMoveToPoint(path, nil, 0, y);
for (float x = 0.0f; x <= waveWidth ; x++) {
y = amplitude * cos(cycle * x + offsetX - forword) + 70 ;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, waveWidth, 100);
CGPathAddLineToPoint(path, nil, 0, 100);
CGPathCloseSubpath(path);
secondWaveLayer.path = path;
CGPathRelease(path);
}
}
主要的思想就是通过一个布尔值控制振幅的增长,当增长到了最高值的时候让振幅减小,减小到最低值的时候再增长,以此来产生一个动态的振幅,然后就会看到下面的效果了:
至此,其实核心的开发已经完成了,剩下的就是通过UIScrollView的偏移量来计算出一个动态的波浪振幅:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat offset = (-scrollView.contentOffset.y-scrollView.contentInset.top);
CGFloat times = offset/10 + 1;
}```
可以用计算出的 times 变量来动态控制振幅的变化。
通过UIScrollView来动态控制振幅的难点在于不能通过UIScrollView的代理来实现具体的算法,因为不能把View层的东西冗余到Controller层去,秉承良好的设计模式,需要给UIScrollView实现一个拓展方法,在拓展方法里面让我们实现波浪函数的View添加为UIScrollView的观察者,在观察到UIScrollView的offset每次变化时,动态计算振幅,具体的实现还是在源码中了解吧。
最后,完整的项目地址在这里: [HHPullToRefreshWave](https://github.com/red3/HHPullToRefreshWave)
文/real潘(简书作者)