Created
October 12, 2025 09:18
-
-
Save adam-zethraeus/33343bc008b3e43f1f17ea28fd5a2432 to your computer and use it in GitHub Desktop.
SwiftUI onShake operator for iOS (devices) and macCatalyst (windows).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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