Skip to content

sunny-maeng/ios-diary

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

85 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

README

📒 Diary

CoreData를 이용해 기록을 남기는 일기장 어플입니다.

  • 새로운 일기를 저장할 수 있습니다.
    • 자동 저장기능을 지원합니다.
  • 저장된 일기를 확인 / 수정/ 공유 / 삭제 할 수 있습니다.
    • 가장 최근순으로 저장된 일기리스트를 확인 할 수 있습니다.
    • 저장된 일기를 터치해 확인하는 화면에서 수정이 가능합니다.
    • 수정내용을 자동 저장하는 기능을 지원합니다.
    • 일기를 복사하거나 외부 SNS로 공유하는 내보내기 서비스를 지원합니다.
    • 리스트 목록에서 Swipe하거나 상세화면의 버튼을 이용해 공유/삭제 할 수 있습니다.
  • 기기의 위치에 맞는 날씨를 파악해 자동 기록합니다.
    • 사용자의 위치 정보를 요청하고, 권한이 허락되면 기기의 위치를 자동파악 합니다.
    • 저장된 다이어리의 해당 날짜의 날씨 아이콘을 리스트에 함께 보여줍니다.
  • 가로모드를 지원합니다.
    • 큰 기기의 가로모드에서도 사용자가 읽기 편안한 컨텐츠 레이아웃을 제공합니다.

📖 목차

  1. 팀 소개
  2. 기능 소개
  3. Class Diagram
  4. 폴더 구조
  5. 프로젝트에서 경험하고 배운 것
  6. 타임라인
  7. 고민한 부분
  8. 트러블 슈팅
  9. 참고 링크

🌱 팀 소개

써니쿠키 LJ
개발 및 리팩토링 개발

🛠 기능 소개

[Step1 - 다이어리 리스트 화면, 상세화면]
다이어리 리스트 화면
다이어리 상세 화면

[Step2 - 다이어리 CRUD]

생성 읽기 수정
삭제 공유,취소 스와이프

Step3 - 날씨 API 활용

Lightweight Migration 위치정보 권한 요청 날씨 아이콘 표시

👀 Class Diagram

리팩토링 이전 - [MVC]
Model
ViewController

MVVM + CleanArchitecture


🗂 폴더 구조

├── 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 타입별 기능 설명]

1️⃣ DTO - Diary 구조체

  • JSON파일(sample.json)을 decode하기위한 DTO(데이터전송객체) 입니다.

2️⃣ DiaryListViewController 클래스

  • 일기장 목록화면인 DiaryListView를 담당하는 뷰컨트롤러입니다.
  • collectionView(collectionView:didSelectItemAt:) : UICollectionViewDelegate프로토콜을 채택하여 목록 중 하나의 항목을 선택했을 때 다음화면으로 전환하는 메서드입니다.

3️⃣ DiaryDetailViewController 클래스

  • 일기장 목록 중 하나의 일기상세화면인 DiaryDetailView를 담당하는 뷰컨트롤러입니다. -configureDiary()메서드 일기상세화면에 제목,내용,일자 각각 항목별 데이터를 바인딩하는 메서드입니다.
  • setupNotification()메서드 키보드가 보이거나 숨겨질 때의 노티피케이션에 Observer를 추가해서 controlKeyboard() 메서드를 호출합니다.


🕛 Step 2
1 CoreData 모델 생성
2 일기장 CRUD기능(자동저장, 읽기, 수정, 삭제) 구현
3 컬렉션뷰 리스트에서 스와이프를 액션 구현
4 Text View Delegate의 활용
[Details - Step2 타입별 기능 설명]

1️⃣ CoreDataManager 구조체

  • CRUD 기능별 메서드 구현
  • deleteAllNoDataDiaries()메서드 새로운 일기 생성 후 아무것도 입력하지 않았을 경우, 저장 후 fetch해올 때 삭제합니다.
  • searchDiary(id:)메서드 수정 시 해당 일기만 찾아 데이터를 가져옵니다.

2️⃣ RegisterDiaryViewController 클래스

  • 새로운 일기화면으로 내용없이 PlaceHolder만 입력되어있는 상태의 DiaryDetailView를 담당합니다.

3️⃣ DiaryDetailViewController 클래스

  • RegisterDiaryViewController 클래스를 상속받습니다.
  • 이미 작성한 일기내용이 입력되어있는 상태의 DiaryDetailView를 담당합니다.

4️⃣ CustomActivityViewController 클래스

  • 공유 시 뷰 하단에서 올라오는 ActivityView를 담당하는 컨트롤러입니다.


🕛 Step 3
1 위치정보, 날씨정보 획득을 위한 클래스 내부 구현
2 URLSession 활용 및 서버와 통신하는 관련 클래스 구현
3 CoreData 마이그레이션
[Details - Step3 타입별 기능 설명]

1️⃣ DiaryInfo DTO

  • 코어데이터에 다이어리 내용을 전달해주는 데이터전송객체 입니다.

2️⃣ WeatherInfo DTO

  • 날씨API에 있는 json 중첩타입데이터를 전역타입으로 변환하여 저장하고, DiaryInfo타입의 속성 중 하나의 타입을 담당하는 구조체입니다.

3️⃣ WeatherManager 클래스

  • fetchWeatherInfo(completion:) : 서버로부터 날씨정보를 받아오기위해 URL을 만들고, URLSessionProvider()를 통해 데이터를 얻습니다.
  • fetchWeatherIcon(icon:,completion:) : 매개변수로 받은 icon의 문자열에 해당하는 날씨아이콘이 위치한 URL을 이용하여 URLSessionProvider()로부터 이미지를 얻습니다.

4️⃣ WeatherURL 열거형

  • 위치정보를 기반으로 날씨에 맞는 날씨아이콘을 가져올 때 해당 아이콘이 위치한 URL을 생성하는 열거형입니다.

5️⃣ LocationManager 클래스

  • 위치정보 접근을 담당하는 클래스입니다. 위치 정확도가 지정된 객체인 managerCLLocationManager타입으로 생성하여 관련된 일들을 처리합니다.
  • 위치값을 담은 location 변수는 위도와 경도로 받아오므로 튜플타입으로 정의하였습니다.
    • fetchLocation() : 사용자의 현재위치 획득은 배터리효율을 위해 requestLocation()로 한번만 위치를 요청합니다.
    • CLLocationManagerDelegate 관련 메서드
      • locationManagerDidChangeAuthorization(manager:) : 앱에 대한 위치 접근권한 설정이 변경되면 호출됩니다. 사용자의 선택에 따라 위치 접근권한을 획득하거나 결정해달라는 요청을 보냅니다.
      • locationManager(manager:didUpdateLocations:) : 사용자의 위치를 성공적으로 가져왔을 경우 호출됩니다.
      • locationManager(manager:didFailWithError:) : 사용자의 위치를 가져오지 못했을 경우 호출됩니다.

6️⃣ URLSessionProvider 구조체

  • URLSession을 이용하여 매개변수로 받은 url에 있는 데이터를 받아옵니다.



💭 고민한 부분

1️⃣ 키보드가 나타나고 사라질 때, 컨텐츠를 가리지않도록 하는 방법

    1. ScrollView 이용
    • 키보드가 나타날 때, 좁아진 화면으로도 스크롤 해서 가려진 화면을 볼 수 있도록 ScrollView에 컨텐츠들을 담았습니다.
    1. ScrollView의 inset, offset 조정
    • 키보드가 나타나고 화면을 스크롤 할 때 컨텐츠의 가장 밑부분이 키보드 뒤에 가려지는 문제를 해결하기 위해, 스크롤뷰가 나타날 때의 Notification을 받아 ScrollView의 Bottom방향으로 키보드 높이만큼의 Inset을 더하도록 했습니다. 더불어 사용자가 보고있던 화면을 유지하기위해 offset.y도 키보드의 높이만큼 빼줍니다.
    • 키보드가 사라질 때는 반대로 Inset을 삭제합니다.
    1. 입력이 끝나고 키보드를 내리기위한 keyboardDismissMode 속성 사용
    • 사용자가 입력을 끝내고 키보드를 내리는 방법으로 UIScrollView의 keyboardDismissMode 속성을 사용해 사용자가 스크롤하여 키보드를 다시 내릴 수 있도록 구현했습니다.

2️⃣ 상속으로 공통기능 구현시, 주요 프로퍼티에 접근제어(private)를 설정해주기 위해 이니셜라이저에서 데이터를 갈아끼우는 로직 구현

  • RegisterDiaryViewController(다이어리 등록화면 VC)를 상속받는 DiaryDetailViewController(다이어리 수정화면 VC)를 구현하면서 필수로 private으로 설정해주어야 할 프로퍼티들에 접근제어를 설정할 수 없는 문제가 있었습니다. VC 두 개가 똑같이 diaryPageView, diaryPage 데이터를 갖고있는데 외부에서 접근할 수 없게 Private설정을 해주어야 했습니다.
  • 상속받은 프로퍼티를 사용하지 않고 새로운 프로퍼티를 생성해서 사용하게되면, 이 프로퍼티들을 사용하는 메서드들을 모두 override해서 새로 생성한 프로퍼티를 사용할 수 있도록 모두 override후 재구현 해야 했습니다.
  • 이니셜라이저에서 해답을 얻었습니다. 이니셜라이저에서 데이터를 갈아끼우는 로직을 구현하니 프로퍼티는 인스턴스생성시 바뀌기 때문에 Private 접근제어를 설정할 수 있고, 상속받은 메서드들은 override없이 사용할 수 있었습니다.

3️⃣ 데이터가 없는 페이지는 자동 삭제되는 로직

  • 처음엔 다이어리 등록 시 작성내용이 있는지 확인한 후 저장을 결정하는 로직을 구현하려했는데, 저장할때도 확인해야하고, 한글자를 써서 이미 저장이 된 데이터들에 대해서는 업데이트시 확인 후 삭제해야하고, 등록화면 뿐 아니라 이미 저장된 다이어리를 보는 화면에서도 내용을 모두 지우면 업데이트가 아닌 삭제를 해야해야하는 번거로움이 있어 일단 데이터가 없는 페이지도 자동저장 된 후 fetch해 올 때(저장된 리스트를 보여줄 때) 빈 값의 데이터를 자체적으로 삭제한 후 fetch하는 로직으로 구현했습니다.

4️⃣ 디코딩에 필요한 DTO타입 구현에서 중첩타입 없이 구현하기

  • 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)
     	}
     }

🚀 트러블 슈팅

1️⃣ DiffableDataSource사용할 때, Value값이 똑같은 데이터로 인한 문제 해결

  • 프로젝트에 포함된 견본 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() // 👈
       	// ...
       }

2️⃣ 일기장 목록의 셀 높이 조절 - 오토레이아웃 문제 해결

  • DiaryCellconfigureDiaryCellLayout()메서드에서 AutoLayout Constraints 설정 시 일기 제목과 작성일 등의 Label을 담은 totalStackViewConstraintsself를 기준으로 했을 때 의도한대로 셀높이가 조절되지 않았습니다.

    수정 전 수정 후
  • ✅ 수정: LayoutConstraintcontentView에 걸어주기

    • LayoutConstraintself(DiaryCell)이 아닌 contentView를 기준으로 topAnchorbottomAnchorconstant값을 주어 해결하였습니다.

3️⃣ 일기장 목록의 셀 높이 조절 - 클릭 시 정상높이가 되는 문제 해결

  • 다이어리 리스트화면에서 DiaryCell의 높이가 아래GIF처럼 작았다가 클릭 시 정상높이로 돌아오는 문제가 있습니다.
  • 원인은 셀의 레이아웃을 잡아주는 configureDiaryCellLayout() 메서드가 Cell의 제목, 날씨아이콘, 내용을 담아주는 configureCell() 메서드 내부에서 호출이 되는데, 이 configureCell() 메서드의 호출 시점이 쓰레드관련 문제로 가장 마지막에 호출되는 문제였습니다.
    • 서버에서 가져오는 날씨아이콘을 담아주기 위해서 URLSessionDataTask를 활용할 때 다른쓰레드에서 작업이 진행되고, 작업이 끝난 후엔 가져온 아이콘을 갖고 DispatchQueue.main.async로 보내 main쓰레드에서 configureCell()메서드가 진행되도록 했었습니다. 이 때 문제는, 다시 main으로 보내진 작업이 원래 main의 모든 작업이 끝난 후에 진행이 되므로 컬렉션뷰리스트가 모두 그려진 후, configureCell()이 불리고, 여기에 포함된 레이아웃을 잡아주는configureDiaryCellLayout()도 나중에 불려지는게 원인이었습니다.
수정 전 수정 후
  • ✅ 수정: 날씨아이콘을 담아주는 작업만 DispatchQueue.main.async로 처리
    • configureCell(),configureDiaryCellLayout() 메서드는 main쓰레드에서 호출해서 먼저 레이아웃을을 잡고 제목과 내용만 담겨있게 되고, 서버 통신을 위해 다른 쓰레드에서 작업이 필요한 날씨아이콘 fetch 작업만 기능을 분리해 작업이 끝나면 다시 main쓰레드로 보내는 DispatchQueue.main.async로 처리해 정상적으로 레이아웃이 잡혀있는 Cell에 icon을 넣을 수 있었습니다.

4️⃣ 다이어리 저장 시 2개씩 저장되는 문제 해결

  • 다이어리 한 개 저장 시 두 개가 저장되어 목록에 나오는 문제가 있었습니다. 하나는 사용자가 입력한 내용의 다이어리이고, 다른 하나는 내용없는 다이어리가 생성되어 목록에 보였습니다.
  • 원인은 CoreData에서 WeatherDiary 엔티티의 Relationship설정 중에 의 Parent EntityDiary를 설정해 놓은게 문제였습니다.
  • ✅ 수정: No Parent Entity 설정
    • Weather 속성 설정을 Xcode 우측에 있는 Data Model Inspector - Entity - Parent Entity옵션에서 No Parent Entity로 설정하여 해결하였습니다.

5️⃣ TextView PlaceHolder 설정 방법

  • PlaceHolder를 TextView.text 속성으로 "일기 제목", "일기 내용" 과같은 PlaceHolder를 만들고 textViewDidBeginEditing메서드로 값이 바뀌었을 때, PlaceHolder가 지워지는 로직을 구현했을 때 아래와 같은 문제가 있었습니다.
    • 코어데이터에 데이터 저장 시 입력값이 없을 때도 입력되어있는 PlaceHolder인 "일기 제목", "일기 내용"을 Text로 인식해 저장되는 문제가 있었습니다.
    • 화면전환 시 키보드를 바로 띄우기 위해 TitleTextView를 FirstResponder로 지정해놓으니 textViewDidBeginEditing 메서드가 호출되어 PlaceHolder가 보이지 않는 문제가 있었습니다.
    • textViewDidBeginEditing대신, textViewDidChange로 값이 하나라도 적힐 때 PlaceHolder가 제거되는 로직으로 변경하면 PlaceHolder는 화면에 보이지만 타자커서가 PlaceHolder 뒤쪽에 위치하는 문제가 있었습니다.
  • ✅ 수정: Label사용
    • UILabel인 titlePlaceHolderbodyPlaceHolder를 만들어 TextView에 추가해 보이게 하고 UITextViewDelegate의 textViewDidChange메서드로 추가된 Label이 삭제되도록 수정해 해결했습니다.
    • Label 사용시, 입력값이 없을 때에 저장되는 데이터의 값도 빈값으로 정상 저장되고, 화면전환시 FirstResponder여도 PlaceHolder가 잘 보이고, 커서의 위치도 정상적으로(제일 앞쪽에) 위치합니다.

🔗 참고 링크

[공식문서]

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •