BLACK FRIDAY: Save 50% on all books and bundles! >>

Binding an alert to an optional string

Paul Hudson    @twostraws   

SwiftUI lets us present an alert when an optional value changes, but this isn’t quite so straightforward when working with optional strings as you’ll see.

To demonstrate this, we’re going to rewrite the way our resort facilities are shown. Right now we have a plain text view generated like this:

Text(ListFormatter.localizedString(byJoining: resort.facilities))
    .padding(.vertical)

We’re going to replace that with icons that represent each facility, and when the user taps on one we’ll show an alert with a description of that facility.

As usual we’re going to start small then work our way up. First, we need a way to convert facility names like “Accommodation” into an icon that can be displayed. Although this will only happen in ResortView right now, this functionality is exactly the kind of thing that should be available elsewhere in our project. So, we’re going to create a new struct to hold all this information for us.

Create a new Swift file called Facility.swift, replace its Foundation import with SwiftUI, and give it this code:

struct Facility {
    static func icon(for facility: String) -> some View {
        let icons = [
            "Accommodation": "house",
            "Beginners": "1.circle",
            "Cross-country": "map",
            "Eco-friendly": "leaf.arrow.circlepath",
            "Family": "person.3"
        ]

        if let iconName = icons[facility] {
            let image = Image(systemName: iconName)
                            .accessibility(label: Text(facility))
                            .foregroundColor(.secondary)
            return image
        } else {
            fatalError("Unknown facility type: \(facility)")
        }
    }
}

As you can see, that has a single static method that accepts a facility name, looks it up in a dictionary, and returns a new image we can use for that facility. I’ve picked out various SF Symbols icons that work well for the facilities we have, and I also used an accessibility(label:) modifier for the image to make sure it works well in VoiceOver.

We can now drop that facilities view into ResortView by replacing this code:

Text(ListFormatter.localizedString(byJoining: resort.facilities))
    .padding(.vertical)

With this:

HStack {
    ForEach(resort.facilities, id: \.self) { facility in
        Facility.icon(for: facility)
            .font(.title)
    }
}
.padding(.vertical)

That loops over each item in the facilities array, converting it to an icon and placing it into a HStack. I used the .font(.title) modifier to make the images larger – using the modifier here rather than inside Facility allows us more flexibility if we wanted to use these icons in other places.

That was the easy part. The harder part comes next: we want to add an onTapGesture() modifier to those facility images so that we can show an alert when they are tapped.

Using the optional form of alert() this starts easily enough – add a new property to ResortView to store the currently selected facility name:

@State private var selectedFacility: String?

Now add this modifier to the facility icons, just below .font(.title):

.onTapGesture {
    self.selectedFacility = facility
}

We can create the alert in a very similar manner as we created the icons – by adding a static method to the Facility struct that looks up the name in an dictionary:

static func alert(for facility: String) -> Alert {
    let messages = [
        "Accommodation": "This resort has popular on-site accommodation.",
        "Beginners": "This resort has lots of ski schools.",
        "Cross-country": "This resort has many cross-country ski routes.",
        "Eco-friendly": "This resort has won an award for environmental friendliness.",
        "Family": "This resort is popular with families."
    ]

    if let message = messages[facility] {
        return Alert(title: Text(facility), message: Text(message))
    } else {
        fatalError("Unknown facility type: \(facility)")
    }
}

And now we can just add an alert(item:) modifier to the scroll view in ResortView, showing the correct alert whenever selectedFacility has a value:

.alert(item: $selectedFacility) { facility in
    Facility.alert(for: facility)
}

If you try building that code you’ll be disappointed, because it doesn’t work. You see, we can’t just bind any @State property to the alert(item:) modifier – it needs to be something that conforms to the Identifiable protocol. The reason for this is subtle, but important: if we set selectedFacility to some string an alert should appear, but if we then change it to a different string SwiftUI needs to be able to see that the value changed.

Strings don’t conform to Identifiable, which is why we need to use ForEach(resort.facilities, id: \.self) { when looping over the facilities.

There are two ways to fix this: one that probably seems dubious at first but on reflection actually makes a lot of sense, and one that is more work but a bit less invasive.

The first solution is to make strings conform to Identifiable, so that we no longer need to use id: \.self everywhere. This can be done with a tiny extension placed in any of the files in your project:

extension String: Identifiable {
    public var id: String { self }
}

With that our code now builds, and in fact you can change the aforementioned ForEach to this:

ForEach(resort.facilities) { facility in

If you run the app now you’ll see it all works correctly, and tapping any of the icons also shows an alert.

Even better, we can now use strings natively anywhere that previously required us to use id: \.self. This simplifies quite a bit of SwiftUI, and if you ever do plan to use strings for these situations you have no choice but to use id: \.self regardless so this solution is exactly what you want.

However, one small thing does sit uncomfortably with me, which is this: it makes it a little too easy to use strings for identifiers, when often something like a UUID or an integer might work better.

If this also sits uncomfortably with you, I want to explore what a solution looks like. On the other hand if you’re perfectly comfortable using strings here – and honestly I think it’s a perfectly reasonable thing to do as long as you always remember that your strings need to be unique! – then by all means skip on to the next chapter.

Still here? OK. To fix this we need to upgrade a few parts of our code.

First, we’re going to make Facility itself be Identifiable, which means giving it a unique id property and also storing the facility name somewhere in there. This means we’ll create an instance of Facility and use its data, rather than relying on static methods.

So, change the Facility struct to this:

struct Facility: Identifiable {
    let id = UUID()
    var name: String

    var icon: some View {
        let icons = [
            "Accommodation": "house",
            "Beginners": "1.circle",
            "Cross-country": "map",
            "Eco-friendly": "leaf.arrow.circlepath",
            "Family": "person.3"
        ]

        if let iconName = icons[name] {
            let image = Image(systemName: iconName)
                            .accessibility(label: Text(name))
                            .foregroundColor(.secondary)
            return image
        } else {
            fatalError("Unknown facility type: \(name)")
        }
    }

    var alert: Alert {
        let messages = [
            "Accommodation": "This resort has popular on-site accommodation.",
            "Beginners": "This resort has lots of ski schools.",
            "Cross-country": "This resort has many cross-country ski routes.",
            "Eco-friendly": "This resort has won an award for environmental friendliness.",
            "Family": "This resort is popular with families."
        ]

        if let message = messages[name] {
            return Alert(title: Text(name), message: Text(message))
        } else {
            fatalError("Unknown facility type: \(name)")
        }
    }
}

Next, we’re going to update the Resort struct so that it has a computed property containing its facilities as an array of Facility rather than String. This means we still load the original string array from JSON, but have a [Facility] alternative ready to hand. Ideally this would be done with a custom Codable initializer, but I don’t want to cover that all over again!

So, add this property to Resort now:

var facilityTypes: [Facility] {
    facilities.map(Facility.init)
}

Now in ResortView we can update our code to use a Facility? rather than a String?.

First, change the property:

@State private var selectedFacility: Facility?

Next, change the ForEach to use our new facilityTypes property rather than facilities, which in turn means we can access the icon directly because we have real Facility instances now:

HStack {
    ForEach(resort.facilityTypes) { facility in
        facility.icon
            .font(.title)
            .onTapGesture {
                self.selectedFacility = facility
            }
    }
}
.padding(.vertical)

And finally we can replace the alert() modifier to use the facility alert, like this:

.alert(item: $selectedFacility) { facility in
    facility.alert
}

That’s quite a bit of work, but it does now mean we can remove the custom String extension – that’s quite an invasive change to make, and if Apple had meant it to be that easy I’m sure they would have made alert(item:) use a more common protocol that String already conforms to, such as Equatable.

Save 50% in my Black Friday sale.

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.8/5

Link copied to your pasteboard.