Interactive Widget (iOS 17) reload timeline on interaction

Hello everyone 👋

I'm currently implementing the new interactive widget for iOS 17 but there is a behaviour I don't understand.

My Goal :

The widget I want to create is pretty simple : I have a Store where I can find a list of MyObject, and in the widget I want to display 2 items randomly every X hours. For each item, there is a button to like/unlike the item.

The Issue :

When I tap the like button, the function func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) of the widget is called again and so, two items are get randomly (so the items I liked disappear).

What I want :

When two items are got randomly and I tap a like button, I want my item isLiked property to be toggled without refreshing my timeline.

My current code :

(The code is simplified for the demo, but if you copy/paste all of this code in a new widget, it should compile and run)

MyObject

final class MyObject {
    var isLiked: Bool
    let name: String
    
    init(isLiked: Bool, name: String) {
        self.isLiked = isLiked
        self.name = name
    }
}

MyStore


final class MyStore {
    static let shared: MyStore = .init()
    private init() { }
    
    var myObjects: [MyObject] = [
        .init(isLiked: false, name: "Test 1"),
        .init(isLiked: true, name: "Test 2"),
        .init(isLiked: false, name: "Test 3"),
        .init(isLiked: false, name: "Test 4"),
        .init(isLiked: false, name: "Test 5"),
        .init(isLiked: true, name: "Test 6"),
        .init(isLiked: false, name: "Test 7"),
        .init(isLiked: false, name: "Test 8"),
        .init(isLiked: false, name: "Test 9"),
        .init(isLiked: true, name: "Test 10"),
        .init(isLiked: false, name: "Test 11"),
        .init(isLiked: false, name: "Test 12"),
        .init(isLiked: true, name: "Test 13"),
        .init(isLiked: false, name: "Test 14"),
    ]
    
    func getRandom(_ number: Int) -> [MyObject] {
        guard !myObjects.isEmpty else { return [] }
        
        var random: [MyObject] = []
        for _ in 0 ... number - 1 {
            let randomIndex: Int = Int.random(in: 0...myObjects.count - 1)
            random.append(myObjects[randomIndex])
        }
        return random
    }
}

My action intent

import AppIntents

struct AddOrRemoveFromFavoriteAppIntent: AppIntent {
    static let title: LocalizedStringResource = "My title"
    static let description: IntentDescription = .init("My description")
    
    @Parameter(title: "name")
    var name: String
    
    init() { }
    
    init(name: String) {
        self.name = name
    }
    
    func perform() async throws -> some IntentResult {
        MyStore.shared.myObjects.first(where: { $0.name == name })?.isLiked.toggle()
        return .result()
    }
}

My widget

struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

My widget view

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        if entry.objects.count >= 2 {
            HStack {
                HStack {
                    Text(entry.objects[0].name)
                    Button(intent: AddOrRemoveFromFavoriteAppIntent(name: entry.objects[0].name)) {
                        Text(entry.objects[0].isLiked ? "Unlike" : "Like")
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.red)
                
                HStack {
                    Text(entry.objects[1].name)
                    Button(intent: AddOrRemoveFromFavoriteAppIntent(name: entry.objects[1].name)) {
                        Text(entry.objects[1].isLiked ? "Unlike" : "Like")
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.green)
            }
        } else {
            Text("No data")
        }
    }
}

My widget model

struct SimpleEntry: TimelineEntry {
    let date: Date
    let objects: [MyObject]
}

My widget provider

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), objects: [])
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), objects: [])
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, objects: MyStore.shared.getRandom(2))
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

What am I missing ? Are widgets not designed for this use case ?

Thanks 🙏

Alexandre

Answered by Jordan in 763032022

It is intentional behavior for the widget to reload upon performing an AppIntent. This is what enables the system to get a new timeline entry and animate between the old and new entries. Your code is not executing in the widget except to get a static “snapshot” and then the widget process may terminate, so the state is not being saved in memory to be able to modify upon tapping the button.

With that being the case perhaps you can think of another way to achieve the desired behavior. For example perhaps you can persist the last liked item identifier in UserDefaults and show that instead of a random item. Probably need more than that to achieve what you have in mind but should point you in the right direction to persist to disk instead of relying on in-memory state.

Accepted Answer

It is intentional behavior for the widget to reload upon performing an AppIntent. This is what enables the system to get a new timeline entry and animate between the old and new entries. Your code is not executing in the widget except to get a static “snapshot” and then the widget process may terminate, so the state is not being saved in memory to be able to modify upon tapping the button.

With that being the case perhaps you can think of another way to achieve the desired behavior. For example perhaps you can persist the last liked item identifier in UserDefaults and show that instead of a random item. Probably need more than that to achieve what you have in mind but should point you in the right direction to persist to disk instead of relying on in-memory state.

Hello @Jordan,

Mmh, I'll try to think my feature differently. Maybe I need a random part only when the user doesn't interact, and for other case (like when he likes an item), I need to save items I want to display next like you said.

Thanks for your response,

Alexandre

An 'easy' fix for this would be to base your seed (used by getRandom) on the current hour (more precisely, the time when your widget is not being refreshed).

That way, if your timeline is refreshed during the period when your displayed data should be the same, then your randomly returned object will always be the same.

Interactive Widget (iOS 17) reload timeline on interaction
 
 
Q