I am trying to create a SwiftUI Marquee component that:
- Can take 2 string items
- If any of the two string items are longer then the width of its parent then animate them right to left
- If they are smaller then no animation occurs on that particular text (So you might have both animating, only one animation or none animating).
- When the text scrolls across the screen it is repeated and stops when the second copy of the text returns to the starting position.
- Animation starts after 5 seconds.
- After the animation is completed it should wait 5 seconds before animating again
- If both are animating they should animate at the same speed
- If both are animating and one finishes before the other due to string length, it should wait for the other animation to complete before waiting 5 seconds again and starting the animation again.
- When the texts are changed, texts should return to their original offset and then animation starts again after 5 seconds.
I am very close to having this working. I have tried multiple versions of this with onChange(of:) and lots of some useful and not very useful help from Copilot. This iteration is using a PassthroughSubject to send a message to the component to help start the animations and keep them in sync with each other.
There are issues where sometimes the labels don't animate after changing the texts, sometimes when changing the labels the offset is incorrect and is being drawn off the screen.
Wondering if anyone can offer any guidance on this, any help would be greatly appreciated?
Here's what I have...
import SwiftUI import Combine struct WidthPreference: PreferenceKey { static let defaultValue: CGFloat? = nil static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { value = value ?? nextValue() } } struct MarqueeText: View { @Binding var text: String let animationSubject: PassthroughSubject<Void, Never> @State var startAnimating: Bool = false var animationComplete: () -> Void @State private var width: CGFloat = 0 var body : some View { let _ = Self._printChanges() let stringWidth = text.widthOfString(usingFont: UIFont.systemFont(ofSize: 15)) let speed: CGFloat = 30 let duration = Double(stringWidth * 1.3 / speed) let animationOne = Animation.linear(duration: duration).delay(5) ZStack { GeometryReader { geometry in Text(text).lineLimit(1) .font(.subheadline) .offset(x: startAnimating ? -stringWidth * 1.3 : 0) .fixedSize(horizontal: true, vertical: false) .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading ) .preference(key: WidthPreference.self, value: geometry.size.width) if width < stringWidth { Text(text).lineLimit(1) .font(.subheadline) .offset(x: startAnimating ? 0 : stringWidth * 1.3) .fixedSize(horizontal: true, vertical: false) .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading ) } } } .id(text) .onPreferenceChange(WidthPreference.self) { value in width = value ?? 0 } .onReceive(animationSubject) { let content = text.prefix(5) print("Animation starting for text starting '\(content)' (stringWidth: \(stringWidth), width: \(width))") if width < stringWidth { withAnimation(animationOne) { print("Animation starting for '\(content)'") startAnimating = true } completion: { print("Animation finished for '\(content)'") startAnimating = false animationComplete() } } } } } extension String { func widthOfString(usingFont font: UIFont) -> CGFloat { let fontAttributes = [NSAttributedString.Key.font: font] let size = self.size(withAttributes: fontAttributes) return size.width } } struct ContentView: View { private let strings = [ "Some shorter text", "Another shorter text", "This is some very long text for a song", "My tea's gone cold I'm wondering why, I got out of bed at all...", "The morning rain clouds up my window and I can't see at all...", "And even if I could it'd all be grey, but your picture on my wall...", "It reminds me that it's not so bad, it's not so bad..." ] @State var text: String = "This is some very long text for a song" @State var moreText: String = "My tea's gone cold I'm wondering why, I got out of bed at all..." @State var animationsCompletedCount: Int = 0 let animationSubject = PassthroughSubject<Void, Never>() let animation2Subject = PassthroughSubject<Void, Never>() var body: some View { let _ = Self._printChanges() VStack (alignment: .leading) { MarqueeText(text: $text, animationSubject: animationSubject) { print("First animation finished") animationsCompletedCount += 1 animationComplete() } MarqueeText(text: $moreText, animationSubject: animation2Subject) { print("Second animation finished") animationsCompletedCount += 1 animationComplete() } Button("Change texts") { let newText = randomString(butNot: self.text) let newMoreText = randomString(butNot: self.moreText) print("Setting new texts to '\(newText)' and '\(newMoreText)'") self.text = newText self.moreText = newMoreText } } .onAppear { print("Starting both animations") startAnimation() } .frame(width: 230, height: 70) .clipShape(RoundedRectangle(cornerRadius: 0, style: .continuous)) } private func animationComplete() { if animationsCompletedCount == 2 { print("Restarting both animations") startAnimation() } } private func startAnimation() { animationsCompletedCount = 0 animationSubject.send() animation2Subject.send() } private func randomString(butNot: String) -> String { let string = strings.randomElement()! if string == butNot { return randomString(butNot: butNot) } return string } } #Preview { ContentView() } 