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 파일을 만든다.
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
'iOS > App' 카테고리의 다른 글
Scrumdinger 분석하기 01. /scrum/CardView.swift (0) | 2025.04.14 |
---|---|
Scrumdinger 개발 10 : Recording audio (0) | 2025.04.10 |
Scrumdinger 개발 08 : Handling errors (0) | 2025.04.08 |
Scrumdinger 개발 07 : Persisting data (0) | 2025.04.07 |
Scrumdinger 개발 06 : Updating app data (0) | 2025.04.06 |