Introducing HandySwift 4.0

Investing time in Open Source again: Complete revamp of HandySwift with vastly improved documentation and lots of added handy features extracted from my apps. Read on to learn which helpers I use most often!

Introducing HandySwift 4.0

It's been a while since I last did open-source work. I've been focusing on shipping new apps lately to make sure I can continue my Indie career long-term. After launching 6 apps within 3 months, I thought it's about time to share the most reused parts of code. I already have an open-source library for that: HandySwift.

But it's been more than 2 years since I made a new release. Of course, I was adding some functionality over time to the main branch so I could easily reuse them across my apps. But undocumented features that aren't part of a new release can be considered "internal" even if the APIs themselves are marked public.

So I took the time in the last few days to clean up all the code, making sure everything is consistent with the latest additions to Swift, removing unused code, adding @available attributes for renames (so Xcode can provide fix-its), and documenting a bunch of new APIs. I even designed an entirely new logo!

Additionally, I decided to embrace Swift-DocC which means that I could shrink my README file to the bare minimum and instead host my documentation on the Swift Package Index site. With some help of ChatGPT I could elaborate even on my existing documentation, leading to the best documented library I ever released!

I'll be writing a dedicated post about the details of the migration later. But because I've never written about HandySwift before, let me explain some of the conveniences you get when using it. I recommend adding it to every project. It has no dependencies, is lightweight itself, supports all platforms (including Linux & visionOS) and the platform support goes back to iOS 12. A no-brainer IMO. 💯


Extensions

Some highlights of the >100 functions & properties added to existing types, each with a practical use case directly from one of my apps:

Safe Index Access

In FocusBeats I’m accessing an array of music tracks using an index. With subscript(safe:) I avoid out of bounds crashes:

var nextEntry: ApplicationMusicPlayer.Queue.Entry? {
   guard let nextEntry = playerQueue.entries[safe: currentEntryIndex + 1] else { return nil }
   return nextEntry
}

You can use it on every type that conforms to Collection including ArrayDictionary, and String. Instead of calling the subscript array[index] which returns a non-Optional but crashes when the index is out of bounds, use the safer array[safe: index] which returns nil instead of crashing in those cases.

Blank Strings vs Empty Strings

A common issue with text fields that are required to be non-empty is that users accidentally type a whitespace or newline character and don’t recognize it. If the validation code just checks for .isEmpty the problem will go unnoticed. That’s why in TranslateKit when users enter an API key I make sure to first strip away any newlines and whitespaces from the beginning & end of the String before doing the .isEmpty check. And because this is something I do very often in many places, I wrote a helper:

Image(systemName: self.deepLAuthKey.isBlank ? "xmark.circle" : "checkmark.circle")
   .foregroundStyle(self.deepLAuthKey.isBlank ? .red : .green)

Just use isBlank instead of isEmpty to get the same behavior!

Readable Time Intervals

Whenever I used an API that expects a TimeInterval (which is just a typealias for Double), I missed the unit which lead to less readable code because you have to actively remember that the unit is “seconds”. Also, when I needed a different unit like minutes or hours, I had to do the calculation manually. Not with HandySwift!

Intead of passing a plain Double value like 60 * 5, you can just pass .minutes(5). For example in TranslateKit to preview the view when a user unsubscribed I use this:

#Preview("Expiring") {
   ContentView(
      hasPremiumAccess: true, 
      premiumExpiresAt: Date.now.addingTimeInterval(.days(3))
   )
}

You can even chain multiple units with a + sign to create a day in time like “09:41 AM”:

let startOfDay = Calendar.current.startOfDay(for: Date.now)
let iPhoneRevealedAt = startOfDay.addingTimeInterval(.hours(9) + .minutes(41))

Note that this API design is in line with Duration and DispatchTimeInterval which both already support things like .milliseconds(250). But they stop at the seconds level, they don’t go higher. HandySwift adds minutes, hours, days, and even weeks for those types, too. So you can write something like this:

try await Task.sleep(for: .minutes(5))
⚠️
Advancing time by intervals does not take into account complexities like daylight saving time. Use a Calendar for that.

Calculate Averages

In the crossword generation algorithm within CrossCraft I have a health function on every iteration that calculates the overall quality of the puzzle. Two different aspects are taken into consideration:

/// A value between 0 and 1.
func calculateQuality() -> Double {
   let fieldCoverage = Double(solutionBoard.fields) / Double(maxFillableFields)
   let intersectionsCoverage = Double(solutionBoard.intersections) / Double(maxIntersections)
   return [fieldCoverage, intersectionsCoverage].average()
}

In previous versions I played around with different weights, for example giving intersections double the weight compared to field coverage. I could still achieve this using average() like this in the last line:

return [fieldCoverage, intersectionsCoverage, intersectionsCoverage].average()

Round Floating-Point Numbers

When solving a puzzle in CrossCraft you can see your current progress at the top of the screen. I use the built-in percent formatter (.formatted(.percent)) for numerics, but it requires a Double with a value between 0 and 1 (1 = 100%). Passing an Int like 12 unexpectedly renders as 0%, so I can’t simply do this:

Int(fractionCompleted * 100).formatted(.percent)  // => "0%" until "100%"

And just doing fractionCompleted.formatted(.percent) results in sometimes very long text such as "0.1428571429".

Instead, I make use of rounded(fractionDigits:rule:) to round the Double to 2 significant digits like so:

Text(fractionCompleted.rounded(fractionDigits: 2).formatted(.percent))
ℹ️
There’s also a mutating round(fractionDigits:rule:) function if you want to change a variable in-place.

Symmetric Data Cryptography

Before uploading a crossword puzzle in CrossCraft I make sure to encrypt it so tech-savvy people can’t easily sniff the answers from the JSON like so:

func upload(puzzle: Puzzle) async throws {
   let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
   let plainData = try JSONEncoder().encode(puzzle)
   let encryptedData = try plainData.encrypted(key: key)
   
   // upload logic
}

Note that the above code makes use of two extensions, first init(base64Encoded:) is used to initialize the key, then encrypted(key:) encrypts the data using safe CryptoKit APIs internally you don’t need to deal with.

When another user downloads the same puzzle, I decrypt it with decrypted(key:) like so:

func downloadPuzzle(from url: URL) async throws -> Puzzle {
   let encryptedData = // download logic
   
   let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
   let plainData = try encryptedPuzzleData.decrypted(key: symmetricKey)
   return try JSONDecoder().decode(Puzzle.self, from: plainData)
}
ℹ️
HandySwift also conveniently ships with encrypted(key:) and decrypted(key:) functions for String which return a base-64 encoded String representation of the encrypted data. Use it when you’re dealing with String APIs.

New Types

Besides extending existing types, HandySwift also introduces 7 new types and 2 global functions. Here are the ones I use in nearly every single app:

Gregorian Day & Time

You want to construct a Date from year, month, and day? Easy:

GregorianDay(year: 1960, month: 11, day: 01).startOfDay() // => Date 

You have a Date and want to store just the day part of the date, not the time? Just use GregorianDay in your model:

struct User {
   let birthday: GregorianDay
}


let selectedDate = // coming from DatePicker
let timCook = User(birthday: GregorianDay(date: selectedDate))
print(timCook.birthday.iso8601Formatted)  // => "1960-11-01"

You just want today’s date without time?

GregorianDay.today

Works also with .yesterday and .tomorrow. For more, just call:

let todayNextWeek = GregorianDay.today.advanced(by: 7)
ℹ️
GregorianDay conforms to all the protocols you would expect, such as CodableHashable, and Comparable. For encoding/decoding, it uses the ISO format as in “2014-07-13”.

GregorianTimeOfDay is the counterpart:

let iPhoneAnnounceTime = GregorianTimeOfDay(hour: 09, minute: 41)
let anHourFromNow = GregorianTimeOfDay.now.advanced(by: .hours(1))


let date = iPhoneAnnounceTime.date(day: GregorianDay.today)  // => Date

Delay & Debounce

Have you ever wanted to delay some code and found this API annoying to remember & type out?

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) {
   // your code
}

HandySwift introduces a shorter version that’s easier to remember:

delay(by: .milliseconds(250)) {
   // your code
}

It also supports different Quality of Service classes like DispatchQueue (default is main queue):

delay(by: .milliseconds(250), qosClass: .background) {
   // your code
}

While delaying is great for one-off tasks, sometimes there’s fast input that causes performance or scalability issues. For example, a user might type fast in a search field. It’s common practice to delay updating the search results and additionally cancelling any older inputs once the user makes a new one. This practice is called “Debouncing”. And it’s easy with HandySwift:

@State private var searchText = ""
let debouncer = Debouncer()


var body: some View {
    List(filteredItems) { item in
        Text(item.title)
    }
    .searchable(text: self.$searchText)
    .onChange(of: self.searchText) { newValue in
        self.debouncer.delay(for: .milliseconds(500)) {
            // Perform search operation with the updated search text after 500 milliseconds of user inactivity
            self.performSearch(with: newValue)
        }
    }
    .onDisappear {
        debouncer.cancelAll()
    }
}

Note that the Debouncer was stored in a property so cancelAll() could be called on disappear for cleanup. But the delay(for:id:operation:) is where the magic happens – and you don’t have to deal with the details!


🌐
Enjoyed this article? Check out TranslateKit!
A drag & drop translator for String Catalog files – it's really easy.
Get it now to machine-translate your app to up to 150 languages!
👨🏻‍💻
Want to Connect?
Follow me on 🐦 Twitter (X), on 🧵 Threads, and 🦣 Mastodon.