iOS/App

Scrumdinger 개발 09 : Drawing the timer view

태애니 2025. 4. 9. 19:26
728x90

 

 

이제 주요 서비스인 타이머에 맞게 미팅 진행을 보여주는 뷰를 만들 차례이다.

 

 

Create the meeting timer view 

 

 

 

MeetingTimerView 를 생성해서 원의 형태로 현재의 진행 상황을 보여주도록 할 것이다.

 

import SwiftUI
import ThemeKit
import TimerKit

struct MeetingTimerView: View {
    // TimerView 를 그리기 위해서는 Speaker 배열 정보만 MeetingView 에서 넘겨주면 된다.
    let speakers: [ScrumTimer.Speaker]
    let theme: Theme
    
    private var currentSpeaker: String {
        speakers.first(where: { !$0.isCompleted})?.name ?? "없음"
        // 발언자 리스트 중에 isCompleted 가 false 인 사람의 첫번째 배열 요소를 찾는다.
    }
    
    var body: some View {
        Circle()
            .strokeBorder(lineWidth: 24)
            .overlay {
                VStack {
                    Text("\(currentSpeaker) 님")
                        .font(.title)
                    Text("발언 시간 입니다.")
                }
                .accessibilityElement(children: .combine)
                .foregroundStyle(theme.accentColor)
            }
    }
}

#Preview {
    let speakers = [ScrumTimer.Speaker(name: "이미함", isCompleted: true), ScrumTimer.Speaker(name: "이제함", isCompleted: false)]
    MeetingTimerView(speakers: speakers, theme: .yellow)
}

 

기본 뷰는 이와 같이 구성되어있다.

발언자들의 배열을 받은 뒤 완료 여부에 따라 제일 첫번째 미완료자를 보여준다.

아마 여기도 리팩토링이 들어갈 것이다.

 

 

 

일단 구성은 이렇게!!!!

프리뷰를 보면 이렇게 보인다. 

 

이걸 이제 MeetingView 에 적용해준다.

 

 

 

주석 지옥이라서 전체 코드로 첨부함.

 

 

@@@@@ 여기 @@@@@ 라고 표시된 곳!!

 

import SwiftUI
import TimerKit
import AVFoundation

struct MeetingView: View {
    //    @Binding var scrum: DailyScrum
    
    @Environment(\.modelContext) private var context
    let scrum: DailyScrum // swiftData
    @State var scrumTimer = ScrumTimer()
    
    @Binding var errorWrapper: ErrorWrapper? // error
    // 여기서 Binding 받는 이유는, 상위의 뷰에서 전달 받은 에러를 띄우기 위해서이다.
    
    private let player = AVPlayer.dingPlayer()
    
    var body: some View {
        ZStack{
            RoundedRectangle(cornerRadius: 16.0)
                .fill(scrum.theme.mainColor)
            VStack{
                
                // 요 아래의 애들을 모두 MeetingHeaderView 로 바꿔치기할 것이다***
                //                ProgressView(value: 5, total: 10)
                //                HStack {
                //                    VStack(alignment: .leading) {
                //                        Text("Seconds Elapsed")
                //                            .font(.caption)
                //                        Label("300", systemImage: "hourglass.tophalf.fill")
                //                    }
                //                    Spacer()
                //                    VStack(alignment: .trailing) {
                //                        Text("Seconds Remaining")
                //                            .font(.caption)
                //                        Label("600", systemImage: "hourglass.bottomhalf.fill")
                //                    }
                //                }
                //                // 접근성
                //                .accessibilityElement(children: .ignore)
                //                .accessibilityLabel("Time remaining")
                //                .accessibilityValue("10 minutes")
                
                MeetingHeaderView(secondsElapsed: scrumTimer.secondsElapsed,
                                  secondsRemaining: scrumTimer.secondsRemaining,
                                  theme: scrum.theme)
                
//                Circle()
//                    .strokeBorder(lineWidth: 24)

				// @@@@@@@@ 여기 @@@@@@@@
                MeetingTimerView(speakers: scrumTimer.speakers, theme: scrum.theme)
				// @@@@@@@@ 여기 @@@@@@@@
                
                //            MeetingFooterView
                //            HStack {
                //                    Text("Speaker 1 of 3")
                //                    Spacer()
                //                    Button(action: {}) {
                //                        Image(systemName: "forward.fill")
                //                    }
                //                    .accessibilityLabel("Next speaker")
                //                }
                
                MeetingFooterView(speakers: scrumTimer.speakers, skipAction: scrumTimer.skipSpeaker)
            }
        }
        .padding()
        .foregroundColor(scrum.theme.accentColor)
        .onAppear {
            startScrum()
        }
        .onDisappear{
            do {
                try endScrum()
            } catch {
                errorWrapper = ErrorWrapper(error: error, guidance: "Meeting time was not recorded. Try again later.")
            }
        }
        .navigationBarTitleDisplayMode(.inline)//Configures the title display mode for this view.
    }
    
    private func startScrum() {
        // scrumTimer 를 reset 시킨 뒤, 시작한다
        // attendees 들의 name 을 맵핑하여 리스트로 넘긴다.
        scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendeeNames: scrum.attendees.map { $0.name })
        scrumTimer.speakerChangedAction = {
            player.seek(to: .zero)
            player.play()
        }
        // scrumTimer 공부 좀 더 해보자
        scrumTimer.startScrum()
    }
    
    private func endScrum() throws {
        scrumTimer.stopScrum()
        // view 가 사라지면 멈춘다.
        
        // history 를 작성하여 저장한다.
        let newHistory = History(attendees: scrum.attendees)
        scrum.history.insert(newHistory, at: 0)
        
        try? context.save() // swiftData
    }
}

#Preview {
    //    @Previewable @State var scrum = DailyScrum.sampleData[0]
    //    MeetingView(scrum: $scrum)
    
    //    let scrum = DailyScrum.sampleData[0]
    //    MeetingView(scrum: scrum)
    
    var scrum = DailyScrum.sampleData[0]
    MeetingView(scrum: scrum, errorWrapper: .constant(nil))
    // 프리뷰에서 에러 상태를 만들 필요가 없다.
    // .constant(nil) 는 읽기 전용 nil로 고정하겠다는 의미이다.
}

 

 

 

 

Draw an arc segment 

 

Shape 프로토콜을 체택해서 구조체를 꾸밀 것이다.

Shape 프로토콜을 체택 후 path(in:) 을 필수 함수로 작성해줘야한다.

 

 

 

SpeakerArc.swift 파일을 만든다.

 

path fucn 를 내놓아라

 

 

import SwiftUI

struct SpeakerArc: Shape {
    let speakerIndex: Int // 몇 번째 발언자가 말을 하고 있는지 = currentSpeaker 의 index
    let totalSpeakers: Int //총 발언자 수
    
    
    // 발언자 한명 당 각도를 계산한다.
    private var degreesPerSpeaker: Double {
        360.0 / Double(totalSpeakers)
    }
    private var startAngle: Angle {
        Angle(degrees: degreesPerSpeaker * Double(speakerIndex) + 1.0)
        // 1.0도는 시각적인 간격을 주기 위해서라고 한다
    }
    private var endAngle: Angle {
        Angle(degrees: startAngle.degrees + degreesPerSpeaker - 1.0)
        // startAngle 에 1.0 이 추가 되어있으므로 endAngle 에서 1.0 뺌
    }
    
    func path(in rect: CGRect) -> Path {
        let diameter = min(rect.size.width, rect.size.height) - 24.0 // 24.0 은 여백을 말한다. lineWidth 를 뺀 값이다.
        // 24.0 이라는 값도 따로 빼서 관리할 필요가 있을까? 궁금🤔
        let radius = diameter / 2.0 //반지름
        let center = CGPoint(x: rect.midX, y: rect.midY) // 중심 좌표
        return Path { path in
            path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
        }
        // 계산한 값들로 shape path 를 설정해준다.
        // clockwise 는 시계 방향으로 그릴건지를 묻는 방향 플래그이다.
        
        // 찾아보니까 SwiftUI 는 각도가 시계 반대 방향으로 증가한다고 한다.
        /**
         .degrees(0)는 오른쪽
         .degrees(90)는 위쪽
         .degrees(180)는 왼쪽
         .degrees(270)는 아래쪽
         */
    }
}

 

Draw the progress ring 

 

timer 를 그리는 구조를 조금 이해할 수 있게 된 것 같다.

 

일단 background circle 을 깔아두고, 그 위에 하나하나 overlay 하는 거였구나🤔

 
 
발언자의 overlay 부분 SpeakerArc 쪽을 살펴보면, 어느 방향으로 rotation 돌 건지,
그리고 stroke 를 어떻게 그릴 건지도 알 수 있다. lineWidth 는 그려지는 Arc의 두께이다.
rotation degrees 를 변경해보니 어디서부터 그려지는지를 정할 수 있었다.
 
import SwiftUI
import ThemeKit
import TimerKit

struct MeetingTimerView: View {
    // TimerView 를 그리기 위해서는 Speaker 배열 정보만 MeetingView 에서 넘겨주면 된다.
    let speakers: [ScrumTimer.Speaker]
    let theme: Theme
    
    private var currentSpeaker: String {
        speakers.first(where: { !$0.isCompleted})?.name ?? "없음"
        // 발언자 리스트 중에 isCompleted 가 false 인 사람의 첫번째 배열 요소를 찾는다.
    }
    
    var body: some View {
        Circle()
            .strokeBorder(lineWidth: 24)
            .overlay {
                // 배경이 된다 이 overlay 는
                VStack {
                    Text("\(currentSpeaker) 님")
                        .font(.title)
                    Text("발언 시간 입니다.")
                }
                .accessibilityElement(children: .combine)
                .foregroundStyle(theme.accentColor)
            }
            .overlay{
                ForEach(speakers) { speaker in
                    if speaker.isCompleted, let index = speakers.firstIndex(where: { $0.id == speaker.id }) {
                        SpeakerArc(speakerIndex: index, totalSpeakers: speakers.count)
                            .rotation(Angle(degrees: -90))
                            .stroke(theme.mainColor, lineWidth: 12)
                    }
                }
            }
            .padding(.horizontal)
    }
}

#Preview {
    let speakers = [ScrumTimer.Speaker(name: "이미함", isCompleted: true), ScrumTimer.Speaker(name: "이제함", isCompleted: false), ScrumTimer.Speaker(name: "아직안함", isCompleted: false)]
    MeetingTimerView(speakers: speakers, theme: .yellow)
}



 

 

 

 

question 문제가 진짜 기본샘플이라 알아두기 좋은 것 같다.

 

#Preview {
    @Previewable @State var names = ["Jonathan", "Lucy", "Kim"]
    MyView(names: $names)
}
The variable names is a @Previewable state variable, so you can access a binding with $names.
struct MyShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path {  path in
            path.addLine(to: CGPoint(x: 100, y: 100))
        }
     }
}
The structure declares the correct function to conform to Shape.
Rectangle()
   .rotation(Angle(degrees: 45))
Use the rotation modifier and pass an Angle to rotate the shape.
728x90