In iOS 18 the same animations inside the main app and a widget behave very differently. This was not the case in iOS 17 and it seems like a regression.
Bellow I described the full source code for an example application to reproduce the issue. But I'll try to describe it as well.
I'm using a simple bounce animation .animation(.bouncy(duration: 0.25, extraBounce: 0.3).delay(delay), value: toggle)
on multiple views with a variable delay to create an effect of staggered motion.
In the main app, the bounce parameters and delays behave exactly as specified, but on the widget they seem to be largely ignored and the timing functions for the animations look very different. It's also visible that on some elements the delay
is ignored completely and animation starts right away.
I double-checked that the whole animation duration never exceeds 2 seconds as I saw that that's a hard cutoff for animations in widgets.
In iOS 17 the same code works identically inside the main app and the widget. The issue is only present in iOS 18.
Another point is that in Xcode previews for widgets animations behave as expected. The issue is only present on the device or in the simulator.
Maybe there is some additional limitation introduced for widget animations? I could not find anything relevant in the documentation, unfortunately, so I'd appreciate any help here.
Please let me know if I can provide any additional information that would help. Thank you!
Example app source code:
ContentView.swift
// ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var toggle: Bool = true
var body: some View {
VStack {
BounceView(toggle: toggle)
Button("Toggle Bounce") { toggle.toggle() }
}
.padding()
}
}
BounceView.swift
import SwiftUI
let ROWS: Int = 5
let COLS: Int = 5
let MAX_DISTANCE: Double = sqrt(pow(Double(COLS - 1), 2) + pow(Double(ROWS - 1), 2))
struct BounceView: View {
let toggle: Bool
var body: some View {
VStack(spacing: 3) {
ForEach(0..<ROWS, id: \.self) { y in
HStack(spacing: 3) {
ForEach(0..<COLS, id: \.self) { i in
let distance: Double = sqrt(Double(y) * Double(y) + Double(i) * Double(i))
let influence = distance / MAX_DISTANCE
let delay: Double = 0.4 * influence
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(width: 14, height: 14)
.scaleEffect(toggle ? 1 : 0.1)
.animation(.bouncy(duration: 0.25, extraBounce: 0.3).delay(delay), value: toggle)
}
}
}
}
}
}
DemoWidget.swift
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), toggle: false)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), toggle: false)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let currentToggle = UserDefaults.standard.bool(forKey: "animationToggle")
let entries: [SimpleEntry] = [SimpleEntry(date: Date(), toggle: currentToggle)]
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let toggle: Bool
}
struct DemoWidget: Widget {
let kind: String = "DemoWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
VStack {
BounceView(toggle: entry.toggle)
Button(
intent: ToggleAppIntent(),
label: { Text("Toggle Bounce") }
)
}
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
#Preview(as: .systemMedium) {
DemoWidget()
} timeline: {
SimpleEntry(date: .now, toggle: true)
SimpleEntry(date: .now, toggle: false)
}
ToggleAppIntent.swift
import Foundation
import WidgetKit
import AppIntents
struct ToggleAppIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle App Intent"
func perform() async throws -> some IntentResult {
let currentToggle = UserDefaults.standard.bool(forKey: "animationToggle")
UserDefaults.standard.set(!currentToggle, forKey: "animationToggle")
WidgetCenter.shared.reloadTimelines(ofKind: "DemoWidget")
return .result()
}
}