iOS 18 seems to have broken animations in Widgets

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

Our engineering teams need to investigate this issue, as resolution may involve changes to Apple's software. I'd greatly appreciate it if you could open a bug report, include sample project and post the FB number here once you do. Bug Reporting: How and Why? has tips on creating your bug report.

Thank you for your response!

Here is the FB number: FB14760003. It has an example project attached as well as video recordings highlighting the issue.

Please let me know if I can provide any other information from my side.

Same problems here. Animation seems broken in Widget of iOS 18.

In iOS 18, the func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) method of TimelineProvider is being called twice, which is causing an issue.

However, in iOS 17, it's calling only once.

iOS 18 seems to have broken animations in Widgets
 
 
Q