swiftcocoaasynchronousgrand-central-dispatchnsprogressindicator

Swift: determinate NSProgressIndicator, async refreshing and waiting for return


Working in Swift3; I've got a pretty expensive operation running in a loop iterating through stuff and building it into an array that on return would be used as the content for an NSTableView.

I wanted a modal sheet showing progress for this so people don't think the app is frozen. By googling, looking around in here and not a small amount of trial and error I've managed to implement my progressbar and have it show progress adequately as the loop progresses.

The problem right now? Even though the sheet (implemented as an NSAlert, the progress bar is in the accesory view) works exactly as expected, the whole thing returns before the loop is finished.

Here's the code, hoping somebody can tell me what am I doing wrong:

class ProgressBar: NSAlert {
    var progressBar = NSProgressIndicator()
    var totalItems: Double = 0
    var countItems: Double = 0
    override init() {
        progressBar.isIndeterminate = false
        progressBar.style = .barStyle
        super.init()
        self.messageText = ""
        self.informativeText = "Loading..."
        self.accessoryView = NSView(frame: NSRect(x:0, y:0, width: 290, height: 16))
        self.accessoryView?.addSubview(progressBar)
        self.layout()
        self.accessoryView?.setFrameOrigin(NSPoint(x:(self.accessoryView?.frame)!.minX,y:self.window.frame.maxY))
        self.addButton(withTitle: "")
        progressBar.sizeToFit()
        progressBar.setFrameSize(NSSize(width:290, height: 16))
        progressBar.usesThreadedAnimation = true
        self.beginSheetModal(for: ControllersRef.sharedInstance.thePrefPane!.mainCustomView.window!, completionHandler: nil)
    }
}

static var allUTIs: [SWDAContentItem] = {
    var wrappedUtis: [SWDAContentItem] = []
    let utis = LSWrappers.UTType.copyAllUTIs()

        let a = ProgressBar()

        a.totalItems = Double(utis.keys.count)
        a.progressBar.maxValue = a.totalItems

        DispatchQueue.global(qos: .default).async {
            for uti in Array(utis.keys) {
                a.countItems += 1.0
                wrappedUtis.append(SWDAContentItem(type:SWDAContentType(rawValue: "UTI")!, uti))
                Thread.sleep(forTimeInterval:0.0001)

                DispatchQueue.main.async {
                    a.progressBar.doubleValue = a.countItems
                    if (a.countItems >= a.totalItems && a.totalItems != 0) {
                        ControllersRef.sharedInstance.thePrefPane!.mainCustomView.window?.endSheet(a.window)
                    }
                }
            }
        }
    Swift.print("We'll return now...")
    return wrappedUtis // This returns before the loop is finished.
}()

Solution

  • In short, you're returning wrappedUtis before the asynchronous code has had a chance to finish. You cannot have the initialization closure return a value if the update process itself is happening asynchronously.

    You clearly successfully diagnosed a performance problem in the initialization of allUTIs, and while doing this asynchronously is prudent, you shouldn't be doing that in that initialization block of the allUTIs property. Move this code that initiates the update of allUTIs into a separate function.


    Looking at ProgressBar, it's really an alert, so I'd call it ProgressAlert to make that clear, but expose the necessary methods to update the NSProgressIndicator within that alert:

    class ProgressAlert: NSAlert {
        private let progressBar = NSProgressIndicator()
    
        override init() {
            super.init()
    
            messageText = ""
            informativeText = "Loading..."
            accessoryView = NSView(frame: NSRect(x:0, y:0, width: 290, height: 16))
            accessoryView?.addSubview(progressBar)
            self.layout()
            accessoryView?.setFrameOrigin(NSPoint(x:(self.accessoryView?.frame)!.minX,y:self.window.frame.maxY))
            addButton(withTitle: "")
    
            progressBar.isIndeterminate = false
            progressBar.style = .barStyle
            progressBar.sizeToFit()
            progressBar.setFrameSize(NSSize(width:290, height: 16))
            progressBar.usesThreadedAnimation = true
        }
    
        /// Increment progress bar in this alert.
    
        func increment(by value: Double) {
            progressBar.increment(by: value)
        }
    
        /// Set/get `maxValue` for the progress bar in this alert
    
        var maxValue: Double {
            get {
                return progressBar.maxValue
            }
            set {
                progressBar.maxValue = newValue
            }
        }
    }
    

    Note, this doesn't present the UI. That's the job of whomever presented it.

    Then, rather than initiating this asynchronous population in the initialization closure (because initialization should always be synchronous), create a separate routine to populate it:

    var allUTIs: [SWDAContentItem]?
    
    private func populateAllUTIs(in window: NSWindow, completionHandler: @escaping () -> Void) {
        let progressAlert = ProgressAlert()
        progressAlert.beginSheetModal(for: window, completionHandler: nil)
    
        var wrappedUtis = [SWDAContentItem]()
        let utis = LSWrappers.UTType.copyAllUTIs()
    
        progressAlert.maxValue = Double(utis.keys.count)
    
        DispatchQueue.global(qos: .default).async {
            for uti in Array(utis.keys) {
                wrappedUtis.append(SWDAContentItem(type:SWDAContentType(rawValue: "UTI")!, uti))
    
                DispatchQueue.main.async { [weak progressAlert] in
                    progressAlert?.increment(by: 1)
                }
            }
            DispatchQueue.main.async { [weak self, weak window] in
                self?.allUTIs = wrappedUtis
                window?.endSheet(progressAlert.window)
                completionHandler()
            }
        }
    }
    

    Now, you declared allUTIs to be static, so you can tweak the above to do that, too, but it seems like it's more appropriate to make it an instance variable.

    Anyway, you can then populate that array with something like:

    populateAllUTIs(in: view.window!) {
        // do something
        print("done")
    }
    

    Below, you said:

    In practice, this means allUTIs is only actually initiated when the appropriate TabViewItem is selected for the first time (which is why I initialize it with a closure like that). So, I'm not really sure how to refactor this, or where should I move the actual initialization. Please keep in mind that I'm pretty much a newbie; this is my first Swift (also Cocoa) project, and I've been learning both for a couple of weeks.

    If you want to instantiate this when the tab is selected, then hook into the child view controllers viewDidLoad. Or you can do it in the tab view controller's tab​View(_:​did​Select:​)

    But if the population of allUTIs is so slow, are you sure you want to do this lazily? Why not trigger this instantiation sooner, so that there's less likely to be a delay when the user selects that tab. In that case, you might trigger it the tab view controller's own viewDidLoad, so that the tab that needs those UTIs is more likely to have them.

    So, if I were considering a more radical redesign, I might first change my model object to further isolate its update process from any specific UI, but rather to simply return (and update) a Progress object.

    class Model {
    
        var allUTIs: [SWDAContentItem]?
    
        func startUTIRetrieval(completionHandler: (() -> Void)? = nil) -> Progress {
            var wrappedUtis = [SWDAContentItem]()
            let utis = LSWrappers.UTType.copyAllUTIs()
    
            let progress = Progress(totalUnitCount: Int64(utis.keys.count))
    
            DispatchQueue.global(qos: .default).async {
                for uti in Array(utis.keys) {
                    wrappedUtis.append(SWDAContentItem(type:SWDAContentType(rawValue: "UTI")!, uti))
    
                    DispatchQueue.main.async {
                        progress.completedUnitCount += 1
                    }
                }
    
                DispatchQueue.main.async { [weak self] in
                    self?.allUTIs = wrappedUtis
                    completionHandler?()
                }
            }
    
            return progress
        }
    
    }
    

    Then, I might have the tab bar controller instantiate this and share the progress with whatever view controller needed it:

    class TabViewController: NSTabViewController {
    
        var model: Model!
    
        var progress: Progress?
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            model = Model()
            progress = model.startUTIRetrieval()
    
            tabView.delegate = self
        }
    
        override func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) {
            super.tabView(tabView, didSelect: tabViewItem)
    
            if let item = tabViewItem, let controller = childViewControllers[tabView.indexOfTabViewItem(item)] as? ViewController {
                controller.progress = progress
            }
        }
    
    }
    

    Then the view controller could observe this Progress object, to figure out whether it needs to update its UI to reflect this:

    class ViewController: NSViewController {
    
        weak var progress: Progress? { didSet { startObserving() } }
    
        weak var progressAlert: ProgressAlert?
    
        private var observerContext = 0
    
        private func startObserving() {
            guard let progress = progress, progress.completedUnitCount < progress.totalUnitCount else { return }
    
            let alert = ProgressAlert()
            alert.beginSheetModal(for: view.window!)
            progressAlert = alert
            progress.addObserver(self, forKeyPath: "fractionCompleted", context: &observerContext)
        }
    
        override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            guard let progress = object as? Progress, context == &observerContext else {
                super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
                return
            }
    
            dispatchPrecondition(condition: .onQueue(.main))
    
            if progress.completedUnitCount < progress.totalUnitCount {
                progressAlert?.doubleValue = progress.fractionCompleted * 100
            } else {
                progress.removeObserver(self, forKeyPath: "fractionCompleted")
                view.window?.endSheet(progressAlert!.window)
            }
        }
    
        deinit {
            progress?.removeObserver(self, forKeyPath: "fractionCompleted")
        }
    
    }
    

    And, in this case, the ProgressAlert only would worry about doubleValue:

    class ProgressAlert: NSAlert {
        private let progressBar = NSProgressIndicator()
    
        override init() {
            super.init()
    
            messageText = ""
            informativeText = "Loading..."
            accessoryView = NSView(frame: NSRect(x:0, y:0, width: 290, height: 16))
            accessoryView?.addSubview(progressBar)
            self.layout()
            accessoryView?.setFrameOrigin(NSPoint(x:(self.accessoryView?.frame)!.minX,y:self.window.frame.maxY))
            addButton(withTitle: "")
    
            progressBar.isIndeterminate = false
            progressBar.style = .barStyle
            progressBar.sizeToFit()
            progressBar.setFrameSize(NSSize(width: 290, height: 16))
            progressBar.usesThreadedAnimation = true
        }
    
        /// Set/get `maxValue` for the progress bar in this alert
    
        var doubleValue: Double {
            get {
                return progressBar.doubleValue
            }
            set {
                progressBar.doubleValue = newValue
            }
        }
    }
    

    I must note, though, that if these UTIs are only needed for that one tab, it raises the question as to whether you should be using a NSAlert based UI at all. The alert blocks the whole window, and you may want to block interaction with only that one tab.