创建Jeff Broderick的地图动画

优质
小牛编辑
130浏览
2023-12-01

在本指南的前面,我提到了一些Jeff Broderick设计并发布到Dribbble的很棒的动画。

如我所说,这里有一些不懂得动画。首先,当地图的图标被点击时,应用的主界面(包括导航栏)同时有不透明度和比例的动画来让其淡出到黑色的背景中并且有一点点缩小。同时,地图伴随着不透明度和比例的动画显著地显现到界面的前面来。地图还会向屏幕上方移动一点,就像过度动画一样。地图图标会保持在原位。

在我们编码重现Jeff的动画前,先看一眼我们创建的最终的动画效果。

我们通过一些简单的UIImageViewUIButton来重新开发这个动画,因为它们可以准确地得到动画的感觉,但在真实的地图中这会是一个真实的可伸缩的地图视图。

首先,让我们添加代表app主界面的图片。

// 添加app的主背景图片
self.appBackground = [[UIImageView alloc] initWithFrame:CGRectMake(0, 20,
    self.window.bounds.size.width, 548)];
self.appBackground.image = [UIImage imageNamed:@"app-bg"];
[self.window addSubview:self.appBackground];

我们添加了一个图片属性为“app-bg@2x.png”的简单的UIImageView。app的运行时很聪明,你只用写“app-bg”它就会在app包的图片资源中找到“app-bg@2x.png”。这个视图被添加为类的@property了,这样我们就可以在之后的代码中引用它。这里显示了如何声明一个@porperty。

@property (assign) UIImageView *appBackground;

这个@property既可以定义在类的.h文件的@interface中,也可以定义在.m实现文件的@interface块中来让其私有。在苹果的开发者网站的Objective-C指南中可以阅读更多关于程序的属性的内容。

最后,我们将UIImageView作为主屏幕的一个子视图添加进去。这是一个快速的模型,否则我会创建另一个UIViewController的子类来装载我们的UI代码。

如果我构建并运行,这就是app目前看起来的样子。

非常棒!现在让我们添加地图,它会是透明的,并且会伴随着变化开始。我们会在主应用图片后立即添加它,因为我们想要最后添加图标按钮,这样它就会使z轴上最高的,也就是在其他视图的顶部。

// 添加地图视图
self.mapView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 62,
    self.window.bounds.size.width, 458)];
self.mapView.image = [UIImage imageNamed:@"map-arrow"];
self.mapView.alpha = 0.0f;
self.mapView.transform = CGAffineTransformMakeTranslation(0, 30);
self.mapView.transform = CGAffineTransformScale(self.mapView.transform, 1.1, 1.1);
[self.window addSubview:self.mapView];

想在Swift下开发这些例子么?这里就是Swift下的上面Objective-C的代码。

self.mapView =
    UIImageView(frame: CGRectMake(0, 62, self.window!.bounds.size.width, 458))
self.mapView!.image = UIImage(named: "map-arrow")
self.mapView!.alpha = 0.0
self.mapView!.transform = CGAffineTransformMakeTranslation(0, 30)
self.mapView!.transform = CGAffineTransformScale(self.mapView!.transform, 1.1, 1.1)
self.window!.addSubview(self.mapView!)

地图视图的frame开始会在左上角,但会距离顶部62像素,这样就会正好位于我们要添加的地图按钮的下方一点点。图片属性被设为“map-arrow”,这只是一个地图图片,我将其和一个箭头放在一起,来模仿Jeff在他的动画中所涉及的样子。

一开始,这个视图会是完全透明的,所以alpha属性被设为0。有两个变换添加到视图中:第一个将视图往下移动30像素,第二个将其从正常尺寸拉伸到1.1倍。

这里是它现在看起来的样子,我注视了alpha那一行,这样我们就可以看到地图在哪。

这看起来是动画开始的准确位置了。

现在让我们添加我们的图标按钮。

// 添加图标
UIButton *icon = [UIButton buttonWithType:UIButtonTypeCustom];
[icon setImage:[UIImage imageNamed:@"map-icon"] forState:UIControlStateNormal];
[icon addTarget:self action:@selector(didTapMapIcon:)
    forControlEvents:UIControlEventTouchUpInside];
[icon setFrame:CGRectMake(self.window.bounds.size.width - 49, 19, 49, 44)];
[self.window addSubview:icon];

这是一个非常典型的添加图标按钮的方式。UIButton类有一个便利的方式来构建一个按钮:+buttonWithType:类方法。我将按钮类型设为UIButtonTypeCustom,意味着没有默认的风格会被设置,完全取决于我。这是一种实用的简单图标按钮,没有边界和其他怪异的风格需要移除。有点类似于CSS中对按钮进行重置。

接下来我设置按钮的图片为我app包中的“map-icon”图片。参数UIControlStateNormal意味着这个图标会在常规、默认状态下为显示按钮的图片。你可以用多种图片多次设置这个值,只要你想要改变图标,比如UIControlStateHighlighted状态。默认情况下,当一个UIButton被点击时,iOS会自动暗化图片。

最后,我让按钮可被点击并且会调用我定义的一个方法。self参数值意味着我想要这个按钮调用其被点击时所在的类,而@selector(didTapMapIcon:)是我想要调用的Objective-C方法。接下来我通过设置frame将按钮放置在准确的位置。

让我们看看现在app的样子,地图的alpha值被设为了0,所以它是不可见的。

好,现在我们将动画的所有主要部件都添加到界面上了,是时候在地图图标被点击时添加一些动画了。

首先,我们需要实现按钮被点击时被调用的方法。这里是不含任何内容的方法看起来的样子。

- (void)didTapMapIcon:(id)sender {
    // 暂时没有任何内容!
}

它会在用户点击地图按钮时被调用,因为我们之前通过 -addTarget:action:forControlEvents:方法进行了设置。

所以,按照逻辑,当你点击按钮时,下面两种事件之一会发生:将地图动画到界面上,或者如果地图已经可见了,则将地图动画出界面。我们可以检查我们的界面元素并查看它们的位置来决定我们应该做什么,但那太麻烦了,所以让我们通过一个简单的作为类@property的 BOOL 变量来跟踪状态。在这个文件的顶部我添加了一个名为mapShowing的BOOL变量来管理我们是需要打开还是关闭地图视图。这个属性会放置在我们按钮方法的下面,而我们添加的其他属性是我们界面的主视图。

@interface DTCAppDelegate ()

- (void)didTapMapIcon:(id)sender;

@property (assign) BOOL mapShowing;

@property (strong) UIImageView *appBackground;
@property (strong) UIImageView *mapView;

@end

现在,回到我们的按钮点击方法,我们需要在这里添加一些逻辑,来检查地图是显示还是不显示,然后将变量设为相反的。

- (void)didTapMapIcon:(id)sender {
	
if (self.mapShowing) {	
    self.mapShowing = NO;

    // 当地图已经可见时要运行的代码
} else {
    self.mapShowing = YES;

    // 当地图不可见时要运行的代码
}

让我们从else的情况开始,此时地图未显示,我们需要进行不透明度的动画。我们需要做的是淡出主app背景一点点然后淡入地图。主app背景的淡出速度会比地图的淡入速度慢一点点,这样地图会更显眼。

[UIView animateWithDuration:.5 delay:0 
    options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionBeginFromCurrentState 
    animations:^{
    self.appBackground.alpha = 0.3f;
} completion:NULL];
		
[UIView animateWithDuration:.15 delay:0
    options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionBeginFromCurrentState 
    animations:^{
	self.mapView.alpha = 1.0f;
} completion:NULL];

你可能注意到了放置在这个基于block的UIView动画方法总的options依据里的巨大的参数。这实际上是两个选项通过二进制 | 操作组合在一起的:UIViewAnimationOptionCurveEaseInOut用来定义动画的淡入淡出,UIViewAnimationOptionBeginFromCurrentState会从其alpha的当前值开始动画,这样即使动画被打断了,它也不会跳回开始动画前的初始值。这对像这样被用户动作管理的动画非常重要,因为你不知道用户会不会在动画发生后不停点击按钮,而且你肯定不想在动画完成后都没做任何事。

当然,调整主app界面和地图的不透明度并没有准确地完成我们的动画,因为我们还需要动画地图的比例和位置,这样它才能够到达它最终的位置和尺寸。对于主app界面,我们只会稍微动画其比例。

即使这些动画可以通过一个淡出动画曲线来完成,我也想使用含有相同damping和stiffness值得弹簧动画,这样我就可以减缓速度。这里不会有弹性,只是非常平滑的过渡。

CGFloat dampingStiffness = 16.0f;

// 主app背景的比例动画
JNWSpringAnimation *scale =
    [JNWSpringAnimation animationWithKeyPath:@"transform.scale"];
scale.damping = dampingStiffness;
scale.stiffness = dampingStiffness;
scale.mass = 1;
scale.fromValue = @([[self.appBackground.layer.presentationLayer
    valueForKeyPath:scale.keyPath] floatValue]);
scale.toValue = @(0.9);

[self.appBackground.layer addAnimation:scale forKey:scale.keyPath];
self.appBackground.transform =
    CGAffineTransformScale(CGAffineTransformIdentity, .9, .9);

我将damping和stiffness值设为一个CGFloat变量,这样我就可以更简单地调整它们而不用一次更新两个值。

这个block代码中的一个主要的与其他例子不同的改变是比例动画的fromValue没有被设为一个常量,而是设为[[self.appBackground.layer.presentationLayer valueForKeyPath:scale.keyPath] floatValue]。这是什么意思呢?如果你一块块拆开,这些事要发生的事:

  • 我会使用self.appBackground来访问这个类的appBackground属性
  • 我会获取到这个视图的CALayer对象
  • 我在layer上获取presentationLayer属性,通过它来获取特殊的presentation model layer,让我们看到动画改变时的值
  • 当我有了presentationLayer后,我会调用 -valueForKeyPath: 来取得变换的比例部分的当前值。(scale.keyPath = @"transform.scale")
  • 当我最后有了当前的比例值后,它不是JNWSpringAnimation需要的数据格式,所以我使用了floatValue。

记得之前我提到过在动画中layer上的很多属性值都不会改变么?以及presentation model layer是Core Animation用来存储动画发生过程中精确的变更值的?我们需要获取比例变换的当前值,这样就可以在当前任何点开始动画(记住如果用户很开心地不停点击,我们不想要动画重新开始!)。我们需要获取特殊的显示层来查看值。然后我们使用它作为我们动画的fromValue,这样他就能始终正常工作,无论fromValue是我们为视图设置的正常的、未触摸的比例值,还是动画中的某个值。如果我们不通过presentationLayer获取它,这个值在动画中就始终不会正确,直到动画结束。

我们不仅仅需要动画主app背景,还需要动画地图,将比例降回1.0,,并且通过过渡移动到屏幕上。让我们现在做。

// 地图有两个分开的动画,一个用于位置,一个用于比例
JNWSpringAnimation *mapScale =
    [JNWSpringAnimation animationWithKeyPath:@"transform.scale"];
mapScale.damping = dampingStiffness;
mapScale.stiffness = dampingStiffness;
mapScale.mass = 1;
mapScale.fromValue =
    @([[self.mapView.layer.presentationLayer valueForKeyPath:mapScale.keyPath] floatValue]);
mapScale.toValue = @(1.0);

[self.mapView.layer addAnimation:mapScale forKey:mapScale.keyPath];
self.mapView.transform = CGAffineTransformScale(CGAffineTransformIdentity, 1, 1);

JNWSpringAnimation *mapTranslate =
    [JNWSpringAnimation animationWithKeyPath:@"transform.translation.y"];
mapTranslate.damping = dampingStiffness;
mapTranslate.stiffness = dampingStiffness;
mapTranslate.mass = 1;
mapTranslate.fromValue =
    @([[self.mapView.layer.presentationLayer valueForKeyPath:mapTranslate.keyPath] floatValue]);
mapTranslate.toValue = @(0);

[self.mapView.layer addAnimation:mapTranslate forKey:mapTranslate.keyPath];
self.mapView.transform = CGAffineTransformTranslate(self.mapView.transform, 0, 0);

这里没有什么很复杂的,除了获取当前变化的值来从其开始,如前面的动画一样。我在这也使用了damping和stiffness变量,这样所有的动画都感觉是同一个类型的动作。

锁着这是一块正统的代码,好在其非常简单,而且现在你应该习惯了JNWSpringAnimation代码块的样子。这是目前动画看起来的样子。

现在是时候添加这个界面的其他动画了,即当用户点击地图图标且地图可见时,我们想要将其淡出并且将主app背景放回到前面。因为它和我们刚才展示的动画除了开始和结束值外完全一样,这里就直接放一个大块来解释发生了什么。

if (self.mapShowing) {
		
    self.mapShowing = NO;

    // 再次使用这些动画相同的damping和stiffness
    // 这样我们就可以获取CGFloat形式的值。注意这个值会高一点
    // 意味着动画会花费更少的时间(在匹配此damping和stiffness的弹簧动画下)。
    // 少时间是好的,因为我们要回到界面的默认状态,而此时用户只想让地图赶紧消失。
    CGFloat dampingStiffnessOut = 24.0f;

    // 再说一次,从当前状态开始很重要,这样用户点击按钮时就不会抽动
    [UIView animateWithDuration:.5 delay:0 
        options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionBeginFromCurrentState
        animations:^{
    	self.appBackground.alpha = 1.0f;
    } completion:NULL];

    [UIView animateWithDuration:.3 delay:0
        options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionBeginFromCurrentState 
        animations:^{
    	self.mapView.alpha = 0.0f;
    } completion:NULL];

    // 地图有两个分开的动画,一个是位置一个是比例。
    // 我们通过presentationLayer获取@“transform.scale”的变化的值,如之前的例子一样
    JNWSpringAnimation *mapScale =
        [JNWSpringAnimationanimationWithKeyPath:@"transform.scale"];
    mapScale.damping = dampingStiffnessOut;
    mapScale.stiffness = dampingStiffnessOut;
    mapScale.mass = 1;
    mapScale.fromValue =
        @([[self.mapView.layer.presentationLayer
        valueForKeyPath:mapScale.keyPath] floatValue]);
    mapScale.toValue = @(1.1);

    [self.mapView.layer addAnimation:mapScale forKey:mapScale.keyPath];
    self.mapView.transform =
        CGAffineTransformScale(CGAffineTransformIdentity, 1.1, 1.1);

    JNWSpringAnimation *mapTranslate =
        [JNWSpringAnimation animationWithKeyPath:@"transform.translation.y"];
    mapTranslate.damping = dampingStiffnessOut;
    mapTranslate.stiffness = dampingStiffnessOut;
    mapTranslate.mass = 1;
    mapTranslate.fromValue =
        @([[self.mapView.layer.presentationLayer
        valueForKeyPath:mapTranslate.keyPath] floatValue]);
    mapTranslate.toValue = @(30);

    [self.mapView.layer addAnimation:mapTranslate forKey:mapTranslate.keyPath];
    self.mapView.transform = CGAffineTransformTranslate(self.mapView.transform, 0, 30);

    // 主app背景的比例动画。我们将其动画回1.0倍
    JNWSpringAnimation *scale =
        [JNWSpringAnimation animationWithKeyPath:@"transform.scale"];
    scale.damping = dampingStiffnessOut;
    scale.stiffness = dampingStiffnessOut;
    scale.mass = 1;
    scale.fromValue =
        @([[self.appBackground.layer.presentationLayer
        valueForKeyPath:@"transform.scale.x"] floatValue]);
    scale.toValue = @(1.0);

    [self.appBackground.layer addAnimation:scale forKey:scale.keyPath];
    self.appBackground.transform =
        CGAffineTransformScale(CGAffineTransformIdentity, 1.0, 1.0);

}

这里是完整的、最终的动画的样子。如果你想一个疯子一样点击,会发现它确实是从当前值开始动画的,而且不会抽动。

这很有意思!现在让我们去转眼有些断断续续的动画。