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
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.