Reading time: 2 min

This recipe shows how to implement a marquee - self-scrolling content that goes across the screen - in SwiftUI. You'll be able to control the speed and direction of the animation, as well as if it reverses or resets once it ends.

The end result looks like this:

preview

Recipe

This recipe relies on two components from previous recipes:

  • measureSize modifier to get the width of the screen and the marquee view.
  • animationObserver to know when the marquee animation ends and needs to be reset.

Beyond that, the recipe is fairly straightforward:

  1. Figure out the width of the parent view of the marquee.
  2. Figure out the width of the marquee view.
  3. Offset the marquee view by the full width of either it or the parent view, depending on which one is greater.
  4. Animate the offset change so that the marquee view passes the full width and dissapears on the other side.

Here's the full code:

struct Marquee: ViewModifier {
  let duration: TimeInterval
  let direction: Direction
  let autoreverse: Bool

  @State private var offset = CGFloat.zero
  @State private var parentSize = CGSize.zero
  @State private var contentSize = CGSize.zero

  func body(content: Content) -> some View {
    // measures parent view width
    Color.clear
      .frame(height: 0)
      // measureSize from https://swiftuirecipes.com/blog/getting-size-of-a-view-in-swiftui
      .measureSize { size in
        parentSize = size
        updateAnimation(sizeChanged: true)
      }

    content
      .measureSize { size in
        contentSize = size
        updateAnimation(sizeChanged: true)
      }
      .offset(x: offset)
      // animationObserver from https://swiftuirecipes.com/blog/swiftui-animation-observer
      .animationObserver(for: offset, onComplete: {
        updateAnimation(sizeChanged: false)
      })
  }

  private func updateAnimation(sizeChanged: Bool) {
    if sizeChanged || !autoreverse {
      offset = max(parentSize.width, contentSize.width) * ((direction == .leftToRight) ? -1 : 1)
    }
    withAnimation(.linear(duration: duration)) {
      offset = -offset
    }
  }

  enum Direction {
    case leftToRight, rightToLeft
  }
}

extension View {
  func marquee(duration: TimeInterval,
               direction: Marquee.Direction = .rightToLeft,
               autoreverse: Bool = false) -> some View {
    self.modifier(Marquee(duration: duration,
                          direction: direction,
                          autoreverse: autoreverse))
  }
}

Then, you can simply attach the marquee modifier to your views:

struct MarqueeTest: View {
  let longText = "Just a really long block of text that breaks into multiple lines and goes quickly in a marquee without an autoreverse."
  let autoreverseText = "Also a long text but in a single line and autoreverses."
  let symbols = ["sun.min",
                 "sun.min.fill",
                 "sun.max",
                 "sun.max.fill",
                 "sun.max.circle",
                 "sun.max.circle.fill",
                 "sunrise",
                 "sunrise.fill",
                 "sunset",
                 "sunset.fill",
                 "sun.and.horizon",
                 "sun.and.horizon.fill",
                 "sun.dust",
                 "sun.dust.fill",
                 "sun.haze",
                 "sun.haze.fill",
                 "moon",
                 "moon.fill",
                 "moon.circle",
                 "moon.circle.fill",
                 "sparkles",
                 "moon.stars",
                 "moon.stars.fill"]

  @State private var size = CGSize.zero

  var body: some View {
    Text(longText)
      .marquee(duration: 5, direction: .leftToRight)

    Text(autoreverseText)
      .lineLimit(1)
      .fixedSize()
      .marquee(duration: 7, autoreverse: true)

    ScrollView(.horizontal, showsIndicators: false) {
      HStack {
        ForEach(symbols, id: \.self) { symbol in
          Image(systemName: symbol)
            .frame(width: 32, height: 32)
        }
      }
      .marquee(duration: 10)
    }
  }
}

Next Post Previous Post