I'm creating this animation and would like to know if there is a way to make it more enjoyable.
here is the code:
import SwiftUI
struct TypewriterView: View {
let text: String
@State private var animatedText = ""
var body: some View {
Text(animatedText)
.font(.title)
.padding()
.onAppear {
animateText()
}
}
private func animateText() {
for (index, character) in text.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.05) {
withAnimation {
animatedText += String(character)
}
}
}
}
}
// Usage example
struct ContentView: View {
var body: some View {
TypewriterView(text: "Hello, Twitter! This is a typewriter animation.")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Maybe I should set an Animation in withAnimation
...
Any thoughts?
You want just the opposite here. You don't want an animation. That's leading to it blending between the two states. You just want to add characters. I recommend this approach, replacing GCD with async/await:
var body: some View {
Text(animatedText)
.font(.title)
.padding()
.task { await animateText() } // Use .task to create async context
}
private func animateText() async {
for character in text {
animatedText.append(character)
// Use `.sleep` to separate the updates rather than `asyncAfter`
try! await Task.sleep(for: .milliseconds(75))
}
}
There is a unrelated problem I see with this where Text changes its mind about its layout when word-wrapping. When the text drops the last word, the "a" is put on the second line with "typewriter." When a third word is added to that line, then the view resizes and the "a" moves up a line, which breaks the "typewriter" illusion. I haven't figure out a good solution to that (but it's separate from the animation question).
To fix the problem with the text reformatting, it does seem to work to create a string of spaces of the correct length, and then swap in letters. It works even better if you add .monospaced()
to the Text, which better matches the look of a typewriter, but this isn't required.
var body: some View {
Text(animatedText)
.font(.title)
.monospaced() // If desired
.padding()
.task { await animateText() }
.onTapGesture {
// makes testing it easier
animatedText = ""
Task { await animateText() }
}
}
private func animateText() async {
var characters = Array(repeating: Character(" "),
count: text.count)
for (index, character) in zip(characters.indices, text) {
characters[index] = character
animatedText = String(characters)
try! await Task.sleep(for: .milliseconds(50))
}
}
Wrapping is still a bit awkward, since it won't wrap until it exceeds the width of the line, so it's more like a word processor than a typewriter, but it's a fairly good effect for a small amount of code.
I did a little more thinking on this, and a possibly better approach is to make all the text clear, and then progressively remove that attribute. This way there's only one layout step. Here's an updated version that does that and also restarts the animation any time the text is changed.
struct TypewriterView: View {
var text: String
var typingDelay: Duration = .milliseconds(50)
@State private var animatedText: AttributedString = ""
@State private var typingTask: Task<Void, Error>?
var body: some View {
Text(animatedText)
.onChange(of: text) { _ in animateText() }
.onAppear() { animateText() }
}
private func animateText() {
typingTask?.cancel()
typingTask = Task {
let defaultAttributes = AttributeContainer()
animatedText = AttributedString(text,
attributes: defaultAttributes.foregroundColor(.clear)
)
var index = animatedText.startIndex
while index < animatedText.endIndex {
try Task.checkCancellation()
// Update the style
animatedText[animatedText.startIndex...index]
.setAttributes(defaultAttributes)
// Wait
try await Task.sleep(for: typingDelay)
// Advance the index, character by character
index = animatedText.index(afterCharacter: index)
}
}
}
}
// Usage example
struct ContentView: View {
@State var text = "Hello, Twitter! This is a typewriter animation."
var body: some View {
TypewriterView(text: text)
.font(.title)
.padding()
}
}