iOS/App

Scrumdinger 개발 05 - Managing state and life cycle

태애니 2025. 4. 5. 20:37
728x90

 

 

 

import SwiftUI


struct MeetingView: View {
    @Binding var scrum: DailyScrum
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 16.0)
                .fill(scrum.theme.mainColor)
            VStack {
                ProgressView(value: 5, total: 15)
                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")
                Circle()
                    .strokeBorder(lineWidth: 24)
                HStack {
                    Text("Speaker 1 of 3")
                    Spacer()
                    Button(action: {}) {
                        Image(systemName: "forward.fill")
                    }
                    .accessibilityLabel("Next speaker")
                }
            }
        }
        .padding()
        .foregroundColor(scrum.theme.accentColor)
        .navigationBarTitleDisplayMode(.inline)
    }
}


#Preview {
    @Previewable @State var scrum = DailyScrum.sampleData[0]
    MeetingView(scrum: $scrum)
}

위에 있는 MeetingView 를 조각조각 기능에 맞게 쪼개야한다.

 

오늘 온라인 세션 SwiftUI 를 들으면서 instrument 를 사용하여 성능 측정하는 것을 봤는데,

뷰의 값이 변경될 때마다 계속 갱신되기 때문에, 각 뷰를 따로 만들어서 관리하는 것이 성능 상 좋다는 것을 시각적으로 알게된 것 같다.

 

단일 뷰보다는 각각의 뷰에 Binding 시킨 후, 값의 변화에 따라 조각난 뷰만 갱신시키는 것이 좋은 방법이라는 것이 크게 와닿았다.

 

 

일단~~!! 쪼개보자

 

 

 

 

 

MeetingHeaderView

 

- private computed property  연산 코드를 분리한다

 

totalSeconds 전체 시간 계산

progress 

minutesRemaining : 초를 분으로 계산

 

 

커스텀하게 HeaderView 를 만들기 위해

세가지 값을 받도록 View 를 구성한다.

 

import SwiftUI
import ThemeKit

struct MeetingHeaderView: View {
    let secondsElapsed: Int
    let secondsRemaining: Int
    let theme: Theme
    
    private var totalSeconds: Int {
        secondsElapsed + secondsRemaining
    }
    
    private var progress: Double {
        
        guard totalSeconds > 0 else { return 1 }
        return Double (secondsElapsed) / Double(totalSeconds)
    }
    
    private var minutesRemaining: Int {
        // 초 -> 분
        secondsRemaining / 60
    }
    
    var body: some View {
        ProgressView(value: progress)
// 				아래의 ScrumProgressViewStyle 은 만들어야함***
//            .progressViewStyle(ScrumProgressViewStyle(theme: theme)))
        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")
                    .labelStyle(.trailingIcon) // text > 아이콘 순으로 변경
            
            }
        }
        // 접근성 적용
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Time remaining")
        .accessibilityValue("10 minute")
        .padding([.top, .horizontal])
    }
}

#Preview(traits: .sizeThatFitsLayout) {
    MeetingHeaderView(secondsElapsed: 60, secondsRemaining: 30, theme: .bubblegum)
}

 

 

 

 

TimerKit -> ScrumTimer

 

apple 에서는 TimerKit 을 제공해주고 있었다.

이 구조에 대해서도 좀더 분해해서 공부해볼 예정.

저런 식으로 하면 여기저기 타이머 관련 개발 시에 차용해서 쓰면 좋을 거 같다는 생각이 들었다.

 

 

import TimerKit //추가




.progressViewStyle(ScrumProgressViewStyle(theme: theme)) // 주석 제거

 

 

 

어?? scrumTier..? 오타 찾았다ㅎ

 

 

 

 

ScrumTimer 파일은 아래와 같은 구조로 만들어져있다.

 

// Speaker (발표자) 의 구조체가 있다.
// 여기서 isCompleted 로 발표시간이 종료되었는지를 감지하며, Identifiable 하다.

public var activeSpeaker (isCompleted 가 true 인 발표자)
public var secondsElapsed : 경과된 시간(초)
public var secondsRemaining : 남은 시간(초)

private var _speakers: [Speaker] = [] //발표할 사람들의 배열
public var speakers: [Speaker] {
	_speakers // public 하게 해서 외부에서 접근할 수 있ㄷ록
}



public var speakerChangedAction: (() -> Void)? //발표자 변경 시 = 새로운 발표자가 나타날 경우 실행되는 closure


// ScrumTimer 에서 처리되는 요소들
	private var lengthInMinutes: Int
    private weak var timer: Timer?
    private var timerStopped = false
    private var frequency: TimeInterval { 1.0 / 60.0 }
    private var lengthInSeconds: Int { lengthInMinutes * 60 }
    
    // 남은시간 퍼센트 표시
    private var secondsPerSpeaker: Int {
        (lengthInMinutes * 60) / _speakers.count
    }
    // 해당 발표자가 경과한 시간 표시
    private var secondsElapsedForSpeaker: Int = 0
    
    
    // speakers 의 값에 따라 적용되는 요소들
    private var speakerIndex: Int = 0
    private var speakerText: String {
        return "Speaker \(speakerIndex + 1): " + _speakers[speakerIndex].name
    }
    private var startDate: Date?

 

 

 

 

함수들 살펴보기!!!

init() < Timer 의 초기화

 

startScrum

stopScrum

 

skipSpeaker 를 보고 있는데, 조금 낯선 단어의 등장.

// nonisolated 가 뭐지??
    nonisolated public func skipSpeaker() {
        Task { @MainActor in
            changeToSpeaker(at: speakerIndex + 1)
        }
    }

 

 

Swift 에서 actor, isolated, non isolated 

 

Actor, isolated, nonisolated 키워드는 동시성(Concurrency) 관련한 개념과 연관이 있다.

Actor 모델을 활용하여 데이터를 보호하도록 한다.

 

 

 

 

Actor = 멀티스레드 환경에서 안전하게 공유 데이터를 보호하는 특수한 타입을 말한다.

 

Actor 내부의 속성과 메소드는 기본적으로 Serial화 되어 실행된다

이 내부 속성에 접근하려면 await 키워드를 사용해야한다.

 

 

Await 키워드를 활용하면 actor 내부의 속성 및 메소드에 접근할 수 있다. 

 

 

 

Isolated = 특정 매개변수가 Actor의 인스턴스를 의미할 때 사용한다. Isolated 를 사용하면 해당 actor 내부에서 실행할 때 awaait 없이 접근이 가능하다.

 

 

Actor 안에서 실행할 경우에는 별다른 키워드 추가 없이 내부에서 await 없이 사용하면 된다.

 

이 키워드도 일단은 ~~~~~ 다음번에 좀 더 정리하는 걸로ㅠㅠㅠㅠ 점점 길어지는 군

 

 


 

reset

changeToSpeaker

update

 

generateSpeakersList // name list 를 이용해 Speaker 구조체 배열을 만듦

 

 

private static func generateSpeakersList(with names: [String]) -> [Speaker] {}

// 여기서 with 는 함수의 매개변수 레이블(Parameter Label)이다.

 

// 여기선 person 이 매개변수이다. 함수 호출 시 더 자연스럽고 읽기 쉬운 코드를 만들기 위해 사용한다.
func greet(person name: String) {
    print("Hello, \(name)!")
}

greet(person: "Taeni") // "Hello, Taeni!"



//보통 우리가 자주 봤던 코드 모양은 이런거였겠지만,이는 생략한 의미이다.
func greet(_ name: String) {}

 

 

그리고 generateSpeakersList 에는 guard 키워드가 있는데, 

조건을 만족하지 않으면 즉시 함수나 코드 블록을 빠져나가도록 하는 키워드이다.

 

if 해서 else 일 경우 return 시키거나 하는 식으로 쓰는데, 그를 더 깔끔하게 작성하기 위한 코드이다.

 

다시 한번 더 찾아봤다.

 

guard 조건을 만족하지 않으면 코드 블록 종료, 옵셔널 바인딩과 함께 사용하고 루프에서 continue가 가능함. 조기종료가 가능함

return 현재 함수 실행 중단 후 반환

break switch 또는 loop 종료

throw 예외(Error) 발생

continue 루프의 현재 반복 건너뛰기

 

 

 

 

import SwiftUI
import TimerKit

struct MeetingView: View {
    @Binding var scrum: DailyScrum
    @State var scrumTimer = ScrumTimer()
    
    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")
                Circle()
                    .strokeBorder(lineWidth: 24)
                
                HStack {
                    Text("Speaker 1 of 3")
                    Spacer()
                    Button(action: {}) {
                        Image(systemName: "forward.fill")
                    }
                    .accessibilityLabel("Next speaker")
                }
            }
        }
        .padding()
        .foregroundColor(scrum.theme.accentColor)
        .navigationBarTitleDisplayMode(.inline)//Configures the title display mode for this view.
    }
}

#Preview {
    @Previewable @State var scrum = DailyScrum.sampleData[1]
    MeetingView(scrum: $scrum)
}

 

 

 

 


Add life cycle events 

 

onAppear onDisappear 이용해서 뷰가 나타나고 사라질 때의 이벤트에 맞게 life cycle method 사용할 있다.

 

 

struct MeetingView: View {


// 중간생략 ~_~     
ZStack {
//...어쩌구저쩌구
}
//...어쩌구저쩌구
.onAppear {
            // scrumTimer 를 reset 시킨 뒤, 시작한다
            // attendees 들의 name 을 맵핑하여 리스트로 넘긴다.
            scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendeeNames: scrum.attendees.map { $0.name })
            scrumTimer.startScrum()
            // scrumTimer 공부 좀 더 해보자
        }
.onDisappear{
            scrumTimer.stopScrum()
            // view 가 사라지면 멈춘다.
}
// 중간생략 ~_~                
}

 


Extract the meeting footer 

 

footer 도 분리해보자.

일단 있던 기존 코드를 뜯어냈다.

 

import SwiftUI

struct MeetingFooterView: View {
    var body: some View {
        HStack {
            Text("Speaker 1 of 3")
            Spacer()
            Button(action: {}) {
                Image(systemName: "forward.fill")
            }
            .accessibilityLabel("Next speaker")
        }
    }
}

#Preview(traits: .sizeThatFitsLayout) {
    MeetingFooterView()
}

 

 

 

 

import SwiftUI
import TimerKit

struct MeetingFooterView: View {
    let speakers: [ScrumTimer.Speaker]
    var skipAction: () -> Void
    
    private var speakerNumber: Int? {
        guard let index = speakers.firstIndex(where: { !$0.isCompleted }) else { return nil }
        return index + 1
    }
    
    private var isLastSpeaker: Bool {
        //func dropLast(_ k: Int = 1) -> ArraySlice<Element>
        //배열(Array) 또는 문자열(String)에서 마지막 n개의 요소를 제거한 새로운 시퀀스를 반환하는 메서드
        return speakers.dropLast().allSatisfy { $0.isCompleted }
    }
    
    private var speakerText: String {
        guard let speakerNumber = speakerNumber else { return "No more speakers" }
        return "Speaker \(speakerNumber) of \(speakers.count)"
    }
    
    var body: some View {
        VStack {
            HStack {
                if isLastSpeaker {
                    Text("Last Speaker")
                }else {
                    Text(speakerText)
                    Spacer()
                    Button(action: skipAction) {
                        Image(systemName: "forward.fill")
                    }
                    .accessibilityLabel("Next speaker")
                }
            }
        }
        .padding([.bottom, .horizontal])
    }
}

#Preview(traits: .sizeThatFitsLayout) {
    @Previewable var speakers = DailyScrum.sampleData[0].attendees
        .map { $0.name }
        .map { ScrumTimer.Speaker(name: $0, isCompleted: false) }
    MeetingFooterView(speakers: speakers, skipAction: {})
}

 

 

 

 

let nums = [1, 2, 3, 4, 5]
let dropped = nums.dropLast(2) // [1, 2, 3]
let suffix = nums.suffix(3) // [3, 4, 5]
 
let empty: [Int] = []
let result = empty.dropLast(2)

print(result) // []
빈 배열에 dropLast(_:)를 적용하면 그냥 빈 배열 반환한다 (에러안남)
 

 

 

 

// 예시!!

let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 모든 요소가 0보다 큰가?
let allPositive = numbers.allSatisfy { $0 > 0 }
print(allPositive) // true

// 하나라도 5 이상인 요소가 있는가?
let hasLargeNumber = numbers.contains { $0 >= 5 }
print(hasLargeNumber) // true

// 짝수만 필터링하기
let evenNumbers = numbers.filter { $0 % 2 == 0 }
print(evenNumbers) // [2, 4, 6, 8, 10]

// 첫 번째 짝수 찾기
let firstEven = numbers.first { $0 % 2 == 0 }
print(firstEven) // Optional(2)

// 특정 조건을 만족하는 동안 요소 버리고 남은 요소 반환
let droppedNumbers = numbers.drop(while: { $0 < 5 })
print(droppedNumbers) // [5, 6, 7, 8, 9, 10]

 

 

 

 

 

Trigger sound with AVFoundation 

 
import AVFoundation
// ~생략~
private let player = AVPlayer.dingPlayer()

// ~생략~

.onAppear {
    scrumTimer.speakerChangedAction = {
                    player.seek(to: .zero) // 처음으로 돌려서
                    player.play() // 딩 소리가 나게 한다.
            	}
}
728x90