ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Marquee
    SwiftUI 2025. 3. 18. 19:00

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 Marquee에 대해 알아보고 간단히 SwiftUI로 구현해보려고 합니다. 🙋🏻

     


    Marquee?

    Marquee는 텍스트가 좌우 혹은 상하 형태로 자동으로 이동하는 UI 요소입니다.

    일반적인 쓰임으로는 많이들 보셨듯, 공지사항이나 주식 시세와 같은 표시 등에서 많이 사용됩니다!

    어떤건지는 아래 실제 구현하면서 더 자세히 볼께요ㅎㅎ

    UIKit에서는 UIView.animate를 활용해 Marquee 효과를 구현해볼 수 있죠.

    그런데 이번 포스팅은 SwiftUI에 초점이라 GeometryReader, Animation 등을 조합해 한번 Marquee 컴포넌트를 만들어볼까 합니다.

     

    그럼 시작해볼까요?


    Marquee in SwiftUI

    코드부터 보시죠!

     

    struct ContentView: View {
      let text: String
      let speed: Double
      @State private var offset: CGFloat = 0
      
      var body: some View {
        GeometryReader { geometry in
          let textWidth = textSize(width: geometry.size.width).width
          let containerWidth = geometry.size.width
          
          Text(text)
            .font(.system(size: 50))
            .bold()
            .foregroundStyle(.green)
            .offset(x: offset)
            .onAppear {
              startMarquee(textWidth: textWidth, containerWidth: containerWidth)
            }
        }
        .clipped()
      }
      
      private func startMarquee(textWidth: CGFloat, containerWidth: CGFloat) {
        let totalWidth = textWidth + containerWidth
        let duration = speed * Double(totalWidth / containerWidth)
        
        offset = containerWidth
        
        withAnimation(
          Animation.linear(duration: duration).repeatForever(autoreverses: false)
        ) {
          offset = -textWidth
        }
      }
      
      private func textSize(width: CGFloat) -> CGSize {
        let attributes = [NSAttributedString.Key.font: UIFont.preferredFont(
          forTextStyle: .headline
        )]
        let size = (text as NSString).size(withAttributes: attributes)
        return size
      }
    }

     

    코들르 살펴보면, text를 이용하고 두 메서드가 핵심으로 작용합니다.

    startMarquee 메서드에서 해당 텍스트의 넓이와 전체를 감싼 컨테이너의 넓이를 총 넓이로 계산해요.

    그리고, 그거에 맞게 전체 넓이에서 컨테이너 넓이를 가지고 주어진 속도 값으로 총 흘러가는 속도를 뽑아냅니다.

     

    처음 offset은 맨 우측에서 시작할 수 있도록 컨테이너 넓이만큼 줍니다.

    그리고, 애니메이션의 속도를 계산된 속도로 지정하면서 무한 반복되도록 해줍니다.

    애니메이션으로 해당 텍스트의 offset을 텍스트의 넓이만큼 빼주면서 진행해요.

     

    그리고 두번째로 textSize 메서드에서 해당 텍스트를 GeometryReader를 사용해 실제 Text의 크기를 얻었죠.

    거기서 실제 문자열이 특정 폰트에서 차지하는 크기를 계산하는 함수라고 보면 됩니다.

    SwiftUI에선 직접 Text 크기를 가져올 수 없기에 UIFont를 이용해 크기를 측정하게 된 코드입니다.

     

    그리고 이 구현을 Text가 나타날때 작동시켜주면 됩니다.

     

    그럼 이런 동작을 가지게 됩니다 😃

     

     

    그런데 여기서 잠깐!

     

    조금 어색하지 않나요?

     

    텍스트가 화면 밖으로 나가면 다시 시작하는 방식이 되기에 순간적으로 끊기고 연속된 느낌이 없다고 느껴질 수 있어요.

     

    연속되는 느낌을 줄 수 있게 개선해볼께요 🙋🏻

     

    import SwiftUI
    
    struct ContentView: View {
      let text: String
      let speed: Double
      @State private var offset: CGFloat = 0
      @State private var textWidth: CGFloat = 0
      @State private var containerWidth: CGFloat = 0
      @State private var isReady: Bool = false
      
      var body: some View {
        GeometryReader { geometry in
          let containerWidth = geometry.size.width
          
          ZStack(alignment: .leading) {
            // 텍스트 측정용 뷰
            Text(text)
              .font(.system(size: 50))
              .bold()
              .foregroundStyle(.green)
              .fixedSize(horizontal: true, vertical: false)
              .opacity(0)
              .background(
                GeometryReader { textGeometry in
                  Color.clear
                    .onAppear {
                      textWidth = textGeometry.size.width
                      self.containerWidth = containerWidth
                      isReady = true
                      startMarquee()
                    }
                }
              )
            
            // 실제 보여지는 마퀴 뷰
            if isReady {
              HStack(spacing: 0) {
                ForEach(0..<calculateRepeats(), id: \.self) { _ in
                  Text(text)
                    .font(.system(size: 50))
                    .bold()
                    .foregroundStyle(.green)
                    .fixedSize(horizontal: true, vertical: false)
                }
              }
              .offset(x: offset)
            }
          }
        }
        .clipped()
      }
      
      // 필요한 반복 횟수를 계산하는 함수
      private func calculateRepeats() -> Int {
        guard textWidth > 0 else { return 3 }
        
        // 컨테이너를 채우기 위해 필요한 텍스트 인스턴스 수 계산
        let repeatsNeeded = ceil(containerWidth / textWidth)
        
        // 짧은 텍스트의 경우 최소 3번 반복하고, 긴 텍스트는 필요한만큼 + 1개 추가
        return max(3, Int(repeatsNeeded) + 1)
      }
      
      private func startMarquee() {
        guard textWidth > 0 else { return }
        
        // 텍스트가 컨테이너보다 짧을 경우 자연스러운 속도로 조정
        let animationWidth = textWidth
        let duration = speed * Double(animationWidth / containerWidth * 2)
        
        withAnimation(Animation.linear(duration: duration).repeatForever(autoreverses: false)) {
          offset = -textWidth
        }
      }
    }

     

    조금 코드가 많아졌지만, 개선되었어요!

    핵심은 텍스트를 몇번 더 붙여서 자연스럽게 이어져서 표현되도록 하느냐를 다뤄줍니다.

    isReady 상태 변수를 추가해서 텍스트 넓이 측정이 완료된 후 실제 Marquee 뷰를 표시하게 됩니다.

    calculateRepeats() 메서드를 통해서 컨테이너 넓이와 텍스트 넓이를 기준으로 몇번의 반복이 필요한지 계산해주죠.

    최소 3번은 반복하도록 넣어줬는데, 조정해도 됩니다.

    텍스트 측정을 위한 상위 뷰는 투명하게 처리해 실제 보이지 않게 됩니다.

     

    이렇게 로직을 개선한다면 아래와 같이 이제는 정말 연속되고 자연스러운 Marquee 효과를 얻을 수 있어요!

     

     


    Precautions for implementing marquee

    1️⃣ 성능 최적화

    SwiftUI 애니메이션을 사용하기에 CPU, GPU 활용되는 부분에서 과도한 애니메이션은 프레임 드롭을 유발할 수 있어요.

    그렇기에 성능적으로 최적화를 고민해봐야 합니다.

    2️⃣ 사용자 인터랙션 고려

    사용자가 해당 Marquee를 뭔가 멈추고 싶거나 혹은 비지니스 관점에서 해당 Marquee를 직접 스크롤 할 수도 있어야 한다면 그런 부분들도 고려해야 합니다.


    Conclusion

    Marquee의 아주 간단하게 구현된 형태만 다뤄봤는데요.

    또한 하나의 Text로만 다뤘는데, 여러 다양한 이미지, 문자열의 조합을 가진 Marquee를 구현한다면 더 그에 맞춰 구현해나가야 할것입니다.

    이 포스팅에서는 Marquee가 무엇이고 어떤 프로세스로 연속적으로 보여지는지에 대해 다뤄봤어요.

Designed by Tistory.