# Step 1 안녕하세요 줄라이! 해리, 로웬 조 Diary 프로젝트 첫 PR 보내드립니다 🙌 이번 프로젝트 동안 잘 부탁드립니다!! --- # 고민한 점 ## DateFormmater.locale Diary 작성일자가 사용자의 지역 포멧에 맞게 표현될 수 있도록 DateFormatter의 locale 프로퍼티를 설정하였습니다. ```swift dateFormatter.locale = Locale(identifier: Locale.current.identifier) ``` </br> ## Diary 편집 화면 Diary List 화면에서 네비게이션 바의 `+` 버튼을 눌렀을 경우와 cell을 선택했을 경우 모두 Diary 내용을 편집하는 화면이 나올 수 있도록 `DiaryContentViewController`를 만들었습니다. 528da9dbb5264cb96d00d86dc965bd87007baba4 </br> ## TextView가 Keyboard에 가려지는 현상 UIResponder에 이미 구현되어있는 keyboardWillShowNotification, keyboardWillHideNotification을 활용하여 키보드가 화면에 표시되기 직전, 사라지기 직전에 해당 Notification을 `DiaryContentViewController`가 받을 수 있도록 만들었습니다. 이후 다양한 문제해결 방법을 찾아보고 적절하다고 판단되는 방법으로 구현해봤습니다. </br> ### RootView의 frame origin 조절하기 `view.frame.origin.y`를 keyboard의 높이에 맞게 이동시켜주는 방법이 있었습니다. 하지만 rootView의 frame을 옮기면 keyboard가 화면에 나타나있는 상태에서 최상단으로 스크롤 할 경우 text의 위쪽 부분이 keyboard의 높이만큼 잘리게 되기 때문에 부적절하다고 생각하여 채택하지 않았습니다. ### UIKeyboardLayoutGuide 활용하기 뷰컨트롤러 루트뷰에 있는 `keyboardLayoutGuide` 프로퍼티를 통해 텍스트뷰와의 제약관계를 설정해 간단하게 편집중인 텍스트가 가리지 않도록 할 수 있었습니다. (available iOS 15.0) 프로젝트의 minimum deployment를 올려야했기 때문에 이 방법을 채택하지는 않았습니다. 🤔 ### 텍스트뷰의 contentInset 활용하기 NotificationCenter를 이용하여 `UIResponder.keyboardWillShowNotification`과 `UIResponder.keyboardWillHideNotification` 이벤트가 발생할 때, 텍스트뷰의 contentInset을 설정해주었습니다. 텍스트뷰는 스크롤뷰를 상속하고 있어서 contentInset 프로퍼티를 활용할 수 있었고, 키보드의 크기를 계산해서 contentInset의 바텀을 키보드 높이로 할당하여 이 방법으로 편집중인 텍스트가 가려지지 않도록 해주었습니다. </br> # 조언받고 싶은 점 ## 텍스트뷰와 키보드 사이의 여백 <img src="https://i.imgur.com/zbtWRve.png" width="300"> ```swift guard let keyboardSize = noti.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } let keyboardHeight = keyboardSize.cgRectValue.height textView.contentInset.bottom = keyboardHeight ``` - 키보드의 크기를 구해서 텍스트뷰의 bottom inset을 설정해주는 방식으로 편집중인 텍스트가 가리지 않도록 해주었습니다. - 위에 그림과 같이 텍스트뷰와 키보드 사이에 공백이 존재하는데, 이 부분을 어떤 방식으로 설정해야 할 지 조언을 주시면 감사하겠습니다. </br> --- # 학습내용 ## WWDC Making Apss Adaptive Part 1,2 - [링크](https://hackmd.io/@Rowan/SkZvzsX73) ## UITextViewDelegate Method ### Editing Notification 응답 메서드 * 텍스트뷰를 탭하여 First Responder가 되면 아래 메서드가 차례대로 호출됩니다. ``` swift func textViewShouldBeginEditing(_ textView: UITextView) -> Bool func textViewDidBeginEditing(_ textView: UITextView) ``` * `textViewShouldBeginEditing` 메서드에서 return 값을 false로 해주게 되면, edit 모드에 들어갈 수 없기 때문에 `textViewDidBeginEditing` 메서드가 호출되지 않습니다. </br> * 텍스트뷰의 edit 모드가 끝나게 되면, 아래 메서드가 차례대로 호출됩니다. ``` swift func textViewShouldEndEditing(_ textView: UITextView) -> Bool func textViewDidEndEditing(_ textView: UITextView) ``` * 사용자가 텍스트 편집을 마쳤을 때 `textViewDidEndEditing(_:)`에서 입력된 text를 CoreData에 저장하는 코드를 작성할 수 있을 것 같습니다. ## PNG SVG 각 화소가 화상의 하나 개의 작은 부분에 대응하는 직사각형 픽셀 그리드를 사용하여 이미지를 저장하는 도트 매트릭스 구조를 사용한다 # Step 2 안녕하세요 줄라이! 해리, 로웬 조 Diary Step2 PR 보내드립니다 🙌 학습 내용을 제시해주셔서 더 깊이 공부할 수 있었어요☺️ 감사합니다! 👍👍👍 사전 학습 내용 - [CoreDataStack / App Life Cycle](https://hackmd.io/@Rowan/HJbrYyF73) </br> ## 고민한 점 ### 🔸 App Life Cycle Method Scene Manifest가 적용된 프로젝트에서는 AppDelegate의 app life cycle 메서드는 호출되지 않고, SceneDelegate의 app life cycle 메서드가 호출됩니다. 따라서 앱이 백그라운드로 진입하는 경우 일기를 자동 저장하기 위해 SceneDelegate에서 `sceneDidEnterBackground` 메서드를 사용하였습니다. </br> ### 🔸 SceneDelegate에서 최상단 뷰컨트롤러 얻기 SceneDelegate의 window 프로퍼티를 이용해 `rootViewController`(as UINavigationController) -> `topViewController`(as DiaryContentViewController) 순으로 최상위 뷰컨트롤러를 얻었습니다. ```swift guard let navigationController = window?.rootViewController as? UINavigationController, let topViewController = navigationController.topViewController, let diaryContentViewController = topViewController as? DiaryContentViewController else { return } ``` </br> ## 조언받고 싶은 점 ### 🔸 뷰 컨트롤러 사이에서 주고 받는 데이터 타입 ``` swift public class Diary: NSManagedObject ``` 현재 첫 번째 화면과 두 번째 화면 사이의 데이터를 위 ManagedObject 타입으로 전달하고 있습니다. step1에서 갖고 있던 DiaryContents 타입을 중간 객체로 사용하여 두 번째 뷰컨트롤러에서 사용하고 있었는데 큰 의미 없이 Depth만 깊어진다고 생각하여 중간 객체를 삭제하고 직접 ManagedObject 객체를 전달하게 되었습니다. ManagedObject 객체 자체를 전달하는 것이 일반적으로 사용되는 방법이 맞는지 궁금합니다. 🤔 </br> ### 🔸 updateDiary, createDiary 메서드 파라미터의 타입(코어데이터 매니져) #### step1에서 사용하던 방식 ``` swift func updateDiary(data: DiaryContents) func createDiary(data: DiaryContents) ``` #### step2에서 새롭게 바꾼 방식 ```swift func updateDiary(title: String?, body: String?, date: Double, id: UUID) func createDiary(title: String, body: String, date: Double, id: UUID) -> Diary? ``` 위의 [뷰 컨트롤러 사이에서 주고 받는 데이터 타입](#뷰-컨트롤러-사이에서-주고-받는-데이터-타입)에서 말씀드린 것처럼 뷰 컨트롤러에서 주고 받는 데이터의 타입을 **DiaryContents**(구조체)에서 **Diary**(NSManagedObject)로 변경했습니다. 이후 CoreData create / update 메서드를 호출할 때, 뷰 컨트롤러에서 Diary 타입을 직접 생성할 책임이 없다고 생각하였습니다. 따라서, 뷰 컨트롤러에서 NSManagedObject인 Diary 인스턴스를 직접 생성하여 전달하는 것보다 업데이트하거나 생성할 데이터를 하나씩 파라미터로 전달하기로 했습니다. 저희의 생각이 괜찮은 방향인지, 여쭤보고 싶습니다. </br> ### 🔸 SceneDelegate or Notification UIScene 클래스의 기본적으로 구현된 Notification에 scene life cycle과 관련된 것들이 있는 것을 확인하고 SceneDelegate 메서드와 UIScene Notification 중 어느 것을 사용해야 할 지 고민했습니다. App이 백그라운드로 진입하는 경우 일반적으로 자주 사용하는 방법이 어떤 것인지 여쭤보고 싶습니다. </br> ## 해결되지 않은 점 ActivityView ItemSource(ActivityView 최상단 프리뷰 부분)가 스텝 예제와 다르게 표시되는 부분을 해결하지 못했습니다. #### 프로젝트 요구사항 이미지 <img src="https://i.imgur.com/HTm9zUT.png" width="300"> #### 프로젝트 요구사항과 다른 점 - "Options > 버튼"이 없는 문제 - "썸네일 이미지"가 기본 이미지인 문제 - "title", "type" 나타내는 문자 위치가 서로 바뀌어서 나는 문제 ### 💭 시도한 방법 UIActivityItemSource 프로토콜을 채택하여 서브 클래스를 만들었습니다. 서브 클래스 내에서 아래 메서드를 구현해보았습니다. ``` swift @available(iOS 13.0, *) func activityViewControllerLinkMetadata(_: UIActivityViewController) -> LPLinkMetadata? { let metadata = LPLinkMetadata() metadata.title = title metadata.imageProvider = NSItemProvider(contentsOf: tumbnail) return metadata } ``` 뷰컨트롤러에서 다음과 같이 ActivityViewController를 초기화했습니다. ``` swift let title = "My Title" let thumbnail = UIImage(named: "myImage") let activityItemSource = MyActivityItemSource(title: title, thumbnail: thumbnail!) let activityViewController = UIActivityViewController(activityItems: [activityItemSource], applicationActivities: nil) ``` - 결과적으로 이미지와 제목은 잘 바뀝니다. - 옵션 버튼을 달아주는 방법은 해결하지 못했습니다. - 제목과 공유하고자 하는 데이터 타입을 지정하는 것은 가능했으나 위 아래 순서가 뒤바뀌는 문제가 있었습니다. # Step 3 안녕하세요 줄라이! 해리, 로웬 조 Diary Step3 PR 보내드립니다 🙌 중간에 피드백 주신 부분에 대해서는 적당히 고민/수정 해봤는데 부족한 부분에 대해서는 코멘트 달아주시면 감사드리겠습니다! 🫡 이번 리뷰도 잘 부탁드립니다~~ 사전 학습 내용 - [STEP 3사전학습](https://hackmd.io/@Vb2d_yXZSoyaNGLxdwowWg/S1FaNXfNh) </br> ## 🔶 고민한 점 ### 🔸 CoreData ManagedObjectModel - Diary Entity의 attribute에 weather API에서 받아온 main, icon을 추가하기 보다는 해당 정보를 포함하는 Weather Entity를 추가했습니다. - 두 entity의 relationship을 설정해 Diary 삭제 시, 연결된 Weather를 같이 삭제할 수 있도록 하였습니다. - Lightweight Migration으로 모델의 변경사항을 반영할 수 있도록 version을 추가했습니다. </br> ### 🔸 DiaryDataManager - DiaryDAO 인스턴스를 만들 때, WeatherDAO 객체를 함께 생성할 수 없었습니다. - CoreDataManager를 프로퍼티로 갖고 있는 DiaryDataManager를 정의하여 DiaryDAO와 WeatherDAO의 CRUD를 묶어서 관리할 수 있도록 하였습니다. </br> ### 🔸 NetworkError 처리 OpenWeatherAPI 네트워크 통신이 실패할 경우 던져진 error를 처리할 때, request를 다시 보낼 수 있도록 Alert를 띄워주었습니다. 해당 Alert의 retry Action completion에 `loadWeather` 메서드를 캡처하고 있습니다. escaping closure로 캡처하기 때문에 재귀함수가 아니라는 생각으로 이렇게 작성해봤습니다. ```swift private func loadWeather(coordinate: CLLocationCoordinate2D) { weatherHelper.loadData(latitude: coordinate.latitude, longitude: coordinate.longitude) { [weak self] result in guard let self else { return } switch result { case .success(let currentWeather): guard let weather = currentWeather.weather.first else { return } self.diary?.weather?.updateContents(main: weather.main, icon: weather.icon) case .failure(let error): print(error.localizedDescription) let alertData = self.alertDataMaker.reloadAlertData { self.loadWeather(coordinate: coordinate) } let alert = self.alertFactory.reloadAlert(for: alertData) DispatchQueue.main.async { self.present(alert, animated: true) } } } } ``` </br> ## 🔶 조언받고 싶은 점 ### 🔸 Core Data Entity의 구조 ``` swift class DiaryDAO { @NSManaged public var body: String? @NSManaged public var date: Double @NSManaged public var id: UUID @NSManaged public var title: String? @NSManaged public var weather: WeatherDAO? } ``` ``` swift class WeatherDAO { @NSManaged public var main: String? @NSManaged public var icon: String? @NSManaged public var id: UUID @NSManaged public var diary: DiaryDAO? } ``` - 코어데이터에서 두 가지 엔티티를 사용하고 있습니다. - 1:1 관계의 relationship을 가지고 있도록 했는데, 엔티티 생성 과정에서 relationship 때문에 조금 복잡해진 느낌이 있는 것 같습니다. - DiaryDAO 엔티티 객체를 생성하고 attribute들을 초기화 할 때, WeatherDAO도 같이 생성하여 DiaryDAO의 weather가 참조하도록 해주는 방식으로 구현했습니다. - 이 부분은 CoreDataManager, DiaryDataManager에서 각 타입의 setValues, updateValues를 호출해 값을 초기화하고 업데이트 해주고 있습니다. - 따라서 DiaryDAO가 또 다른 엔티티와 relationship이 생기게 되면 수정해야 할 코드가 많아서 너무 확장성이 떨어진다는 생각이 듭니다. - 관계가 있는 엔티티를 관리하는 방법에 대한 조언이 있을까요? </br> ### 🔸 Weather 인스턴스의 두 번 생성(DTO) - 일기장을 새로 작성할 때(+ 버튼을 누를 때) Weather의 인스턴스를 생성하고 코어데이터에도 업데이트 하도록 해주고 있습니다. - Weather의 인스턴스를 생성한 이유: id가 optional type이 아닌데 DB에는 nil로 저장되어 fetch할 때 crash가 발생했기 때문 - CLLocationManager Delegate 메소드 중 didUpdateLocations에서 비동기로 Weather 객체를 서버로부터 값을 받아 생성해주는데 여기서 인스턴스 자체를 교체하지 않고, 값만 이전에 생성해놓은 인스턴스에 교체해주고 있습니다. - 서버에 요청해 얻은 인스턴스는 값만 사용하고 인스턴스 자체는 사용하지 않고 있습니다. 사용하지 않는 인스턴스를 괜히 한번 더 생성하는 것 같아서 좋은 방법이 없을지 궁금합니다.. </br> ### 🔸 File Tree 프로젝트 폴더 - 파일 관리가 아직도 생소한 것 같습니다. 피드백에 따라 폴더를 나누는 것을 고민하다보니 타입의 이름도 더 고민해봐야겠다고 생각했습니다. 시간을 너무 투자하지 않아도 되는 선에서 수정하다보니 부족한 부분이 많을 것 같아요 😅 실제로 사용하시거나 일반적인 폴더링 방법에 대해 간략히 답변 부탁드립니다🙏 참고해서 잘 적용해보도록 하겠습니다~!