[SwiftUI] EmotionDiary

Woozoo·2023년 3월 14일
0

[SwiftUI Review]

목록 보기
14/41
post-custom-banner

프로젝트 구성

모델부터

모델들을 구성해줍시다
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데이터들도 전역으로 설정해줌


Storage 구현

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도 구성해주고!


DiaryList View

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")
요렇게 추가해줬다

버튼도 추가해주고!


DiaryDetailsView


orderedItems 를 가지고 DiaryDetailsView로 네비게이션 되게 해줌

근데 이거 Section안에서 이렇게 let 선언해서 하는 게 안 좋은 거 같은데...?

🤔나중에 수정합시다

Diary detailsView의 내용을 채워줄겨


날짜 표시 바꿔주는 메소드 작성함
근데 이거 뷰모델에서 다 가지고 있는 게 낫지 않나...?
formatter의 dateFormat을 한 줄 뛰고 바로 바꾸는 것두 어색하게 느껴진다

🤔 어떻게 바꾸면 더 괜찮아질까?

하단에 삭제 버튼 작성해주고!


다이어리 생성


데이트 인풋뷰가 sheet으로 띄워지게 해주자


dismiss도 만들어주고!!
(.toolbar는 NavigationView 안에 작성되어야 함!! )


DatePicker 구성해주고


레이아웃 에러 나서 DatePicker 프레임 하드코딩해줌


뷰모델도 구성해주고!


버튼

버튼 만듬


Mood Input

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값이 바뀌게 해주면 끝!
어려운거 없죠


Subscription 추가

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메소드로 구현해줬다!


Diary Text Input

여기서 기억할 건 @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에서 가져가야 될 거 같음



삭제




허허
요렇게 삭제를 하다니..

profile
우주형
post-custom-banner

0개의 댓글