Inuk Blog

For my current project, I needed to have some UIKit code which manipulated an @EnvironmentObject, but unfortunately UIKit doesn't know aboutthis property wrapper, I had to find an alternative. The @EnvironmentObject in question, was to be manipulated by a UIDocumentPickerDelagate's documentPicker(_:), this specific method gets called when a file gets picked, and I wanted to tell my @EnvironmentObject that a file was picked.

My solution is to bring the handling of selected documents up into SwiftUI. For starters, let me show you the simplest part of my code, the picker delegate:

typealias URLS = ([URL]) -> ()
typealias voidFunc = () -> ()

class DocumentDelagate: UIView ,UIDocumentPickerDelegate {
    var onOpen: URLS = { print($0) }
    var onCancel: voidFunc = {}
    
    func documentPicker(_ controller: UIDocumentPickerViewController,
                        didPickDocumentsAt urls: [URL]) {
        onOpen(urls)
    }
    
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        onCancel()
    }
    
}

It's a simple subclass of UIView and UIDocumentPickerDelagate. The reason I subclass UIView is so I don't have to add a lot of protocol stubs from NSProtocol. This unfortunately means making a custom init is a bit difficult, as such I gave my callbacks default values, but fret not, this is changed in my UIKit SwiftUI wrapper.

struct DocumentPickerController: UIViewControllerRepresentable {
    
    typealias UIViewControllerType = DocumentPicker
    let picker: UIViewControllerType
    
    init(documentTypes: [String],
         onOpen:  @escaping URLS,
         onCancel: @escaping voidFunc) {
        self.picker = UIViewControllerType(documentTypes: documentTypes, in: .open)
        let delagate = DocumentDelagate()
        delagate.onOpen = onOpen
        delagate.onCancel = onCancel
        picker.documentDelegate = delagate
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        
    }
    
    func makeUIViewController(context: Context) -> UIViewControllerType {
        picker
    }
    
}

The type alias is for quicker iterations and testing between needing to supclass the pickerview or not. As I have a reference to the delagate in the supclassed document picker, I only need it in init so I can mmodify the callback variables. And then I instantiate the supclassed document picker, as well as return the picker in makeUIViewController(context:), as you can see, I've still yet to bring in @EnvironmentObject, that'll come later. The subclassed document picker is basic, looks as following:

class DocumentPicker: UIDocumentPickerViewController {
    var documentDelegate: UIDocumentPickerDelegate? = nil
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.delegate = self.documentDelegate
    }
}

The supclassed document picker is not interesting. All it does is keep a strong instance of the delagate and assign it to its superclass's weak instance of the delagate. This is so the delagate is not deallocated when needed. And to finish it all off, I wrap this view into a neat little SwiftUI View which looks as following:

struct DocumentPickerButton<Label: View>: View {
    @State
    var showPicker = false
    
    private let pickerController: DocumentPickerController
    private let onOpen: URLS
    private let onCancel: voidFunc
    private let view: () -> (Label)
    
    init(documentTypes: [String],
         onOpen: @escaping ([URL]) -> (),
         onCancel: @escaping () -> () = {},
         @ViewBuilder view: @escaping () -> (Label))
    {
        self.documentTypes = documentTypes
        self.onOpen = onOpen
        self.onCancel = onCancel
        self.view = view
    }
    
    var body: some View {
        Button(action: {self.showPicker.toggle() }) {
            view()
        }
        .sheet(isPresented: self.$showPicker) {
            DocumentPickerController(documentTypes: self.documentTypes,
                                     onOpen: self.onOpen,
                                     onCancel: self.onCancel)
        }
    }
}

This looks a bit complicated but, but let's break it down a bit. The init takes the callback, and as not everyone needs have an onCancel handler, I made a default empty implementation. Reason I didn't instantiate the picker here, is because I don't want the picker view polluting in the background, it might not matter but alas. The button is an action, which takes the view from the initializer as label. I then spawn the document picker, when the button is pressed. The document browser view from the picker is in a sheet, this is to closer match the look and behaviour from UIKit, you can of course have the browser in any view you prefer.

And I promised some @EnvironmentObject, how did I use that? Once the document picker view is a SiftUI view, I can bring in the object like I usually would, and the way I call DocumentPickerButton in my app, looks a little like this:

struct ContentView: View {
    @EnvironmentObject var player: Player
    var body: some View {
        NavigationView {
            List {
                ForEach(player.queue, id: \.self) { song in
                    SongCellView(song: song)
                }
            }.onAppear {
                UITableView.appearance()
                    .separatorStyle = .none
            }
            .navigationBarTitle(Text("Song"))
            .navigationBarItems(trailing: 
                DocumentPickerButton(documentTypes: ["public.mp3"],
                                     onOpen: self.openSong){
                    Image(systemName: "plus.circle.fill")
                        .resizable()
            } )
        }
        
    }
    
    func openSong (urls: [URL]) -> () {
        print("Reading URLS")
        urls.forEach { url in
            guard url.startAccessingSecurityScopedResource() else {
                print("Failed to open the file")
                return
            }
            print(url)
            defer { url.stopAccessingSecurityScopedResource() }
            
            guard let bookmark = try? url.bookmarkData() else {
                return
            }
            
            let defaults = UserDefaults.standard
            
            var array = defaults.array(forKey: "Songs") as? [Data]
            array?.append(bookmark)
            defaults.set(array, forKey: "Songs")
            
            self.player.song = .AVPlayer(.init(url: url))
        }
    }
}

And this is how I brought @EnvironmentObject into the world of UIKit.

Tagged with: