CoreData를 이용해 기록을 남기는 일기장 어플입니다.
- 새로운 일기를 저장할 수 있습니다.
- 자동 저장기능을 지원합니다.
- 저장된 일기를 확인 / 수정/ 공유 / 삭제 할 수 있습니다.
- 가장 최근순으로 저장된 일기리스트를 확인 할 수 있습니다.
- 저장된 일기를 터치해 확인하는 화면에서 수정이 가능합니다.
- 수정내용을 자동 저장하는 기능을 지원합니다.
- 일기를 복사하거나 외부 SNS로 공유하는 내보내기 서비스를 지원합니다.
- 리스트 목록에서 Swipe하거나 상세화면의 버튼을 이용해 공유/삭제 할 수 있습니다.
- 기기의 위치에 맞는 날씨를 파악해 자동 기록합니다.
- 사용자의 위치 정보를 요청하고, 권한이 허락되면 기기의 위치를 자동파악 합니다.
- 저장된 다이어리의 해당 날짜의 날씨 아이콘을 리스트에 함께 보여줍니다.
- 가로모드를 지원합니다.
- 큰 기기의 가로모드에서도 사용자가 읽기 편안한 컨텐츠 레이아웃을 제공합니다.
써니쿠키 | LJ |
---|---|
개발 및 리팩토링 | 개발 |
[Step2 - 다이어리 CRUD]
생성 | 읽기 | 수정 |
---|---|---|
삭제 | 공유,취소 | 스와이프 |
---|---|---|
Step3 - 날씨 API 활용
Lightweight Migration | 위치정보 권한 요청 | 날씨 아이콘 표시 |
---|---|---|
├── Diary
│ ├── Base.lproj
│ ├── Application
│ │ ├── AppDelegate
│ │ └── SceneDelegate
│ │ ├── AppFlowCoordinator
│ │ └── DIContainer
│ │ ├── AppDIContainer
│ │ └── DiariesSceneDIContainer
│ │
│ ├── Domain
│ │ ├── Model
│ │ │ ├── DiaryInfo
│ │ │ └── WeatherInfo
│ │ ├── UseCase
│ │ │ ├── DeleteDiaryUseCase
│ │ │ ├── FetchWeatherIconUseCase
│ │ │ ├── FetchWeatherUsecase
│ │ │ ├── SaveDiaryUseCase
│ │ │ ├── UpdateDiaryUseCase
│ │ │ ├── fetchDiariesUseCase
│ │ │ └── Protocol
│ │ │ └── Usecases
│ │ └── Interfaces
│ │ ├── DiariesRepository
│ │ └── WeatherIconRepository
│ │
│ ├── Presentation
│ │ └── DiariesScene
│ │ │ └── DiaryList
│ │ │ │ ├── View
│ │ │ │ │ ├── DiaryCell
│ │ │ │ │ └── DiaryListViewController
│ │ │ │ └── ViewModel
│ │ │ │ ├── DiaryListCellViewModel
│ │ │ │ └── DiaryListViewModel
│ │ │ │
│ │ │ └── DiaryDetailView
│ │ │ ├── DiaryModifyingView
│ │ │ │ ├── View
│ │ │ │ │ └── DiaryModifyingViewController
│ │ │ │ └── ViewModel
│ │ │ │ └── DiaryModifyingViewModel
│ │ │ └── DiaryRegistrationView
│ │ │ ├── View
│ │ │ │ └── DiaryRegistrationViewController
│ │ │ └── ViewModel
│ │ │ └── DiaryRegistrationViewModel
│ │ └── Utils
│ │ ├── Observable
│ │ ├── Protocols
│ │ │ └── Alertable
│ │ └── Extension
│ │ ├── Date+localizedDateFormat
│ │ ├── UIActivityViewController+ConvenienceInit
│ │ ├── UILabel+ConvenienceInit
│ │ └── UITextView+ConvenienceInit
│ │
│ ├── Data
│ │ ├── Repositories
│ │ │ ├── DefaultDiariesRepository
│ │ │ └── DefaultWeatherRepository
│ │ ├── Network
│ │ │ ├── APIEndPoints
│ │ │ ├── DataMapping
│ │ │ │ └── WeatherResponseDTO+Mapping
│ │ │ └── WeatherAPI
│ │ ├── PersistentStorages
│ │ │ ├── CoreDataStorage
│ │ │ │ ├── CoreDataStorage
│ │ │ │ └── Diary.xcdatamodeld
│ │ │ │ ├── Diary 2.xcdatamodel
│ │ │ │ │ └── contents
│ │ │ │ └── Diary.xcdatamodel
│ │ │ │ └── contents
│ │ │ ├── DataMapping
│ │ │ │ ├── DataMapping.swift
│ │ │ │ └── MappintModelV1ToV2.xcmappingmodel
│ │ │ │ └── xcmapping.xml
│ │ │ └── DiaryCRUDableStorage
│ │ │ ├── CoreDataDiaryCRUDStorage
│ │ │ └── DiaryCRUDStorage
│ │ └── TemporaryStorage
│ │ ├── Cache
│ │ │ └── Cache.swift
│ │ └── WeatherIconTemporaryStorage
│ │ ├── CacheStorage
│ │ └── WeatherIconCache
│ │
│ ├── Infrastructure
│ │ ├── DecodeManager
│ │ │ └── DecodeManager
│ │ ├── LocationManager
│ │ │ ├── Location
│ │ │ └── LocationManager
│ │ └── Network
│ │ ├── Diary++Bundle+apiKey
│ │ ├── EndPoint
│ │ ├── NetworkConfig
│ │ └── NetworkServie
│ │
│ └── Resource
│ ├── Assets.xcassets
│ ├── Info.plist
│ └── WeatherInfo.plist
│
├── Podfile
├── Podfile.lock
└── Pods
└── SwiftLint
- CoreData 활용하기
- CoreData 모델을 싱글톤으로 생성했습니다.
- CRUD 메서드를 구현했습니다.
- CoreData Migration 적용하기
- 날씨정보를 추가하면서 코어데이터의 모델 변경을 위해 Lightweight Migration 했습니다.
- Open API의 활용
- Open Weather 서비스를 사용했습니다.
- URLSessionDataTask를 활용해 기기의 위치를 기반으로 현재 날씨정보와 날씨 아이콘을 요청해서 받아옵니다.
- Core Location 프레임워크의 활용
- 기기의 위치 정보에 대한 권한을 요청합니다.
-
CLLocationManager
를 활용하여 기기의 위치를 파악했습니다.
- 스토리보드없이 코드로만 View구현하기
- 스토리보드를 삭제하고 SceneDelegate를 수정해 navigationController가 embeded된 View를 구현했습니다.
- swiftLint 라이브러리 사용하기
- pod 을 이용해 라이브러리를 적용했습니다.
-
swiftLint
라이브러리로 코드컨벤션을 통일했습니다.
- modernCollectionView를 이용한 List구현
-
UICollectionViewCompositionalLayout.list
를 이용해 ListView 구현했습니다. - DiffableDataSource, snapShot을 이해하고 적용했습니다.
-
- readableContentGuide 사용하기
- app adaptivity 및 가로모드의 레이아웃에 readableContentGuide를 사용했습니다.
- 상속으로 등록/수정 과정의 공통기능을 구현
- VC1와 공통기능을 갖는 VC2를 Class상속을 통해 구현했습니다.
- 컬렉션뷰 리스트에서 스와이프를 액션 구현
- Cell 스와이프 시, 데이터를 공유/삭제하는 기능을 구현했습니다.
- Text View Delegate의 활용
- 사용자 입력시 PlaceHolder를 지우기위해 Text View Delegate를 사용했습니다.
🕛 Step 1 | |
---|---|
1 | SwiftLint 활용 |
2 | DTO구현 - JSON 데이터와 매핑할 Diary 타입 구현 |
3 | 일기장 목록화면 구현, 일기장 영역화면 UI구현, readableContentGuide 적용 |
[Details - Step1 타입별 기능 설명]
- JSON파일(sample.json)을 decode하기위한 DTO(데이터전송객체) 입니다.
- 일기장 목록화면인 DiaryListView를 담당하는 뷰컨트롤러입니다.
- collectionView(collectionView:didSelectItemAt:) : UICollectionViewDelegate프로토콜을 채택하여 목록 중 하나의 항목을 선택했을 때 다음화면으로 전환하는 메서드입니다.
- 일기장 목록 중 하나의 일기상세화면인 DiaryDetailView를 담당하는 뷰컨트롤러입니다.
-
configureDiary()
메서드 일기상세화면에 제목,내용,일자 각각 항목별 데이터를 바인딩하는 메서드입니다. setupNotification()
메서드 키보드가 보이거나 숨겨질 때의 노티피케이션에 Observer를 추가해서 controlKeyboard() 메서드를 호출합니다.
🕛 Step 2 | |
---|---|
1 | CoreData 모델 생성 |
2 | 일기장 CRUD기능(자동저장, 읽기, 수정, 삭제) 구현 |
3 | 컬렉션뷰 리스트에서 스와이프를 액션 구현 |
4 | Text View Delegate의 활용 |
[Details - Step2 타입별 기능 설명]
- CRUD 기능별 메서드 구현
deleteAllNoDataDiaries()
메서드 새로운 일기 생성 후 아무것도 입력하지 않았을 경우, 저장 후 fetch해올 때 삭제합니다.searchDiary(id:)
메서드 수정 시 해당 일기만 찾아 데이터를 가져옵니다.
- 새로운 일기화면으로 내용없이 PlaceHolder만 입력되어있는 상태의
DiaryDetailView
를 담당합니다.
RegisterDiaryViewController
클래스를 상속받습니다.- 이미 작성한 일기내용이 입력되어있는 상태의
DiaryDetailView
를 담당합니다.
- 공유 시 뷰 하단에서 올라오는
ActivityView
를 담당하는 컨트롤러입니다.
🕛 Step 3 | |
---|---|
1 | 위치정보, 날씨정보 획득을 위한 클래스 내부 구현 |
2 | URLSession 활용 및 서버와 통신하는 관련 클래스 구현 |
3 | CoreData 마이그레이션 |
[Details - Step3 타입별 기능 설명]
- 코어데이터에 다이어리 내용을 전달해주는 데이터전송객체 입니다.
- 날씨API에 있는 json 중첩타입데이터를 전역타입으로 변환하여 저장하고,
DiaryInfo
타입의 속성 중 하나의 타입을 담당하는 구조체입니다.
- fetchWeatherInfo(completion:) : 서버로부터 날씨정보를 받아오기위해 URL을 만들고,
URLSessionProvider()
를 통해 데이터를 얻습니다. - fetchWeatherIcon(icon:,completion:) : 매개변수로 받은
icon
의 문자열에 해당하는 날씨아이콘이 위치한 URL을 이용하여URLSessionProvider()
로부터 이미지를 얻습니다.
- 위치정보를 기반으로 날씨에 맞는 날씨아이콘을 가져올 때 해당 아이콘이 위치한 URL을 생성하는 열거형입니다.
- 위치정보 접근을 담당하는 클래스입니다. 위치 정확도가 지정된 객체인
manager
를CLLocationManager
타입으로 생성하여 관련된 일들을 처리합니다. - 위치값을 담은
location
변수는 위도와 경도로 받아오므로 튜플타입으로 정의하였습니다.- fetchLocation() : 사용자의 현재위치 획득은 배터리효율을 위해
requestLocation()
로 한번만 위치를 요청합니다. - CLLocationManagerDelegate 관련 메서드
- locationManagerDidChangeAuthorization(manager:) : 앱에 대한 위치 접근권한 설정이 변경되면 호출됩니다. 사용자의 선택에 따라 위치 접근권한을 획득하거나 결정해달라는 요청을 보냅니다.
- locationManager(manager:didUpdateLocations:) : 사용자의 위치를 성공적으로 가져왔을 경우 호출됩니다.
- locationManager(manager:didFailWithError:) : 사용자의 위치를 가져오지 못했을 경우 호출됩니다.
- fetchLocation() : 사용자의 현재위치 획득은 배터리효율을 위해
URLSession
을 이용하여 매개변수로 받은url
에 있는 데이터를 받아옵니다.
-
- ScrollView 이용
- 키보드가 나타날 때, 좁아진 화면으로도 스크롤 해서 가려진 화면을 볼 수 있도록
ScrollView
에 컨텐츠들을 담았습니다.
-
- ScrollView의 inset, offset 조정
- 키보드가 나타나고 화면을 스크롤 할 때 컨텐츠의 가장 밑부분이 키보드 뒤에 가려지는 문제를 해결하기 위해, 스크롤뷰가 나타날 때의 Notification을 받아 ScrollView의 Bottom방향으로 키보드 높이만큼의
Inset
을 더하도록 했습니다. 더불어 사용자가 보고있던 화면을 유지하기위해offset.y
도 키보드의 높이만큼 빼줍니다. - 키보드가 사라질 때는 반대로
Inset
을 삭제합니다.
-
- 입력이 끝나고 키보드를 내리기위한
keyboardDismissMode
속성 사용
- 사용자가 입력을 끝내고 키보드를 내리는 방법으로 UIScrollView의
keyboardDismissMode
속성을 사용해 사용자가 스크롤하여 키보드를 다시 내릴 수 있도록 구현했습니다.
- 입력이 끝나고 키보드를 내리기위한
RegisterDiaryViewController
(다이어리 등록화면 VC)를 상속받는DiaryDetailViewController
(다이어리 수정화면 VC)를 구현하면서 필수로private
으로 설정해주어야 할 프로퍼티들에 접근제어를 설정할 수 없는 문제가 있었습니다. VC 두 개가 똑같이diaryPageView
,diaryPage
데이터를 갖고있는데 외부에서 접근할 수 없게Private
설정을 해주어야 했습니다.- 상속받은 프로퍼티를 사용하지 않고 새로운 프로퍼티를 생성해서 사용하게되면, 이 프로퍼티들을 사용하는 메서드들을 모두
override
해서 새로 생성한 프로퍼티를 사용할 수 있도록 모두override
후 재구현 해야 했습니다. - 이니셜라이저에서 해답을 얻었습니다. 이니셜라이저에서 데이터를 갈아끼우는 로직을 구현하니 프로퍼티는 인스턴스생성시 바뀌기 때문에 Private 접근제어를 설정할 수 있고, 상속받은 메서드들은 override없이 사용할 수 있었습니다.
- 처음엔 다이어리 등록 시 작성내용이 있는지 확인한 후 저장을 결정하는 로직을 구현하려했는데, 저장할때도 확인해야하고, 한글자를 써서 이미 저장이 된 데이터들에 대해서는 업데이트시 확인 후 삭제해야하고, 등록화면 뿐 아니라 이미 저장된 다이어리를 보는 화면에서도 내용을 모두 지우면 업데이트가 아닌 삭제를 해야해야하는 번거로움이 있어 일단 데이터가 없는 페이지도 자동저장 된 후
fetch
해 올 때(저장된 리스트를 보여줄 때) 빈 값의 데이터를 자체적으로 삭제한 후fetch
하는 로직으로 구현했습니다.
-
Open Weather 서비스에 요청해 가져온 현재 날씨정보를 담은 JSON데이터 중 실제로 필요한 정보는 사진에 표시한
weather
내부의main
,icon
데이터 입니다. -
디코딩을 위해 DTO를 만들때, 처음에는 아래와같이
WeatherInfo
타입에서Weather
타입을 중첩으로 갖고, 이Weather
타입이main
,icon
을 갖는 형식으로 만들었습니다.// 중첩타입을 사용한 DTO struct WeatherInfo: Decodable { struct Weather: Decodable { let main: String let icon: String } let weather: Weather }
-
하지만 의미없는 중첩인 것 같아 중첩타입을 사용하지 않고,
CodingKey
와, Decodable의 init내부에서KeyedDecodingContainer
를 활용해WeatherInfo
타입이main
,icon
을 갖도록 했습니다.// 중첩타입을 사용하지 않는 DTO struct WeatherInfo { let main: String let icon: String } extension WeatherInfo: Decodable { enum CodingKeys: String, CodingKey { case main, icon, weather } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) var weatherContainer = try container.nestedUnkeyedContainer(forKey: .weather) let nestedContainer = try weatherContainer.nestedContainer(keyedBy: CodingKeys.self) main = try nestedContainer.decode(String.self, forKey: .main) icon = try nestedContainer.decode(String.self, forKey: .icon) } }
- 프로젝트에 포함된 견본 JSON 데이터를 사용해 List Cell을 구성할 때, 값이 같은 데이터가있어 정상작동 되지않는 문제가 있었습니다.
- 원인: Diffable datasource를 사용하려면
ItemIdentifier
가 각각 고유한 Hash값을 갖고 있어야하는데 아래 캡쳐 화면 처럼 value가 완전히 같을 때는 두 개가 같은 Hash값을 갖기 때문입니다. - ✅ 수정: UUID 사용
- DTO에
UUID
를 생성해 각 value들이 각각 고유한 Hash값을 갖도록 했습니다.struct Diary: Decodable, Hashable { let title: String let body: String let createdAt: Double let id = UUID() // 👈 // ... }
- DTO에
-
DiaryCell
에configureDiaryCellLayout()
메서드에서 AutoLayout Constraints 설정 시 일기 제목과 작성일 등의 Label을 담은totalStackView
의Constraints
를self
를 기준으로 했을 때 의도한대로 셀높이가 조절되지 않았습니다.수정 전 수정 후 -
✅ 수정:
LayoutConstraint
을contentView
에 걸어주기LayoutConstraint
을self(DiaryCell)
이 아닌contentView
를 기준으로topAnchor
와bottomAnchor
에constant
값을 주어 해결하였습니다.
- 다이어리 리스트화면에서
DiaryCell
의 높이가 아래GIF처럼 작았다가 클릭 시 정상높이로 돌아오는 문제가 있습니다. - 원인은 셀의 레이아웃을 잡아주는
configureDiaryCellLayout()
메서드가 Cell의 제목, 날씨아이콘, 내용을 담아주는configureCell()
메서드 내부에서 호출이 되는데, 이configureCell()
메서드의 호출 시점이 쓰레드관련 문제로 가장 마지막에 호출되는 문제였습니다.- 서버에서 가져오는 날씨아이콘을 담아주기 위해서 URLSessionDataTask를 활용할 때 다른쓰레드에서 작업이 진행되고, 작업이 끝난 후엔 가져온 아이콘을 갖고
DispatchQueue.main.async
로 보내 main쓰레드에서configureCell()
메서드가 진행되도록 했었습니다. 이 때 문제는, 다시 main으로 보내진 작업이 원래 main의 모든 작업이 끝난 후에 진행이 되므로 컬렉션뷰리스트가 모두 그려진 후,configureCell()
이 불리고, 여기에 포함된 레이아웃을 잡아주는configureDiaryCellLayout()
도 나중에 불려지는게 원인이었습니다.
- 서버에서 가져오는 날씨아이콘을 담아주기 위해서 URLSessionDataTask를 활용할 때 다른쓰레드에서 작업이 진행되고, 작업이 끝난 후엔 가져온 아이콘을 갖고
수정 전 | 수정 후 |
---|---|
- ✅ 수정: 날씨아이콘을 담아주는 작업만
DispatchQueue.main.async
로 처리configureCell()
,configureDiaryCellLayout()
메서드는 main쓰레드에서 호출해서 먼저 레이아웃을을 잡고 제목과 내용만 담겨있게 되고, 서버 통신을 위해 다른 쓰레드에서 작업이 필요한 날씨아이콘 fetch 작업만 기능을 분리해 작업이 끝나면 다시 main쓰레드로 보내는DispatchQueue.main.async
로 처리해 정상적으로 레이아웃이 잡혀있는 Cell에 icon을 넣을 수 있었습니다.
- 다이어리 한 개 저장 시 두 개가 저장되어 목록에 나오는 문제가 있었습니다. 하나는 사용자가 입력한 내용의 다이어리이고, 다른 하나는 내용없는 다이어리가 생성되어 목록에 보였습니다.
- 원인은 CoreData에서
Weather
와Diary
엔티티의 Relationship설정 중에 의Parent Entity
로Diary
를 설정해 놓은게 문제였습니다. - ✅ 수정: No Parent Entity 설정
Weather
속성 설정을 Xcode 우측에 있는Data Model Inspector
-Entity
-Parent Entity
옵션에서No Parent Entity
로 설정하여 해결하였습니다.
- PlaceHolder를 TextView.text 속성으로 "일기 제목", "일기 내용" 과같은 PlaceHolder를 만들고
textViewDidBeginEditing
메서드로 값이 바뀌었을 때, PlaceHolder가 지워지는 로직을 구현했을 때 아래와 같은 문제가 있었습니다.- 코어데이터에 데이터 저장 시 입력값이 없을 때도 입력되어있는 PlaceHolder인 "일기 제목", "일기 내용"을 Text로 인식해 저장되는 문제가 있었습니다.
- 화면전환 시 키보드를 바로 띄우기 위해 TitleTextView를 FirstResponder로 지정해놓으니
textViewDidBeginEditing
메서드가 호출되어 PlaceHolder가 보이지 않는 문제가 있었습니다. textViewDidBeginEditing
대신,textViewDidChange
로 값이 하나라도 적힐 때 PlaceHolder가 제거되는 로직으로 변경하면 PlaceHolder는 화면에 보이지만 타자커서가 PlaceHolder 뒤쪽에 위치하는 문제가 있었습니다.
- ✅ 수정: Label사용
- UILabel인
titlePlaceHolder
와bodyPlaceHolder
를 만들어 TextView에 추가해 보이게 하고 UITextViewDelegate의textViewDidChange
메서드로 추가된 Label이 삭제되도록 수정해 해결했습니다. - Label 사용시, 입력값이 없을 때에 저장되는 데이터의 값도 빈값으로 정상 저장되고, 화면전환시 FirstResponder여도 PlaceHolder가 잘 보이고, 커서의 위치도 정상적으로(제일 앞쪽에) 위치합니다.
- UILabel인
[공식문서]