SwiftUI의 상태 관리(state management) 와
생명 주기(life cycle) 기능을 활용해 데이터 흐름(data flow) 을 구현해본다.
ScrumTimer 가 변경됨에 따라 MeetingView 에서 각 View 들에게 어떻게 처리하는지를 알아본다.
보면 ScrumTimer @Observable의 변동에 대한 알림은 MeetingView 만 받는다.
MeetingView 에서 @State 가 변경 됨에 따라 각 뷰들을 자동으로 변화되기 때문이다.
그리고 SpeechRecognizer 는 마이크에 접근해 오디오를 캡처한 후, 들리는 음성을 텍스트로 작성한다.
이 또한 @Observable 매크로를 적용한다.
@State를 사용하면 회의 뷰 안에서 음성 인식기의 단일 인스턴스를 생성할 수 있고, 앱의 수명 동안 해당 인스턴스를 계속해서 공유할 수 있다.
ScrumTimer 모델과
SpeechRecognizer 는 모두 observable 하지만
차이가 있다.
ScrumTimer 는 자주 업데이트 된다. 하지만 이 데이터들은 MeetingView 자체에 의존되어있지 않는다.
SpeechRecognizer 는 MeetingView 에서 객체를 생성하긴 하지만 변하는 모든 정보를 업데이트 하는 게 아니라, “최종 값”만 넘겨주면 된다.
그럼 MeetingView 나 Appear 될 때 실행해야하는 건 뭘까?
- 만약 음성인식 중이였다면 이를 초기화한다.
- 그리고 다시 STT 텍스트 변환을 시작한다.
Disappear 될 때는?
- stopTranscribing() 으로 텍스트 변환을 멈추고, 기록 작성을 중단한다.
- 해당 기록들을 받아서 최종적으로 회의 기록을 생성한다.
Transcribing speech to text
제공해주는 TranscriptionKit 도 import 시켜준다
Integrate speech recognition
MeetingRoom 에 recording -> transcription 기능을 추가한다.
앱의 life cycle 안에서 기능이 구현되도록 해야한다.
MeetingRoom 에서는 시작 / 종료여부를 체크하고 @State 인 isRecording 의 값을 변경시켜 전파시킨다.
import TranscriptionKit
// 중략
@State var speechRecognizer = SpeechRecognizer()
@State private var isRecording = false //녹음여부
//
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()
}
// speechRecognizer 를 리셋하고, 시작한다.
speechRecognizer.resetTranscript()
speechRecognizer.startTranscribing()
isRecording = true
// scrumTimer 공부 좀 더 해보자
scrumTimer.startScrum()
}
private func endScrum() throws {
scrumTimer.stopScrum()
// view 가 사라지면 멈춘다.
// speechRecognizer 를 멈춘다
speechRecognizer.stopTranscribing()
isRecording = false
// history 를 작성하여 저장한다.
let newHistory = History(attendees: scrum.attendees)
scrum.history.insert(newHistory, at: 0)
try? context.save() // swiftData
}
struct MeetingTimerView: View {
// TimerView 를 그리기 위해서는 Speaker 배열 정보만 MeetingView 에서 넘겨주면 된다.
let speakers: [ScrumTimer.Speaker]
let theme: Theme
let isRecording: Bool // MeetingView 에서 넘기는 값이다
// 중략
}
isRecording 을 받을 수 있도록 값을 변경해주고, Preview 에서도 값을 던진다.
MeetingRoom 에서 MeetingTimerView 를 부를 때도 isRecording 값을 추가한다.
MeetingTimerView(speakers: scrumTimer.speakers, theme: scrum.theme, isRecording: isRecording)
그리고, isRecording 상태에 따라 아이콘을 다르게 보이게 한다.
Image(systemName: isRecording ? "mic" : "mic.slash")
.font(.title)
.padding(.top)
.accessibilityLabel(isRecording ? "with transcription" : "without transcription")
Create a history view
import Foundation
import SwiftData
@Model
class History: Identifiable {
var id: UUID
var date: Date
var attendees: [Attendee]
var dailyScrum: DailyScrum? // 1:N 관계 작업
var transcript: String?
init(id: UUID = UUID(), date: Date = Date(), attendees: [Attendee], transcript: String? = nil ){
self.id = id
self.date = date
self.attendees = attendees
self.transcript = transcript
}
}
transcript 를 추가한다.
그리고 transcript 를 볼 수 있는 화면인 HistoryView 를 구현한다.
ListFormatter.localizedString 은 배열에 있는 문자열들을 자연스럽고 읽기 좋은 방식으로 연결해준다.
커스터마이징을 딱히 할 수 없기 때문에 이런식으로 쓰면 비슷하게 코드를 구현할 수 있다.
func joinWithCustomConjunction(_ items: [String], conjunction: String) -> String {
switch items.count {
case 0:
return ""
case 1:
return items[0]
case 2:
return "\(items[0]) \(conjunction) \(items[1])"
default:
let allExceptLast = items.dropLast().joined(separator: ", ")
return "\(allExceptLast), \(conjunction) \(items.last!)"
}
}
let names = ["태니", "탱구", "태애니"]
let result = joinWithCustomConjunction(names, conjunction: "또는")
// "태니, 탱구, 또는 태애니"
//
// HistoryView.swift
// Scrumdinger
//
// Created by taeni on 4/8/25.
//
import SwiftUI
struct HistoryView: View {
let history: History
var body: some View {
ScrollView {
VStack (alignment: .leading) {
Divider()
.padding(.bottom)
Text("참석자")
.font(.headline)
Text(history.attendeeString)
if let transcript = history.transcript {
Text("Transcript")
.font(.headline)
.padding(.top)
Text(transcript)
}
}
}
.navigationTitle(Text(history.date, style: .date))
.padding()
}
}
extension History {
var attendeeString: String {
ListFormatter.localizedString(byJoining: attendees.map { $0.name })
}
}
#Preview {
let history = History(attendees: [
Attendee(name: "Taeni"),
Attendee(name: "MarTaeni"),
Attendee(name: "GarTaeni")
],transcript: "아.. 오늘 회의 몇시까지해요?")
HistoryView(history: history)
}
["A", "B", "C"] 같은 문자열 배열을 넣어주면 "A, B, and C"처럼 연결된 문자열로 반환해준다.
한국어로 설정되어 있으면 "A, B, 그리고 C"
로 나온다.
/*
See LICENSE folder for this sample’s licensing information.
*/
import SwiftUI
struct HistoryView: View {
let history: History
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Divider()
.padding(.bottom)
Text("Attendees")
.font(.headline)
Text(history.attendeeString)
if let transcript = history.transcript {
Text("Transcript")
.font(.headline)
.padding(.top)
Text(transcript)
}
}
}
.navigationTitle(Text(history.date, style: .date))
.padding()
}
}
extension History {
var attendeeString: String {
ListFormatter.localizedString(byJoining: attendees.map { $0.name })
}
}
#Preview {
let history = History(attendees: [
Attendee(name: "Jon"),
Attendee(name: "Darla"),
Attendee(name: "Luis")
],
transcript: "Darla, would you like to start today? Sure, yesterday I reviewed Luis' PR and met with the design team to finalize the UI...")
HistoryView(history: history)
}
HistoryView 까지 작업 완
Scrumdinger 앱이 끝났다. (?) 뭐지 벌써?
더 길줄알았는데 뒤에는 다른 개발관련 문서들이 있었다.
1/3 인 줄 알았는데... 아니였넴..
아무튼 하나의 앱 서비스 구현이 완료됐다.
전체적인 코드 개발 방식을 확인해보고 느낀거는
리팩토링 할때 진짜 정신이 없다는 것... 역시 초반에 잘 잡아놔야함
챌린지2가 이제 시작되었다. 열심히 해봐야지. 할 수 있따.
'iOS > App' 카테고리의 다른 글
Scrumdinger 분석하기 02-1. /scrum/ScrumsView.swift (0) | 2025.04.15 |
---|---|
Scrumdinger 분석하기 01. /scrum/CardView.swift (0) | 2025.04.14 |
Scrumdinger 개발 09 : Drawing the timer view (0) | 2025.04.09 |
Scrumdinger 개발 08 : Handling errors (0) | 2025.04.08 |
Scrumdinger 개발 07 : Persisting data (0) | 2025.04.07 |