본문 바로가기
프로젝트

[SwiftUI] 개인앱에 2024년 리포트 보여주는 기능 추가 해보는건 어때요? (SwiftUI로 Youtube recap 구현하기)

by 마라민초닭발로제 2025. 1. 2.

글은 SwiftUI로 Recap관련한 뷰를 생성했던 여정을 기록하기 위함입니다. 

 

실제 프로젝트 완성 화면

 

1. 상황 분석

개인앱에 2024년 리포트를 보여주고 싶다는 계획을 했습니다. Recap을 어떤 방식으로 구현할까 고민하다가, 실제 Recap을 이용해보면서 어떤 느낌이 들었는지 분석해 봤습니다. 실제 해보고시고 싶으면 (YoutubeMusic -> Avatar -> 나의 Recap) flow로 만나보실 수 있습니다. 

 

 

리캡을 쓰고나서 있는 기능들을 정리해 보았습니다.

 

기능 

1. 뷰들이 순차적으로 넘어갈 수 있다. progress의 경우 상단에 표시된다.

2. 화면을 좌 우로 나누고 클릭시 기능이 수행된다

   - 좌 클릭: 이전 Recap화면으로 돌아간다

   - 우 클릭: 다음 Recap화면으로 넘어간다

3. Progress의 경우 화면 이동시 초기화 된다.

4. 끝 화면에서 다음을 누를 경우 Recap Content상영이 종료된다. 

 

 

2. 어떻게 풀어가면 좋을까

위와 같은 기능정의를 어떻게 해결하면 좋을까 고민했습니다. 그리고 어떤 방식으로 화면을 중첩하고 화면들을 넘길까에 대한 고민을 했습니다. 화면을 넘기는 기능을 구현하기 위해 상단 NavigationPath를 받아야 할까 고민했지만, 저는 LayoutProtocol을 쓰기로 마음 먹었습니다. LayoutProtocol을 쓰게 되면 화면에 보이는 뷰 말고도 다른 뷰가 안보여도 메모리를 잡아먹는 문제가 발생합니다. 하지만 이러한 문제는 간과해도 될 것이라 생각했습니다. 왜냐하면 보여줄 화면이 4장에서 5장 사이라 큰 문제가 없다고 판단했기 때문입니다. 

 

 

2.1 RecapLayout

그러면 layout protocol로 화면을 배치하는 코드를 작성합니다. RecapLayout을 작성할 때 중요하게 생각했던 점은 전체 Page를 외부와 소통할 수 있는 방식으로 만들었다는 점 입니다. 왜 이런 설계를 하게 되었냐면, RecapView를 관리하는 RecapController가 PageCount도 알아야 하는 것이 관리적 측면에서 안좋아 보였기 때문입니다. 이를 그림으로 설명하면 다음과 같습니다. RecapLayout에 View를 넘기는데 View의 개수가 달라질 수 있습니다. 만약 user에 따라서 view가 보이거나 보여지지 않는다면 특별한 로직이 따로 필요할 것 같았습니다. 물론 이부분은 관리를 잘 해도 좋지만 나중에 코드를 관리하기 어려울 것으로 사료되었습니다. 따라서 layout page update 대한 클로저를 view단에서 controller method를 넘겨주기로 했습니다. 

 

실제 코드는 다음과 같습니다. 

 RecapLayout(currentPageCount: controller.updatePageCount(_:)) {
  content()
    .ignoresSafeArea(.all)
}
.offset(x: -CGFloat(controller.currentPage) * proxy.size.width)

 

 

//MARK: RecapLayout

public struct RecapLayout: Layout {
  private var pageCount: Int = 1
  private var currentPageCount: (Int) -> Void

  public init(currentPageCount: @escaping (Int) -> Void) {
    self.currentPageCount = currentPageCount
  }

  public func sizeThatFits(proposal: ProposedViewSize, subviews _: Subviews, cache _: inout ()) -> CGSize {
    CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
  }

  public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) {
    currentPageCount(subviews.count)
    let pageWidth = bounds.width

    for (index, subview) in subviews.enumerated() {
      let subviewSize = subview.sizeThatFits(proposal)

      var widthWeight: CGFloat = 0
      var heightWeight: CGFloat = 0
      if let proposalWidth = proposal.width {
        widthWeight = max((proposalWidth - subviewSize.width) / 2, 0)
      }

      if let proposalHeight = proposal.height {
        heightWeight = max((proposalHeight - subviewSize.height) / 2, 0)
      }

      let xOffset = CGFloat(index) * pageWidth
      let subviewBounds = CGRect(
        x: bounds.minX + xOffset + widthWeight, // ✅ subview를 가로로 길게 이어지게 만듦
        y: bounds.minY + heightWeight,
        width: bounds.width,
        height: bounds.height
      )

      subview.place(at: subviewBounds.origin, proposal: ProposedViewSize(subviewBounds.size))
    }
  }
}

 

 

2.2 Controller

RecapController는 intiail arugment로 특정 페이지에 얼마나 채류할지에 대해서 값을 받습니다. Controller가 하는 일은 다음과 같습니다.

1. 채류시간이 지난다면 다음 페이지로 넘기기, 이전 페이지로 돌아가기(goNext, goPrev)

2. 체류시간동안 계속해서 얼마나 지났는지 업데이트 해주기 (currentDurationPercentage를 통한 업데이트)

3. 만약 다음 페이지가 없다면 신호 보내기(dismissPublisher를 활용)

 

특별한 점은 체류시간동안 percentage를 보여주기 위해서 tickWeight를 계속해서 더해줍니다. tickWeight는 0.1초마다 업데이트 되게 설계 하였습니다. 만약 _currentDurationPercentage가 100이 넘으면, 즉 정해진 체류시간을 채웠다면 다음 페이지로 넘어가는 신호를 주는 것 입니다. 

@Observable
public final class RecapMainController {
  private var _currentPage: Int = 0
  var currentPage: Int { _currentPage }

  private let pageRemainSeconds: Double
  private let tickWeight: Double
  private var _currentDurationPercentage: Double = 0
  var currentDurationPercentage: Double { _currentDurationPercentage / 100 }

  private var subscriptions = Set<AnyCancellable>()
  let dismissPublisher: PassthroughSubject<Void, Never> = .init()

  private var _pageCount: Int = 1
  var pageCount: Int { _pageCount }

  public init(pageRemainSeconds: Int = 10) {
    self.pageRemainSeconds = Double(pageRemainSeconds)
    tickWeight = Double(pageRemainSeconds) / 20
    configure()
  }

  func updatePageCount(_ newPageCount: Int) {
    _pageCount = newPageCount
  }

  func goNextPage() {
    _currentDurationPercentage = 0
    if _currentPage + 1 >= pageCount {
      dismissPublisher.send() // ✅ 만약 마지막 페이지라면 신호를 보냅니다. 
      return
    }
    _currentPage += 1
  }

  func goPrevPage() {
    _currentPage = max(_currentPage - 1, 0)
    _currentDurationPercentage = 0
  }

  func configure() {
    Timer.publish(every: 0.1, tolerance: .infinity, on: .main, in: .default)
      .autoconnect()
      .sink { [weak self] _ in
        guard let self else { return }
        tickStream()
      }
      .store(in: &subscriptions)
  }

  private func tickStream() {
    _currentDurationPercentage += tickWeight
    if _currentDurationPercentage >= 100 { // ✅체류시간을 넘었다면 다음 페이지로 넘어가게 됩니다.
      goNextPage()
    }
  }
}

 

 

2.3 View

View가 하는 일은 다음과 같습니다. 사실 대부분의 기능을 Controller쪽에 만들어서 대부분 View가 User에게 받는 이벤트 버스 및, 뷰 업데이트 관한 역할을 합니다. 

 

1. Layout 만들기, Controller의 updatePage함수 건네기

2. Page마다 Offset을 조정하여 다음페이지 혹은 이전 페이지 보여주기

3. Tick마다 offset을 움직여 진행상황 유저에게 표시하기

4. 오른쪽 누를 경우 nextPage, 왼쪽 누를 경우 prevPage controller에게 전달하기 

 

// MARK: - RecapMainView

public struct RecapMainView<Content: View>: View {
  @Bindable
  var controller: RecapMainController

  @ViewBuilder
  var content: () -> Content

  public init(
    controller: RecapMainController,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.controller = controller
    self.content = content
  }

  public var body: some View {
    VStack(alignment: .center, spacing: 10) {
      makeRecapStatue()
      GeometryReader { proxy in
        RecapLayout(currentPageCount: controller.updatePageCount(_:)) { // 1️⃣ Controller 함수 전달하기
          content()
            .ignoresSafeArea(.all)
        }
        .offset(x: -CGFloat(controller.currentPage) * proxy.size.width) // 2️⃣ Offset 조정
      }
    }
    .padding(.top, 10)
    .overlay {
      makeOverlayView()
    }
  }

  @ViewBuilder
  private func makeRecapStatue() -> some View {
    let pageCount = controller.pageCount
    let data: [Int] = (0 ..< pageCount).map { $0 }
    HStack(alignment: .center, spacing: 4) {
      ForEach(data, id: \.self) { currentIndex in
        if currentIndex < controller.currentPage {
          makeRecapStatusWhiteBarView()
        } else if currentIndex == controller.currentPage {
          makeCurrentPageRecapStatusView()
        } else {
          makeRecapStatusGrayBarView()
        }
      }
    }
    .padding(.horizontal, 12)
  }

  @ViewBuilder // 3️⃣ status에 따라 white bar offset 조작 
  private func makeCurrentPageRecapStatusView() -> some View {
    makeRecapStatusGrayBarView()
      .overlay(alignment: .leading) {
        GeometryReader { proxy in
          let width = proxy.size.width
          let widthOffset = -width + width * controller.currentDurationPercentage 
          makeRecapStatusWhiteBarView()
            .animation(.linear(duration: 0.1), value: widthOffset)
            .offset(x: widthOffset)
        }
      }
      .clipShape(RoundedRectangle(cornerRadius: 4))
  }

  @ViewBuilder
  private func makeRecapStatusWhiteBarView() -> some View {
    Color
      .white
      .frame(height: 15)
      .clipShape(RoundedRectangle(cornerRadius: 4))
  }

  @ViewBuilder
  private func makeRecapStatusGrayBarView() -> some View {
    SLColor
      .gray03
      .opacity(0.5)
      .frame(height: 15)
      .clipShape(RoundedRectangle(cornerRadius: 4))
  }

  
  @ViewBuilder // 4️⃣ Controller로 이벤트 전달
  private func makeOverlayView() -> some View {
    HStack(spacing: 0) {
      Color
        .clear
        .contentShape(Rectangle())
        .onTapGesture {
          controller.goPrevPage()
        }
      Color
        .clear
        .contentShape(Rectangle())
        .onTapGesture {
          controller.goNextPage()
        }
    }
  }
}

 

 

RecapView의 CurrentRecapStatus의 라이프사이클이 정상적으로 동작하는 것을 볼 수 있습니다.

 

이로서 Recap뼈대가 완성되었습니다. 

 

 

3. 실제 코드로 적용하기

struct ContentView: View {
  @State
  var controller = RecapMainController(pageRemainSeconds: 5)
  var body: some View {
    RecapMainView(controller: controller) {
      VStack {
        Image(systemName: "globe")
          .imageScale(.large)
          .foregroundStyle(.tint)
        Text("Hello, page 1 world!")
      }
      .padding()

      VStack {
        Image(systemName: "star")
          .imageScale(.large)
          .foregroundStyle(.tint)
        Text("hi, page 2 land!")
      }
      .padding()

      VStack {
        Image(systemName: "person.fill")
          .imageScale(.large)
          .foregroundStyle(.tint)
        Text("nihao, page 3 world!")
      }
      .padding()
    }
    .background(Color.blue)
  }
}

 

 

동작 화면 

 

전체 코드 

https://github.com/MaraMincho/MakingFrogWithoutDissecting/tree/main/RecapExample/RecapExample

 

MakingFrogWithoutDissecting/RecapExample/RecapExample at main · MaraMincho/MakingFrogWithoutDissecting

개구리를 해부하지 않고 직접 만들기, 공부 레포. Contribute to MaraMincho/MakingFrogWithoutDissecting development by creating an account on GitHub.

github.com

 

 

감사합니다.