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