LAST CHANCE: Save 50% on all my Swift books and bundles! >>

Customizing our filter using confirmationDialog()

Paul Hudson    @twostraws   

So far we’ve brought together SwiftUI and Core Image, but the app still isn’t terribly useful – after all, the sepia tone effect isn’t that interesting.

To make the whole app better, we’re going to let users customize the filter they want to apply, and we’ll accomplish that using a confirmation dialog. On iPhone this is a list of buttons that slides up from the bottom of the screen, and you can add as many as you want – it can even scroll if you really need it to.

First we need a property that will store whether the confirmation dialog should be showing or not, so add this to ContentView:

@State private var showingFilters = false

Now we can add our buttons using the confirmationDialog() modifier. This works identically to alert(): we provide a title and condition to monitor, and as soon as the condition becomes true the confirmation dialog will be shown.

Start by adding this modifier below the navigation title:

.confirmationDialog("Select a filter", isPresented: $showingFilters) {
    // dialog here

Now fill in the changeFilter() method with this:

showingFilters = true

In terms of what to show in the confirmation dialog, we can create an array of buttons to show and an optional message. These buttons work just like with alert(): we provide a text title and an action to run when it’s selected.

For the confirmation dialog in this app, we want users to select from a range of different Core Image filters, and when they choose one it should be activated and immediately applied. To make this work we’re going to write a method that modifies currentFilter to whatever new filter they chose, then calls loadImage() straight away.

There is a wrinkle in our plan, and it’s a result of the way Apple wrapped the Core Image APIs to make them more Swift-friendly. You see, the underlying Core Image API is entirely stringly typed – it uses strings to set values, rather than fixed properties – so rather than invent all new classes for us to use Apple instead created a series of protocols.

When we assign CIFilter.sepiaTone() to a property, we get an object of the class CIFilter that happens to conform to a protocol called CISepiaTone. That protocol then exposes the intensity parameter we’ve been using, but internally it will just map it to a call to setValue(_:forKey:).

This flexibility actually works in our favor because it means we can write code that works across all filters, as long as we’re careful not to send in an invalid value.

So, let’s start solving the problem. Please change your currentFilter property to this:

@State private var currentFilter: CIFilter = CIFilter.sepiaTone()

So, again, CIFilter.sepiaTone() returns a CIFilter object that conforms to the CISepiaTone protocol. Adding that explicit type annotation means we’re throwing away some data: we’re saying that the filter must be a CIFilter but doesn’t have to conform to CISepiaTone any more.

As a result of this change we lose access to the intensity property, which means this code won’t work any more:

currentFilter.intensity = Float(filterIntensity)

Instead, we need to replace that with a call to setValue(:_forKey:). This is all the protocol was doing anyway, but it did provide valuable extra type safety.

Replace that broken line of code with this:

currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey)

kCIInputIntensityKey is another Core Image constant value, and it has the same effect as setting the intensity parameter of the sepia tone filter.

With that change, we can return to our confirmation dialog: we want to be able to change that filter to something else, then call loadImage() to reset everything and apply initial processing. So, add this method to ContentView:

func setFilter(_ filter: CIFilter) {
    currentFilter = filter

Tip: This means image loading is triggered every time a filter changes. If you wanted to make this run a little faster, you could store beginImage in another @State property to avoid reloading the image each time a filter changes.

With that in place we can now replace the // dialog here comment with a series of buttons that try out various Core Image filters.

Put this in its place:

Button("Crystallize") { setFilter(CIFilter.crystallize()) }
Button("Edges") { setFilter(CIFilter.edges()) }
Button("Gaussian Blur") { setFilter(CIFilter.gaussianBlur()) }
Button("Pixellate") { setFilter(CIFilter.pixellate()) }
Button("Sepia Tone") { setFilter(CIFilter.sepiaTone()) }
Button("Unsharp Mask") { setFilter(CIFilter.unsharpMask()) }
Button("Vignette") { setFilter(CIFilter.vignette()) }
Button("Cancel", role: .cancel) { }

I picked out those from the vast range of Core Image filters, but you’re welcome to try using code completion to try something else – type CIFilter. and see what comes up!

Go ahead and run the app, select a picture, then try changing Sepia Tone to Vignette – this applies a darkening effect around the edges of your photo. (If you’re using the simulator, remember to give it a little time because it’s slow!)

Now try changing it to Gaussian Blur, which ought to blur the image, but will instead cause our app to crash. By jettisoning the CISepiaTone restriction for our filter, we’re now forced to send values in using setValue(_:forKey:), which provides no safety at all. In this case, the Gaussian Blur filter doesn’t have an intensity value, so the app just crashes.

To fix this – and also to make our single slider do much more work – we’re going to add some more code that reads all the valid keys we can use with setValue(_:forKey:), and only sets the intensity key if it’s supported by the current filter. Using this approach we can actually query as many keys as we want, and set all the ones that are supported. So, for sepia tone this will set intensity, but for Gaussian blur it will set the radius (size of the blur), and so on.

This conditional approach will work with any filters you choose to apply, which means you can experiment with others safely. The only thing you need be careful with is to make sure you scale up filterIntensity by a number that makes sense – a 1-pixel blur is pretty much invisible, for example, so I’m going to multiply that by 200 to make it more pronounced.

Replace this line:

currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey)

With this:

let inputKeys = currentFilter.inputKeys

if inputKeys.contains(kCIInputIntensityKey) { currentFilter.setValue(filterIntensity, forKey: kCIInputIntensityKey) }
if inputKeys.contains(kCIInputRadiusKey) { currentFilter.setValue(filterIntensity * 200, forKey: kCIInputRadiusKey) }
if inputKeys.contains(kCIInputScaleKey) { currentFilter.setValue(filterIntensity * 10, forKey: kCIInputScaleKey) }

And with that in place you can now run the app safely, import a picture of your choosing, then try out all the various filters – nothing should crash any more. Try experimenting with different filters and keys to see what you can find!

Hacking with Swift is sponsored by Essential Developer.

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until July 28th.

Click to save your free spot now

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

Buy Pro Swift Buy Pro SwiftUI 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 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 Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5

Unknown user

You are not logged in

Log in or create account

Link copied to your pasteboard.