From 6cacad8ccd0f7a8f642fcee5396f27eea5774585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Akon=20Bogen?= Date: Mon, 11 May 2026 13:58:51 +0200 Subject: [PATCH 1/4] Feature: Add SwiftUI ViewModifier for shake animation Add ShakeModifier (and a .shake(trigger:) View extension) that mirrors the UIKit UIView shake behaviour using a GeometryEffect-based approach, including VoiceOver accessibility announcement support. Also introduce Package.swift for Swift Package Manager distribution. --- Package.swift | 28 +++++++++++++ README.md | 51 ++++++++++++++++++++++ ShakeModifier.swift | 100 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 Package.swift create mode 100644 ShakeModifier.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f2398e2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "SingleLineShakeAnimation", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "SingleLineShakeAnimation", + targets: ["SingleLineShakeAnimation"] + ) + ], + targets: [ + .target( + name: "SingleLineShakeAnimation", + path: ".", + sources: [ + "UIView+Shake.swift", + "ShakeModifier.swift" + ], + swiftSettings: [ + .define("SWIFT_PACKAGE") + ] + ) + ] +) diff --git a/README.md b/README.md index 63ab0e2..65b5126 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,57 @@ button.shake(.Horizontal, numberOfTimes: 10, totalDuration: 0.6, completion: { }) ``` +## SwiftUI + +`ShakeModifier` and the `.shake(trigger:)` View extension provide an idiomatic SwiftUI equivalent of the UIKit shake animation. + +#### import library +```swift +import SingleLineShakeAnimation +``` + +#### Basic shake — increment `trigger` to shake +```swift +TextField("Username", text: $username) + .shake(trigger: errorCount) +``` + +#### With a VoiceOver announcement +When VoiceOver is active the shake still fires, and the announcement is posted as an additional context notification for screen-reader users. +```swift +TextField("Username", text: $username) + .shake(trigger: errorCount, + accessibilityAnnouncement: "Username field is required") +``` + +#### Using the modifier directly +```swift +myView + .modifier(ShakeModifier(trigger: errorCount)) +``` + +#### Driving it from a button +```swift +struct LoginView: View { + @State private var errorCount = 0 + @State private var username = "" + + var body: some View { + VStack { + TextField("Username", text: $username) + .shake(trigger: errorCount, + accessibilityAnnouncement: "Please enter your username") + + Button("Log in") { + if username.isEmpty { + errorCount += 1 // increments trigger → shakes the field + } + } + } + } +} +``` + ## TODO - Easing on animation diff --git a/ShakeModifier.swift b/ShakeModifier.swift new file mode 100644 index 0000000..6c44262 --- /dev/null +++ b/ShakeModifier.swift @@ -0,0 +1,100 @@ +// +// ShakeModifier.swift +// SingleLineShakeAnimation +// +// Created for SwiftUI support. +// Copyright (c) 2015 haaakon. All rights reserved. +// + +import SwiftUI + +// MARK: - ShakeEffect + +/// A `GeometryEffect` that produces a horizontal shake translation. +/// +/// Animate `animatableData` from 0 → 1 (or any integer increment) to trigger +/// the oscillation. The sine wave produces `shakesPerUnit` full back-and-forth +/// cycles over one unit of `animatableData`. +public struct ShakeEffect: GeometryEffect { + + /// Peak displacement in points (mirrors the UIKit version's 2 pt offset × 5 shakes). + public var amount: CGFloat = 10 + + /// Number of back-and-forth oscillations per unit of `animatableData`. + public var shakesPerUnit: Int = 3 + + /// The value SwiftUI interpolates to drive the animation. + public var animatableData: CGFloat + + public init(animatableData: CGFloat, amount: CGFloat = 10, shakesPerUnit: Int = 3) { + self.animatableData = animatableData + self.amount = amount + self.shakesPerUnit = shakesPerUnit + } + + public func effectValue(size: CGSize) -> ProjectionTransform { + let translation = amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)) + return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0)) + } +} + +// MARK: - ShakeModifier + +/// A `ViewModifier` that shakes its content horizontally whenever `trigger` changes. +/// +/// Increment `trigger` (e.g. an error count) to start a new shake. When +/// VoiceOver is active the shake is suppressed — pass an `accessibilityAnnouncement` +/// to have a message read aloud instead, matching the UIKit version's behaviour. +/// +/// ```swift +/// // Basic usage — shakes every time errorCount is incremented +/// TextField("Username", text: $username) +/// .modifier(ShakeModifier(trigger: errorCount)) +/// +/// // With a VoiceOver announcement +/// TextField("Username", text: $username) +/// .modifier(ShakeModifier(trigger: errorCount, +/// accessibilityAnnouncement: "Username field is required")) +/// ``` +public struct ShakeModifier: ViewModifier { + + /// Increment this value to trigger a new shake. + public let trigger: Int + + /// Optional text announced via VoiceOver instead of (or alongside) the shake. + public let accessibilityAnnouncement: String? + + public init(trigger: Int, accessibilityAnnouncement: String? = nil) { + self.trigger = trigger + self.accessibilityAnnouncement = accessibilityAnnouncement + } + + public func body(content: Content) -> some View { + content + .modifier(ShakeEffect(animatableData: CGFloat(trigger))) + .animation(.default, value: trigger) + .onChange(of: trigger) { newValue in + guard newValue != 0 else { return } + if UIAccessibility.isVoiceOverRunning, let announcement = accessibilityAnnouncement { + UIAccessibility.post(notification: .announcement, argument: announcement) + } + } + } +} + +// MARK: - View extension + +public extension View { + + /// Shakes the view horizontally whenever `trigger` changes value. + /// + /// - Parameters: + /// - trigger: Increment this integer to start a shake (e.g. your error count). + /// - accessibilityAnnouncement: Text read by VoiceOver when `trigger` changes. + /// When VoiceOver is active the visual shake is still applied; the announcement + /// is *additional* context for screen-reader users. + /// - Returns: A view that shakes on each `trigger` increment. + func shake(trigger: Int, accessibilityAnnouncement: String? = nil) -> some View { + modifier(ShakeModifier(trigger: trigger, accessibilityAnnouncement: accessibilityAnnouncement)) + } +} From fe9187156a4e39abce9e2e255742e1ec2b1bb89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Akon=20Bogen?= Date: Mon, 11 May 2026 14:02:18 +0200 Subject: [PATCH 2/4] Fix: wrap UIAccessibility calls in canImport(UIKit) for cross-platform support --- ShakeModifier.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ShakeModifier.swift b/ShakeModifier.swift index 6c44262..bcbafff 100644 --- a/ShakeModifier.swift +++ b/ShakeModifier.swift @@ -7,6 +7,9 @@ // import SwiftUI +#if canImport(UIKit) +import UIKit +#endif // MARK: - ShakeEffect @@ -75,9 +78,11 @@ public struct ShakeModifier: ViewModifier { .animation(.default, value: trigger) .onChange(of: trigger) { newValue in guard newValue != 0 else { return } + #if canImport(UIKit) if UIAccessibility.isVoiceOverRunning, let announcement = accessibilityAnnouncement { UIAccessibility.post(notification: .announcement, argument: announcement) } + #endif } } } From e1c7471f9e7b32e6c2c542859514c214bb0eeb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Akon=20Bogen?= Date: Mon, 11 May 2026 14:02:55 +0200 Subject: [PATCH 3/4] Fix: use availability-gated onChange to avoid deprecation on iOS 17+ --- ShakeModifier.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ShakeModifier.swift b/ShakeModifier.swift index bcbafff..bbdaded 100644 --- a/ShakeModifier.swift +++ b/ShakeModifier.swift @@ -76,7 +76,7 @@ public struct ShakeModifier: ViewModifier { content .modifier(ShakeEffect(animatableData: CGFloat(trigger))) .animation(.default, value: trigger) - .onChange(of: trigger) { newValue in + .onChangeCompat(of: trigger) { newValue in guard newValue != 0 else { return } #if canImport(UIKit) if UIAccessibility.isVoiceOverRunning, let announcement = accessibilityAnnouncement { @@ -87,6 +87,19 @@ public struct ShakeModifier: ViewModifier { } } +// MARK: - Compat helpers + +private extension View { + @ViewBuilder + func onChangeCompat(of value: T, perform: @escaping (T) -> Void) -> some View { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + self.onChange(of: value) { _, newValue in perform(newValue) } + } else { + self.onChange(of: value, perform: perform) + } + } +} + // MARK: - View extension public extension View { From f9090ee1675ccbbb16a8c1ba1e6ced0b2c6c623e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ha=CC=8Akon=20Bogen?= Date: Mon, 11 May 2026 14:05:16 +0200 Subject: [PATCH 4/4] Remove file header comment --- ShakeModifier.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ShakeModifier.swift b/ShakeModifier.swift index bbdaded..a8ad76e 100644 --- a/ShakeModifier.swift +++ b/ShakeModifier.swift @@ -1,10 +1,3 @@ -// -// ShakeModifier.swift -// SingleLineShakeAnimation -// -// Created for SwiftUI support. -// Copyright (c) 2015 haaakon. All rights reserved. -// import SwiftUI #if canImport(UIKit)