Marquee Effect in SwiftUI

Created
Updated
TagsSwiftSwiftUI

Marquee Effect is when a long-view (typically text-based) has its content moving horizontally to disclose full content. Like this:

It's fairly simple (albeit not straightforward) to do in SwiftUI. Android's Jetpack Compose has it out of the box. As does also HTML: <marquee>.

In this tutorial we're going to use shaders and .distortionEffect modifier so this solution will be working for iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0 and further. This tutorial also goes through the process step-by-step, if you just need the full code, I'm leaving it in the end of this blogpost.

Let's start with some barebones: a text and a view modifier we're going to update to reach the needed effect. For now the modifier is just going to return the content itself, we'll address this a little later:

struct ContentView: View {
    var body: some View {
        Text("A very-very-very long text")
            .marquee()
    }
}

struct MarqueeModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
    }
}

extension View {
    func marquee() -> some View {
        self.modifier(MarqueeModifier())
    }
}

Alright, now let's write the most straightforward part – Shader. First let's start with the base and move from there. Since we're going to update pixels’ position on screen, we're gonna need their initial position. For now let's return the same position:

[[ stitchable ]] float2 marquee(float2 position) {
    return position;
}

Returning the same position won't change a thing, the view will stay intact but now we can apply this shader to our view. We don't utilize maxSampleOffset so let's assign .zero to it:

struct MarqueeModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .distortionEffect(
                ShaderLibrary.marquee(),
                maxSampleOffset: .zero
            )
    }
}

Now let's have a little theory: we need the content of the view move to the left with time. Move to the left. With time. Let's update our shader. We're going to add time parameter and update position's x value so that it's moving to the left with time. By default it will move 1 pixel a second, hence we multiply time by 20 to make it slightly faster:

[[ stitchable ]] float2 marquee(float2 position, float time) {
		float newX = position.x + time * 20;

    return float2(newX, position.y);
}

Now we need to get that time somewhere. Here's where TimelineView comes into play. We're passing .animation to its schedule parameter to update the view as often as possible. We're taking a date when the view was rendered and calculating time elapsed since then, then passing this value to the shader:

struct MarqueeModifier: ViewModifier {
    func body(content: Content) -> some View {
        let startDate = Date.now // Saving the date the view is rendered at

        TimelineView(.animation) { context in
            // Calculating time elapsed since start date
            let timeElapsed = startDate.distance(to: context.date)

            content
                .distortionEffect(
                    ShaderLibrary.marquee(
                        .float(timeElapsed) // Passing time to shader
                    ),
                    maxSampleOffset: .zero
                )
        }
    }
}

We're halfway done, our view moves with time, yay! Wait, it moves to the left and disappears. Now we need to show it again from the right edge. But what is "right edge”? Shader doesn't really know. It's applied to every pixel of the view separately so it doesn't really have any context of the whole view as such. We need to pass it! For this SwiftUI team provided us a brilliant modifier – .visualEffect, it lets us read view's dimensions and apply shaders to it right away. Here's how it looks like:

content
    .visualEffect { view, geometryProxy in
        // geometryProxy holds view's dimensions

        view
            .distortionEffect(
                ShaderLibrary.marquee(
                    .float(timeElapsed),
                    .float(geometryProxy.size.width) // Passing width to shader
                ),
                maxSampleOffset: .zero
            )
    }

Now, what do we do with this width in the shader? We need to check when the pixel goes off the left edge and show it again from the right edge. Technically speaking – when pixel is off of left edge, we offset it with our width so that it appears on the right edge. Doing a simple calculation like the following would work but only once – when the position.x at some point is as small as -width then adding width to it will offset it to 0 but it's still off of left edge

if (position.x < 0) {
    position.x += width
}

Therefore we need to make sure the value is always from 0 to width – here is where modulo-calculation comes into play, MSL (Metal Shading Language) has fmod for that. Let's also account for some spacing between the repetitions, say, 20. Here's how the shader will look like now:

[[ stitchable ]] float2 marquee(float2 position, float time) {
		float newX = fmod(position.x + time * 20, width + 20); // width + spacing

    return float2(newX, position.y);
}

That's pretty much it! We got a marquee effect with just a few dozen of lines of code. We could stop here but there's a small problem – if we constraint our text to a certain width (e.g. using .frame(width: 50) ) then the text will occupy multiple lines. That's not exactly what marquee effect is for, is it? Now, there's a catch: if you try to limit the amount of lines it still won't work, the text will wrap (e.g. Very-very lo...) and then the marquee effect will be applied. Not good. Let's fix it.

This is the not-straightforward thing I mentioned in the beginning. A small hack and we have what we need. I won't go step-by-step here but rather explain the idea and show the code. We need the view to be laid out naturally but then it will be wrapped. To make it not wrapped we can apply .fixedSize() to it … but the it will not laid out naturally. We can mix these approaches to get the effect we need. We lay it out naturally but apply .hidden() modifier to let the layout engine know it dimensions but not let SwiftUI render it since it's not the presentation we need. We then overlay it with the same content but now apply .fixedSize() to show the whole width which can be marqueed (marquee effected?). The last thing is that we have to apply a mask to the view since without it the fixed-sized view will be overlaying the views around it, mask will constraint the rendering to the natural layout width. Before masking though we got to apply .compositingGroup() to group all the views in one, otherwise the shader will go crazy. Sounds a bit complex but take a look at the code, it's not:

content
    .lineLimit(1)
    .hidden()
    .overlay(alignment: .leading) {
        content
            .fixedSize()
            .visualEffect { view, geometryProxy in
                view
                    .distortionEffect(
                        ShaderLibrary.marquee(
                            .float(timeElapsed),
                            .float(geometryProxy.size.width)
                        ),
                        maxSampleOffset: .zero
                    )
            }
    }
    .compositingGroup()
    .mask(Rectangle())

Alright, now that's it! We have a reusable modifier that delivers us the effect we need! In the full code you'll find some bonus points: adjustable speed and spacing and accounting for accessibility setting – Reduce Motion. Thanks for reading! Let me know if you have any feedback: ☎️Contact !

Here, as promised, full code:

[[ stitchable ]] float2 marquee(float2 position, float time, float width, float speed, float spacing) {
    float newX = fmod(position.x + time * 20 * speed, width + spacing);

    return float2(newX, position.y);
}
struct ContentView: View {
    var body: some View {
        Text("Very-very-very-very long text")
            .marquee()
            .frame(width: 100)
    }
}

extension View {
    func marquee(speed: Double = 1, spacing: Double = 20) -> some View {
        self.modifier(MarqueeModifier(speed: speed, spacing: spacing))
    }
}

struct MarqueeModifier: ViewModifier {
    @Environment(\.accessibilityReduceMotion) private  var isMotionReduced

    let speed: Double
    let spacing: Double

    func body(content: Content) -> some View {
        if isMotionReduced {
            content
                .lineLimit(1)
        } else {
            let start = Date.now
            TimelineView(.animation) { context in
                let elapsed = start.distance(to: context.date)

                content
                    .lineLimit(1)
                    .hidden()
                    .overlay(alignment: .leading) {
                        content
                            .fixedSize()
                            .visualEffect { view, geometryProxy in
                                view
                                    .distortionEffect(
                                        ShaderLibrary.marquee(
                                            .float(elapsed),
                                            .float(geometryProxy.size.width),
                                            .float(speed),
                                            .float(spacing)
                                        ),
                                        maxSampleOffset: .zero
                                    )
                            }
                    }
                    .compositingGroup()
                    .mask(Rectangle())
            }
        }
    }
}