iOS CAKeyframeAnimation旋转模式对UIImageView的影响

12
我正在尝试使用关键帧动画来沿着贝塞尔曲线移动UIImageView对象的位置。这张图片展示了动画之前的初始状态。蓝色线条是路径 - 最初向上直线移动,浅绿色框是图像的初始边界框,而深绿色的“幽灵”是我正在移动的图像。

Initial state

当我将rotationMode设置为nil并启动动画时,图像在整个路径中保持相同的方向,正如预期的那样。但是,当我将rotationMode设置为kCAAnimationRotateAuto并启动动画时,图像立即逆时针旋转90度,并在整个路径中保持这个方向。当它到达路径的末端时,它会以正确的方向重新绘制(实际上是显示我在最后位置重新定位的UIImageView)。

enter image description here

我最初以为rotationMode会将图像定位到路径的切线而不是法线,特别是当苹果文档中CAKeyframeAnimation rotationMode的说明如下:

确定沿路径动画的对象是否旋转以匹配路径切线。

所以这里的解决方案是什么?我需要预先将图像顺时针旋转90度吗?还是有什么我没注意到的地方?
感谢您的帮助。

编辑于3月2日

我在路径动画之前添加了一个旋转步骤,使用了仿射旋转,例如:

theImage.transform = CGAffineTransformRotate(theImage.transform,90.0*M_PI/180);

在路径动画之后,使用以下方式重置旋转:
theImage.transform = CGAffineTransformIdentity;

这使得图像按预期方式跟随路径移动。然而,我现在遇到了一个不同的问题,即图像闪烁。我已经在这个SO问题中寻找解决闪烁问题的方法:iOS CAKeyFrameAnimation scaling flickers at animation end。所以现在我不知道我是否让事情变得更好还是更糟!

3月12日编辑

Caleb指出我确实需要预先旋转我的图像,Rob提供了一个很棒的代码包,几乎完全解决了我的问题。唯一的问题是Rob没有补偿我的资源被垂直而不是水平方向绘制,因此仍然需要在动画之前将它们预先旋转90度。但是,我认为这样公平,必须要付出一些努力才能让事情运行起来。

因此,我对Rob的解决方案进行了一些微小的更改,以适应我的需求:

  1. 当我添加UIView时,我会将其预先旋转以抵消设置rotationMode所添加的固有旋转:

    theImage.transform = CGAffineTransformMakeRotation(90*M_PI/180.0);

  2. 我需要在动画结束时保持该旋转状态,因此在定义完成块后,不仅要用新的比例因子炸掉视图的变换,还要基于当前变换构建比例:

    theImage.transform = CGAffineTransformScale(theImage.transform, scaleFactor, scaleFactor);

这就是我所做的一切,使我的图像按照我预期的路径跟随!


3月22日编辑

我刚刚在GitHub上上传了一个演示项目,展示了沿着贝塞尔曲线移动物体的过程。代码可以在PathMove找到。

我也在我的博客Moving objects along a bezier path in iOS中写了关于它的内容。


这对我帮助很大!谢谢! - RyanG
3个回答

8
这里的问题在于Core Animation的自动旋转保持视图的水平轴与路径的切线平行。那就是它的工作原理。
如果你想让视图的垂直轴跟随路径的切线,那么像你目前正在做的旋转视图内容是合理的做法。

好的,那就是它的工作原理。我会把这个归因于文档问题。但是当我使用仿射旋转时,我遇到了在上面编辑中提到的其他SO问题。我应该使用CA来进行预旋转而不是仿射旋转吗?或者我在那个其他问题中做错了什么? - Peter M
我从未测试过使用CGAffineTransform是否有所不同,因此无法对此发表意见。直觉上,由于您已经在使用核心动画时考虑了图层,因此使用核心动画的变换可能是有意义的:[theView.layer setValue:[NSNumber numberWithFloat:M_PI/2] forKey:@"transform.rotation"];。但似乎也应该使用CALayer的变换能力来实现CGAffineTransform,因此这可能并不重要。我现在时间很短,但我会在有机会时查看您的其他问题。 - Caleb
从我在另一个问题的答案中得出,使用核心动画进行旋转是正确的方法。当我得到那个问题的确定答案后,我也会尝试以同样的方式解决旋转问题。 - Peter M
你知道苹果公司所要做的就是计算物体初始状态和路径起点之间向量的角度,并将其用于定位物体与路径。这就是我真正想要的... 抽泣 - Peter M

6
以下是消除闪烁的注意事项:
- 正如Caleb所指出的,核心动画会将图层旋转,使其正X轴沿着路径的切线。您需要让图像的“自然”方向与其相匹配。因此,假设在您的示例图像中有一艘绿色宇宙飞船,则当未对其应用旋转时,您需要使飞船指向右侧。 - 设置包含旋转的变换会干扰`kCAAnimationRotateAuto`应用的旋转。在应用动画之前,需要从变换中删除旋转。 - 当然,这意味着您需要在动画完成时重新应用变换。当然,您希望在图像外观中看不到任何闪烁。这并不难,但其中涉及到一些秘诀,我在下面进行解释。 - 您可能希望您的宇宙飞船在没有被动画化之前就沿着路径的切线开始。如果您的飞船图像指向右侧,但路径向上,那么您需要设置图像的变换以包括90°的旋转。但也许您不想硬编码该旋转-而是要查看路径并找出其起始切线。
以下是重要代码的一部分。您可以在我的github测试项目中找到它。您可以下载并尝试它。只需点击绿色“宇宙飞船”即可查看动画。
在我的测试项目中,我已将UIImageView连接到名为animate:的操作。当您触摸它时,图像沿着8字形的一半移动并加倍大小。再次触摸它时,图像沿着8字形的另一半(回到起始位置),并返回其原始大小。这两个动画都使用kCAAnimationRotateAuto,因此图像沿路径的切线指向。
以下是animate:的开始部分,我在其中确定了图像应该达到的路径、比例和目标点:
- (IBAction)animate:(id)sender {
    UIImageView* theImage = self.imageView;

    UIBezierPath *path = _isReset ? _path0 : _path1;
    CGFloat newScale = 3 - _currentScale;

    CGPoint destination = [path currentPoint];

所以,我需要做的第一件事是从图像变换中移除任何旋转,因为正如我所提到的,它会干扰“kCAAnimationRotateAuto”的运作:
    // Strip off the image's rotation, because it interferes with `kCAAnimationRotateAuto`.
    theImage.transform = CGAffineTransformMakeScale(_currentScale, _currentScale);

接下来,我进入一个UIView动画块,以便系统可以对图像视图应用动画效果。
    [UIView animateWithDuration:3 animations:^{

我创建了关键帧动画,用于设置位置,并对其属性进行了调整:

        // Prepare my own keypath animation for the layer position.
        // The layer position is the same as the view center.
        CAKeyframeAnimation *positionAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
        positionAnimation.path = path.CGPath;
        positionAnimation.rotationMode = kCAAnimationRotateAuto;

下面是防止动画结束时出现闪烁的“秘密武器”。请记住,动画不会影响您将它们附加到的“模型层”的属性(在这种情况下为theImage.layer),而是更新“演示层”的属性,该层反映实际屏幕上的内容。
因此,首先我为关键帧动画设置removedOnCompletionNO。这意味着当动画完成时,动画将保持附加到模型层,这意味着我可以访问演示层。然后,我从演示层获取变换矩阵,删除动画,并将变换应用于模型层。由于所有这些属性更改都发生在主线程上的一个屏幕刷新周期内,因此不会产生闪烁。
        positionAnimation.removedOnCompletion = NO;
        [CATransaction setCompletionBlock:^{
            CGAffineTransform finalTransform = [theImage.layer.presentationLayer affineTransform];
            [theImage.layer removeAnimationForKey:positionAnimation.keyPath];
            theImage.transform = finalTransform;
        }];

现在我已经设置好完成块,可以实际改变视图属性。在我这样做时,系统会自动将动画附加到层。

        // UIView will add animations for both of these changes.
        theImage.transform = CGAffineTransformMakeScale(newScale, newScale);
        theImage.center = destination;

我从自动添加的位置动画中复制了一些关键属性到我的关键帧动画中:

        // Copy properties from UIView's animation.
        CAAnimation *autoAnimation = [theImage.layer animationForKey:positionAnimation.keyPath];
        positionAnimation.duration = autoAnimation.duration;
        positionAnimation.fillMode = autoAnimation.fillMode;

最后,我用关键帧动画替换了自动添加的位置动画:
        // Replace UIView's animation with my animation.
        [theImage.layer addAnimation:positionAnimation forKey:positionAnimation.keyPath];
    }];

最后,我更新实例变量以反映图像视图的更改:

    _currentScale = newScale;
    _isReset = !_isReset;
}

这就是无闪烁动画图像视图的全部内容。

现在,正如史蒂夫·乔布斯所说,“最后一件事”。当我加载视图时,需要设置图像视图的变换,使其旋转以沿着我将用来动画化它的第一条路径的切线指向。我在一个名为reset的方法中完成这个操作:

- (void)reset {
    self.imageView.center = _path1.currentPoint;
    self.imageView.transform = CGAffineTransformMakeRotation(startRadiansForPath(_path0));
    _currentScale = 1;
    _isReset = YES;
}

当然,棘手的部分隐藏在startRadiansForPath函数中。这并不难。我使用CGPathApply函数处理路径的元素,挑选出实际形成子路径的前两个点,并计算由这两个点形成的线段的角度。(曲线路径段是二次或三次贝塞尔样条,这些样条具有这样的性质:在样条的第一个点处的切线是从第一个点到下一个控制点的直线。)。
我只是为了记录而在此处转储代码,没有解释:
typedef struct {
    CGPoint p0;
    CGPoint p1;
    CGPoint firstPointOfCurrentSubpath;
    CGPoint currentPoint;
    BOOL p0p1AreSet : 1;
} PathState;

static inline void updateStateWithMoveElement(PathState *state, CGPathElement const *element) {
    state->currentPoint = element->points[0];
    state->firstPointOfCurrentSubpath = state->currentPoint;
}

static inline void updateStateWithPoints(PathState *state, CGPoint p1, CGPoint currentPoint) {
    if (!state->p0p1AreSet) {
        state->p0 = state->currentPoint;
        state->p1 = p1;
        state->p0p1AreSet = YES;
    }
    state->currentPoint = currentPoint;
}

static inline void updateStateWithPointsElement(PathState *state, CGPathElement const *element, int newCurrentPointIndex) {
    updateStateWithPoints(state, element->points[0], element->points[newCurrentPointIndex]);
}

static void updateStateWithCloseElement(PathState *state, CGPathElement const *element) {
    updateStateWithPoints(state, state->firstPointOfCurrentSubpath, state->firstPointOfCurrentSubpath);
}

static void updateState(void *info, CGPathElement const *element) {
    PathState *state = info;
    switch (element->type) {
        case kCGPathElementMoveToPoint: return updateStateWithMoveElement(state, element);
        case kCGPathElementAddLineToPoint: return updateStateWithPointsElement(state, element, 0);
        case kCGPathElementAddQuadCurveToPoint: return updateStateWithPointsElement(state, element, 1);
        case kCGPathElementAddCurveToPoint: return updateStateWithPointsElement(state, element, 2);
        case kCGPathElementCloseSubpath: return updateStateWithCloseElement(state, element);
    }
}

CGFloat startRadiansForPath(UIBezierPath *path) {
    PathState state;
    memset(&state, 0, sizeof state);
    CGPathApply(path.CGPath, &state, updateState);
    return atan2f(state.p1.y - state.p0.y, state.p1.x - state.p0.x);
}

感谢您提供如此详细的答案,我非常感激。然而,从您的项目中我看到,您预先绘制了指向右侧的资源,将其旋转到路径切线上并在动画中去除了该旋转。在我的情况下,我需要使用已经预先绘制好的朝上的资源,这样在动画中它们需要被旋转90度,然后沿着路径移动。现在您已经给了我一个很好的起点,我会稍后尝试自己解决问题。再次感谢。 - Peter M
今天早上我很快就弄清楚了需要做什么来满足我的要求。感谢你付出的努力帮助我! - Peter M

3
你提到需要使用"rotationMode set to YES"来启动动画,但是文档中写明应该使用NSString来设置rotationMode...
具体而言:
这些常数用于rotationMode属性。
NSString * const kCAAnimationRotateAuto
NSString * const kCAAnimationRotateAutoReverse 你尝试设置过以下内容吗:
keyframe.animationMode = kCAAnimationRotateAuto;
文档中说明了:
kCAAnimationRotateAuto: 对象沿路径行驶的切线方向。

问题只是文本有误,我正在使用 nilkCAAnimationRotateAuto。我刚刚编辑了问题以反映这一点。 - Peter M

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接