You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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。

修正步骤

  1. 在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
}
  1. 修改旋转动画方法,移除重复设置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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.18 14:40:50