Skip to content

Instantly share code, notes, and snippets.

@randomor
Created January 23, 2025 04:20
Show Gist options
  • Save randomor/266759a6ec48b61cbbace9f4df570cea to your computer and use it in GitHub Desktop.
Save randomor/266759a6ec48b61cbbace9f4df570cea to your computer and use it in GitHub Desktop.
Animating Menubar Icon
import SwiftUI
import AppKit
@main
struct AnimatedSVGMenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ControlPanelView()
.environmentObject(appDelegate) // Pass the AppDelegate to the view
}
.windowStyle(HiddenTitleBarWindowStyle())
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var statusItem: NSStatusItem? // Make it optional and observable
@Published var rotationAngle: CGFloat = .pi / 4 // Start at 45 degrees (X shape)
private var timer: Timer?
private var targetAngle: CGFloat = .pi / 4
func applicationDidFinishLaunching(_ notification: Notification) {
// Create the status bar item
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
statusItem = item
updateIcon()
}
func updateIcon() {
guard let button = statusItem?.button else { return }
button.image = renderRotatedSVG(angle: rotationAngle)
}
func renderRotatedSVG(angle: CGFloat) -> NSImage? {
let size = NSSize(width: 20, height: 20) // Icon size
let image = NSImage(size: size)
image.lockFocus()
guard let context = NSGraphicsContext.current?.cgContext else { return nil }
// Clear the background
context.clear(CGRect(origin: .zero, size: size))
// Apply rotation
context.translateBy(x: size.width / 2, y: size.height / 2)
context.rotate(by: angle)
context.translateBy(x: -size.width / 2, y: -size.height / 2)
// Draw the cross
context.setStrokeColor(NSColor.white.cgColor)
context.setLineWidth(1.4) // Thin line
let rect1 = CGRect(x: 2.5, y: 7.5, width: 15, height: 5) // Adjusted width and height
let rect2 = CGRect(x: 7.5, y: 2.5, width: 5, height: 15)
let path1 = CGPath(roundedRect: rect1, cornerWidth: 2, cornerHeight: 2, transform: nil)
let path2 = CGPath(roundedRect: rect2, cornerWidth: 2, cornerHeight: 2, transform: nil)
context.addPath(path1)
context.addPath(path2)
context.strokePath()
image.unlockFocus()
return image
}
func startRotationAnimation() {
guard timer == nil else { return } // Prevent multiple timers
// Update target angle
targetAngle = normalizeAngle(targetAngle + .pi / 2)
timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in
guard let self = self else { return }
// Normalize current angle
self.rotationAngle = self.normalizeAngle(self.rotationAngle)
if abs(self.rotationAngle - self.targetAngle) < 0.01 {
self.rotationAngle = self.targetAngle
self.updateIcon()
self.timer?.invalidate()
self.timer = nil
} else {
let step: CGFloat = .pi / 60 // Smooth incremental rotation
if self.shouldRotateClockwise(from: self.rotationAngle, to: self.targetAngle) {
self.rotationAngle += step
} else {
self.rotationAngle -= step
}
self.updateIcon()
}
}
}
private func normalizeAngle(_ angle: CGFloat) -> CGFloat {
var normalized = angle.truncatingRemainder(dividingBy: 2 * .pi)
if normalized < 0 {
normalized += 2 * .pi
}
return normalized
}
private func shouldRotateClockwise(from current: CGFloat, to target: CGFloat) -> Bool {
let delta = normalizeAngle(target - current)
return delta <= .pi // Rotate clockwise if delta is less than or equal to 180 degrees
}
}
struct ControlPanelView: View {
@EnvironmentObject var appDelegate: AppDelegate // Access the AppDelegate object
var body: some View {
VStack(spacing: 16) {
Button("Rotate 90°") {
appDelegate.startRotationAnimation()
}
.padding()
.buttonStyle(DefaultButtonStyle())
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.padding()
.buttonStyle(DefaultButtonStyle())
}
.frame(width: 200, height: 100)
.background(Color(NSColor.windowBackgroundColor))
.cornerRadius(12)
.padding()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment