Skip to content

Instantly share code, notes, and snippets.

@adam-zethraeus
Created October 12, 2025 09:18
Show Gist options
  • Select an option

  • Save adam-zethraeus/33343bc008b3e43f1f17ea28fd5a2432 to your computer and use it in GitHub Desktop.

Select an option

Save adam-zethraeus/33343bc008b3e43f1f17ea28fd5a2432 to your computer and use it in GitHub Desktop.
SwiftUI onShake operator for iOS (devices) and macCatalyst (windows).
import SwiftUI
extension View {
/// A view modifier which triggers `action` when the iOS device is shaken or the mac catalyst window is shaken.
public func onShake(perform action: @escaping () -> Void) -> some View {
#if targetEnvironment(macCatalyst)
self.modifier(WindowShakeModifier(action: action))
#else
self.modifier(OnShakeModifier(action: action))
#endif
}
}
#if targetEnvironment(macCatalyst)
private struct WindowShakeModifier: ViewModifier {
init(action: @escaping () -> Void) {
_windowObserver = .init(wrappedValue: WindowSceneGeometryObserver(action: action))
}
@State private var windowObserver: WindowSceneGeometryObserver
func body(content: Content) -> some View {
content
.background {
WindowReader { window in
if let windowScene = window?.windowScene {
windowObserver.setupObserver(for: windowScene)
}
}
}
}
}
@MainActor
private final class WindowSceneGeometryObserver {
init(action: @escaping () -> Void) {
self.action = action
}
let action: () -> Void
var observation: NSKeyValueObservation? = nil
var origins: [ObjectIdentifier: [(point: CGPoint, date: Date)]] = [:]
var lastShake: Date = Date.distantPast
func setupObserver(for windowScene: UIWindowScene) {
observation = windowScene.observe(\.effectiveGeometry, options: .new) {
[weak self] (scene, change) in
Task { @MainActor in
guard let self else { return }
// Catalyst UIWindows always report a frame origin of (0,0) — so hacks are required.
// We use the objc runtime to acquire our underlying NSWindows and their frame.
for window in scene.windows {
var targetNSWindow: AnyObject? = nil
let nsWindows =
(NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication.windows")
as? [AnyObject])!
for nsWindow in nsWindows {
let uiWindows = nsWindow.value(forKeyPath: "uiWindows") as? [UIWindow] ?? []
if uiWindows.contains(window) {
targetNSWindow = nsWindow
}
}
let now = Date.now
if let found = targetNSWindow {
let id = ObjectIdentifier(found)
if let realFrame = found.value(forKeyPath: "_frame") as? CGRect {
// Store our real AppKit frame origin for this window.
self.origins[id] = self.origins[id] ?? []
self.origins[id]?.append((point: realFrame.origin, date: now))
}
}
// remove tracked origins older than 1 second.
for key in self.origins.keys {
self.origins[key] = self.origins[key]?.filter { now.timeIntervalSince($0.date) < 1.0 }
}
for origins in self.origins.values {
// if a window was not recently shaken, check if this window's pattern is a shake.
if now.timeIntervalSince(self.lastShake) > 2.0
&& self.windowWasShaken(origins: origins)
{
// mark the shake so we don't immediately fire another.
self.lastShake = now
// do the action
self.action()
}
}
}
}
}
}
func windowWasShaken(origins: [(point: CGPoint, date: Date)]) -> Bool {
guard origins.count > 3 else { return false }
// Compute successive deltas
var directionChanges = 0
var lastDeltaX: CGFloat = 0
var lastDeltaY: CGFloat = 0
for i in 1..<origins.count {
let dx = origins[i].point.x - origins[i - 1].point.x
let dy = origins[i].point.y - origins[i - 1].point.y
let dist = hypot(dx, dy)
// ignore tiny movements as noise
guard dist > 2 else { continue }
// detect direction flips
if dx.sign != lastDeltaX.sign && abs(dx) > 3 { directionChanges += 1 }
if dy.sign != lastDeltaY.sign && abs(dy) > 3 { directionChanges += 1 }
lastDeltaX = dx
lastDeltaY = dy
}
// Heuristic: 5+ flips in <1s likely indicates a shake
return directionChanges > 5
}
}
import UIKit
import SwiftUI
private struct WindowReader: UIViewRepresentable {
let handler: (UIWindow?) -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.isHidden = true
DispatchQueue.main.async {
handler(view.window)
}
return view
}
func updateUIView(_ nsView: UIView, context: Context) {
DispatchQueue.main.async {
handler(nsView.window)
}
}
}
#elseif canImport(UIKit)
@MainActor
private final class ShakeStream {
init() {
let (stream, cont) = AsyncStream<Void>.makeStream(
bufferingPolicy: .bufferingNewest(0)
)
self.stream = stream
self.cont = cont
}
let stream: AsyncStream<Void>
let cont: AsyncStream<Void>.Continuation
var task: Task<Void, Never>?
static let shared = ShakeStream()
func startIfNeeded() {
if task == nil {
task = Task {
for await _ in stream {
continuations.forEach { idc in
idc.cont.yield()
}
}
}
}
}
struct IDCont: Identifiable {
let id: UUID = .init()
let cont: AsyncStream<Void>.Continuation
}
var continuations: [IDCont] = []
func getStream() -> AsyncStream<Void> {
let (proxyStream, proxyContinuation) = AsyncStream<Void>.makeStream(
bufferingPolicy: .bufferingNewest(0)
)
let idcont = IDCont(cont: proxyContinuation)
continuations.append(idcont)
proxyContinuation.onTermination = { _ in
Task { @MainActor in
self.continuations.removeAll { c in
c.id == idcont.id
}
}
}
startIfNeeded()
return proxyStream
}
func send() {
cont.yield()
}
}
extension UIWindow {
open override func motionEnded(_ motion: UIEvent.EventSubtype, with _: UIEvent?) {
if motion == .motionShake {
ShakeStream.shared.send()
}
}
}
private struct OnShakeModifier: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content
.task {
for await _ in ShakeStream.shared.getStream() {
action()
}
}
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment