iOS/App

Scrumdinger 개발 07 : Persisting data

태애니 2025. 4. 7. 17:58
728x90

Persisting data 

 

이제 SwiftData 프레임 워크를 이용해서

데이터 영속성(persistence) 를 추가하고,

영구 저장소(persistence store) 데이터를 CRUD 하도록 하여 동기화시킨다.

 

 

 

이러기 위해선 다시 model 리팩토링에 들어간다.

SwiftData 를 import 하여 사용하겠음을 체택하고 SwiftData 를 사용할 수 있도록 선언해주는 과정을 알아보려고 한다.

그리고 @Relationship 을 사용해서 관계형 데이터임을 알려준다.

 

DailyScrum, Attendee, History 모두 @model 을 붙인 뒤 class 화 시켜준다.

 

 

import Foundation
import ThemeKit
import SwiftData //swiftdata

@Model //swiftdata
//struct DailyScrum: Identifiable {
class DailyScrum: Identifiable { // swiftdata 는 class로 선언
//    let id: UUID
    var id: UUID // swiftdata 는 id를 mutable 하게 해야한다.
    var title: String
    var attendees: [Attendee]
    var lengthInMinutes: Int
    
    // slider 조절을 위해 계산할 수 있는 property 를 설정한다
    var lengthInMinutesAsDouble: Double {
        // lengthInMinutes
        get{
            Double(lengthInMinutes)
        }
        set {
            lengthInMinutes = Int(newValue)
        }
    }
    
    var theme: Theme
    
    // history 추가
    var history: [History] = []
    
    init(id: UUID = UUID(), title: String, attendees: [String], lengthInMinutes: Int, theme: Theme) {
        self.id = id
        self.title = title
        self.attendees = attendees.map { Attendee(name: $0) }
        self.lengthInMinutes = lengthInMinutes
        self.theme = theme
    }
}

 

extension 에 있던 Attendee 도 따로 떼어내고

import Foundation
import SwiftData

@Model
class Attendee: Identifiable {
    var id: UUID
    var name: String
    
    var dailyScrum: DailyScrum? // 1:N 관계 작업
    
    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
}

 

 

Attendee 와 History 는 DailyScrum 에 1:N 관계로 여러개를 가질 수 있는 구조이다.

일단 History도 SwiftData @model 을 적용시켜준다.

그리고, 두 모델에게 해야할 것은 관계 설정해주는데,

 

DailyScrum 에게 여러개를 가질 수 있는 model 인 두 모델에 DailyScrum 을 옵셔널로 설정해줌으로써

1:N 관계임을 명시해준다. -> 역방향 관계(inverse relationship) 설정

 

 

 

@Model
class History: Identifiable {
    var id: UUID
    var date: Date
    var attendees: [Attendee]
    
    var dailyScrum: DailyScrum? // 1:N 관계 작업


    init(id: UUID = UUID(), date: Date = Date(), attendees: [Attendee]) {
        self.id = id
        self.date = date
        self.attendees = attendees
    }
}

 

 

//DetailEditView 에도 수정
    // swift data 사용
let attendee = Attendee(name: attendeeName)
scrum.attendees.append(attendee)

 

 

DailyScrum 에는 @Relationship 매크로를 추가하고

cascade 를 추가한다. DailyScrum 이 삭제되면 해당 모델들도 삭제해준다는 의미이다.

@Model //swiftdata
//struct DailyScrum: Identifiable {
class DailyScrum: Identifiable { // swiftdata 는 class로 선언
//    let id: UUID
    var id: UUID // swiftdata 는 id를 mutable 하게 해야한다.
    var title: String
    
    @Relationship(deleteRule: .cascade, inverse: \Attendee.dailyScrum)
    var attendees: [Attendee]
    var lengthInMinutes: Int
    
    // slider 조절을 위해 계산할 수 있는 property 를 설정한다
    var lengthInMinutesAsDouble: Double {
        // lengthInMinutes
        get{
            Double(lengthInMinutes)
        }
        set {
            lengthInMinutes = Int(newValue)
        }
    }
    
    var theme: Theme
    
    // history 추가
    @Relationship(deleteRule: .cascade, inverse: \History.dailyScrum)
    var history: [History] = []
    
    init(id: UUID = UUID(), title: String, attendees: [String], lengthInMinutes: Int, theme: Theme) {
        self.id = id
        self.title = title
        self.attendees = attendees.map { Attendee(name: $0) }
        self.lengthInMinutes = lengthInMinutes
        self.theme = theme
    }
}

 

 

 

Create sample SwiftData for previews

 

샘플데이터 제공 방식도 변경해야한다.

 

import SwiftData
import SwiftUI

// PreviewModifier 프로토콜을 사용한다.
struct DailyScrumSampleData: PreviewModifier {

    // PreviewModifier 를 체택하면 해당 메소드를 정의해주어야한다.
    // ModelContainer inmemory 저장소에 샘플데이터 주입
    static func makeSharedContext() async throws -> ModelContainer {
        let container = try ModelContainer(for: DailyScrum.self, configurations: .init(isStoredInMemoryOnly: true))
        DailyScrum.sampleData.forEach { container.mainContext.insert($0) }
        return container
    }

    func body(content: Content, context: ModelContainer) -> some View {
        content.modelContainer(context)
    }
}

// to provide a constant PreviewTrait
extension PreviewTrait where T == Preview.ViewTraits {
    @MainActor static var dailyScrumsSampleData: Self = .modifier(DailyScrumSampleData())
}

 

SwiftData 를 사용할 때 흔히 볼 수있는 형태의 저장소 주입 형태인데,

여기서 Preview.ViewTrait 을 사용하는 방식도 설명이 되어 있다.

 

 

이런식으로 깔꼼하게 쓸 수 있다 굿b

SampleView()
  .previewTrait(.dailyScrumsSampleData)

 

 

 

Integrate SwiftData models with SwiftUI views 

 

참고로 SwiftData 를 사용하면 모델 객체 변경을 자동 관리하기 때문에 @Binding 없이 관리가 된다.

@Binding 받아서 $로 표시했던 것들을 모두 정리해야한다.

 

 

이미 이 SwiftData 객체는 참조타입(reference type) 이기 때문에 일관성과 동기화가 쉽게 유지된다.

리팩토링 !@

 

 

//
//  ScrumsView.swift
//  Scrumdinger
//
//  Created by taeni on 3/31/25.
//
import SwiftUI
import SwiftData // swiftData

struct ScrumsView: View {
    //    let scrums: [DailyScrum]
//    @Binding var scrums: [DailyScrum] swiftdata 쓰기 때문에 필요없음
    
    @Query(sort: \DailyScrum.title) private var scrums: [DailyScrum] //swiftData
    @State private var isPresentingNewScrumView = false
            // 생성뷰를 보여줄 지에 대한 여부
    
    var body: some View {
        // Identifiable protocol, you can simplify the List initializer.
        //        List(scrums) { scrum in
        //            CardView(scrum: scrum)
        //                .listRowBackground(scrum.theme.mainColor)
        //        }
        
        // NavigationStack
        NavigationStack {
            List(scrums) { scrum in
                NavigationLink(destination: DetailView(scrum: scrum)) {
                    //Pass a Binding<DailyScrum> to the DetailView initializer.
                    
                    CardView(scrum: scrum)
                }
                .listRowBackground(scrum.theme.mainColor)
            }
            .navigationTitle("Daily Scrums")
            .toolbar {
                //Pass an empty action to the button for now.
                Button(action: {
                    isPresentingNewScrumView = true
                }) {
                    Image(systemName: "plus")
                }
                .accessibilityLabel("New Scrum")
            }
            .sheet(isPresented: $isPresentingNewScrumView) {
                NewScrumSheet()
            }
        }
    }
}

#Preview {
    //    ScrumsView(scrums: DailyScrum.sampleData)
    
//    @Previewable @State var scrums = DailyScrum.sampleData
    ScrumsView()
}

 

 

SwiftData 모델 객체는 생성된 스레드와 동일한 스레드에서 접근해야한다.

 

코드 완전 깔꼼해짐..

import SwiftUI
import SwiftData //swiftData

@main
struct ScrumdingerApp: App {
    //add a private @State property named scrums.
//    @State private var scrums = DailyScrum.sampleData
    
    var body: some Scene {
        WindowGroup {
            ScrumsView()
        }
        .modelContainer(for: DailyScrum.self)
    }
}

 

 

//
//  DetailEditView.swift
//  Scrumdinger
//
//  Created by taeni on 3/31/25.
//
import SwiftUI
import ThemeKit
import SwiftData

struct DetailEditView: View {
    //    @Binding var scrum: DailyScrum // 상위 뷰에서 받는 값
    //    @State private var scrum = DailyScrum.emptyScrum
    let scrum: DailyScrum //swiftData
    
    //    let saveEdits: (DailyScrum) -> Void // user가 Done 버튼을 눌렀을 때 저장/수정이 일어나도록 하는 closure
    
    @State private var attendeeName = "" // input 받을 참석자명
    
    // 입력 필드 상태 분리
    @State private var title: String
    @State private var lengthInMinutesAsDouble: Double
    @State private var attendees: [Attendee]
    @State private var theme: Theme
    
    
    @Environment(\.dismiss) private var dismiss
    // @Environment(\.dismiss) private var dismiss 을 이용하여 DismissAction 인스턴스를 호출시킨다.
    @Environment(\.modelContext) private var context
    
    private let isCreatingScrum: Bool
    
    // 생성자 정의
    // DailyScrum 값을 넘겨 받으면 이를 분해해서 입력 필드를 입력해줄 수 있도록 한다.
    init(scrum: DailyScrum?) {
            let scrumToEdit: DailyScrum
            if let scrum {
                scrumToEdit = scrum
                isCreatingScrum = false
            } else {
                scrumToEdit = DailyScrum(title: "", attendees: [], lengthInMinutes: 5, theme: .sky)
                isCreatingScrum = true
            }


            self.scrum = scrumToEdit
            self.title = scrumToEdit.title
            self.lengthInMinutesAsDouble = scrumToEdit.lengthInMinutesAsDouble
            self.attendees = scrumToEdit.attendees
            self.theme = scrumToEdit.theme
        }
    
    var body: some View {
        Form {
            Section(header: Text("Meeting Info")) {
                TextField("Title", text: $title)
                /**
                 TextField takes a binding to a String. You can use the $ syntax to create a binding to scrum.title. The current view manages the state of the data property.
                 */
                HStack {
                    Slider(value: $lengthInMinutesAsDouble, in: 5...30, step: 1) {
                        Text("Length")
                        //the slider and the label stay in sync.
                    }
                    .accessibilityValue("\(String(format: "%.0f", lengthInMinutesAsDouble)) minutes") // 접근성
                    Spacer()
                    Text("\(String(format: "%.0f", lengthInMinutesAsDouble)) minutes")
                        .accessibilityHidden(true)
                }
                ThemePicker(selection: $theme)
            }
            Section(header: Text("참석자 목록")) {
                ForEach(attendees) { attendee in
                    Text(attendee.name)
                }
                .onDelete { indices in
                    attendees.remove(atOffsets: indices)
                    //onDelete when the user swipes to delete a row
                }
                
                HStack {
                    TextField("새로운 참석자", text: $attendeeName)
                    Button(action: {
                        withAnimation {
                            //setting the value to the empty string also clears the contents of the text field.
                            //                            let attendee = DailyScrum.Attendee(name: attendeeName)
                            //                            scrum.attendees.append(attendee)
                            
                            // swift data 사용
                            let attendee = Attendee(name: attendeeName)
                            attendees.append(attendee)
                            
                            // binding to attendeeName, setting the value to the empty string also clears the contents of the text field
                            // animation block 안에서 처리!
                            attendeeName = ""
                        }
                    }) {
                        Image(systemName: "plus.circle.fill")
                            .accessibilityLabel("참석자 추가")
                    }
                    .disabled(attendeeName.isEmpty)
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") {
                    dismiss()
                }
            }
            ToolbarItem(placement: .confirmationAction) {
                Button("Done") {
                    saveEdits()
                    dismiss()
                }
            }
        }
    }
    
    private func saveEdits() {
        scrum.title = title
        scrum.lengthInMinutesAsDouble = lengthInMinutesAsDouble
        scrum.attendees = attendees
        scrum.theme = theme
        
        if isCreatingScrum {
            context.insert(scrum)
        }


        try? context.save()
    }
}

#Preview {
//    @Previewable @State var scrum = DailyScrum.sampleData[0]
//    DetailEditView(scrum: $scrum, saveEdits: { _ in })
    
    @Previewable @Query(sort: \DailyScrum.title) var scrums: [DailyScrum]
    DetailEditView(scrum: scrums[0])
}

DetailEditView 도 휙휙 바꿔야할게 많았다.

 

일단은 각각을 @State 로 분해해서 담는 부분이 있어서 init 으로 분해하는 과정이 있어야했고.

저장 시에도 다시 scrum 에 각 요소들을 합쳐서 insert, save 하는 과정을 추가해줘야한다.

 

@Environment(\.modelContext) private var context

 

modelContext 를 선언하여 저장 처리를 할 수 있도록 한다.

 

 

$ 지옥에서 헷갈리는게 많아서 좀 더 정리를 해보고 싶은데,

막상 알아서 Warning 걸어주니까 그냥 하라는 대로 했던...ㅠ

 

View 의 코드 안에서 Scrum 의 요소를 가져오는 게 아니라

init 에서 View 에 선언된 각 @State 필드에 담아두는게 포인트인 듯 하다!

 

 

 

 

NewScrumSheet 도 바꿔주고.

import SwiftUI

struct NewScrumSheet: View {
    //    @State private var newScrum = DailyScrum.emptyScrum
    //    @Binding var scrums: [DailyScrum]
    
    var body: some View {
        NavigationStack {
            //            DetailEditView(scrum: $newScrum) { dailyScrum in
            //                scrums.append(newScrum)
            //        }
            // 새로운 scrum 을 추가한다
            
            DetailEditView(scrum: nil)
        }
    }
}

#Preview {
    //    NewScrumSheet(scrums: .constant(DailyScrum.sampleData))
    NewScrumSheet()
}

 

 

MeetingView 는 그리 할게 많지는 않았는데...

암튼 그냥 전체 코드 때려붙임!

//
//  MeetingView.swift
//  Scrumdinger
//
//  Created by taeni on 3/27/25.
//
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()
    
    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)
                
                //            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{
            endScrum()
        }
        .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(){
        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)
}

 

 

 

일부러 과정들을 남기려고 주석처리 하며 코드를 리팩토링 하다보니

아주 더러워졌다...ㅎ

 

 

거기다가 아직 할게 산더미다

특히 예외처리가 하나도 안되어있어서 테스트 해보는데

참석자 빈값인데 막 들어가지고...

그런 섬세한 작업 하나하나가 사용자에게 편안함을 준다고 생각한다.

~_~ 개발하는 과정에서 더 꼼꼼해져야할 것 같다. 특히 디자인 픽셀 제대로 체크 안하던 버릇.. 테스트 안하는 버릇.. 등등

지금까지 고질적이던 습관도 조금씩 개선해나가야할 듯함!!

 

암튼 작업 완~~~!!

 

 

 

import Foundation
import SwiftData


@Model class Employee {
    var firstName: String
    var lastName: String
    
    var team: Team?
    
    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}


@Model class Team {
    var manager: Employee
    @Relationship(deleteRule: .cascade, inverse: \Employee.team) 
        var members: [Employee]
    
    init(manager: Employee, members: [Employee]) {
        self.manager = manager
        self.members = members
    }
}

import Foundation
import SwiftData


@Model class Employee {
    var firstName: String
    var lastName: String
    
    var team: Team?
    
    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}


@Model class Team {
    var manager: Employee
    @Relationship(deleteRule: .nullify, inverse: \Employee.team) 
        var members: [Employee]
    
    init(manager: Employee, members: [Employee]) {
        self.manager = manager
        self.members = members
    }
}
This solution persists a team of employees and preserves the employees if the team is deleted.
728x90