00:06 Today we'll start building a little container view that should feel
familiar to people who did HTML in the early days: the marquee. We'll build this
as a container of horizontally laid out views that slide across the frame. The
moment the content scrolls out of view, it wraps back to the beginning so that
the animation can play continuously.
00:37 We'd also like to make the marquee interactive so that we can
scroll through the content. After releasing the drag, the marquee should resume
the automatic scrolling in a fluid motion by taking the drag gesture's velocity
into account.
Setting Up
00:52 We create a struct, Marquee
, that's generic over a content view.
In the body, we wrap the content view in an HStack
, and we give the stack a
fixed spacing:
struct Marquee<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
HStack(spacing: 10) {
content
}
}
}
01:36 In our ContentView
, we create a marquee containing a number of
text views with blue, capsule-shaped backgrounds:
struct ContentView: View {
var body: some View {
Marquee {
ForEach(0..<5) { i in
Text("Item \(i)")
.padding()
.foregroundColor(.white)
.background {
Capsule()
.fill(.blue)
}
}
}
}
}
02:06 By default, the views are resized to fit in the available space,
but we want the items to stretch to their ideal size, and we want to align them
to the leading edge. So, we call fixedSize
on the content
, and we wrap it in
a flexible frame that can grow infinitely wide:
struct Marquee<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
HStack(spacing: 10) {
content
.fixedSize()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
02:30 If we increase the number of items, we still only see the first
few items aligned to the leading edge, and we see more of the remaining items as
we make the window wider. Because we're running the app on a Mac, we have to add
a flexible frame to our ContentView
to make the window resizable:
struct ContentView: View {
var body: some View {
Marquee {
ForEach(0..<5) { i in
Text("Item \(i)")
.padding()
.foregroundColor(.white)
.background {
Capsule()
.fill(.blue)
}
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
03:12 Next, we can make the items move by applying and animating an
offset. And to make the contents loop, we should place multiple instances of
content
next to each other. That way, we can reset the offset when we reach
the end of the first batch of items, and it'll look like an endlessly repeating
stream of these items.
04:03 The number of repeated instances needed to fill the available
space depends on the size of the container and the size of all the items. We'll
compute this number later on, but for now, we can use two copies of content
:
struct Marquee<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
HStack(spacing: 10) {
content
content
}
.fixedSize()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Animating
05:51 There are various ways to create the animated scrolling. On newer
platforms like iOS 17, we could use an animation with a completion handler that
restarts the animation. We could also create a custom view or view modifier that
conforms to Animatable
. But since we eventually want to make this interactive,
we'll choose to work with a TimelineView
.
06:34 In the context of SwiftUI, the timeline view is a bit of an odd
one. Normally, our views are redrawn when we modify some observed piece of
state. This happens automatically, so we don't have to think about view updates
in a lot of cases. But with a timeline view, instead of responding to state
changes, we recreate the content view for every update of the display. In other
words, we don't animate from point A to point B, but we get called for a certain
tick of the clock, and we recompute our view for that specific moment in time.
This system will come in handy when we introduce gestures to our marquee.
07:35 We add a TimelineView
, and we move the HStack
into its content
view builder. The TimelineView
lets us specify the schedule at which it should
reexecute its content view. We can use the .animation
constant for this
schedule, which means the timeline view will call us at the necessary refresh
rate to get a smooth animation. This could be 60 or 120 times per second,
depending on what the system decides:
struct Marquee<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
TimelineView(.animation) { context in
HStack(spacing: 10) {
content
content
}
.fixedSize()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
08:11 The content view closure receives a context
value containing a
Date
that represents the current time. If we define a start time, we can
compare context.date
to it to find out how many seconds have elapsed. By
multiplying this duration with a velocity, we can compute an offset for the
HStack
:
struct Marquee<Content: View>: View {
var velocity: Double = 50
@ViewBuilder var content: Content
@State private var startDate = Date.now
var body: some View {
TimelineView(.animation) { context in
let offset = context.date.timeIntervalSince(startDate) * velocity
HStack(spacing: 10) {
content
content
}
.offset(x: offset)
.fixedSize()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
10:05 We reset the startDate
in onAppear
so that the offset starts
at zero when the view appears:
struct Marquee<Content: View>: View {
var velocity: Double = 50
@ViewBuilder var content: Content
@State private var startDate = Date.now
var body: some View {
TimelineView(.animation) { context in
let offset = context.date.timeIntervalSince(startDate) * velocity
HStack(spacing: 10) {
content
content
}
.offset(x: offset)
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
}
}
10:33 When we run this, we see that the items move in the wrong
direction. So we flip the offset to make them scroll to the left:
struct Marquee<Content: View>: View {
var velocity: Double = 50
@ViewBuilder var content: Content
@State private var startDate = Date.now
var body: some View {
TimelineView(.animation) { context in
let offset = context.date.timeIntervalSince(startDate) * -velocity
HStack(spacing: 10) {
content
content
}
.offset(x: offset)
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Wrapping the Animation
10:54 Now the marquee scrolls in the correct direction, but once the
second batch of items is over, we run into blank space. So our next step is to
detect at which point we should wrap the contents.
11:12 To do that, we need to measure the size of our content. We create
a state variable for the width, and we call a measureWidth
helper on the first
instance of the content view:
struct Marquee<Content: View>: View {
var velocity: Double = 50
@ViewBuilder var content: Content
@State private var startDate = Date.now
@State private var contentWidth: CGFloat? = nil
func offset(at time: Date) -> CGFloat {
var result = time.timeIntervalSince(startDate) * -velocity
if let c = contentWidth {
result.formTruncatingRemainder(dividingBy: c + spacing)
}
return result
}
var body: some View {
TimelineView(.animation) { context in
let offset = context.date.timeIntervalSince(startDate) * -velocity
HStack(spacing: 10) {
content
.measureWidth { contentWidth = $0 }
content
}
.offset(x: offset)
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
}
}
11:43 In an extension of View
, we write the helper function, which
takes a closure that receives the measured width. We can call the closure in an
onChange(of:)
block or by using preferences. In this case, we don't need to
combine multiple widths, so a single onChange(of:)
call will probably suffice.
12:19 We start with an overlay
or background
layer so that we don't
influence the view's layout. Inside this layer, we create a GeometryReader
containing a Color.clear
placeholder view on which we can call onAppear
and
onChange(of:)
.
12:43 For iOS 17+ and macOS 14+, there's a newer version of the
onChange
API that runs the closure with the initial value as well, which would
allow us to skip the onAppear
call. But since we haven't yet updated our Mac,
we have to use the old API:
extension View {
func measureWidth(_ onChange: @escaping (CGFloat) -> ()) -> some View {
background {
GeometryReader { proxy in
let width = proxy.size.width
Color.clear
.onAppear {
onChange(width)
}
.onChange(of: width) { _ in
onChange(width)
}
}
}
}
}
13:28 In Marquee
, we write a function to compute the offset for a
given time. First, we get the seconds since the start date, and we multiply this
time by the velocity. To create a loop from this offset, we want to divide it by
the content width and only use the remainder of this division. That way, it
doesn't matter if 1, 10, or 500 seconds have passed; the offset will always be
less than the content's width:
struct Marquee<Content: View>: View {
var velocity: Double = 50
@ViewBuilder var content: Content
@State private var startDate = Date.now
@State private var contentWidth: CGFloat? = nil
func offset(at time: Date) -> CGFloat {
var result = time.timeIntervalSince(startDate) * -velocity
if let c = contentWidth {
result.formTruncatingRemainder(dividingBy: c)
}
return result
}
var body: some View {
TimelineView(.animation) { context in
HStack(spacing: spacing) {
content
.measureWidth { contentWidth = $0 }
content
}
.offset(x: offset(at: context.date))
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
}
}
14:56 The marquee jumps a little bit. The first thing that's wrong is
the fact that we're measuring the width of a single item — because we're calling
measureWidth
on content
, which contains multiple items. We can fix this by
wrapping content
in an HStack
and measuring the stack in its entirety:
struct Marquee<Content: View>: View {
var body: some View {
TimelineView(.animation) { context in
HStack(spacing: spacing) {
HStack(spacing: spacing) {
content
}
.measureWidth { contentWidth = $0 }
content
}
.offset(x: offset(at: context.date))
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
.measureWidth { containerWidth = $0 }
}
}
15:23 Now the items scroll continuously until the fifth item, but then
they jump again because we don't yet take the spacing into account when we wrap
the offset. So we add a property to define the spacing, and we use it in our
offset(at:)
method. We could also make this work with implicit spacing by
measuring the origin of the second content batch, but the fixed spacing works
fine for now:
struct Marquee<Content: View>: View {
var velocity: Double = 50
var spacing: CGFloat = 10
@ViewBuilder var content: Content
@State private var startDate = Date.now
@State private var contentWidth: CGFloat? = nil
func offset(at time: Date) -> CGFloat {
var result = time.timeIntervalSince(startDate) * -velocity
if let c = contentWidth {
result.formTruncatingRemainder(dividingBy: c + spacing)
}
return result
}
var body: some View {
TimelineView(.animation) { context in
HStack(spacing: spacing) {
HStack(spacing: spacing) {
content
}
.measureWidth { contentWidth = $0 }
content
}
.offset(x: offset(at: context.date))
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Number of Copies
16:10 The marquee's animation now loops smoothly. What's still an issue
is the blank space that becomes visible when we make our window wider than the
marquee's contents. We already know how wide the contents are, and by also
measuring the container's width, we can calculate how many times we should
repeat content
to fill the container.
16:56 We measure the width of the outer frame of the TimelineView
,
and we store it in a new state variable, containerWidth
:
struct Marquee<Content: View>: View {
var velocity: Double = 50
var spacing: CGFloat = 10
@ViewBuilder var content: Content
@State private var startDate = Date.now
@State private var contentWidth: CGFloat? = nil
@State private var containerWidth: CGFloat? = nil
var body: some View {
TimelineView(.animation) { context in
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
.measureWidth { containerWidth = $0 }
}
}
17:34 From the ratio of the content width to the container width, we
want to determine how many copies of content
we should add to the HStack
. We
first check if the sum of contentWidth
and spacing
isn't zero, and then we
divide containerWidth
by it. To figure out how we should round the result of
this division, we can think of an example. Let's say the available space is
100
points wide and our content is 30
points wide. The result of this
division is something like 3.33
. That means we need at least four instances of
our content to fill the space. But since we already have one instance, we can
round down to find the number of extra instances:
struct Marquee<Content: View>: View {
var body: some View {
TimelineView(.animation) { context in
HStack(spacing: spacing) {
HStack(spacing: spacing) {
content
}
.measureWidth { contentWidth = $0 }
let contentPlusSpacing = (contentWidth ?? 0) + spacing
if contentPlusSpacing != 0 {
let numberOfInstances = Int(((containerWidth ?? 0) / contentPlusSpacing).rounded(.down))
ForEach(Array(0..<numberOfInstances), id: \.self) { _ in
content
}
}
}
.offset(x: offset(at: context.date))
.fixedSize()
}
.onAppear { startDate = .now }
.frame(maxWidth: .infinity, alignment: .leading)
.measureWidth { containerWidth = $0 }
}
}
21:29 When we run the app and make our window wider, we can see that
we're still seeing some blank space at the end, because we didn't account for
the contents scrolling offscreen. We need one extra instance to fill the screen
when the first batch of items is scrolled away.
22:02 So, instead of rounding the result of the division down, we round
it up:
let numberOfInstances = Int(((containerWidth ?? 0) / contentPlusSpacing).rounded(.up))
22:27 There's still a small stutter when the animation wraps, but we'll
take a look at that later. So far, we've taken a rather elaborate approach to
animating the marquee — a normal animation with a completion handler would've
been simpler. However, with the current setup, we're ready to make the marquee
interactive.