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!
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 Array
, Dictionary
, 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))
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))
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)
}
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 Codable
, Hashable
, 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!
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!