0

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...

enter image description here Existing code...

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() } 
4
  • @Sweeper iOS 17 Commented Sep 18 at 13:31
  • There might be some useful techniques to be found in this answer Commented Sep 18 at 13:34
  • @BenzyNeez thanks, I already tried some of the stuff in that post, still stuck with where I am :( Commented Sep 18 at 13:50
  • @Sweeper Awesome. Looking forward to seeing it. Commented Sep 18 at 19:18

1 Answer 1

2

Let's start with one marquee text first,

struct MarqueeText: View { let text: Text let containerWidth: CGFloat let keyframeTimings: KeyframeTimings @Binding var textSize: CGSize var body: some View { let limitedText = text .lineLimit(1) .fixedSize() Color.clear.frame(height: textSize.height) .overlay(alignment: .leading) { limitedText .onGeometryChange(for: CGSize.self, of: \.size) { textSize = $0 } .hidden() } .overlay(alignment: .leading) { if textSize.width <= containerWidth { limitedText .frame(maxWidth: .infinity) // centers the text if it is short } else { HStack(spacing: 8) { limitedText limitedText } .keyframeAnimator(initialValue: 0) { view, offset in view.offset(x: offset) } keyframes: { _ in KeyframeTrack { LinearKeyframe(0, duration: keyframeTimings.standardWaitTime) LinearKeyframe(-keyframeTimings.distance, duration: keyframeTimings.travelTime) LinearKeyframe(-keyframeTimings.distance, duration: keyframeTimings.additionalWaitTime) LinearKeyframe(-keyframeTimings.distance, duration: keyframeTimings.standardWaitTime) LinearKeyframe(0, duration: keyframeTimings.travelTime) LinearKeyframe(0, duration: keyframeTimings.additionalWaitTime) } } } } .clipped() } } 
  • I've designed it to take note just String, but any Text, so that the font, foreground color, etc can be customised at the use-site. In fact I didn't use any modifier that is specific to Text in the implementation, so this can generalise to any View.

  • It takes a containerWidth from the parent to check whether the text overflows.

  • The "root" of the body is a Color.clear, and everything else are added as overlays of it. This is because it is desirable for the MarqueeText to expand to have the same width as the parent, but with the same height as the text.

  • The first overlay is a hidden copy of the text, used only for measuring its size.

  • The second overlay is the actual visible part of the view. When text overflows, we use a keyframeAnimator to animate the marquee sequence.

    • wait 5 seconds (standardWaitTime),
    • move to the left until the end
    • wait until the other marquee reaches the end (additionalWaitTime)
    • move to the right until it reaches the start of the text
    • wait until the other marquee reaches the start

    The timings are all passed in from the parent view, as a KeyframeTimings struct, which is simply

    struct KeyframeTimings: Hashable { let distance: CGFloat let travelTime: CGFloat let additionalWaitTime: CGFloat let standardWaitTime: CGFloat = 5 // adjustable } 

Now onto the parent view:

struct TwinMarqueeText: View { let text1: Text let text2: Text let speed: CGFloat // points per second @State private var text1Size: CGSize = .init(width: 10, height: 10) @State private var text2Size: CGSize = .init(width: 10, height: 10) @State private var containerWidth: CGFloat = 10 @State private var id = UUID() var body: some View { VStack { let timings = keyframeTimings(for: [text1Size.width, text2Size.width], containerWidth: containerWidth) MarqueeText(text: text1, containerWidth: containerWidth, keyframeTimings: timings[0], textSize: $text1Size) MarqueeText(text: text2, containerWidth: containerWidth, keyframeTimings: timings[1], textSize: $text2Size) } .id(id) .onGeometryChange(for: CGFloat.self, of: \.size.width) { containerWidth = $0 } .onChange(of: [text1, text2]) { id = UUID() } } func keyframeTimings(for textWidths: [CGFloat], containerWidth: CGFloat) -> [KeyframeTimings] { let distances = textWidths.map { // 8 for the HStack spacing $0 * 2 + 8 - containerWidth } let travelTimes = distances.map { $0 / speed } guard let maxTravelTime = travelTimes.max() else { return [] } return zip(travelTimes, distances).map { time, distance in KeyframeTimings(distance: distance, travelTime: time, additionalWaitTime: maxTravelTime - time) } } } 

This is basically just a VStack with 2 MarqueeTexts. It gathers the sizes of each text and uses them to calculate KeyframeTimings. The algorithm itself should be pretty self-explanatory.

For resetting marquees when the text is changed, I simply reset the id of the VStack, so that it is created from scratch again.

Usage Example:

TwinMarqueeText( text1: Text("Short text"), text2: Text("This is some very long text."), speed: 100 ) .frame(width: 100) 
Sign up to request clarification or add additional context in comments.

4 Comments

This is great thank you so much. I need to spend some time digesting this solution. We want to make it so that the animation stops at the second text.... So that the part where we have HStack(spacing: 8) { limitedText limitedText } When the second limitedText reaches the position the first limitedText started in the animation stops, then it should wait 5 seconds and do the exact same animation again (not reversing). Can you point me to where I would be looking at to change it to do that?
I'm sorted, thanks so much, just had to change the keyframeTimings function a little bit and remove the keyframes that reversed it and it's not working how I want it to. You're a star!
@kaylanx Yeah, it should be easy to modify. I was just about to post an updated version but it seems like you got there before me. This actually simplifies the logic a bit, since the keyframe timings is no longer dependent on containerWidth. TwinMarqueeText now only needs to send additionalWaitTime to the MarqueeTexts - eveything else (distance, travelTime) can be calculated in MarqueeText directly.
@kaylanx As for the key frames, you should keep the first 3, and replace the rest with a LinearKeyframe(0, duration: 0), so that the text resets to its original position "unnoticeably". Is that what you did? Did I understand correctly?

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.