使用AutoLayout实现圆形UIView的大小调整...如何在调整大小动画期间同时动画化cornerRadius?

13

我有一个子类化的UIView,我们可以称之为CircleView。 CircleView会自动将其圆角半径设置为其宽度的一半,以便成为圆形。

问题在于,当“CircleView”由AutoLayout约束重新调整大小时...例如设备旋转时...它会严重失真,直到调整大小发生,因为“cornerRadius”属性必须赶上,而操作系统只向视图的框架发送单个“bounds”更改。

我想知道是否有人有一个很好、清晰的策略来实现"CircleView",在这种情况下不会失真,但仍然可以将其内容掩盖成圆形,并允许该UIView周围存在边框。


你把代码放在哪里才能自动重置“角半径”? - J.Wang
layoutSubviews() 方法怎么样? - J.Wang
@J.Wang 这只会在调整大小/旋转时被调用一次。 - Ben Guild
@BenGuild,两个答案都是一样的,但一个是用ObjC写的,另一个是用Swift写的。但被标记为重复的不是答案,而是问题,而且这两个问题是一样的。 - Simon
@Simon,这些答案不同但相似。他提到它是受你的启发而来的。此外,他很好地解释了它并进行了可视化。我尝试了SGBRoundView,但它的效果不如他的答案好。 - Ben Guild
显示剩余4条评论
5个回答

42

更新: 如果您的部署目标为iOS 11或更高版本:

从iOS 11开始,如果在动画块内更新,则cornerRadius将以动画方式显示。只需在UIView动画块中设置视图的layer.cornerRadius,或者(为了处理界面方向更改)在layoutSubviewsviewDidLayoutSubviews中设置。

原始内容:如果您的部署目标早于iOS 11:

所以你想要这个:

smoothly resizing circle view

(我打开了Debug > Slow Animations,使平滑性更易于观察。)

顺带发牢骚,随意跳过这段文字:实际上这比它应该的要难得多,因为iOS SDK没有以一种方便的方式提供自动旋转动画的参数(持续时间,计时曲线)。您可以(我认为)通过重写您的视图控制器上的-viewWillTransitionToSize:withTransitionCoordinator:来调用过渡协调器上的-animateAlongsideTransition:completion:来获取它们,然后在您传递的回调中,从UIViewControllerTransitionCoordinatorContext获取transitionDurationcompletionCurve。然后,您需要将该信息传递给您的CircleView,它必须保存它(因为它尚未调整大小!),并且稍后,当它接收到layoutSubviews时,可以使用它来创建一个CABasicAnimation,用于带有这些保存的动画参数的cornerRadius。而且,请不要在不是动画调整大小时意外创建动画... 顺带牢骚结束。

哇,听起来像是很多工作,并且必须涉及视图控制器。这里是另一种完全在CircleView内部实现的方法。它现在可以工作(在iOS 9中),但我不能保证它在将来始终能够正常工作,因为它做出了两个假设,这些假设理论上可能是错误的。

这是方法:在CircleView中覆盖-actionForLayer:forKey:以返回一个操作,该操作在运行时安装了cornerRadius的动画。

这是这两个假设:

  • bounds.originbounds.size具有独立的动画效果。(目前是这样,但未来的iOS可能会使用单个动画来处理bounds。如果没有找到bounds.size的动画效果,那么检查bounds是否存在动画效果将非常容易。)
  • bounds.size的动画效果会在Core Animation请求cornerRadius操作之前被添加到图层中。

在以上假设的情况下,当Core Animation请求cornerRadius操作时,我们可以从图层中获取bounds.size的动画效果,复制它,并修改该副本来代替原始效果实现对cornerRadius的动画效果。除非我们对动画参数进行修改,否则副本与原始效果具有相同的动画参数,因此其动画持续时间和时间曲线是正确的。

以下是CircleView的开头:

class CircleView: UIView {

    override func layoutSubviews() {
        super.layoutSubviews()
        updateCornerRadius()
    }

    private func updateCornerRadius() {
        layer.cornerRadius = min(bounds.width, bounds.height) / 2
    }

请注意,视图的边界在视图接收到 layoutSubviews 之前就已经设置好了,因此在我们更新 cornerRadius 之前。这就是为什么先安装 bounds.size 动画,然后才请求 cornerRadius 动画。每个属性的动画都安装在属性的setter中。

当我们设置 cornerRadius 时,核心动画会要求我们运行一个 CAAction

    override func action(for layer: CALayer, forKey event: String) -> CAAction? {
        if event == "cornerRadius" {
            if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
                let animation = boundsAnimation.copy() as! CABasicAnimation
                animation.keyPath = "cornerRadius"
                let action = Action()
                action.pendingAnimation = animation
                action.priorCornerRadius = layer.cornerRadius
                return action
            }
        }
        return super.action(for: layer, forKey: event)
    }
在上述代码中,如果我们被要求对 cornerRadius 进行操作,我们会查找在 bounds.size 上的 CABasicAnimation。如果我们找到了一个,我们会将其复制并更改关键路径为 cornerRadius,然后将其保存在自定义的 CAAction 中(该类名为 Action,我稍后会展示它)。我们还会保存当前的 cornerRadius 属性值,因为 Core Animation 在更新属性之前会调用 actionForLayer:forKey:

actionForLayer:forKey: 返回之后,Core Animation 会更新图层的 cornerRadius 属性。然后,它通过发送 runActionForKey:object:arguments: 来执行动作。动作的工作是安装适当的任何动画。这是 CAAction 的自定义子类,我已经将其嵌套在 CircleView 中:

    private class Action: NSObject, CAAction {
        var pendingAnimation: CABasicAnimation?
        var priorCornerRadius: CGFloat = 0
        public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
            if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
                if pendingAnimation.isAdditive {
                    pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
                    pendingAnimation.toValue = 0
                } else {
                    pendingAnimation.fromValue = priorCornerRadius
                    pendingAnimation.toValue = layer.cornerRadius
                }
                layer.add(pendingAnimation, forKey: "cornerRadius")
            }
        }
    }
} // end of CircleView
runActionForKey:object:arguments: 方法会设置动画的 fromValuetoValue 属性,然后将该动画添加到图层中。但是,这里有一个复杂性:UIKit 使用“可叠加”的动画,因为如果在早期动画仍在运行时启动了其他属性的动画,则它们可以更好地工作。因此,我们的操作会对此进行检查。
如果动画是可叠加的,则会将fromValue设置为旧圆角半径与新圆角半径之间的差值,并将toValue设置为零。由于图层的cornerRadius属性在动画运行时已经更新,因此在动画开始时添加该fromValue使其看起来像旧的圆角半径,在动画结束时添加toValue使其看起来像新的圆角半径。
如果动画不是可叠加的(据我所知,如果 UIKit 创建动画,则不会发生这种情况),则只需按照明显的方式设置fromValuetoValue即可。
下面是整个文件,以供您参考:
import UIKit

class CircleView: UIView {

    override func layoutSubviews() {
        super.layoutSubviews()
        updateCornerRadius()
    }

    private func updateCornerRadius() {
        layer.cornerRadius = min(bounds.width, bounds.height) / 2
    }

    override func action(for layer: CALayer, forKey event: String) -> CAAction? {
        if event == "cornerRadius" {
            if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
                let animation = boundsAnimation.copy() as! CABasicAnimation
                animation.keyPath = "cornerRadius"
                let action = Action()
                action.pendingAnimation = animation
                action.priorCornerRadius = layer.cornerRadius
                return action
            }
        }
        return super.action(for: layer, forKey: event)
    }

    private class Action: NSObject, CAAction {
        var pendingAnimation: CABasicAnimation?
        var priorCornerRadius: CGFloat = 0
        public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
            if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
                if pendingAnimation.isAdditive {
                    pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
                    pendingAnimation.toValue = 0
                } else {
                    pendingAnimation.fromValue = priorCornerRadius
                    pendingAnimation.toValue = layer.cornerRadius
                }
                layer.add(pendingAnimation, forKey: "cornerRadius")
            }
        }
    }
} // end of CircleView

我的回答受到Simon的这个回答的启发


谢谢!你应该将它发布为 Cocoa Pod。 :) - Ben Guild
感谢@sethmr将此转换为Swift 3。 - rob mayoff
@robmayoff 我无法使它适用于我的目的。如果您看一下 SwiftySwitch,我正在尝试让圆形动画完美圆形。它看起来还不错,但我不介意为将来参考而使其运作。我尝试将我用于扩展/收缩圆的 tempView 设为该类,但不确定接下来该怎么做。 - Sethmr
我快速浏览了你的项目,但是我没有看到任何使用CircleView或本答案中描述的技术。我在playground中测试了你版本的我的代码,它可以工作。无论如何,如果你遇到问题,应该发布你自己的问题。 - rob mayoff
@robmayoff 太棒了!这个答案对我来说完美无缺。如果你想以同样的方式处理渐变图层,你会怎么做呢?也就是说,我希望一个图层在没有延迟的情况下更新其边界。 - Stuart Casarotto
显示剩余2条评论

2
这个答案是在早前的回答的基础上建立起来的,作者是rob mayoff。基本上,我在我们的项目中实现了它,在iPhone(iOS 9和10)上运行得很好,但在iPad(iOS 9或10)上问题仍然存在。
调试时,我发现if语句:
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {

在iPad上总是失败。看起来iPad上的动画序列与iPhone上不同。回顾Simon的原始答案,似乎序列已经改变过了。因此,我结合了两个答案,得到了类似这样的东西:
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
    let buildAction: (CABasicAnimation) -> Action = { boundsAnimation in
        let animation = boundsAnimation.copy() as! CABasicAnimation
        animation.keyPath = "cornerRadius"
        let action = Action()
        action.pendingAnimation = animation
        action.priorCornerRadius = layer.cornerRadius
        return action
    }

    if event == "cornerRadius" {
        if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
            return buildAction(boundsAnimation)
        } else if let boundsAnimation = self.action(for: layer, forKey: "bounds") as? CABasicAnimation {
            return buildAction(boundsAnimation)
        }
    }

    return super.action(for: layer, forKey: event)
}

通过结合这两个答案,似乎可以在iOS 9和10下的iPhone和iPad上正常工作。我没有进行进一步的测试,并且不了解足够的CoreAnimation以完全理解这个变化。

不幸的是,新的iOS版本10.3似乎已经破坏了它,动画又重新变成了方形。有人对此有什么想法吗? - Daniel Brotherston

1
在iOS 10中,您不需要创建CAAction,只需创建一个CABasicAnimation并在您的操作(for layer:,for key :) -> CAAction?函数中提供它即可(请参见Swift示例):
private var currentBoundsAnimation: CABasicAnimation? {
    return layer.animation(forKey: "bounds.size") as? CABasicAnimation ?? layer.animation(forKey: "bounds") as? CABasicAnimation
}

override public var bounds: CGRect {
    didSet {
        layer.cornerRadius = min(bounds.width, bounds.height) / 2
    }
}

override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
    if(event == "cornerRadius"), let boundsAnimation = currentBoundsAnimation {
        let animation = CABasicAnimation(keyPath: "cornerRadius")
        animation.duration = boundsAnimation.duration
        animation.timingFunction = boundsAnimation.timingFunction
        return animation
    }

    return super.action(for: layer, forKey: event)
}

你可以重写layoutSubviews:方法来代替重写bounds属性。

override func layoutSubviews() {
    super.layoutSubviews()
    layer.cornerRadius = min(bounds.width, bounds.height) / 2
}

这个神奇的方法之所以有效,是因为CABasicAnimation会从模型层和呈现层推断缺失的起始值和结束值。要正确设置时间,您需要使用私有的currentBoundsAnimation属性来获取当前动画(iPad的“bounds”和iPhone的“bounds.size”),这些动画是在设备旋转时添加的。

0

这些翻译答案通常是针对 Objective-c ==> Swift 的,但如果还有任何固执的 Objective-c 作者,这里是 @Rob 的答案翻译...

// see https://dev59.com/TFsV5IYBdhLWcg3w2h4s#35714554

#import "RoundView.h"

@interface Action : NSObject<CAAction>
@property(strong,nonatomic) CABasicAnimation *pendingAnimation;
@property(assign,nonatomic) CGFloat priorCornerRadius;
@end

@implementation Action
- (void)runActionForKey:(NSString *)event object:(id)anObject
              arguments:(nullable NSDictionary *)dict {

    if ([anObject isKindOfClass:[CALayer self]]) {
        CALayer *layer = (CALayer *)anObject;
        if (self.pendingAnimation.isAdditive) {
            self.pendingAnimation.fromValue = @(self.priorCornerRadius - layer.cornerRadius);
            self.pendingAnimation.toValue = @(0);
        } else {
            self.pendingAnimation.fromValue = @(self.priorCornerRadius);
            self.pendingAnimation.toValue = @(layer.cornerRadius);
        }
        [layer addAnimation:self.pendingAnimation forKey:@"cornerRadius"];
    }
}
@end

@interface RoundView ()
@property(weak,nonatomic) UIImageView *imageView;
@end

@implementation RoundView

- (void)layoutSubviews {
    [super layoutSubviews];
    [self updateCornerRadius];
}

- (void)updateCornerRadius {
    self.layer.cornerRadius = MIN(self.bounds.size.width, self.bounds.size.height)/2.0;
}

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
    if ([event isEqualToString:@"cornerRadius"]) {
        CABasicAnimation *boundsAnimation = (CABasicAnimation *)[self.layer animationForKey:@"bounds.size"];
        CABasicAnimation *animation = [boundsAnimation copy];
        animation.keyPath = @"cornerRadius";
        Action *action = [[Action alloc] init];
        action.pendingAnimation = animation;
        action.priorCornerRadius = layer.cornerRadius;
        return action;
    }
    return [super actionForLayer:layer forKey:event];;
}

@end

-1
我建议不要使用圆角,而是使用CAShapeLayer作为视图内容层的蒙版。
您可以安装一个填充的360°弧形CGPath作为形状层的形状,并将其设置为图层视图的蒙版。
然后,您可以为蒙版层动画一个新的比例变换,或者动画路径半径的更改。这两种方法都应该保持圆形,尽管在较小的像素大小下,比例变换可能无法给您提供清晰的形状。
时间控制是棘手的部分(使蒙版层的动画与边界动画同步发生)。

好的,那么这就没有完全解决问题。我同意使用CAShapeLayer作为遮罩是更好的方法,但硬编码以匹配调整大小/旋转动画的延迟似乎并不是一个很好的策略。 - Ben Guild

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