Higlight speech utterance in SwiftUI
I suggest you wrap UILabel
in UIViewRepresentable
so that you can keep using an attributed string like before:
struct ContentView : View {
@ObservedObject var speaker = Speaker()
var body: some View {
VStack {
LabelRepresented(text: speaker.label)
}.onAppear {
speaker.speak("Hi. This is a test.")
}
}
}
struct LabelRepresented: UIViewRepresentable {
var text : NSAttributedString?
func makeUIView(context: Context) -> UILabel {
return UILabel()
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = text
}
}
class Speaker: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
let synth = AVSpeechSynthesizer()
@Published var label: NSAttributedString? // <- change to AttributedString
override init() {
super.init()
synth.delegate = self
}
func speak(_ string: String) {
let utterance = AVSpeechUtterance(string: string)
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
utterance.rate = 0.4
synth.speak(utterance)
}
// Functions to highlight text
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
let mutableAttributedString = NSMutableAttributedString(string: utterance.speechString)
mutableAttributedString.addAttribute(.foregroundColor, value: UIColor.red, range: characterRange)
label = mutableAttributedString
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
label = NSAttributedString(string: utterance.speechString)
}
}
I changed label
into an NSAttributedString?
and made it a @Published property on an ObservableObject
-- that way, when it changes, the view gets notified right away.
The UIViewRepresentable
creates a label and updates it with the attributed string any time it changes.
In the event that you did want to try a more pure SwiftUI approach, this blog posts has some good resources for using NSAttributedString in SwiftUI (including the approach I took): https://swiftui-lab.com/attributed-strings-with-swiftui/
Controlling the loop execution
You could always use Combine for this
import Combine
let speaker = Speaker()
let capitals = ["Canberra is the capital of Australia", "Seoul is the capital of South Korea", "Tokyo is the capital of Japan", "Berlin is the capital of Germany"]
var playerCancellable: AnyCancellable? = nil
Button("Play Sound") {
playSound()
}
func playSound() {
// Fairly standard timer publisher. The call to .autoconnect() tells the timer to start publishing when subscribed to.
let timer = Timer.publish(every: 20, on: .main, in: .default)
.autoconnect()
// Publishers.Zip takes two publishers.
// It will only publish when there is a "symmetrical" output. It behaves in a similar manner as `zip` on sequences.
// So, in this scenario, you will not get the next element of your array until the timer emits another event.
// In the call to sink, we ignore the first element of the tuple relating to the timer
playerCancellable = Publishers.Zip(timer, capitals.publisher)
.sink { _, item in
speaker.speak(item)
}
}
Edit
You mentioned in the comments that you want to be able to variably control the delay between utterances. That's not really something a Timer can be used for. I hacked around a bit because I found it to be an interesting problem and was able to make this work as you describe that you want in the comments:
class Speaker: NSObject {
let synth = AVSpeechSynthesizer()
private var timedPhrases: [(phrase: String, delay: TimeInterval)]
// This is so you don't potentially block the main queue
private let queue = DispatchQueue(label: "Phrase Queue")
override init() {
timed = []
super.init()
synth.delegate = self
}
init(_ timedPhrases: [(phrase: String, delay: TimeInterval)]) {
self.timedPhrases = timedPhrases
super.init()
synth.delegate = self
}
private func speak(_ string: String) {
let utterance = AVSpeechUtterance(string: string)
utterance.voice = AVSpeechSynthesisVoice(language: "en-GB")
utterance.rate = 0.5
synth.speak(utterance)
}
func speak() {
guard let first = timed.first else { return }
speak(first.value)
timed = Array(timed.dropFirst())
}
}
extension Speaker: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
if !timed.isEmpty {
queue.sync {
Thread.sleep(forTimeInterval: TimeInterval(timed.first!.delay))
self.speak()
}
} else {
print("all done")
}
}
}
let speaker = let speaker = Speaker([
(phrase: "1", delay: 0),
(phrase: "2", delay: 3),
(phrase: "3", delay: 1),
(phrase: "4", delay: 5),
(phrase: "5", delay: 10)
])
speaker.speak()
Take this with a huge grain of salt. I don't really consider using Thread.sleep to be a very good practice, but maybe this will give you some ideas on how to approach it. If you want variable timing, a Timer instance is not going to give you that.
How can I get text to wrap in a UILabel (via UIViewRepresentable) without having a fixed width?
Possible solution is to declare the width as a variable on MyTextView:
struct MyTextView: UIViewRepresentable {
var width: CGFloat
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.preferredMaxLayoutWidth = width
label.text = "Here's a lot of text for you to display. It won't fit on the screen."
return label
}
func updateUIView(_ view: UILabel, context: Context) {
}
}
and then use GeometryReader to findout how much space there is avaible and pass it into the intializer:
struct ExampleView: View {
var body: some View {
GeometryReader { geometry in
MyTextView(width: geometry.size.width)
}
}
}
Related Topics
Spritekit: Sprites Are Moving Through Each Other with a Physicsbody Already Set
Verifying The Purchase (Receipt) of Another Application (Mac App Store)
Calling Asynchronous Method Inside For-Loop
Cache That Can Purge Unused Objects by Demand
Creating Decoration View as Custom Column in UIcollection View
Issue with Optional Core Data Relationship Using Nspersistentcloudkitcontainer
Bleed Through from Nsbutton Checkbox on Non-Transparent Nspopover
Decrypting Des with Commoncrypto in Swift 3
How to Get Alphabetic Tableview Sections from an Object
How to Show Two Row of Text in a Menu Bar App in MAC Os
Can't Hide Status Bar in Avplayerviewcontroller's Portrait Mode
Msmessagelivelayout Freeze/Crash in Transcript When Info.Plist Contains Privacy Request
How to Create Travelling Wave in Spritekit
How to Programmatically Scroll iOS Wkwebview, Swift 4
Arkit: How to Tell If User's Face Is Parallel to Camera
Uinavigationbar Turns White and Won't Close When Using Navigationitem.Searchcontroller in iOS 13