Day 54 - 100 Days of Swift
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:


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.