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.
'iOS > App' 카테고리의 다른 글
Scrumdinger 개발 09 : Drawing the timer view (0) | 2025.04.09 |
---|---|
Scrumdinger 개발 08 : Handling errors (0) | 2025.04.08 |
Scrumdinger 개발 06 : Updating app data (0) | 2025.04.06 |
Scrumdinger 개발 05 - Managing state and life cycle (0) | 2025.04.05 |
Scrumdinger 개발 04 - Passing data with bindings (0) | 2025.04.03 |