So I am currently having to manually add new stations to our CarPlay app. However we have a JSON which our app uses for iPhone and iPad. So I am wondering how do I create a list that uses this information instead of me manually creating it.
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) {
let DRN1 = CPListItem(text: "DRN1", detailText: "Perth's No.1 Online Station.")
let Hits = CPListItem(text: "DRN1 Hits", detailText: "Top 40 songs and yesturdays hits.")
let United = CPListItem(text: "DRN1 United", detailText: "Perth's Dedicated LGBTIQA+ Station.")
let Dance = CPListItem(text: "DRN1 Dance", detailText: "Playing the hottest dance tracks.")
if #available(iOS 14.0, *) {
let nowplay = CPNowPlayingTemplate.shared
DRN1.setImage(UIImage(imageLiteralResourceName:"DRN1Logo"))
DRN1.handler = { item, completion in
print("selected DRN1")
AdStichrApi.station = "DRN1"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
Hits.setImage(UIImage(imageLiteralResourceName:"DRN1Hits"))
Hits.handler = { item, completion in
print("selected Hits")
MusicPlayer.shared.player?.pause()
AdStichrApi.station = "DRN1Hits"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1Hits", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
//interfaceController.presentTemplate(nowplay, animated: false)
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
United.setImage(UIImage(imageLiteralResourceName:"DRN1United"))
United.handler = { item, completion in
print("selected United")
AdStichrApi.station = "DRN1United"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1United", type: "radio")
// do work in the UI thread here
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
Dance.setImage(UIImage(imageLiteralResourceName:"DRN1Dance"))
Dance.handler = { item, completion in
print("selected Dance")
AdStichrApi.station = "dance"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/dance", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
completion()
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
}
}
} else {
// Fallback on earlier versions
}
let listTemplate = CPListTemplate(title: "Select a Station", sections: [CPListSection(items:[DRN1,United,Hits,Dance])])
However in my iOS app I just use
Api().getStations { (stations) in
self.stations = stations
}
Which fetches the JSON from the backend and provides me with every station available.
I am wondering How can I create a CPList using this information instead.
Example:
I found this example but it is fetching local data and I don't need the tab bar at this time.
https://github.com/T0yBoy/CarPlayTutorial/tree/master/CarPlayTutorial
From my understanding all I need to do is create a list
for station in (radios) {
let item = CPListItem(text: station.name, detailText: station.name)
item.accessoryType = .disclosureIndicator
item.setImage(UIImage(named: station.imageurl))
item.handler = { [weak self] item, completion in
guard let strongSelf = self else { return }
// strongSelf.favoriteAlert(radio: radio, completion: completion)
}
radioItems.append(item)
}
I know I need to do something along the lines of
Api().getStations { (stations) in
self.radios = stations
INSERT THE STATIONS INTO A LIST for
let listTemplate = CPListTemplate(title: "Select a Station", sections: [CPListSection(items:stations)])
}
This is how it should still look after creating a list from the API
Give this a try:
// In some function in the CarPlaySceneDelegate
Api().getStations { (stations) in
var stationItems: [CPListItem] = []
self.radios = stations
for station in stations {
let item = CPListItem(text: station.name,
detailText: station.description
image: station.image)
item.handler = { [weak self] item, completion in
// manage what should happen on tap
// like navigate to NowPlayingView
}
stationItems.append(item)
}
loadList(withStations: stationItems)
}
// Load the list template
private func loadList(withStations stations: [CPListItem]) {
let section = CPListSection(items: stations)
let listTemplate = CPListTemplate(title: "Select a station",
sections: [section])
// Set the root template of the CarPlay interface
// to the list template with a completion handler
interfaceController?.setRootTemplate(listTemplate,
animated: true) { success, error in
// add anything you want after setting the root template
}
}
The for loop to add the stations in a CPListItem array can be replaced by a map function but I did it this way for clarity.
Update
In your class CarPlaySceneDelegate, fix the spelling from
var interfactController: CPInterfaceController?
to
var interfaceController: CPInterfaceController?
In your templateApplicationScene didConnect comment out the interfactController?.setRootTemplate(listTemplate, animated: false) for now
and add this line to it self.interfaceController = interfaceController
So the updated function looks like this:
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
// Add this
self.interfaceController = interfaceController
Api().getStations { (stations) in
var stationItems: [CPListItem] = []
self.stations = stations
for station in stations {
let item = CPListItem(text: station.name,
detailText: station.name
// image: imageLiteralResourceName:"DRN1Logo")
)
item.handler = { [weak self] item, completion in
// manage what should happen on tap
// like navigate to NowPlayingView
print(item)
}
stationItems.append(item)
}
self.loadList(withStations: stationItems)
}
}
Do you see any better results ?
Related
I'm relatively new to swift and am I'm making a swiftui calling application with a deepfaked chatbot that requires me to transcribe the users speech to text and then play an appropriate response.
I currently have a working flow that that starts a speech recognition session when the user clicks a button, and stops the recording/recognition when the user clicks the button again. They need to keep clicking start/stop in order for this to work.
To make this hands free like a real voice chat app, I would like to get rid of requiring the user to click buttons. I would like them to click a "call" button once to get the recording and the speech recognition going, and then automatically detect when they stop talking with a 2 second timer. Then I can send the text to the backend and I would like to automatically restart the mic and the speech recognition so I can keep doing this in a loop to partition user input, until the user clicks the button again to hang up.
I have implemented a timer to detect when the user stops speaking, but when I try to restart the microphone and the speech recognition session using a repeat while loop, my program doesn't work as I expect and speech recognition doesn't work.
This is what I tried to do to make the "addItem" logic run in a loop once the user clicks the call button initially. The logic to end speech recognition after 2 seconds of silence works fine, but as soon as I add the repeat while loop, the program goes haywire after the first click of the call button. I can't figure out the proper way to make the logic loop after speech recognition ends and I get the text.
Main View code:
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Todo.created, ascending: true)], animation: .default) private var todos: FetchedResults<Todo>
#State private var recording = false
#ObservedObject private var mic = MicMonitor(numberOfSamples: 30)
private var speechManager = SpeechManager()
var body: some View {
NavigationView {
ZStack(alignment: .bottomTrailing) {
List {
Text(todos.last?.text ?? "----")
}
.navigationTitle("Speech To Text")
VStack{
recordButton()
}
}.onAppear {
speechManager.checkPermissions()
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func recordButton() -> some View {
Button(action: addItem) {
Image(systemName: "phone.fill")
.font(.system(size: 40))
.padding()
.cornerRadius(10)
}.foregroundColor(recording ? .red : .green)
}
private func addItem() { //THIS IS THE FUNCTION THAT I WANT TO RUN IN A LOOP WITHOUT NEEDING TO CLICK THE BUTTON EVERYTIME
if speechManager.isRecording {
self.recording = false
mic.stopMonitoring()
speechManager.stopRecording()
} else {
repeat {
self.recording = true
mic.startMonitoring()
speechManager.start { (speechText) in
guard let text = speechText, !text.isEmpty else {
self.recording = false
return
}
print("FINAL TEXT AFTER TIMER ENDS: ", text)
DispatchQueue.main.async {
withAnimation {
let newItem = Todo(context: viewContext)
newItem.id = UUID()
newItem.text = text
newItem.created = Date()
do {
try viewContext.save()
} catch {
print(error)
}
mic.stopMonitoring() //At this point, I want to restart the recording and the speech recognition session and keep doing the else statement in a loop automatically }
}
}
} while self.recording == true
}
speechManager.isRecording.toggle()
print("Toggeled isRecording!!")
}
}
Speech Recognition code:
import Foundation
import Speech
class SpeechManager {
public var isRecording = false
private var audioEngine: AVAudioEngine!
private var inputNode: AVAudioInputNode!
private var audioSession: AVAudioSession!
var timer : Timer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
func checkPermissions() {
SFSpeechRecognizer.requestAuthorization{ (authStatus) in
DispatchQueue.main.async {
switch authStatus {
case .authorized: break
default:
print("Speech recognition is not available")
}
}
}
}
func start(completion: #escaping (String?) -> Void) {
if isRecording {
//stopRecording()
} else {
startRecording(completion: completion)
}
}
func startRecording(completion: #escaping (String?) -> Void) {
//createTimer(4)
guard let recognizer = SFSpeechRecognizer(), recognizer.isAvailable else {
print("Speech recognition is not available")
return
}
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
recognitionRequest!.shouldReportPartialResults = true
recognizer.recognitionTask(with: recognitionRequest!) { (result, error) in
//let defaultText = self.text
guard error == nil else {
print("got error \(error!.localizedDescription)")
return
}
guard let result = result else { return }
////////////////
self.timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { (timer) in
self.timer?.invalidate()
print("invalidated timer")
self.stopRecording()
return
////////////////
})
if result.isFinal {
completion(result.bestTranscription.formattedString)
print("FINAL")
print(result.bestTranscription.formattedString)
}
}
audioEngine = AVAudioEngine()
inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer, _) in
self.recognitionRequest?.append(buffer)
}
audioEngine.prepare()
do {
audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.record, mode: .spokenAudio, options: .duckOthers)
try audioSession.setActive(true, options:.notifyOthersOnDeactivation)
try audioEngine.start()
} catch {
print(error)
}
}
func stopRecording() {
audioEngine.stop()
recognitionRequest?.endAudio()
recognitionRequest = nil
inputNode.removeTap(onBus: 0)
audioSession = nil
}
}
I am able to create my custom style of button with a ButtonStyle but I would like to add haptic feedback when the button is touched down and let go of. I know there is a configuration.isPressed variable available in ButtonStyle, but how do I give haptic feedback when it actually changes.
I know how to give haptic feedback but need help knowing when the user touches the button and let's go.
I have tried this extension as a button:
struct TouchGestureViewModifier: ViewModifier {
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
let abort: (Bool) -> Void
#State private var hasBegun = false
#State private var hasEnded = false
#State private var hasAborted = false
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= 20.0
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasAborted = true
self.abort(true)
}
}
.onEnded { event in
print("ended")
if !self.hasEnded {
if self.isTooFar(event.translation) {
self.hasAborted = true
self.abort(true)
} else {
self.hasEnded = true
self.touchEnd(true)
}
}
self.hasBegun = false
self.hasEnded = false
})
}
}
// add above so we can use it
extension View {
func onTouchGesture(touchBegan: #escaping () -> Void = {},
touchEnd: #escaping (Bool) -> Void = { _ in },
abort: #escaping (Bool) -> Void = { _ in }) -> some View {
modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd, abort: abort))
}
}
Then I do this on a view:
.onTouchGesture(
// what to do when the touch began
touchBegan: {
// tell the view that the button is being pressed
self.isPressed = true
// play click in sound
playSound(sound: "clickin-1", type: "wav")
// check if haptic feedback is possible
if self.engine != nil {
// if it is - give some haptic feedback
let pattern = try? CHHapticPattern(events: [self.event], parameters: [])
let player = try? self.engine?.makePlayer(with: pattern!)
try? self.engine!.start()
try? player?.start(atTime: 0)
}
},
// what to do when the touch ends. sometimes this doesnt work if you hold it too long :(
touchEnd: { _ in
// tell the view that the user lifted their finger
self.isPressed = false
playSound(sound: "clickout-1", type: "wav")
// check if haptic feedback is possible
if self.engine != nil {
// if it is - give some haptic feedback
let pattern = try? CHHapticPattern(events: [self.event], parameters: [])
let player = try? self.engine?.makePlayer(with: pattern!)
try? self.engine!.start()
try? player?.start(atTime: 0)
}
},
// if the user drags their finger away, abort the action
abort: { _ in
self.isPressed = false
}
)
But it gets stuck halfway through sometimes which is not very useable for users. How do I do it on the more reliable Button with ButtonStyle?
I have the following extension which I want to make usable for both an UITextView and an UILabel.
extension UITextView {
func setTextWithTypeAnimation(typedText: String, characterDelay: TimeInterval = 2.5) {
text = ""
var writingTask: DispatchWorkItem?
writingTask = DispatchWorkItem { [weak weakSelf = self] in
for character in typedText {
DispatchQueue.main.async {
weakSelf?.text!.append(character)
}
Thread.sleep(forTimeInterval: characterDelay/100)
}
}
if let task = writingTask {
let queue = DispatchQueue(label: "typespeed", qos: DispatchQoS.userInteractive)
queue.asyncAfter(deadline: .now() + 0.05, execute: task)
}
}
}
I tried to set the type to UIView found here: Stack Overflow: Single extension for UITextView and UITextField in Swift
extension UIView {
func setTextWithTypeAnimation(typedText: String, characterDelay: TimeInterval = 2.5) {
if self is UILabel || self is UITextView {
text = ""
var writingTask: DispatchWorkItem?
writingTask = DispatchWorkItem { [weak weakSelf = self] in
for character in typedText {
DispatchQueue.main.async {
weakSelf?.text!.append(character)
}
Thread.sleep(forTimeInterval: characterDelay/100)
}
}
if let task = writingTask {
let queue = DispatchQueue(label: "typespeed", qos: DispatchQoS.userInteractive)
queue.asyncAfter(deadline: .now() + 0.05, execute: task)
}
}
}
}
That of course doesn't work because UIView has no text property so I get the following error:
Use of unresolved identifier 'text'
How can I solve this?
That of course doesn't work because UIView has no text property
This is exactly the piece you want to think about. What do you need in order to write this method? Two things: it needs a text property. And it needs to be a class type (because you use a weak modifier on it). So say that.
protocol TypeAnimated: AnyObject {
var text: String? { get set }
}
Except that for historical reasons, UITextView has a String! while UILabel has a String?. That's very frustrating, but we can bridge the two with a new property:
protocol TypeAnimated: AnyObject {
var animatedText: String { get set }
}
Now, given that protocol, you can write you method:
extension TypeAnimated {
func setTextWithTypeAnimation(typedText: String, characterDelay: TimeInterval = 2.5) {
animatedText = ""
var writingTask: DispatchWorkItem?
writingTask = DispatchWorkItem { [weak weakSelf = self] in
for character in typedText {
DispatchQueue.main.async {
weakSelf?.animatedText!.append(character)
}
Thread.sleep(forTimeInterval: characterDelay/100)
}
}
if let task = writingTask {
let queue = DispatchQueue(label: "typespeed", qos: DispatchQoS.userInteractive)
queue.asyncAfter(deadline: .now() + 0.05, execute: task)
}
}
}
Now, you just need to label any types you want to conform to this protocol, and they'll get the extension.
extension UILabel: TypeAnimated {
var animatedText: String {
get { return text ?? "" }
set { text = newValue }
}
}
extension UITextView: TypeAnimated {
var animatedText: String {
get { return text ?? "" }
set { text = newValue }
}
}
As a side note, generating a new queue every time this is executed is almost certainly not what you mean. You should probably just set this up as a series of asyncAfter calls to the main queue, or use a Timer, or call asyncAfter inside the asyncAfter block. But none of this really impacts your question.
I haven't tested this, but this is how I would probably approach the problem:
extension TypeAnimated {
func setTextWithTypeAnimation(typedText: String, characterDelay: TimeInterval = 2.5/100) {
func addNextCharacter(from string: Substring) {
DispatchQueue.main.asyncAfter(deadline: .now() + characterDelay) { [weak self] in
if let self = self, let nextChar = string.first {
self.animatedText.append(nextChar)
addNextCharacter(from: string.dropFirst())
}
}
}
animatedText = ""
addNextCharacter(from: typedText[...])
}
}
Life was fairly easy when I first test-developed a text-based application with NSDocument. Now, I have a lot more complicated document-based desktop application with several custom models other than a string with NSTextView. My subclass of NSDocument is the following.
import Cocoa
class Document: NSDocument {
// MARK: - Variables
var image = NSImage()
var myPasteModels = [PasteModel]()
var myPanModel: PanModel?
var myWinModel: WindowModel?
// MARK: - Initialization
override init() {
super.init()
}
// MARK: - Auto saving
override class var autosavesInPlace: Bool {
return false
}
override func data(ofType typeName: String) throws -> Data {
if let viewController = windowControllers[0].contentViewController as? MainViewController {
if viewController.imageModels.count > 0 {
viewController.saveSubViewPositions()
if let window = viewController.view.window {
var pasteModels = [PasteModel]()
for i in 0..<viewController.imageModels.count {
let imageModel = viewController.imageModels[i]
...
...
}
NSKeyedArchiver.setClassName("ColorModel", for: ColorModel.self)
NSKeyedArchiver.setClassName("TextModel", for: TextModel.self)
NSKeyedArchiver.setClassName("ShapeModel", for: ShapeModel.self)
NSKeyedArchiver.setClassName("ShadeModel", for: ShadeModel.self)
NSKeyedArchiver.setClassName("LineModel", for: LineModel.self)
NSKeyedArchiver.setClassName("GradientModel", for: GradientModel.self)
NSKeyedArchiver.setClassName("ArrowModel", for: ArrowModel.self)
NSKeyedArchiver.setClassName("PasteModel", for: PasteModel.self)
NSKeyedArchiver.setClassName("PanModel", for: PanModel.self)
NSKeyedArchiver.setClassName("WindowModel", for: WindowModel.self)
let panModel = PanModel(frameWidth: viewController.panView.frame.size.width, frameHeight: viewController.panView.frame.size.height)
let winModel = WindowModel(width: window.frame.width, height: window.frame.height)
let dict = ["PasteModel": pasteModels, "PanModel": panModel, "WindowModel": winModel] as [String : Any]
do {
let modelData = try NSKeyedArchiver.archivedData(withRootObject: dict, requiringSecureCoding: false)
return modelData
} catch let error as NSError {
Swift.print("\(error)")
}
}
}
}
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}
override func save(withDelegate delegate: Any?, didSave didSaveSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
if let _ = fileURL {
Swift.print("Saved!!!")
} else {
Swift.print("Not saved yet...")
NSApp.sendAction(#selector(NSDocument.saveAs(_:)), to: nil, from: self)
}
}
override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
savePanel.allowedFileTypes = ["fss"]
savePanel.allowsOtherFileTypes = true
savePanel.isExtensionHidden = false
return true
}
}
The problem that I have is that the application never saves a document if I choose Save As under File (or press Command + Shift + S). If I choose Save As, the application goes beep and dismiss the command selection. It does enter the prepareSavePanel method if I set a break point there. So what can I do to go any further? Thanks.
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.
}()
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 tabView(_:didSelect:)
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.