This week I set myself the goal of getting home screen widgets working in Habitat. I hadn’t touched the codebase in over a month since I’ve had to deal with the busiest semester of my MSc course. Before I went on a mini-hiatus, I added a Bubble Grid widget which simply displays all of your habits in a grid of Habitat-style bubbles.

Since my app uses SwiftData for storing habit data, the Bubble Grid had to query the database somehow. After a little research, it turns out this is actually very easy.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import SwiftData
import SwiftUI

struct BubbleGridWidgetEntryView : View {
    var entry: WidgetEntry
    @Query var habits: [Habit]

    var body: some View {
        /* ... */
    }
}

All it takes is a query in the widget entry view and; et voilà! Everything just works. Great.

Now, this is where I stopped for the Bubble Grid, since all I wanted to do was display a list of habits. My next widget idea, the Habit Heatmap, relies on a widget configuration intent to let the user pick whichever habit they want to display a heatmap for (per widget).

It’s here that I ran into problems. The issue with both SwiftData and WidgetKit being so new is that there isn’t much support out there for niche problems. And sometimes Apple’s guides aren’t fully updated, or only partially help.

I set up a new widget with a configuration app intent skeleton that looked something like this. (Note that the HabitEntity struct doesn’t exist yet, but will be created in a later step):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import WidgetKit
import AppIntents

struct HabitConfigurationIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Streak Heatmap"
    static var description = IntentDescription("Shows a history of your habit completions.")

    @Parameter(title: "Habit", description: "The habit you want to display a heatmap for")
    var selectedHabit: HabitEntity?
    
    init() {
        self.selectedHabit = nil
    }
}

Now I needed some way to change this selectedHabit to something representing an actual Habit. Apple’s documentation on custom intents was a useful starting point, and I ended up with this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Foundation
import AppIntents

struct HabitEntity: AppEntity, Codable {
    static var defaultQuery: HabitQuery = HabitQuery()
    
    var id: UUID // id matching one of the habits
    var name: String
    var emoji: String

    static var typeDisplayRepresentation = TypeDisplayRepresentation(stringLiteral: "Habit")
    
    var displayRepresentation: DisplayRepresentation { 
        DisplayRepresentation(title: "\(emoji) \(name)")
    }
}

struct HabitQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [HabitEntity] {
        // ???
    }
    func suggestedEntities() async throws -> [HabitEntity] {
        // ???
    }
}

So now I have a way to select something that represents a habit, and a way to search for that habit via the HabitEntity.id. The part that I was left scratching my head with was the HabitQuery entities functions which are supposed to return a list of HabitEntity. Since you can’t use SwiftData queries outside of SwiftUI views, I thought why not try a raw SwiftData fetch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@MainActor func getHabitEntities() -> [HabitEntity] {
    let fetchDescriptor = FetchDescriptor<Habit>()
    do {
        let modelContainer = try ModelContainer(for: Habit.self)
        let habits = try modelContainer.mainContext.fetch(fetchDescriptor)
        return habits.map { habit in
            HabitEntity(id: habit.id, name: habit.name, emoji: habit.emoji)
        }
    } catch {
        print("Failed to load Habit model.")
    }
    return []
}

Computer says no. The solution? UserDefaults!

The eagle-eyed among you may have noticed HabitEntity conforms to Codable, allowing it to be encoded and decoded from JSON. With this in mind, it’s fairly simple to make a class that manages loading and updating a list of HabitEntity stored in UserDefaults, you just have to be mindful of a couple of things:

  1. You must add the AppGroup capability to both your main app and widget. This is to allow sharing UserDefaults values.
  2. Sharing UserDefaults requires a custom suite, since UserDefaults.standard uses the suite corresponding to the target’s bundle identifier.

Lets deal with the custom UserDefaults suite. Make a file called UserDefaults-shared.swift or something similar and paste the following:

1
2
3
4
5
import Foundation

extension UserDefaults {
    static var shared: UserDefaults = UserDefaults(suiteName: "group.net.mokisoft.habitat")!
}

Now any time you want to access the shared UserDefaults you can use UserDefaults.shared in place of .standard.

At this point, we need a way to save and load the list of HabitEntity. I decided to go with a singleton struct called HabitEntityData:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import Foundation
import OSLog

struct HabitEntityData {
    static var shared = HabitEntityData()
    let HABIT_ENTITY_DATA = "HABIT_ENTITY_DATA"
    
    private init() {
    }

    func habits(for identifiers: [UUID] = []) -> [HabitEntity] {
        if let entityData = UserDefaults.shared.data(forKey: HABIT_ENTITY_DATA) {
            if let entities = try? JSONDecoder().decode([HabitEntity].self, from: entityData) {
                return entities
            }
        }
        return []
    }
    
    func update(_ habits: [Habit]) {
        let entities: [HabitEntity] = habits.map {
            HabitEntity(id: $0.id, name: $0.name, emoji: $0.emoji)
        }

        if let entityData = try? JSONEncoder().encode(entities) {
            UserDefaults.shared.set(entityData, forKey: HABIT_ENTITY_DATA)
        }
    }
}

Now we can go back to the HabitQuery from before and fill it in with our new HabitEntityData methods:

1
2
3
4
5
6
7
8
struct HabitQuery: EntityQuery {
    func entities(for identifiers: [UUID]) async throws -> [HabitEntity] {
        HabitEntityData.shared.habits(for: identifiers)
    }
    func suggestedEntities() async throws -> [HabitEntity] {
        HabitEntityData.shared.habits()
    }
}

I defined a function for updating the HabitEntity list, but we need an appropriate place to call this so that the user always has an up-to-date list of their habits when selecting one in the widget configuration. To do that, I decided to observe changes in the screenPhase environment to make the HabitEntity list update every time the app goes to the background (i.e. is exited):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct ContentView: View {
    @Environment(\.scenePhase) var scenePhase
    @Query var habits: [Habit]

    var body: some View {
        VStack { 
            /* ... */ 
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .background {
                HabitEntityData.shared.update(habits)
            }
        }
    }
}

Finally, all that’s needed is a computed property in the widget entry view so we can find the Habit object with the corresponding id from the SwiftData query:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
struct HeatmapWidgetEntryView: View {
    var entry: Provider.Entry
    @Query var habits: [Habit]

    var habit: Habit? {
        habits.first(where: {
            $0.id == entry.configuration.selectedHabit?.id
        })
    }
    
    var body: some View {
        if let habit {
            /* Display the habit... */
        } else {
            /* No habit selected, tell user to select one */
        }
    }
}

Conclusion

This is a pretty hacky solution. Considering that both SwiftData and WidgetKit are so new, I expected some integration that would make this slightly easier, but at least it doesn’t require any gnarly Objective-C code.

I hope this helped someone going through the same issue!