Skip to content

Jiggle Edit Modifier

A view modifier that makes views wobble slightly, like app icons in edit mode.

Jiggle Edit Modifier, playing
Download .swift
Origin
Original work
Found by
jiggle · wobble · shake · edit mode · home screen · icons · modifier · temblor · sacudir · vibrar · modo edición · pantalla de inicio · iconos · modificador

Wobbles the view, like an app icon in edit mode.

Give each view a different seed so they do not move in step.

private struct JiggleEditDemoApp: Identifiable {
let id: String
let systemImage: String
let colors: [Color]
}
struct JiggleEditModifierDemo: View {
private static let allApps = [
JiggleEditDemoApp(id: "Mail", systemImage: "envelope.fill", colors: [.cyan, .blue]),
JiggleEditDemoApp(id: "Music", systemImage: "music.note", colors: [.pink, .red]),
JiggleEditDemoApp(id: "Photos", systemImage: "camera.macro", colors: [.yellow, .orange]),
JiggleEditDemoApp(id: "Maps", systemImage: "map.fill", colors: [.mint, .green]),
JiggleEditDemoApp(id: "Notes", systemImage: "note.text", colors: [.yellow, .brown]),
JiggleEditDemoApp(id: "Clock", systemImage: "clock.fill", colors: [.gray, .black]),
JiggleEditDemoApp(id: "Health", systemImage: "heart.fill", colors: [.red, .pink]),
JiggleEditDemoApp(id: "Files", systemImage: "folder.fill", colors: [.blue, .indigo]),
JiggleEditDemoApp(id: "Books", systemImage: "book.fill", colors: [.orange, .red]),
JiggleEditDemoApp(id: "Wallet", systemImage: "wallet.pass.fill", colors: [.indigo, .black]),
JiggleEditDemoApp(id: "Home", systemImage: "house.fill", colors: [.orange, .yellow]),
JiggleEditDemoApp(id: "Weather", systemImage: "cloud.sun.fill", colors: [.teal, .blue]),
]
@State private var apps = Self.allApps
@State private var isEditing = true
@ScaledMetric(relativeTo: .caption) private var iconSize = 66.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var columns: [GridItem] { [GridItem(.adaptive(minimum: iconSize + 10), spacing: 16)] }
private var animation: Animation? { reduceMotion ? nil : .snappy }
var body: some View {
ZStack(alignment: .top) {
LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 28) {
HStack {
Button {
withAnimation(animation) { apps = Self.allApps }
} label: {
touchTarget(Text("Reset"))
}
Spacer()
Button {
isEditing.toggle()
} label: {
touchTarget(Text(isEditing ? "Done" : "Edit"))
}
.fontWeight(.bold)
}
.font(.headline)
.foregroundStyle(.white)
LazyVGrid(columns: columns, spacing: 24) {
ForEach(Array(apps.enumerated()), id: \.element.id) { index, app in
icon(for: app)
// Its place in the full list, so removing an icon does not change the rhythm of the rest.
.jiggleEditing(isEditing, seed: Self.allApps.firstIndex { $0.id == app.id } ?? index)
}
}
}
.padding(20)
}
}
}
/// Widens what answers to a touch around something small, without moving the layout.
private func touchTarget(_ content: some View) -> some View {
content
.padding(12)
.contentShape(.rect)
.padding(-12)
}
private func icon(for app: JiggleEditDemoApp) -> some View {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: iconSize * 0.23, style: .continuous)
.fill(LinearGradient(colors: app.colors, startPoint: .top, endPoint: .bottom))
.frame(width: iconSize, height: iconSize)
.overlay {
Image(systemName: app.systemImage)
.font(.title)
.foregroundStyle(.white)
.accessibilityHidden(true)
}
.overlay(alignment: .topLeading) {
if isEditing {
Button {
withAnimation(animation) { apps.removeAll { $0.id == app.id } }
} label: {
touchTarget(
Image(systemName: "minus.circle.fill")
.font(.title3)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .black.opacity(0.7))
)
}
.accessibilityLabel("Remove \(app.id)")
.offset(x: -8, y: -8)
}
}
Text(app.id)
.font(.caption)
.foregroundStyle(.white)
.lineLimit(1)
}
}
}

jiggleEditing(_:amplitude:seed:)

ParameterTypeDefault
isActiveBoolRequired
amplitudeDouble2.4
seedInt0

Copy this code into your project, or download the file.

JiggleEditModifier.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
extension View {
/// Wobbles the view, like an app icon in edit mode.
///
/// Give each view a different `seed` so they do not move in step.
func jiggleEditing(_ isActive: Bool, amplitude: Double = 2.4, seed: Int = 0) -> some View {
modifier(JiggleEditModifier(isActive: isActive, amplitude: amplitude, seed: seed))
}
}
/// With Reduce Motion on, the view stays still.
struct JiggleEditModifier: ViewModifier {
var isActive: Bool
var amplitude: Double
var seed: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var isJiggling: Bool { isActive && !reduceMotion }
func body(content: Content) -> some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isJiggling)) { context in
let phase = context.date.timeIntervalSinceReferenceDate * 22 + Double(seed) * 2.4
content
.rotationEffect(.degrees(isJiggling ? sin(phase) * amplitude : 0))
.offset(y: isJiggling ? cos(phase * 0.9) * 0.6 : 0)
.animation(.easeOut(duration: 0.2), value: isJiggling)
}
}
}
// MARK: - Demo
private struct JiggleEditDemoApp: Identifiable {
let id: String
let systemImage: String
let colors: [Color]
}
struct JiggleEditModifierDemo: View {
private static let allApps = [
JiggleEditDemoApp(id: "Mail", systemImage: "envelope.fill", colors: [.cyan, .blue]),
JiggleEditDemoApp(id: "Music", systemImage: "music.note", colors: [.pink, .red]),
JiggleEditDemoApp(id: "Photos", systemImage: "camera.macro", colors: [.yellow, .orange]),
JiggleEditDemoApp(id: "Maps", systemImage: "map.fill", colors: [.mint, .green]),
JiggleEditDemoApp(id: "Notes", systemImage: "note.text", colors: [.yellow, .brown]),
JiggleEditDemoApp(id: "Clock", systemImage: "clock.fill", colors: [.gray, .black]),
JiggleEditDemoApp(id: "Health", systemImage: "heart.fill", colors: [.red, .pink]),
JiggleEditDemoApp(id: "Files", systemImage: "folder.fill", colors: [.blue, .indigo]),
JiggleEditDemoApp(id: "Books", systemImage: "book.fill", colors: [.orange, .red]),
JiggleEditDemoApp(id: "Wallet", systemImage: "wallet.pass.fill", colors: [.indigo, .black]),
JiggleEditDemoApp(id: "Home", systemImage: "house.fill", colors: [.orange, .yellow]),
JiggleEditDemoApp(id: "Weather", systemImage: "cloud.sun.fill", colors: [.teal, .blue]),
]
@State private var apps = Self.allApps
@State private var isEditing = true
@ScaledMetric(relativeTo: .caption) private var iconSize = 66.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var columns: [GridItem] { [GridItem(.adaptive(minimum: iconSize + 10), spacing: 16)] }
private var animation: Animation? { reduceMotion ? nil : .snappy }
var body: some View {
ZStack(alignment: .top) {
LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 28) {
HStack {
Button {
withAnimation(animation) { apps = Self.allApps }
} label: {
touchTarget(Text("Reset"))
}
Spacer()
Button {
isEditing.toggle()
} label: {
touchTarget(Text(isEditing ? "Done" : "Edit"))
}
.fontWeight(.bold)
}
.font(.headline)
.foregroundStyle(.white)
LazyVGrid(columns: columns, spacing: 24) {
ForEach(Array(apps.enumerated()), id: \.element.id) { index, app in
icon(for: app)
// Its place in the full list, so removing an icon does not change the rhythm of the rest.
.jiggleEditing(isEditing, seed: Self.allApps.firstIndex { $0.id == app.id } ?? index)
}
}
}
.padding(20)
}
}
}
/// Widens what answers to a touch around something small, without moving the layout.
private func touchTarget(_ content: some View) -> some View {
content
.padding(12)
.contentShape(.rect)
.padding(-12)
}
private func icon(for app: JiggleEditDemoApp) -> some View {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: iconSize * 0.23, style: .continuous)
.fill(LinearGradient(colors: app.colors, startPoint: .top, endPoint: .bottom))
.frame(width: iconSize, height: iconSize)
.overlay {
Image(systemName: app.systemImage)
.font(.title)
.foregroundStyle(.white)
.accessibilityHidden(true)
}
.overlay(alignment: .topLeading) {
if isEditing {
Button {
withAnimation(animation) { apps.removeAll { $0.id == app.id } }
} label: {
touchTarget(
Image(systemName: "minus.circle.fill")
.font(.title3)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .black.opacity(0.7))
)
}
.accessibilityLabel("Remove \(app.id)")
.offset(x: -8, y: -8)
}
}
Text(app.id)
.font(.caption)
.foregroundStyle(.white)
.lineLimit(1)
}
}
}
#Preview {
JiggleEditModifierDemo()
}