Paul Hudson's (@twostraws) 100 Days of Swift UI Project 16
Source URL: link
In this project we’re going to build Hot Prospects, which is an app to track who you meet at conferences. You’ve probably seen apps like it before: it will show a QR code that stores your attendee information, then others can scan that code to add you to their list of possible leads for later follow up.
Source URL: link
Really interesting technique for letting users select multiple items in a list and present it in a readable way (see animation below):
This interesting effect is achieved by adding the EditButton()
and the selection.formatted()
in the code below:
import SwiftUI
struct ContentView: View {
let users = ["Tohru", "Yuki", "Kyo", "Momiji"]
@State private var selection = Set<String>()
var body: some View {
List(users, id:\.self, selection: $selection) { user in
Text(user)
}
if !selection.isEmpty {
Text("You selected: \(selection.formatted())")
}
EditButton()
}
}
Source URL: link
Swift provides a special type called Result that allows us to encapsulate either a successful value or some kind of error type, all in a single piece of data. So, in the same way that an optional might hold a string or might hold nothing at all, for example, Result might hold a string or might hold an error. The syntax for using it is a little odd at first, but it does have an important part to play in our projects.
Result
is really interesting, although its syntax is a bit odd. Here's Paul's example usage:
func fetchReadings() async {
let fetchTask = Task {
let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
let readings = try JSONDecoder().decode([Double].self, from: data)
return "Found \(readings.count) readings"
}
}
Paul presents two options for handling Result
, my preferred way...
do {
output = try result.get()
} catch {
output = "Error: \(error.localizedDescription)"
}
... and through the switch
statement as below:
switch result {
case .success(let str):
output = str
case .failure(let error):
output = "Error: \(error.localizedDescription)"
}
Basically, interpolation(.none)
in the code below ensures that the small image will be pixelated, but not blurred, when resized.
Image(.example)
.interpolation(.none)
.resizable()
.scaledToFit()
.background(.black)
The result:
Source URL: link
SwiftUI lets us attach context menus to objects to provide this extra functionality, all done using the
contextMenu()
modifier. You can pass this a selection of buttons and they’ll be shown in order, so we could build a simple context menu to control a view’s background color like this:
Text("Hello, color!")
.padding()
.background(backgroundColor)
Text("Change Color")
.padding()
.contextMenu {
Button("Red", systemImage: "checkmark.circle.fill", role: .destructive) {
backgroundColor = .red
}
Button("Green") {
backgroundColor = .green
}
Button("Blue") {
backgroundColor = .blue
}
}
Source URL: link
We get this full functionality in SwiftUI using the `swipeActions()1 modifier, which lets us register one or more buttons on one or both sides of a list row. By default buttons will be placed on the right edge of the row, and won’t have any color, so this will show a single gray button when you swipe from right to left.
List(users, id:\.self, selection: $selection) { user in
Text(user)
.swipeActions {
Button("Delete", systemImage: "minus.circle", role: .destructive) {
print("Deleting")
}
}
.swipeActions(edge: .leading) {
Button("Pin", systemImage: "pin") {
print("Pinning")
}
.tint(.orange)
}
}
Source URL: link
iOS has a framework called UserNotifications that does pretty much exactly what you expect: lets us create notifications to the user that can be shown on the lock screen. We have two types of notifications to work with, and they differ depending on where they were created: local notifications are ones we schedule locally, and remote notifications (commonly called push notifications) are sent from a server somewhere.
Remote notifications require a server to work, because you send your message to Apple’s push notification service (APNS), which then forwards it to users. But local notifications are nice and easy in comparison, because we can send any message at any time as long as the user allows it.
Here's the code:
VStack {
Button("Request Permission") {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
if success {
print("All set!")
} else if let error {
print(error.localizedDescription)
}
}
}
Button("Schedule Notification") {
let content = UNMutableNotificationContent()
content.title = "Feed the cat"
content.subtitle = "It looks hungry"
content.sound = UNNotificationSound.default
// show this notification five seconds from now
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
// choose a random identifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
// add our notification request
UNUserNotificationCenter.current().add(request)
}
}
Source URL: link
Xcode comes with a dependency manager built in, called Swift Package Manager (SPM). You can tell Xcode the URL of some code that’s stored online, and it will download it for you. You can even tell it what version to download, which means if the remote code changes sometime in the future you can be sure it won’t break your existing code.
The first step is to add the package to our project: go to the File menu and choose Add Package Dependencies. Enter the URL where the code for my example package is stored. Xcode will fetch the package, read its configuration, and show you options asking which version you want to use. The default will be “Version – Up to Next Major”, which is the most common one to use and means if the author of the package updates it in the future then as long as they don’t introduce breaking changes Xcode will update the package to use the new versions.
The reason this is possible is because most developers have agreed a system of semantic versioning (SemVer) for their code. If you look at a version like 1.5.3, then the 1 is considered the major number, the 5 is considered the minor number, and the 3 is considered the patch number. If developers follow SemVer correctly, then they should:
-
Change the patch number when fixing a bug as long as it doesn’t break any APIs or add features.
-
Change the minor number when they added features that don’t break any APIs.
-
Change the major number when they do break APIs.
An interesting part of the code:
We need to convert that array of integers into strings. This only takes one line of code in Swift, because sequences have a
map()
method that lets us convert an array of one type into an array of another type by applying a function to each element. In our case, we want to initialize a new string from each integer, so we can useString.init
as the function we want to call.
var results: String {
let selected = possibleNumbers.random(7).sorted()
let strings = selected.map(String.init)
return strings.formatted()
}
Source URL: link
Branch: release
TavView
is very easy to be implemented, and the code speaks for itself:
TabView {
ProspectsView()
.tabItem {
Label("Everyone", systemImage: "person.3")
}
ProspectsView()
.tabItem {
Label("Contacted", systemImage: "checkmark.circle")
}
ProspectsView()
.tabItem {
Label("Uncontacted", systemImage: "questionmark.diamond")
}
MeView()
.tabItem {
Label("Me", systemImage: "person.crop.square")
}
}
Source URL: link
Notes on using SwiftData
:
It requires a class that works as a model, like the one below which includes the @Model
macro (note: don't forget to import SwiftData
whenever model
, etc are referenced):
@Model
class Prospect {
var name: String
var emailAddress: String
var isContacted: Bool
}
Then add the .modelContainer()
to hold the data as below:
WindowGroup {
ContentView()
}
.modelContainer(for: Prospect.self)
Finally, use it within your views...
@Query(sort: \Prospect.name) var prospects: [Prospect]
@Environment(\.modelContext) var modelContext
... while not forgetting to add the .modelContainer
for previews:
#Preview {
ProspectsView(filter: .none)
.modelContainer(for: Prospect.self)
}
Source URL: link
Branch: release
The line below queries the database for all the Prospects and assings them to a Prospect array sorted by name.
@Query(sort: \Prospect.name) var prospects: [Prospect]
We already have a default query in place, but if we add an initializer we can override that when a filter is set.
init(filter: FilterType) {
self.filter = filter
if filter != .none {
let showContactedOnly = filter == .contacted
_prospects = Query(filter: #Predicate {
$0.isContacted == showContactedOnly
}, sort: [SortDescriptor(\Prospect.name)])
}
}
Source URL: link
Here Paul teaches us how to create a QRCode in Swift, and perhaps unsurprisingly, Apple has a library to help us do so. The first task is importing this package:
import CoreImage.CIFilterBuiltins
Then here's a function that easily creates (but, of course, not reads) QRCodes:
func generateQRCode(from string: String) -> UIImage {
filter.message = Data(string.utf8)
if let outPutImage = filter.outputImage {
if let cgimage = context.createCGImage(outPutImage, from: outPutImage.extent) {
return UIImage(cgImage: cgimage)
}
}
return UIImage(systemName: "xmark.circle") ?? UIImage()
}
SwiftUI's image generation process is kind of confusing, and it's arguably responsible for the convoluted code above.
Another important aspect when generating QRCodes is that SwiftUI will generate images only big enough to show the necessary pixels on screen, which will likely be too small. If you scale the image, it will also try to interpolate, giving your QRCode an undesired blur effect. That's why we need to turn interpolation off as below:
Image(uiImage: generateQRCode(from: "\(name)\n\(emailAddress)"))
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
I also find surprising how there's no need to specify the QRCode type. iOS seems to understand the name and email in the example above in two consecutive lines:
Interesting enough is that I can also use Swift libraries to customize QRCodes. With a little help from ChatGPT, the code below creates a QRCode with custom colors and a logo in the middle (here only for my reference, I haven't tested the result yet):
import SwiftUI
import CoreImage.CIFilterBuiltins
struct CustomQRCodeView: View {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
var body: some View {
if let qrImage = generateCustomQRCode(from: "https://www.ag24horas.com.br") {
Image(uiImage: qrImage)
.resizable()
.interpolation(.none)
.scaledToFit()
.frame(width: 250, height: 250)
} else {
Text("Error while creating the QRCode")
}
}
func generateCustomQRCode(from string: String) -> UIImage? {
// 1. Creates the QRCode
let data = Data(string.utf8)
filter.setValue(data, forKey: "inputMessage")
// the optional value below increases the error correction level to "H" (High)
filter.setValue("H", forKey: "inputCorrectionLevel")
guard let qrCIImage = filter.outputImage else { return nil }
// 2. Applies the color
let colorFilter = CIFilter.falseColor()
colorFilter.inputImage = qrCIImage
colorFilter.color0 = CIColor(color: UIColor.systemPurple) // cor dos quadrados
colorFilter.color1 = CIColor(color: UIColor.white) // cor de fundo
guard let coloredQRImage = colorFilter.outputImage else { return nil }
// 3. Converts to CGImage e resizes
if let cgImage = context.createCGImage(coloredQRImage, from: coloredQRImage.extent) {
let qrUIImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: .up)
// 4. Add logo to the middle of the image
return addLogo(to: qrUIImage, logo: UIImage(named: "logo")!)
}
return nil
}
func addLogo(to qrImage: UIImage, logo: UIImage) -> UIImage {
let size = qrImage.size
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
qrImage.draw(in: CGRect(origin: .zero, size: size))
let logoSize = CGSize(width: size.width * 0.25, height: size.height * 0.25)
let logoOrigin = CGPoint(
x: (size.width - logoSize.width) / 2,
y: (size.height - logoSize.height) / 2
)
logo.draw(in: CGRect(origin: logoOrigin, size: logoSize))
}
}
}
Source URL: link
Branch: release
Sending a local notification (one that does not relies on Apple's cloud services) requires 2 steps:
1- Import the package
import UserNotifications
2- Create a func
to add the notification:
func addNotification(for prospect: Prospect) {
let center = UNUserNotificationCenter.current()
let addRequest = {
let content = UNMutableNotificationContent()
content.title = "Contact \(prospect.name)"
content.subtitle = prospect.emailAddress
content.sound = UNNotificationSound.default
var dateComponents = DateComponents()
dateComponents.hour = 9
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
center.add(request)
}
center.getNotificationSettings { settings in
if settings.authorizationStatus == .authorized {
addRequest()
} else {
center.requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
if success {
addRequest()
} else if let error {
print(error.localizedDescription)
}
}
}
}
}
The code above will trigger an alert at 9am. For testing purposes, we can replace the trigger
above with the following code, which triggers the alert after 5 seconds:
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
Here's the final result (with the alert set to 5 seconds for testing):
Add an icon to the “Everyone” screen showing whether a prospect was contacted or not.
I was able to complete this challenge with a single line of code:
+ (title == "Everyone" && prospect.isContacted ? Text(" ") + Text(Image(systemName: "checkmark.seal.fill")) : Text(""))
There are certainly more elegant solutions, but being able to resolve this with a single line in a couple of minutes was enough for me. Here's the end result:
Add an editing screen, so users can adjust the name and email address of someone they scanned previously. (Tip: Use the simple form of
NavigationLink
rather thannavigationDestination()
, to avoid your list selection code confusing the navigation link.)
To complete this challenge, I created a simple EditProspectView
as below:
import SwiftUI
struct EditProspectView: View {
@State var prospect: Prospect
var body: some View {
NavigationStack {
Form {
Section(footer: Text("Changes are saved automatically.")) {
TextField("Name", text: $prospect.name)
TextField("Email", text: $prospect.emailAddress)
}
}
.navigationTitle("Edit " + prospect.name)
}
}
}
#Preview {
EditProspectView(prospect: Prospect(name: "Test", emailAddress: "[email protected]"))
}
It's interesting noting that a save
button is not necessary because Swift tracks changes to the model, so I left it off with the message Changes are saved automatically in the form's footer.
Then following Paul's instructions, I've added a simple NavigationLink
to the ProspectsView as below:
NavigationLink(destination: EditProspectView(prospect: prospect)) {
VStack(alignment: .leading) {
Text(prospect.name)
.font(.headline) + (title == "Everyone" && prospect.isContacted ? Text(" ") + Text(Image(systemName: "checkmark.seal.fill")) : Text(""))
Text(prospect.emailAddress)
.foregroundStyle(.secondary)
}
.swipeActions {
Button("Delete", systemImage: "trash", role: .destructive){
modelContext.delete(prospect)
}
if prospect.isContacted {
Button("Mark Uncontacted", systemImage: "person.crop.circle.badge.xmark") {
prospect.isContacted.toggle()
}
.tint(.blue)
} else {
Button("Mark Contacted", systemImage: "person.crop.circle.fill.badge.checkmark") {
prospect.isContacted.toggle()
}
.tint(.green)
Button("Remind Me", systemImage: "bell") {
addNotifications(for: prospect)
}
.tint(.orange)
}
}
.tag(prospect)
}
Here's the result of the completed challenge:
Allow users to customize the way contacts are sorted – by name or by most recent.
I completed this challenge by taking the following steps:
- Created a new variable to hold the sorted state. It makes sense to be a
Bool
since we have only two sorting options:
@State private var sortByName: Bool = true // by name is the default option
- Replaced the
Edit
button (now redundant, after challenge 2) with a button that toggles bewtween two states:Sort by Date
andSort by Name
(the default):
ToolbarItem(placement: .topBarLeading) {
Button(sortByName ? "Sort by Date" : "Sort by Name") {
sortByName.toggle()
}
}
- Created a computed property that sorts the prospects by name or most recent date according to the boolean value in
sortByName
:
var sortedProspects: [Prospect] {
sortByName ? prospects.sorted { $0.name < $1.name } : prospects.sorted { $0.dateAdded > $1.dateAdded }
}
- Finally, the
List
now reads fromsortedProspects
instead of the default array that loads with the view.
NavigationStack {
List(sortedProspects, selection: $selectedProspects) { prospect in
// code continues
Here's the final result:
Of course, the solution could have been more complete, for instance, the user could be given the option of sorting ascending and descending, but honestly, I didn`t feel like spending more time on this...
"I will always choose a lazy person to do a difficult job because a lazy person will find an easy way to do it." (Bill Gates)
Original code created by: Paul Hudson - @twostraws (Thank you!)
Made with ❤️ by @cewitte