모델들을 구성해줍시다
Mood 모델 부터 만들어줬다!
enum Mood: String, Codable, CaseIterable {
case bad
case notGreat
case okay
case good
case great
var imageName: String {
switch self {
case .bad: return "cloud.bolt.rain.fill"
case .notGreat: return "cloud.heavyrain.fill"
case .okay: return "cloud.fill"
case .good: return "cloud.sun.fill"
case .great: return "sun.max.fill"
}
}
var name: String {
switch self {
case .bad: return "Bad"
case .notGreat: return "Not\nGreat"
case .okay: return "Okay"
case .good: return "Good"
case .great: return "Great"
}
}
}
Mood 라는 이넘타입이고, String RawValue를 가지면서 모든 케이스들을 나타낼 수 있는 데이터가 될거!
그리고 computed Property로 case에 따라서 이미지이름이나 이름을 표시할 수 있게 해줬다
MoodDiary 모델을 만들어주자!
struct로 선언된 모델에 var을 쓴게 마음에 걸리지만...
🤔나중에 let으로 수정하는 방향으로도 제작해보기!
조금 놀란 점이 있다면 date를 String으로 구성했다는거!
그리고 dateComponent로 MoodDiary읜 date 프로퍼티를 가지고 새로운 DateComponents를 구성했다는 점이 신기했다
그리구 Mock데이터들도 전역으로 설정해줌
public class Storage {
private init() { }
enum Directory {
case documents
case caches
var url: URL {
let path: FileManager.SearchPathDirectory
switch self {
case .documents:
path = .documentDirectory
case .caches:
path = .cachesDirectory
}
return FileManager.default.urls(for: path, in: .userDomainMask).first!
}
}
static func store<T: Encodable>(_ obj: T, to directory: Directory, as fileName: String) {
let url = directory.url.appendingPathComponent(fileName, isDirectory: false)
print("---> save to here: \(url)")
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let data = try encoder.encode(obj)
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
FileManager.default.createFile(atPath: url.path, contents: data, attributes: nil)
} catch let error {
print("---> Failed to store msg: \(error.localizedDescription)")
}
}
static func retrieve<T: Decodable>(_ fileName: String, from directory: Directory, as type: T.Type) -> T? {
let url = directory.url.appendingPathComponent(fileName, isDirectory: false)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
guard let data = FileManager.default.contents(atPath: url.path) else { return nil }
let decoder = JSONDecoder()
do {
let model = try decoder.decode(type, from: data)
return model
} catch let error {
print("---> Failed to decode msg: \(error.localizedDescription)")
return nil
}
}
static func remove(_ fileName: String, from directory: Directory) {
let url = directory.url.appendingPathComponent(fileName, isDirectory: false)
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
try FileManager.default.removeItem(at: url)
} catch let error {
print("---> Failed to remove msg: \(error.localizedDescription)")
}
}
static func clear(_ directory: Directory) {
let url = directory.url
do {
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
for content in contents {
try FileManager.default.removeItem(at: content)
}
} catch {
print("---> Failed to clear directory msg: \(error.localizedDescription)")
}
}
}
generic하게 쓸 수 있는 Storage 클래스를 만들어줌!
뭔가 닉이 만들 때랑 비슷하면서 다르다
directory 자체를 enum으로 분류해서 url에 분기를 나눠준 느낌!!
final class MoodDiaryStorage {
func persist(_ items: [MoodDiary]) {
Storage.store(items, to: .documents, as: "diary_list.json")
}
func fetch() -> [MoodDiary] {
let list = Storage.retrieve("diary_list.json", from: .documents, as: [MoodDiary].self) ?? []
return list
}
}
Storage를 활용한 MoodDiaryStorage도 구성해주고!
ForEach로 그려낼 뷰를 또 만들어주자
여기서 고민인 건 date 월별로 묶어줘야 할 거 같은데
요런 형태가 되어야 할 거 같죠
Sequance -> Dictionary로 바꿔주는 게 있음
뷰모델에서 만들어줍시다
모델에서 년 - 월로 만들어준 monthlyIdentifier로 init 될 때 Grouping 해줌
메인 앱파일에서 vm 넘겨주고
뷰모델에서 keys라는 array도 만들어줌 ( 딕셔너리 키 값들을 정렬 시키는 String 배열 )
extension DiaryListView {
private func formattedSectionTitle(_ id: String) -> String {
let dateComponents = id.components(separatedBy: "-")
.compactMap { Int($0) }
guard let year = dateComponents.first,
let month = dateComponents.last else { return id }
let calendar = Calendar(identifier: .gregorian)
let dateComponent = DateComponents(calendar: calendar, year: year, month: month)
let date = dateComponent.date!
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
}
여기서 나는 좀 커스텀함
formatter에 formatter.dateFormat = "yy년 MMMM"
formatter.locale = Locale(identifier: "ko")
요렇게 추가해줬다
버튼도 추가해주고!
orderedItems 를 가지고 DiaryDetailsView로 네비게이션 되게 해줌
근데 이거 Section안에서 이렇게 let 선언해서 하는 게 안 좋은 거 같은데...?
🤔나중에 수정합시다
Diary detailsView의 내용을 채워줄겨
날짜 표시 바꿔주는 메소드 작성함
근데 이거 뷰모델에서 다 가지고 있는 게 낫지 않나...?
formatter의 dateFormat을 한 줄 뛰고 바로 바꾸는 것두 어색하게 느껴진다
🤔 어떻게 바꾸면 더 괜찮아질까?
하단에 삭제 버튼 작성해주고!
데이트 인풋뷰가 sheet으로 띄워지게 해주자
dismiss도 만들어주고!!
(.toolbar는 NavigationView 안에 작성되어야 함!! )
DatePicker 구성해주고
레이아웃 에러 나서 DatePicker 프레임 하드코딩해줌
뷰모델도 구성해주고!
버튼 만듬
import SwiftUI
// 선택할 감정들 표현
// 감정 선택시, 저장
struct DiaryMoodInputView: View {
@ObservedObject var vm: DiaryViewModel
let moods: [Mood] = Mood.allCases
var body: some View {
VStack {
Spacer()
HStack {
ForEach(moods, id: \.self) { mood in
Button {
vm.mood = mood
} label: {
VStack {
Image(systemName: mood.imageName)
.renderingMode(.original)
.resizable()
.scaledToFit()
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 0)
.padding()
Text(mood.name)
.foregroundColor(.gray)
}
.frame(height: 180)
.background(mood == vm.mood ? .gray.opacity(0.4) : .clear)
.cornerRadius(10)
}
}
}
.padding()
Spacer()
NavigationLink {
DiaryTextInputView()
} label: {
Text("Next")
.foregroundColor(.white)
.font(.title)
.fontWeight(.bold)
.frame(height: 55)
.frame(maxWidth: .infinity)
.background(Color.blue.cornerRadius(30))
.padding()
}
}
}
}
Mood enum에 allCases가 가능함 (CaseIterable을 채택해서)
버튼 선택될 때 mood값이 바뀌게 해주면 끝!
어려운거 없죠
final class DiaryViewModel: ObservableObject {
@Published var diary: MoodDiary = MoodDiary(date: "", text: "", mood: .great)
@Published var date: Date = Date()
@Published var mood: Mood = .great
var cancellables = Set<AnyCancellable>()
init() {
addSubscription()
}
private func addSubscription() {
$date.sink { date in
print("---> selected: \(date)")
self.update(date: date)
}
.store(in: &cancellables)
$mood.sink { mood in
print("---> selected: \(mood)")
self.update(mood: mood)
}
.store(in: &cancellables)
}
private func update(date: Date) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd hh:mm:ss"
self.diary.date = formatter.string(from: date)
}
private func update(mood: Mood) {
self.diary.mood = mood
}
}
인풋 뷰들에서 이뤄져야 하는 것들.
값이 바뀌면 Published로 선언된 MoodDiary에 저장이 되어야함
이걸 Subsription에서 update메소드로 구현해줬다!
여기서 기억할 건 @FocusState의 사용이다!
.focused로 TextEditor에 바인딩 시켜주고
.onAppear일 때 true로 바꿔주면 키보드 입력 바로됨!!
Navigation View의 child view에서 dimiss 가 한겹만 되는걸 발견함
방법은 의외로 간단했다..
childView에서 dismissAction 선언하고 계속 넘겨주면 됨
StateObject에서 바인딩하는 게 보기 싫어서 dismiss로 시작한 거 였는데
이렇게 될 줄은 또 몰랐네
🤔Navigation뷰의 자식에서 dismiss를 전부 시킬라면 결국은 연결시켜줘야함
final class DiaryListViewModel: ObservableObject {
let storage: MoodDiaryStorage
@Published var list: [MoodDiary] = []
@Published var dic: [String: [MoodDiary]] = [:]
var cancellables = Set<AnyCancellable>()
// 데이터 파일에서 일기 리스트 가져오기
// list에 해당 일기 객체들 세팅
// list 세팅 되면, dic도 세팅하기
init(storage: MoodDiaryStorage) {
self.storage = storage
bind()
}
var keys: [String] {
return dic.keys.sorted { $0 > $1 }
}
private func bind() {
$list.sink { items in
self.dic = Dictionary(grouping: items, by: { $0.monthlyIdentifier })
self.persist(items: items)
}
.store(in: &cancellables)
}
func persist(items: [MoodDiary]) {
guard !items.isEmpty else { return }
self.storage.persist(items)
}
func fetch() {
self.list = storage.fetch()
}
}
fetch를 통해서 저장된 list를 불러오고,
이 list가 값이 변경될 때 bind메소드를 통해서 persist메소드가 호출되게 해줌!
🤔 왜 DiaryListViewModel에서 storage를 init에서 넣어줄까? 바로 초기값 싱글톤으로 넣어주는 것도 가능할 거 같은데
DiaryViewModel에 있는 diary 프로퍼티를 DiaryListViewModel에서 가져가야 될 거 같음
허허
요렇게 삭제를 하다니..