macOS应用中NSView爆炸式抖动动画实现及旋转中心点问题
macOS NSView 爆炸式抖动效果实现问题
需求说明
在macOS应用中实现NSView的抖动效果,需满足:
- 包含左右、上下位移
- 附带±2度的旋转,模拟爆炸震动
- 爆炸越近(对应按钮不同强度),抖动次数更多、持续时间更长
最初实现尝试
曾通过循环嵌套动画+回调、条件判断的方式实现,但未达到预期效果,代码如下:
import Cocoa class ViewController: NSViewController { @IBOutlet weak var shakeButton1: NSButton! @IBOutlet weak var shakeButton2: NSButton! @IBOutlet weak var shakeButton3: NSButton! @IBOutlet weak var shakeButton4: NSButton! override func viewDidLoad() { super.viewDidLoad() } override var representedObject: Any? { didSet { } } @IBAction func shakeButtonClicked(button: NSButton) { switch button.identifier { case shakeButton1.identifier: shakeView(on: self.view, shakes: 2) case shakeButton2.identifier: shakeView(on: self.view, shakes: 4) case shakeButton3.identifier: shakeView(on: self.view, shakes: 6) case shakeButton4.identifier: shakeView(on: self.view, shakes: 8) default: break } } func shakeView(on theView: NSView, shakes: Int) { let originalOrigin = self.view.frame.origin let rotation: CGFloat = 2 let moveValue: CGFloat = 5 let contextDuration: Double = 0.05 for shake in 0..<shakes { if shake.isMultiple(of: 2) { print("Even") NSAnimationContext.runAnimationGroup({ firstAnimation in firstAnimation.duration = contextDuration theView.animator().frame.origin.x = moveValue theView.animator().frame.origin.y = moveValue theView.animator().rotate(byDegrees: -rotation) print("First rotation \(shake) of \(shakes) = \(self.view.animator().frameRotation)") }) { theView.frame.origin = originalOrigin theView.rotate(byDegrees: rotation) print("Second rotation \(shake) of \(shakes) = \(self.view.frameRotation)") } } else { print("Odd") NSAnimationContext.runAnimationGroup({ secondAnimation in secondAnimation.duration = contextDuration theView.animator().frame.origin.x = -moveValue theView.animator().frame.origin.y = -moveValue theView.animator().rotate(byDegrees: rotation) print("Third rotation \(shake) of \(shakes) = \(self.view.frameRotation)") }) { theView.frame.origin = originalOrigin theView.rotate(byDegrees: -rotation) print("Fourth rotation \(shake) of \(shakes) = \(self.view.frameRotation)") } } } } }
Storyboard配置:四个按钮关联同一个shakeButtonClicked方法,标识符分别为shake1Button、shake2Button、shake3Button、shake4Button。
编辑更新:旋转中心点偏移问题
改用NotificationCenter实现后,效果接近目标,但旋转时视图中心点未居中,当前代码如下,需解决旋转中心点的问题:
import Cocoa class ViewController: NSViewController { @IBOutlet weak var shake1Button: NSButton! @IBOutlet weak var shake2Button: NSButton! @IBOutlet weak var shake3Button: NSButton! @IBOutlet weak var shake4Button: NSButton! let observatory = NotificationCenter.default var shaken: CGFloat = 0 var intensity: CGFloat = 0 override func viewDidLoad() { super.viewDidLoad() setupObservatory() } override var representedObject: Any? { didSet { } } @IBAction func shakeButtonClicked(button: NSButton) { switch button.identifier { case shake1Button.identifier: intensity = 1 case shake2Button.identifier: intensity = 2 case shake3Button.identifier: intensity = 3 case shake4Button.identifier: intensity = 4 default: break } observatory.post(name: .shakeView, object: nil) } func setupObservatory() { observatory.addObserver(forName: .shakeView, object: nil, queue: nil, using: shakeView) observatory.addObserver(forName: .moveDownAnimation, object: nil, queue: nil, using: moveDownAnimation) observatory.addObserver(forName: .moveLeftAnimation, object: nil, queue: nil, using: moveLeftAnimation) observatory.addObserver(forName: .moveRightAnimation, object: nil, queue: nil, using: moveRightAnimation) observatory.addObserver(forName: .moveUpAnimation, object: nil, queue: nil, using: moveUpAnimation) observatory.addObserver(forName: .rotateLeftAnimation, object: nil, queue: nil, using: rotateLeftAnimation) observatory.addObserver(forName: .rotateRightAnimation, object: nil, queue: nil, using: rotateRightAnimation) } func shakeView(notification: Notification) { if shaken != intensity { shaken += 1 observatory.post(name: .moveDownAnimation, object: nil) } else { shaken = 0 } } func moveDownAnimation(notification: Notification) { let originalOrigin = self.view.frame.origin NSAnimationContext.runAnimationGroup({ verticalAnimation in verticalAnimation.duration = 0.05 self.view.animator().frame.origin.y -= intensity }) { self.view.frame.origin = originalOrigin self.observatory.post(name: .moveLeftAnimation, object: nil) } } func moveLeftAnimation(notification: Notification) { let originalOrigin = self.view.frame.origin NSAnimationContext.runAnimationGroup({ firstAnimation in firstAnimation.duration = 0.05 self.view.animator().frame.origin.x -= intensity }) { self.view.frame.origin = originalOrigin self.observatory.post(name: .rotateLeftAnimation, object: nil) } } func moveRightAnimation(notification: Notification) { let originalOrigin = self.view.frame.origin NSAnimationContext.runAnimationGroup({ horizontalAnimation in horizontalAnimation.duration = 0.05 self.view.animator().frame.origin.x += intensity }) { self.view.frame.origin = originalOrigin self.observatory.post(name: .moveUpAnimation, object: nil) } } func moveUpAnimation(notification: Notification) { let originalOrigin = self.view.frame.origin NSAnimationContext.runAnimationGroup({ verticalAnimation in verticalAnimation.duration = 0.05 self.view.animator().frame.origin.y += intensity }) { self.view.frame.origin = originalOrigin self.observatory.post(name: .rotateRightAnimation, object: nil) } } func rotateLeftAnimation(notification: Notification) { let newAnchorPoint = CGPoint(x: 0.5, y: 0.5) view.layer?.anchorPoint = newAnchorPoint guard var position = view.layer?.position else { return } position.x += view.bounds.maxX * newAnchorPoint.x position.y += view.bounds.maxY * newAnchorPoint.y view.layer?.position = position let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z") rotateAnimation.byValue = intensity / 180 * CGFloat.pi rotateAnimation.duration = 0.05; let unRotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z") unRotateAnimation.byValue = -(intensity / 180 * CGFloat.pi) unRotateAnimation.duration = 0.05; CATransaction.begin() view.layer?.add(rotateAnimation, forKey: "rotate") CATransaction.setCompletionBlock { self.view.layer?.add(unRotateAnimation, forKey: "rotate") self.observatory.post(name: .moveRightAnimation, object: nil) } CATransaction.commit() } func rotateRightAnimation(notification: Notification) { let newAnchorPoint = CGPoint(x: 0.5, y: 0.5) view.layer?.anchorPoint = newAnchorPoint guard var position = view.layer?.position else { return } position.x += view.bounds.maxX * newAnchorPoint.x position.y += view.bounds.maxY * newAnchorPoint.y view.layer?.position = position let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z") rotateAnimation.byValue = -(intensity / 180 * CGFloat.pi) rotateAnimation.duration = 0.05; let unRotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z") unRotateAnimation.byValue = intensity / 180 * CGFloat.pi unRotateAnimation.duration = 0.05; CATransaction.begin() view.layer?.add(rotateAnimation, forKey: "rotate") CATransaction.setCompletionBlock { self.view.layer?.add(unRotateAnimation, forKey: "rotate") self.observatory.post(name: .shakeView, object: nil) } CATransaction.commit() } } extension Notification.Name { static var shakeView: Notification.Name { .init(rawValue: "ViewController.shakeView") } static var moveDownAnimation: Notification.Name { .init(rawValue: "ViewController.moveDownAnimation") } static var moveLeftAnimation: Notification.Name { .init(rawValue: "ViewController.moveLeftAnimation") } static var moveRightAnimation: Notification.Name { .init(rawValue: "ViewController.moveRightAnimation") } static var moveUpAnimation: Notification.Name { .init(rawValue: "ViewController.moveUpAnimation") } static var rotateLeftAnimation: Notification.Name { .init(rawValue: "ViewController.rotateLeftAnimation") } static var rotateRightAnimation: Notification.Name { .init(rawValue: "ViewController.rotateRightAnimation") } }
解决方案:修复旋转中心点偏移问题
问题根源
设置anchorPoint时计算position偏移的逻辑错误。默认anchorPoint为(0,0),改为(0.5,0.5)时,需基于原位置计算偏移量,而非直接累加bounds.maxX * newAnchorPoint.x。
修正步骤
- 在
viewDidLoad中一次性设置anchorPoint为视图中心,避免每次动画重复设置:
override func viewDidLoad() { super.viewDidLoad() setupObservatory() // 提前设置旋转中心点为视图中心 setLayerAnchorPointToCenter() } func setLayerAnchorPointToCenter() { guard let layer = view.layer else { return } let oldAnchor = layer.anchorPoint let newAnchor = CGPoint(x: 0.5, y: 0.5) // 计算位置偏移量 let deltaX = (newAnchor.x - oldAnchor.x) * view.bounds.size.width let deltaY = (newAnchor.y - oldAnchor.y) * view.bounds.size.height // 更新position抵消偏移 layer.position = CGPoint(x: layer.position.x + deltaX, y: layer.position.y + deltaY) layer.anchorPoint = newAnchor }
- 修改旋转动画方法,移除重复设置
anchorPoint的代码,简化动画逻辑:
func rotateLeftAnimation(notification: Notification) { let rotateAngle = intensity * CGFloat.pi / 180 // 组合旋转动画:先左转再复位 let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation.z") rotateAnimation.values = [0, rotateAngle, 0] rotateAnimation.duration = 0.1 // 0.05*2 rotateAnimation.isRemovedOnCompletion = false rotateAnimation.fillMode = .forwards CATransaction.begin() view.layer?.add(rotateAnimation, forKey: "rotateLeft") CATransaction.setCompletionBlock { self.view.layer?.removeAnimation(forKey: "rotateLeft") self.observatory.post(name: .moveRightAnimation, object: nil) } CATransaction.commit() } func rotateRightAnimation(notification: Notification) { let rotateAngle = intensity * CGFloat.pi / 180 // 组合旋转动画:先右转再复位 let rotateAnimation = CAKeyframeAnimation(keyPath: "transform.rotation.z") rotateAnimation.values = [0, -rotateAngle, 0] rotateAnimation.duration = 0.1 // 0.05*2 rotateAnimation.isRemovedOnCompletion = false rotateAnimation.fillMode = .forwards CATransaction.begin() view.layer?.add(rotateAnimation, forKey: "rotateRight") CATransaction.setCompletionBlock { self.view.layer?.removeAnimation(forKey: "rotateRight") self.observatory.post(name: .shakeView, object: nil) } CATransaction.commit() }
额外优化建议
- 无需使用
NotificationCenter串联动画,可通过NSAnimationContext的嵌套回调实现更简洁的流程 - 可使用
CAKeyframeAnimation直接组合位移+旋转的关键帧动画,减少代码复杂度
内容的提问来源于stack exchange,提问作者SouthernYankee65
相关产品推荐
相关产品推荐

