Day 54 - 100 Days of Swift

7 minute read

Project 13 (part 3)

Day 54 is the third part of the thirteenth project. As usual, he gives you a review to make sure you learned the topics covered in this project and he gives you three challenges to extend the photo filtering app. The first challenge is to make the save button show an error if there is no image in the image view. The second is to make the change filter button set its title to be the name of the currently selected filter. And the third is to play around with having more than one slider, so the user can set different attributes individually.

The first challenge was pretty simple. First, I pulled out the error-alert-presenting functionality into its own function:

1
2
3
4
5
6
7
8
9
func presentErrorAlert(title: String?, message: String? = nil) {
    let alertController = UIAlertController(title: title,
                                            message: message,
                                            preferredStyle: .alert)
    alertController.add("Ok",
                        style: .default)
    present(alertController,
            animated: true)
}

Then I modified the previous save error to use it:

1
2
3
4
if let error = error {
    presentErrorAlert(title: "Save Error",
                      message: error.localizedDescription)
}

And added it in the guard statement in save(), so that if there is no image, the user is presented with an alert:

1
2
3
4
5
guard let image = imageView.image else {
    presentErrorAlert(title: "No Photo",
                      message: "You can't save a photo that doesn't exist.")
    return
}

The second challenge was also pretty simple, I just added an outlet for the change filter button:

1
@IBOutlet var filterButton: UIButton!

And then added a didSet{} on currentFilter to update the title of the button:

1
2
3
4
5
6
7
var currentFilter: CIFilter! {
    didSet {
        if let title = currentFilter.attributes[kCIAttributeFilterDisplayName] as? String {
            filterButton.setTitle(title, for: .normal)
        }
    }
}

I’m using the filter’s “Filter Display Name” attribute to set the title, so that it says “Bump Distortion” instead of “CIBumpDistorion” and so on.

The third challenge was the most complex and I admittedly took it a little farther than he intended (probably). Using a method I picked up from Dave DeLong, I made a custom class that encapsulates everything a slider for setting a filter’s attribute needs: an attribute name, a display name, and minimum, maximum and default values. It also holds a slider, a label and a stack view to contain them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class FilterInputSlider {
    let attributeName: String
    let displayName: String
    let minimumValue: Float
    let maximumValue: Float
    let defaultValue: Float

    lazy var slider: UISlider = {
        let s = UISlider()
        s.minimumValue = minimumValue
        s.maximumValue = maximumValue
        s.value = defaultValue
        return s
    }()

    lazy var label: UILabel = {
        let l = UILabel()
        l.text = displayName
        l.setContentCompressionResistancePriority(.required, for: .horizontal)
        return l
    }()

    lazy var view: UIView = {
        let stack = UIStackView(arrangedSubviews: [label, slider])
        stack.axis = .horizontal
        stack.spacing = UIStackView.spacingUseSystem
        stack.heightAnchor.constraint(equalToConstant: 40).isActive = true
        return stack
    }()
}

To actually make a FilterInputSlider in needs an initializer, which I made optional so that I can feed it all the attributes and it will only initialize from those that make sense to use a slider for.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
init?(name: String, attributes: Any) {
    guard let attrs = attributes as? Dictionary<String, Any> else { return nil }

    guard let valueClassName = attrs[kCIAttributeClass] as? String else { return nil }
    guard valueClassName == "NSNumber" else { return nil }

    guard let minValue = attrs[kCIAttributeSliderMin] as? Float else { return nil }
    guard let maxValue = attrs[kCIAttributeSliderMax] as? Float else { return nil }

    let identityValue = attrs[kCIAttributeIdentity] as? Float
    let defaultValue = attrs[kCIAttributeDefault] as? Float

    self.attributeName = name
    self.displayName = attrs[kCIAttributeDisplayName] as? String ?? name
    self.minimumValue = minValue
    self.maximumValue = maxValue
    self.defaultValue = defaultValue ?? identityValue ?? minValue
}

All the guard statements make sure that this attribute makes sense to build a slider out of, and unwraps all the values that we need to keep track of. The last line illustrates that you can chain nil coalescing operators and it will just end up being whatever the first non-nil value is.

After building that custom class, I re-worked the storyboard a little bit to have a stack view I could insert FilterInputSliders into. I basically just constrained it to fill the space under the image view, so that the image view will grow and shrink depending on how many sliders are inserted. I also put the buttons at the bottom of that stack view, to clean things up a little bit.

1
@IBOutlet var controlStackView: UIStackView!

Then I added a sliders array and this buildSliders method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private var sliders: [FilterInputSlider] = []

private func buildSliders() {
    for slider in sliders {
        controlStackView.removeArrangedSubview(slider.view)
        slider.view.removeFromSuperview()
    }

    guard let currentFilter = currentFilter else { return }
    sliders = currentFilter.attributes
        .compactMap { FilterInputSlider(name: $0, attributes: $1) }
        .sorted { $0.displayName > $1.displayName }

    let layoutGuide = UILayoutGuide()
    view.addLayoutGuide(layoutGuide)

    for (_, sliderInput) in sliders.enumerated() {
        controlStackView.insertArrangedSubview(sliderInput.view, at: 0)

        let slider = sliderInput.slider

        let equalWidth = slider.widthAnchor.constraint(equalTo: layoutGuide.widthAnchor)
        equalWidth.isActive = true

        slider.addTarget(self, action: #selector(applyProcessing), for: .valueChanged)
    }
}

This removes any pre-existing sliders, then sets sliders to be the sorted result of compactMap of building FilterInputSliders from the attributes on the filter. This leads to a really clean way of only getting the things you care about in a few lines of code. Then it loops through all those newly created sliders and adds them to the stack view, constraining them to have equal widths. I did have to mark applyProcessing() with @objc for this to work.

Then I called that method from setFilter:

1
2
3
4
5
6
let beginImage = CIImage(image: currentImage)
currentFilter.setValue(beginImage,
                       forKey: kCIInputImageKey)
buildSliders()

applyProcessing()

Finally, I just needed to adjust applyProcessing to use the values from sliders:

1
2
3
4
5
for sliderAttribute in sliders {
    let value = sliderAttribute.slider.value
    currentFilter.setValue(value,
                           forKey: sliderAttribute.attributeName)
}

With that, you get all the sliders (correctly labelled) for the things that make sense on each filter. It looks like this:

Screenshots of working app
Screenshots of working app

You can find my version of this project at the end of day 54 on Github here.

Reflections

Today was good. After I got through the first two challenges, I really didn’t want to put in the work involved in getting the third challenge to work. I’m not even sure why. I didn’t want to deal with the UI changes I guess? Because it was some extra work, but it wasn’t that much extra work. Obviously, I was able to convince myself to do it though, and I’m glad I did. It was a good refresher on how some of the Core Image stuff works, and some more advanced things you can do with that API. I also really like the failable initializer and compactMap combo. That seems like a really clean way to solve a certain kind of problem.