Daily streaks are a great way to boost engagement and help to keep users coming back to your app. If you’re unsure of what a streak is, think of apps such as Duolingo. They track how many days in a row a user has opened your app or completed an action such as finishing a quiz. There’s something about watching the number go up every day and trying not to break the streak that really helps with user retention.
I have a science revision app on the app store so naturally the next feature I wanted to implement was a daily streak system. Although it seems easy at first, just increment and save a number every day, it was important I designed and tested the system carefully and correctly to avoid any accidental lost streaks. Also working with and unit testing dates is always a bit of a nightmare.
I’ve opened sourced the code written for my application and this post in a neat little library that you can find here.
Requirements
To get started, lets define some really clear requirements. I initially got caught up in how to continue a streak; would the cutoff be 24 hours after the last completed action? How about a sliding window? Here’s what worked for me:
- A streak initially starts at zero days.
- Streak data must be persisted.
- To continue a streak, the user has to complete an action at any point in the next day (between 00:00 and 23:59). Completing an action at 23:59 and then another one at 00:01 the next day will successfully continue the streak.
- If an entire next day passes, the streak is broken and reset (back to 1 if the user has completed an action or back to 0 if they are just viewing their streak).
The Data Model
The data model should be pretty easy. We need to track the number of days as well as the last date the user continued their streak. We’ll also make it conform to Codable so parsing and storing is super easy. We’ll also give some sensible defaults to the properties.
struct Streak: Codable {
var length: Int = 0
var lastDate: Date?
}
Persistence
Because our data model is so simple and conforms to Codable
, we can just slap our data anywhere and call it a day. I will be using UserDefaults in this example however my library provides a few different options.
Side note: I read a great post by Christian Sellig on some of the shortcomings of UserDefaults that I would recommend.
Back to our streaks, I want to make this nice and testable so I will make a persistence protocol and make UserDefaults conform to it. You’ll see later but this will make it nice and easy for us to test what happens when data cannot be fetched etc.
protocol StreakPersistence {
func getData() -> Data?
func save(data: Data) throws
}
extension UserDefaults: StreakPersistence {
func getData() -> Data? {
data(forKey: "Streak")
}
func save(data: Data) throws {
set(data, forKey: "Streak")
}
}
StreakManager
We can now make a manager class that will encapsulate all our streak logic and that will eventually be injected into the SwiftUI environment. I’m going to use the old ObservableObject
protocol because I want to support back to iOS 15 but feel free to use the newer Observation framework.
To get started we can use a bit of the old dependency injection with our persistence protocol. We will give the initialiser a default value too to make creating the manager nice and easy from the app side.
final class StreakManager: ObservableObject {
private let persistence: StreakPersistence
init(persistence: StreakPersistance = UserDefaults.standard) {
self.persistence = persistence
}
}
Loading and Saving
We can now load and save streak data. Because our Streak
model conforms to Codable
, this is super easy. I’ve even gone further and injected instances of the JSON encoders and decoders into the methods to make unit testing even more flexible.
func loadStreak(decoder: JSONDecoder = .init()) -> Streak {
guard let data = persistence.getData() else {
return Streak()
}
do {
let fetched = try decoder.decode(Streak.self, from: data)
return fetched
} catch {
print("Failed to decode streak. Error: \(error.localizedDescription)")
return Streak()
}
}
func save(streak: Streak, encoder: JSONEncoder = .init()) {
guard let encoded = try? encoder.encode(streak) else {
print("Failed to encode current streak")
return
}
do {
try persistence.save(data: encoded)
} catch {
print("Error saving streak: \(error)")
}
}
We will also make a currentStreak
property on our manager and load it when the class is initialised.
@Published
var currentStreak: Streak = Streak()
init(persistence: StreakPersistence = UserDefaults.standard) {
self.persistence = persistence
self.currentStreak = loadStreak()
}
We can now test what happens in various scenarios where loading failed or succeeded.
@Test func loadStreakReturnsDefaultIfNoData() {
let sut = StreakManager(persistence: persistence)
let streak = sut.loadStreak()
#expect(streak.lastDate == nil)
#expect(streak.length == 0)
}
@Test func saveStreakFailsIfEncodingError() {
let sut = StreakManager(persistence: persistence)
sut.save(streak: Streak(lastDate: Date()), encoder: MockEncoder())
#expect(persistence.getData() == nil)
}
// etc
Streak Outcomes
Back to some more exciting stuff, determining whether a user can continue a streak. This will involve comparing today’s date with the date saved in the streak data model. There can actually be a few different outcomes when checking the two dates:
- The streak has already been completed today
- Today is the next day compared to the saved date so continue the streak
- Today falls outside of the window to continue the streak so the streak is broken
We can make a method to check the dates and make these comparisons. But first because there’s three outcomes, I will represent this with an enum, ensuring type safety and easy extension in the future. I’m going to wrap the enum in the Streak
model with some nice and clear names.
extension Streak {
enum Outcome {
case alreadyCompletedToday
case streakContinues
case streakBroken
}
}
I can then make a method on the Streak
model too to keep all the model logic neatly organised and testable. The manager class can then ask the model if it’s valid.
Working with dates can be a bit tricky but Apple has some neat Calendar methods that make our lives so much easier.
func determineOutcome(for date: Date) -> Outcome {
guard let lastDate else { return .streakContinues }
let calendar = Calendar.current
if calendar.isDate(date, inSameDayAs: lastDate) {
return .alreadyCompletedToday
}
if let dayAfterLast = calendar.date(byAdding: .day, value: 1, to: lastDate) {
if calendar.isDate(date, inSameDayAs: dayAfterLast) {
return .streakContinues
}
}
return .streakBroken
}
and of course write some tests:
@Test func sameDay() {
let sut = Streak(lastDate: .make(day: 1, hour: 9, minute: 0))
let now = Date.make(day: 1, hour: 13, minute: 0)
#expect(sut.determineOutcome(for: now) == .alreadyCompletedToday)
}
@Test func isValidOnConsecutiveDays() {
// 12:00 -> 12:00
let sut = Streak(lastDate: .make(day: 1, hour: 12, minute: 0))
let now = Date.make(day: 2, hour: 12, minute: 0)
#expect(sut.determineOutcome(for: now) == .streakContinues)
}
@Test func nextDayCloseToMidnight() {
// 23:59 -> 00:01
let sut = Streak(lastDate: .make(day: 5, hour: 23, minute: 59))
let now = Date.make(day: 6, hour: 0, minute: 1)
#expect(sut.determineOutcome(for: now) == .streakContinues)
}
@Test func streakBrokenIfDaySkipped() {
// 12:00 -|+2 days|> 12:00
let sut = Streak(lastDate: .make(day: 10, hour: 12, minute: 0))
let now = Date.make(day: 12, hour: 12, minute: 0)
#expect(sut.determineOutcome(for: now) == .streakBroken)
}
// Helper
extension Date {
static func make(month: Int = 5, day: Int, hour: Int, minute: Int) -> Date {
DateComponents(calendar: Calendar.current, month: month, day: day, hour: hour, minute: minute).date!
}
}
Updating and Getting the Streak
Back to our StreakManager, we can write some methods to access the outcome logic and update the streak accordingly. Firstly, we will make a method to call when the streak will be updated. This could be after completing an action or just signing in for the day. We will check the saved streak, determine the outcome using the current date, then switch over the different outcomes and finally save the streak back to disk. To continue the streak we will increment the length and save the date.
// StreakManager.swift
func updateStreak(onDate date: Date = .now) {
let outcome = currentStreak.determineOutcome(for: date)
switch outcome {
case .alreadyCompletedToday:
return
case .streakContinues:
currentStreak.lastDate = date
currentStreak.length += 1
case .streakBroken:
currentStreak.lastDate = date
currentStreak.length = 1
}
save(streak: currentStreak)
}
We also need a method to access the current length. We need to make sure we check the outcome here too as the user could read their streak without updating first. But this time we’re only interested if the streak is broken.
func getStreakLength(forDate date: Date = .now) -> Int {
let outcome = currentStreak.determineOutcome(for: date)
if outcome == .streakBroken {
currentStreak.length = 0
save(streak: currentStreak)
}
return currentStreak.length
}
Both these methods have dates injected into the parameters so you can unit test them. I won’t show that here.
Putting It All Together
The final few steps are using and accessing the StreakManager in SwiftUI. To start, we will first inject the StreakManager into the environment.
@main struct MyApp: App {
@StateObject var streakManager = StreakManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(streakManager)
}
}
}
We can then update the streak once the user has completed an important action or on app launch. This is done by accessing the environment object:
@EnvironmentObject var streak: StreakManager
Text("Woo, you finished the thing")
.onAppear {
streak.updateStreak()
}
And finally we can display the user’s current streak:
@EnvironmentObject var streak: StreakManager
HStack {
Text("\(streak.getStreakLength())")
Image(systemName: "flame")
}
Conclusion
Hopefully this has been a helpful insight in what goes into planning and implementing a feature in Swift. I originally thought it would be super easy to implement streaks but it was slightly harder than first thought mainly around planning how the streak behaviour would work. This goes to show spending a little bit of time planning a feature and creating solid requirements before diving into the code can be really beneficial.
Unit testing is also a massive help, you can incrementally work on small parts and build up to the entire solution as a whole, all while verifying new logic doesn’t break any existing work.
One final shameless plug. I put all this work into a nice reusable library with different storage mechanisms, a few more features and a nice animated pre-built view to place directly into your SwiftUI apps.
Thanks for reading!