Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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")
]
)
]
)
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 111 additions & 0 deletions ShakeModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif

// 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)
.onChangeCompat(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
}
}
}

// MARK: - Compat helpers

private extension View {
@ViewBuilder
func onChangeCompat<T: Equatable>(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 {

/// 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))
}
}