【问题标题】:Circular (round) UIView resizing with AutoLayout... how to animate cornerRadius during the resize animation?使用 AutoLayout 调整圆形(圆形)UIView 大小...如何在调整大小动画期间为cornerRadius 设置动画?
【发布时间】:2016-03-01 01:47:23
【问题描述】:

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

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

我想知道是否有人有一个好的、清晰的策略来实现“CircleView”,在这种情况下不会扭曲,但仍会将其内容掩盖为圆形并允许边界存在于所述 UIView 周围。

【问题讨论】:

  • 自动重置corner radius的代码放在哪里?
  • @J.Wang 已经尝试了多个地方。问题是我发现我只收到一次关于圆的边界更改的通知,并且在操作系统更改约束的同时为cornerRadius设置动画似乎真的不一致。
  • layoutSubviews() 方法怎么样?
  • @J.Wang 仅在调整大小/旋转时调用一次。

标签: ios uiview


【解决方案1】:

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

从 iOS 11 开始,如果您在动画块中更新 UIKit,它将为 cornerRadius 设置动画。只需将视图的layer.cornerRadius 设置在UIView 动画块中,或者(以处理界面方向更改),将其设置在layoutSubviews 或viewDidLayoutSubviews 中。

ORIGINAL:如果您的部署目标早于 iOS 11:

所以你想要这个:

(我打开了 Debug > Slow Animations 以使平滑度更容易看到。)

旁白,随意跳过这一段:事实证明这比它应该做的要困难得多,因为 iOS SDK 没有使参数(持续时间、时序曲线)以方便的方式提供自动旋转动画。你可以(我认为)通过覆盖视图控制器上的-viewWillTransitionToSize:withTransitionCoordinator: 以在转换协调器上调用-animateAlongsideTransition:completion: 来获取它们,并在你传递的回调中,从UIViewControllerTransitionCoordinatorContext 获取transitionDuration 和completionCurve。然后您需要将该信息传递给您的CircleView,它必须保存它(因为它还没有调整大小!)然后当它收到layoutSubviews时,它可以使用它来创建一个CABasicAnimation对于 cornerRadius 和那些保存的动画参数。当它不是动画调整大小时,不要意外创建动画...... 侧面咆哮结束。

哇,这听起来像是大量的工作,而且您必须让视图控制器参与进来。这是完全在CircleView 内部实现的另一种方法。它现在可以工作(在 iOS 9 中),但我不能保证它在未来总是可以工作,因为它提出了两个理论上将来可能是错误的假设。

方法如下:在CircleView 中覆盖-actionForLayer:forKey: 以返回一个动作,该动作在运行时会为cornerRadius 安装动画。

这是两个假设:

  • bounds.origin 和 bounds.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 之前。这就是为什么在请求cornerRadius 动画之前安装bounds.size 动画的原因。每个属性的动画都安装在属性的 setter 中。

当我们设置cornerRadius 时,Core Animation 要求我们为它运行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: 方法设置动画的fromValue 和toValue 属性,然后将动画添加到图层。有一个复杂的地方:UIKit 使用“附加”动画,因为如果你在一个属性上启动另一个动画,而早期的动画仍在运行,它们会更好地工作。因此,我们的操作会对此进行检查。

如果动画是相加的,它会将fromValue 设置为新旧角半径之间的差值,并将toValue 设置为零。由于该层的cornerRadius 属性在动画运行时已经更新,因此在动画开始处添加fromValue 使其看起来像旧的圆角半径,并在末尾添加为零的toValue的动画使它看起来像新的圆角半径。

如果动画不是附加的(据我所知,如果 UIKit 创建了动画,则不会发生这种情况),那么它只会以明显的方式设置 fromValue 和 toValue。

为方便起见,这是整个文件:

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

我的回答灵感来自this answer by Simon。

【讨论】:

  • 谢谢!您应该将其作为 Cocoa Pod 发布。 :)
  • 感谢 @sethmr 将其转换为 Swift 3。
  • @robmayoff 我无法让它为我的目的工作。如果您看一下SwiftySwitch,我正试图让圆圈以完美的圆形动画。它看起来并不坏,但我不介意让它工作以供将来参考。我尝试使用 tempView 来扩展/减少该类的圆圈,但不确定接下来如何处理它。
  • 我快速浏览了您的项目,但我没有看到任何使用 CircleView 或此答案中描述的技术。我在操场上测试了我的代码版本并且它有效。无论如何,如果您遇到问题,您应该发布自己的问题。
  • @robmayoff 你明白了;)
【解决方案2】:

这个答案建立在the earlier answer by rob mayoff 之上。基本上,我为我们的项目实现了它,它在 iPhone(iOS 9 和 10)上运行良好,但问题仍然存在于 iPad(iOS 9 或 10)上。

调试,发现if语句:

if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {

在 iPad 上总是失败。看起来动画在 iPad 上的构建顺序与 iPhone 不同。回头看original answer by 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 版本上被打破了,动画再次平方。任何人对此都有任何想法。
【解决方案3】:

在 iOS 10 中,您不需要创建 CAAction,它只需创建 CABasicAnimation 并在您的操作中提供它(对于层:,对于键:)-> 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)
}

除了覆盖 bounds 属性,您还可以覆盖 layoutSubviews:

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

这很神奇,因为 CABasicAnimation 从模型和表示层推断出缺失的值。要正确设置时间,您需要私有 currentBoundsAnimation 属性来获取在设备旋转时添加的当前动画(iPad 为“bounds”,iPhone 为“bounds.size”)。

【讨论】:

    【解决方案4】:

    这些翻译答案通常是 Objective-c ==> Swift,但如果还有更多顽固的 Objective-c 作者离开,这里是 @Rob 的答案翻译...

    // see https://stackoverflow.com/a/35714554/294949
    
    #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
    

    【讨论】:

      【解决方案5】:

      我建议不要使用圆角半径,而是使用 CAShapeLayer 作为视图内容层的遮罩。

      您将安装一个填充的 360° 弧形 CGPath 作为形状图层的形状,并将其设置为图层视图的蒙版。

      然后,您可以为遮罩层设置一个新的缩放变换动画,或者为路径半径的变化设置动画。两种方法都应该保持圆形,尽管在较小的像素尺寸下缩放变换可能无法为您提供干净的形状。

      时间将是棘手的部分(让遮罩层的动画与边界动画同步发生。)

      【讨论】:

      • 对,所以这并不能真正解决问题。我同意使用 CAShapeLayer 作为蒙版是一种更好的方法,但硬编码以匹配调整大小/旋转动画的延迟似乎并不是一个好策略。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多