UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Instafilter, day 67, challenge solved: made it generic, adding all possible meaningful filters. Comments on my solution are welcome.

Forums > 100 Days of SwiftUI

The main challenge is to add a number of filters. The problems with that are:

  1. A filter can have more than one scalar control. This would make it necessary to have more than one slider. How to add these in a generic way? (As opposed to hard coding.)
  2. A control can have a different name from the three that are hardcoded in the lesson. How to deal with that, preferably in a generic way?
  3. How to configure a slider for a given filter? One needs a minimum and maximum value, as well as an initial value. Can this be solved in a generic way, or have they to be hard coded for every filter, as seen in the lesson?
  4. Is there a way to query the system for available filters for use in the "Select A Filter" dialog?
  5. If so, are all of these useful in the context of this assignment, and if not, what would be a good criterion to include them?

These issues can be addressed as follows.

  1. This can be solved by replacing the Slider with a variable number of SliderViews, one for each control. A @State variable is used in every SliderView to capture the value, a @Binding variable holding the values for all sliders is used to communicate the value back to the main View, to configure the filter with all control values.
  2. This point, as well as the following points, can be solved by using the introspection possibilities of CIFilter. These can detect many properties of a given filter. For this particular point, there is a way of obtaining the names of all controls for a given filter: the inputKeys property of the filter. These can be used to label the sliders, and to feed their values back into the filter.
  3. Some CIFilters have properties to show the (suggested) minimum and maximum values for a scalar control, as wel as a default value. These can be used to configure each slider for the filter.
  4. Yes, one can ask the system for a list of all available filters. But not all of these are appropriate here, see the following point.
  5. I found that there are controls not only for scalars, but also for point, colors, and many other properties. Only filters with controls of a scalar type are easily configurable by a slider; also, it is not very useful to include filters that have, beside scalar controls, also controls of a non-scalar type, since sliders alone could not fully configure them. There are many of these. So I only consider filters that have scalar controls and no others. In addition, they must have information on minimum, maximum and default values as in point 3 above.

Following is my code. The code to retrieve filter properties is a bit convoluted, so I put many convenience functions on CIFilter in an extension.

All comments and suggestions for improvement are welcome.

ContentView.swift (Yes I know not a very informative name but for this exercise good enough...):

import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins

struct ControlValues: Equatable {
    var values = [String : Double]()
}

struct ContentView: View {
    @State private var image: Image? {
        didSet {
            imageProcessEnabled = false
        }
    }
    @State private var controlValues = ControlValues() 
    @State private var inputImage: UIImage?
    @State private var processedImage: UIImage?
    @State private var filterIntensity = 0.5
    @State private var showingImagePicker = false
    @State private var currentFilter: CIFilter = CIFilter.sepiaTone()
    @State private var showingFilterSheet = false
    @State private var imageProcessEnabled = true

    let context = CIContext()

    var body: some View {
        NavigationView {
            VStack {
                ZStack {
                    Rectangle()
                        .fill(.secondary)

                    Text("Tap to select a picture")
                        .foregroundColor(.white)
                        .font(.headline)

                    image?
                        .resizable()
                        .scaledToFit()
                }
                .onTapGesture {
                    showingImagePicker = true
                }

                ForEach(currentFilter.sliderControls, id: \.self) { control in
                    SliderView(control: control,
                               displayName: currentFilter.displayName(for: control),
                               minSliderValue: currentFilter.minSliderValue(for: control),
                               maxSliderValue: currentFilter.maxSliderValue(for: control),
                               actualValue: currentFilter.scalarDefaultValue(for: control),
                               filterValues: $controlValues)
                    .padding(.trailing)
                }
                .onChange(of: controlValues.values) { _ in applyProcessing() }

                HStack {
                    Button("Change Filter (now: \(currentFilter.displayName)))") {
                        showingFilterSheet = true
                    }

                    Spacer()

                    Button("Save", action: save)
                        .disabled(imageProcessEnabled)
                }

            }
            .padding([.horizontal, .vertical])
            .navigationTitle("Instafilter")
            .sheet(isPresented: $showingImagePicker) {
                ImagePicker(image: $inputImage)
            }
            .confirmationDialog("Select a filter", isPresented: $showingFilterSheet) {
                ForEach(CIFilter.sliderControlledFilterNames, id: \.self) { filterName in
                    let filter = CIFilter(name: filterName)!
                    Button(filter.displayName) { setFilter(filter) }
                }
            }
            .onChange(of: inputImage) { _ in loadImage()}
        }
    }

    func save() {
        guard let processedImage = processedImage else { return }

        let imageSaver = ImageSaver()

        imageSaver.successHandler = {
            print("Success!")
        }

        imageSaver.errorHandler = {
            print("Oops: \($0.localizedDescription)")
        }

        imageSaver.writeToPhotoAlbum(image: processedImage)
    }

    func loadImage() {
        guard let inputImage = inputImage else { return }

        let beginImage = CIImage(image: inputImage)
        currentFilter.setValue(beginImage, forKey: kCIInputImageKey)
        applyProcessing()
    }

    func applyProcessing() {
        let controls = currentFilter.sliderControls

        print("Controls: \(controls)")

        for control in controls {
            currentFilter.setValue(controlValues.values[control], forKey: control)
        }

        guard let outputImage = currentFilter.outputImage else { return }

        if let cgimg = context.createCGImage(outputImage, from: outputImage.extent) {
            let uiImage = UIImage(cgImage: cgimg)
            image = Image(uiImage: uiImage)
            processedImage = uiImage
        }
    }

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

The extension file CIFilterProperties.swift:

import Foundation
import CoreImage

extension CIFilter {

    private static var scalarControlledFilters: [CIFilter] {
        var filterNames = CIFilter.filterNames(inCategories: nil)
        for name in filterNames {
            let filter = CIFilter(name: name)!
            let controls = filter.inputKeys.filter {
                $0 != "inputImage"
            }
            for control in controls {
                if filter.attributeClass(for: control) != "NSNumber" {
                    if let index = filterNames.firstIndex(of: name) {
                        filterNames.remove(at: index)
                    }
                    continue
                }
            }
        }
        return filterNames.map({ CIFilter(name: $0)! })
    }

    static var sliderControlledFilters: [CIFilter] {
        let filters = scalarControlledFilters
        return filters.filter({
            $0.sliderControls != []
        })
    }

    static var sliderControlledFilterNames: [String] {
        return sliderControlledFilters.map( { $0.fullName } )
    }

    var fullName: String {
        attributes["CIAttributeFilterName"] as! String
    }

    var displayName: String {
        if let name = attributes["CIAttributeFilterDisplayName"] {
            return name as! String
        } else {
            return self.fullName
        }
    }

    var sliderControls: [String] {
        inputKeys.filter({ control in
            guard controlsDict(for: control)["CIAttributeClass"] as? String? != nil else {
                return false
            }
            return optionalMinSliderValue(for: control) != nil &&
            optionalMaxSliderValue(for: control) != nil &&
            optionalScalarDefaultValue(for: control) != nil &&
            inputKeys.contains("inputImage")
        })
    }

    func displayName(for control: String) -> String {
        if let result = controlsDict(for: control)["CIAttributeDisplayName"] {
            return result as! String
        } else {
            return control
        }
    }

    func optionalMinSliderValue(for control: String) -> Double? {
        if let result = controlsDict(for: control)["CIAttributeSliderMin"] {
            return (result as! Double)
        } else {
            return nil
        }
    }

    func minSliderValue(for control: String) -> Double {
        return (controlsDict(for: control)["CIAttributeSliderMin"] as! Double)
    }

    func optionalMaxSliderValue(for control: String) -> Double? {
        if let result = controlsDict(for: control)["CIAttributeSliderMax"] {
            if let result = result as? Double {
                return result
            } else {
                return nil
            }
        } else {
            return nil
        }
    }

    func maxSliderValue(for control: String) -> Double {
        return (controlsDict(for: control)["CIAttributeSliderMax"] as! Double)
    }

    func optionalScalarDefaultValue(for control: String) -> Double? {
        let result = controlsDict(for: control)["CIAttributeDefault"]
        if result != nil && attributeClass(for: control) == "NSNumber" {
            return (result as! Double)
        } else {
            return nil
        }
    }

    func scalarDefaultValue(for control: String) -> Double {
        return (controlsDict(for: control)["CIAttributeDefault"] as! Double)
    }

    func attributeClass(for control: String) -> String {
        if let result = controlsDict(for: control)["CIAttributeClass"] {
            return result as! String
        } else {
            return "No class info"
        }
    }

    private func controlsDict(for control: String) -> [String : Any] {
        attributes[control] as! [String : Any]
    }
}

The new SliderView.swift:

import SwiftUI

struct SliderView: View {
    let control: String
    let displayName: String
    let minSliderValue: Double
    let maxSliderValue: Double
    @State var actualValue: Double
    @Binding var filterValues: ControlValues

    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                Text(displayName)
                    .padding(.horizontal)
                Slider(value: $actualValue, in: minSliderValue...maxSliderValue)
                    .onChange(of: actualValue) { _ in
                        filterValues.values[control] = actualValue
                    }
            }
        }
        .onAppear {
            filterValues.values[control] = actualValue
        }
    }
}

For the record, ImageSaver.swift and ImagePicker.swift, unchanged from the lesson:

import UIKit

class ImageSaver: NSObject {
    var successHandler: (() -> Void)?
    var errorHandler: ((Error) -> Void)?

    func writeToPhotoAlbum(image: UIImage) {
        UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveCompleted), nil)
    }

    @objc func saveCompleted(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
        if let error = error {
            errorHandler?(error)
        } else {
            successHandler?()
        }
    }
}
import PhotosUI
import SwiftUI

struct ImagePicker: UIViewControllerRepresentable {
    @Binding var image: UIImage?

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var config = PHPickerConfiguration()
        config.filter = .images
        let picker = PHPickerViewController(configuration: config)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        let parent: ImagePicker

        init(_ parent: ImagePicker) {
            self.parent = parent
        }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            picker.dismiss(animated: true)

            guard let provider = results.first?.itemProvider else { return }

            if provider.canLoadObject(ofClass: UIImage.self) {
                provider.loadObject(ofClass: UIImage.self) { image, _ in
                    self.parent.image = image as? UIImage
                }
            }
        }
    }
}

3      

Impressive! Thanks for sharing.

2      

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

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

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.