[iOS] 如何实现可交互的 UIView 隐式动画

英文标题: How to implement a user interactivable UIView implicit animation

这一篇教程将结合一个小 demo 讲解, 代码在 github 上可以下载到. demo 的运行效果: 通过手势拖动顶端红色矩形到下方目标位置, 拖动距离超过 50% 松手, 矩形会自动移动到目标位置, 少于 50% 松手, 矩形自动归位:

实现一个可交互的动画大概有这么几种思路:

  1. 在响应 GestureRecognizer 的方法中实时更新 view 的状态, 在 GestureRecognizer 结束的时候创建动画完成后续的部分.
  2. 在 GestureRecognizer 开始的时候创建显式动画, 随着 Gesture 变化, 改变动画的 progress, 在 GestureRecognizer 结束之后完成动画.
  3. 在 GestureRecognizer 开始的时候创建隐式动画, 随着 Gesture 变化, 改变动画的 progress, 在 GestureRecognizer 结束之后完成动画.

注意 2 和 3 只有显式和隐式的区别.

对于简单的 view, 单一的动画 (比如 demo 中的这种, 线性移动一个简单的 view), 三种方法差别不大. 而对于复杂的 view (view 中嵌套多个 subView), 多个动画 (view 和 subview 都有各自的动画), 如果在不考虑可交互的前提下, 隐式的动画已经可以满足需求, 那么方法 3 可能会比方法 1 和 2 简洁得多, 本文将对方法 3 进行讲解.

要实现方法 3, 首先要了解 CALayer 的几个属性: speed, timeOffset, beginTime. 对于这几个属性, Controlling Animation Timing 这篇教程图文并茂地给出了直观的解释, 必读, 这里只做简单介绍:

  • speed: 控制动画的速度, 比如设置为 1 (默认), 动画正常运行, 设置为 0 动画暂停, 设置为 -1 动画反向运行.
  • timeOffset: 控制动画在整个动画时间轴上的位置, 比如一个 3 秒的动画, timeOffset 为 1.5, layer 就会处于整个动画过程中间的那个状态.
  • beginTime: 动画的开始时间.

有了这些基础, 我们就可以着手实现了, 首先我们需要建立一个 CALayer 的 Category, 添加一些动画辅助代码:

- (void)gs_pauseAnimation {
    self.speed = 0;
    self.timeOffset = 0;
}
 
- (void)gs_recoverToDefaultState {
    self.speed = 1;
    self.timeOffset = 0;
    self.beginTime = 0;
}
 
- (void)gs_setTimeProgress:(CFTimeInterval)timeProgress {
    self.timeOffset = timeProgress;
}
 
- (void)gs_continueAnimationWithTimeProgress:(CFTimeInterval)timeProgress {
    [self gs_recoverToDefaultState];
 
    self.beginTime = [self convertTime:CACurrentMediaTime() fromLayer:nil] - timeProgress;
}
 
- (void)gs_continueReverseAnimationWithTimeProgress:(CFTimeInterval)timeProgress animationDuration:(CFTimeInterval)duration {
    [self gs_recoverToDefaultState];
 
    self.beginTime = [self convertTime:CACurrentMediaTime() fromLayer:nil] - (duration - timeProgress);
    self.timeOffset = duration;
    self.speed = -1;
}
  • gs_pauseAnimation: 通过将 speed 设置为 0, 来暂停动画.
  • gs_recoverToDefaultState: 将 layer 的各个属性恢复到默认状态
  • gs_setTimeProgress: 在 gs_pauseAnimation 的基础上, 设置动画的时间进度.
  • gs_continueAnimationWithTimeProgress: 从 timeProgress 时间进度开始, 继续播放之前暂停了的动画
    • 这里首先调用了 gs_recoverToDefaultState, 是为了让 convertTime:fromLayer: 得出正确的时间.
    • [self convertTime:CACurrentMediaTime() fromLayer:nil] 将当前系统时间转换到 layer 的时间上, 减去 timeProgress, 假设这是一个 3 秒的动画, timeProgress 为 1 秒, 则这条代码就表示 layer 的动画在一秒之前就已经开始, 所以动画将从运行到第 1 秒的状态开始继续播放.
    • 如果不能理解, 可以结合一下文档中这篇: How do I pause all animations in a layer tree?
  • gs_continueReverseAnimationWithTimeProgress: 从 timeProgress 时间进度开始, 反向播放之前暂停了的动画.
    • 这里 beginTime 设置为 当前时间 - (整个动画时间 - timeProgress), 再假设这是一个 3 秒的动画, timeProgress 为 1 秒, 因为要让动画反向播放, 所以要把动画反过来看, 由终点到起点运动, timeProgress 这 1 秒, 是动画最后的部分, 前 3 – 1 = 2 秒是已经播放完的, 所以动画要从 第 2 秒开始.
    • 通过将 speed 设置为 -1 来反向播放动画

完成了动画控制的实现, 接下来是控制 view 状态的代码:

- (void)switchToState1 {
    [self.moveableView setCenter:CGPointMake(self.view.bounds.size.width / 2, 50)];
    [self.moveableView setTag:1];
}
 
- (void)switchToState2 {
    [self.moveableView setCenter:CGPointMake(self.view.bounds.size.width / 2, 500)];
    [self.moveableView setTag:2];
}
 
- (void)switchState {
    if (self.moveableView.tag == 1) {
        [self switchToState2];
    }
    else {
        [self switchToState1];
    }
}

很简单, 不用过多解释, view 的起始状态是 state1.

然后是手势控制:

- (void)handlePanGesture:(UIPanGestureRecognizer *)panGesture {
    if (panGesture.state == UIGestureRecognizerStateBegan) {
        [self.moveableView.layer removeAllAnimations];
 
        [UIView animateWithDuration:kAnimationDuration animations:^{
            if (self.moveableView.tag == 1) {
                [self switchToState2];
            }
            else {
                [self switchToState1];
            }
        } completion:^(BOOL finished) {
            [self.moveableView.layer gs_recoverToDefaultState];
 
            if (self.animationProgress <= 0.5) { if (self.moveableView.tag == 1) { [self switchToState2]; } else { [self switchToState1]; } } }]; [self.moveableView.layer gs_pauseAnimation]; } else if (panGesture.state == UIGestureRecognizerStateChanged) { CGPoint translation = [panGesture translationInView:self.view]; CGFloat length = ABS(translation.y); self.animationProgress = MAX(0, MIN(length / 500, 1)); [self.moveableView.layer gs_setTimeProgress:self.animationProgress * kAnimationDuration]; } else if (panGesture.state == UIGestureRecognizerStateEnded || panGesture.state == UIGestureRecognizerStateFailed || panGesture.state == UIGestureRecognizerStateCancelled) { if (self.animationProgress > 0.5) {
            [self.moveableView.layer gs_continueAnimationWithTimeProgress:self.animationProgress * kAnimationDuration];
        }
        else {
            [self.moveableView.layer gs_continueReverseAnimationWithTimeProgress:self.animationProgress * kAnimationDuration animationDuration:kAnimationDuration];
        }
    }
}
  1. 在手势开始时, 创建 UIView 隐式动画并暂停;
  2. 随着手势移动, 调整动画的进度
  3. 在手势结束后, 根据当前手势位移来判断是将 view 归位, 还是移动到目标位置.
  4. 在动画结束后, 也就是在手势开始时隐式动画的 completion 中, 恢复 layer 状态, 并在 view 归位的情况下, 设置 view 到初始的状态.

运行一下 (这部分代码在 branch v1), 看起来得到了想要的效果, 但是如果细心观察, 会发现在手势拖动距离不够长, view 移动到原位的时候, view 会闪烁一下. 因为 demo 中 view 太简单了, 所以可能要多试几次才能观察到, 不过也可以在 completion block 里面第一行设置个断点, 可以看到, 执行到断点处时, view 消失了.

这里的原因我没有深入研究, 猜测是因为 speed 设置为 -1, 导致动画运行完成的时候 view 处于一个不存在的状态, 也就无法将这个状态的 view 绘制到屏幕上. 虽然通过将 speed 设置为 -1 来反向运行动画看起来很机智, 但很显然, 这不是一个完美的解决方法.

既然不能让动画自动反向运行, 那就稍微绕个远, 手动反向运行. CADisplayLink 是一个不错的选择, 它与 NSTimer 类似, 但是是在屏幕每一帧刷新之前调用 selector. 新的代码如下:

- (void)handlePanGesture:(UIPanGestureRecognizer *)panGesture {
    if (panGesture.state == UIGestureRecognizerStateBegan) {
        [self.moveableView.layer removeAllAnimations];
 
        [self.displayLink invalidate];
 
        [UIView animateWithDuration:kAnimationDuration animations:^{
            [self switchState];
        } completion:^(BOOL finished) {
 
        }];
 
        [self.moveableView.layer gs_pauseAnimation];
    }
    else if (panGesture.state == UIGestureRecognizerStateChanged) {
        CGPoint translation = [panGesture translationInView:self.view];
        CGFloat length = ABS(translation.y);
 
        self.animationProgress = MAX(0, MIN(length / 500, 1));
 
        [self.moveableView.layer gs_setTimeProgress:self.animationProgress *  kAnimationDuration];
    }
    else if (panGesture.state == UIGestureRecognizerStateEnded || panGesture.state == UIGestureRecognizerStateFailed || panGesture.state == UIGestureRecognizerStateCancelled) {
 
        if (self.animationProgress > 0.5) {
            [self.moveableView.layer gs_continueAnimationWithTimeProgress:self.animationProgress * kAnimationDuration];
        }
        else {
            [self.displayLink invalidate];
 
            self.displayLinkStarTime = CACurrentMediaTime();
            self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateAnimation:)];
            [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
        }
    }
}
 
- (void)updateAnimation:(id)sender {
    CFTimeInterval timeInterval = CACurrentMediaTime() - self.displayLinkStarTime;
    CFTimeInterval animationTimeProgress = self.animationProgress * kAnimationDuration - timeInterval;
 
    if (animationTimeProgress >= 0) {
        [self.moveableView.layer gs_setTimeProgress:animationTimeProgress];
    }
    else {
        [self.displayLink invalidate];
 
        [self.moveableView.layer gs_continueAnimationWithTimeProgress:kAnimationDuration];
        [self switchState];
    }
}

可以看到, 在手势结束后, 我们创建了一个 CADisplayLink 来手动反向运行动画, 并在动画结束后, 重新设置 view 的状态.

运行, 闪烁消失, Cheers.

–以上–