- SwiftUI
- Combine
- Lottie
- AlertToast
- PopView
- Github
- Discord
-
master(defulat)
-
develop(개발)
-
feature
- feature/onboard
- feature/side_menu_enable
- feature/mailbox_sound
...
-
hotfix
- hotfix/setting_animation
- hotfix/on_board_resource
...
RaniPaper
│
├── Audio
│
├── Fonts
│
├── Resource
│ ├── Enum.swift
│
│
└── Source
├── Extension
├── Model
├── Utlis
├── View
└── ViewModel
Path를 이용한 점선찍기
struct Line: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: 0))
return path
}
}
Line()
.stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
.foregroundColor(Color.memoPrimary)
.frame(height: 1).padding(.horizontal,15)
.padding(.top,3)
키보드 옵저빙 및 스크롤 연동
private var subscription = Set<AnyCancellable>()
private let keyboardWillShow = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.compactMap { output in
(output.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height
// 유저 정보 맵에서 keyboard 높이를 얻는다.
}
private let keyboardWillHide = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in CGFloat.zero}
init(){
print("✅ EditTaskViewModel 생성")
Publishers.Merge(keyboardWillShow, keyboardWillHide)
.subscribe(on: DispatchQueue.main) // UI 변화 이므로 메인. 쓰레
.sink(receiveValue: { [weak self] keyboardHeight in
guard let self = self else { return }
self.keyboardHeight = keyboardHeight
})
.store(in: &subscription)
// .assign(to: \.keyboardHeight, on: self)
}
@Namespace var bottom //keyboard 올라올 때 사용할 bottom 버튼 ID
//스크롤 뷰 리더로 덮음
ScrollViewReader { scrollProxy in
ScrollView {
...생략
해당뷰.id(bottom) // 아이디 설정
}
.onChange(of: viewModel.keyboardHeight, perform: { v in
if(v>0)
{
//키보드가 나올 때 바텀 버튼으로 스크롤, center 까지
withAnimation {
scrollProxy.scrollTo(bottom, anchor: .center)
}
}
})
}
데이트(Date) 관련 정리
- DateComponet,calendar.date,range
let range2 = calendar.range(of: .day, in: .month, for: tmpDate)! //해당하는 달의 날짜가 몇일까지 있는지
print(components)
// year: 2022 month: 12 day: 28 isLeapMonth: false ,윤년이 아닌 2022년 12월 28
print(tmpDate)
// 2022-12-27 15:00:00 +0000 , 이거는 UTC +0 과 +9 차이
print(range2)
// 1..<32 (1~31) 12월 31일까지 있음
- ByAdding
calendar.date(byAdding: 어떤날짜 단위를?, value: Int값 , to: Date객체)
to값에 value를 더한다 그 때 byAdding단위에 더한다
let tmp = calendar.date(byAdding: .year, value: 5 , to: tmpDate)!
tmpDate = 2022-12-27 15:00:00 +0000 이고
단위가 year, value가 5이기 때문에
tmp 값은
2027-12-27 15:00:00 +0000이 된다 , 2022+5 = 2027
- DateComponents 추출
func component(_ component: Calendar.Component, from date: Date) -> Int
calendar.component(.day, from: date) Date객체인 date으로 부터 .day속성을 추출
- SameDate ?
func isSameDate(date1: Date, date2: Date)-> Bool{
let calendar = Calendar.current
return calendar.isDate(date1, inSameDayAs: date2)
}
Date to String
- Date날짜 문자열로 변환
Text(Date().formatted(date: .abbreviated, time: .standard))
// Jun 28, 2022, 7:18:59 PM
Text(Date().formatted(date: .numeric, time: .omitted))
// 6/28/2022
Text(Date().formatted(date: .omitted, time: .shortened))
// 7:18 PM
Text(Date().formatted(date: .long, time: .complete))
// June 28, 2022, 7:18:59 PM GMT+9
Text(Date().formatted(date: .complete, time: .complete))
// Tuesday, June 28, 2022, 7:18:59 PM GMT+9
Text(Date().formatted())
// 6/28/2022, 7:18 PM
date
.complete : Tuesday, June 28, 2022 (요일, 날짜, 년도 순)
.long : June 28, 2022 (.complete에서 요일만 없어짐)
.abbreviated : Jun 28, 2022 (월을 3글자로 줄인 후 날짜, 년도는 4자리)
.numeric : 6/28/2022 (월/일/년도 순)
.omitted : 생략
time
.complete : 7:18:59 PM GMT+9 (시:분:초 AM/PM 표준시)
.standard : 7:18:59 PM (표준시 표기 X)
.shortened : 7:18 PM (초 표기 X)
.omitted : 생략
- DateFormatter를 이용한 String 전환
func extraData() ->[String] {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY MM" // MM:숫자 , MMM:월 줄임단어, MMMM:월 풀네임
let date = formatter.string(from: viewModel.currentDate)
return date.components(separatedBy: " ")
}
FileManager를 통한 데이터를 사용자 로컬에 저장
- 사용자의 메모와 할일목록을 로컬에 저장하기 위해 MyFileManager 라는 싱글톤 클래스를 만들어 CRUD 메소드를 정의하였습니다.
- CRUD 메소드 내에 JSON 인/디코딩 파트를 내장하여 커스텀 Codable Struct인 메모와 할일목록을 메소드 호출 한번으로 쉽게 저장하고 사용할 수 있도록 하였습니다.
- 메소드에 자세한 퀵헬프 주석을 달아 메소드를 사용하는 팀원들의 이해를 돕고자 했습니다.
final class MyFilemanager {
static let shared = MyFileManager()// 싱글톤
var fileManager: FileManager
var documentPath: URL
var memoDirectoryPath: URL
var diaryDirectoryPath: URL
enum Folder {
case memo, diary
}
private init() {
// 파일 매니저 인스턴스 생성
self.fileManager = FileManager.default
// 사용자의 문서 경로
self.documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
// 폴더 경로 지정
// Documents/Memo
// Documents/Diary
self.memoDirectoryPath = documentPath.appendingPathComponent("Memo")
self.diaryDirectoryPath = documentPath.appendingPathComponent("Diary")
// 폴더 생성
do {
try fileManager.createDirectory(at: memoDirectoryPath, withIntermediateDirectories: false, attributes: nil)
try fileManager.createDirectory(at: diaryDirectoryPath, withIntermediateDirectories: false, attributes: nil)
} catch let e {
print(e.localizedDescription)
}
}
}
/// CREATE : 파일을 생성(저장) 합니다.
/// - Parameter folder: 저장할 폴더 ( Ex: Documents/RaniPaper/Memo/. )
/// - Parameter fileName: Documents/RaniPaper/FolderName/. 에 저장 될 파일명 (확장자 지정 필요)
/// - Parameter data: 저장할 Codable 객체
/// - Returns: Void
func create(at folder: Folder, fileName: String, _ data: Codable) -> Result<Void, CreateError> {
if fileName.isEmpty { return .failure(.invalidName) }
guard let data = try? JSONEncoder().encode(data) else { return .failure(.encodeError) }
// 폴더 경로
var directoryPath: URL
switch folder {
case .memo:
directoryPath = memoDirectoryPath
case .diary:
directoryPath = diaryDirectoryPath
}
// 파일 경로
let filePath = directoryPath.appendingPathComponent(fileName)
// 파일 생성하기
// 보조파일에 쓰기후 파일교체, 덮어쓰기 방지 옵션
guard (try? data.write(to: filePath, options: .withoutOverwriting)) != nil else { return .failure(.unknown) }
print("파일을 CREATE 합니다. 파일명:", fileName)
return .success(())
}
/// READ : 파일을 읽어옵니다.
/// - Parameter folder: 불러올 폴더 ( Ex: Documents/RaniPaper/Memo/. )
/// - Parameter fileName: Documents/RaniPaper/. 에 저장 된 파일명 (확장자 지정 필요)
/// - Returns: .success 시 Codable 객체 반환
func read(at folder: Folder, fileName: String) -> Result<Codable, ReadError> {
if fileName.isEmpty { return .failure(.invalidName) }
// 폴더 경로
var directoryPath: URL
switch folder {
case .memo:
directoryPath = memoDirectoryPath
case .diary:
directoryPath = diaryDirectoryPath
}
// 파일 경로
let filePath = directoryPath.appendingPathComponent(fileName)
// path를 불러와서 Data타입으로 초기화
guard let dataFromPath: Data = try? Data(contentsOf: filePath) else { return .failure(.unknown) }
// JSON 디코딩
var data: Codable
switch folder {
case .memo:
guard let decodedData = try? JSONDecoder().decode(MemoModel.self, from: dataFromPath) else {
return .failure(.decodeError) }
data = decodedData
case .diary:
guard let decodedData = try? JSONDecoder().decode(TaskModel.self, from: dataFromPath) else {
return .failure(.decodeError) }
data = decodedData
}
print("파일을 READ 합니다. 내용:", data)
return .success(data)
}
/// UPDATE : 파일을 수정 합니다.
/// - Parameter folder: 저장할 폴더 ( Ex: Documents/RaniPaper/Memo/. )
/// - Parameter fileName: Documents/RaniPaper/. 에 저장 될 파일명 (확장자 지정 필요)
/// - Parameter data: 저장할 String
/// - Returns: Void
func update(at folder: Folder, fileName: String, _ data: Codable) -> Result<Void, UpdateError> {
if fileName.isEmpty { return .failure(.invalidName)}
guard let data = try? JSONEncoder().encode(data) else { return .failure(.encodeError) }
// 폴더 경로
var directoryPath: URL
switch folder {
case .memo:
directoryPath = memoDirectoryPath
case .diary:
directoryPath = diaryDirectoryPath
}
// 파일 경로
let filePath = directoryPath.appendingPathComponent(fileName)
// 보조파일에 쓰기후 파일교체
guard (try? data.write(to: filePath, options: .atomic)) != nil else { return .failure(.unknown) }
print("파일을 UPDATE 합니다. 파일명:", fileName)
return .success(())
}
/// DELETE : 파일을 삭제합니다.
/// - Parameter folder: 삭제할 요소가 들어있는 폴더 ( Ex: Documents/RaniPaper/Memo/. )
/// - Parameter fileName: Documents/RaniPaper/. 에 삭제 할 파일명 (확장자 지정 필요)
/// - Returns: Void
func delete(at folder: Folder, fileName: String) -> Result<Void, DeleteError> {
if fileName.isEmpty { return .failure(.invalidName) }
// 폴더 경로
var directoryPath: URL
switch folder {
case .memo:
directoryPath = memoDirectoryPath
case .diary:
directoryPath = diaryDirectoryPath
}
// 파일 경로
let filePath = directoryPath.appendingPathComponent(fileName)
// 파일을 삭제한다.
guard (try? fileManager.removeItem(at: filePath)) != nil else { return .failure(.unknown) }
print("파일을 DELETE 합니다. 파일명:", fileName)
return .success(())
}
enum CreateError: Error {
case invalidName // 잘못된 이름
case encodeError // 인코딩 실패
case alreadyExist // 이미 존재하는 파일
case storageIsFull // 저장공간이 부족
case unknown
public var errorDescription: String {
switch self {
case .invalidName:
return NSLocalizedString("🔥 invalidName exception", comment: "파일명이 잘못됨")
case .encodeError:
return NSLocalizedString("🔥 encodeError exception", comment: "인코딩에서 문제 발생")
case .alreadyExist:
return NSLocalizedString("🔥 alreadyExist exception", comment: "이미 존재하는 파일")
case .storageIsFull:
return NSLocalizedString("🔥 storageIsFull exception", comment: "저장공간이 부족")
case .unknown:
return NSLocalizedString("🔥 unknown exception", comment: "unknown")
}
}
}
enum ReadError: Error { ... }
enum UpdateError: Error { ... }
enum DeleftError: Error { ... }
...
Property Wrapper 를 통해 유저디폴트 접근 편의성 강화
- UserDefaultWrapper 라는 커스텀 프로퍼티 래퍼를 만들어 유저디폴트 get, set 코드의 가독성을 높였습니다.
- Combine을 활용해 변경사항을 옵저빙하고, 값이 갱신되면 실시간으로 뷰에 반영될 수 있도록 하였습니다.
@propertyWrapper
class UserDefaultWrapper<T: Codable> {
private let key: String
private let defaultValue: T?
init(key: String, defaultValue: T?) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T? {
get {
if let savedData = UserDefaults.standard.object(forKey: key) as? Data {
let decoder = JSONDecoder()
if let lodedObejct = try? decoder.decode(T.self, from: savedData) {
return lodedObejct
}
}
return defaultValue
}
set {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(newValue) {
UserDefaults.standard.setValue(encoded, forKey: key)
}
subject.send(newValue)// 값이 변경되면 subject 로 변경된 값을 보냅니다.
}
}
// CurrentValueSubject는 가장 최근에 발행된 요소를 버퍼에 저장합니다.
private lazy var subject = CurrentValueSubject<T?, Error>(wrappedValue)
public var projectedValue: AnyPublisher<T?, Error> {
return subject.eraseToAnyPublisher()
}
}
struct RollingPaper: Codable { }
@UserDefaultWrapper(key: "rollingPaperList", defaultValue: nil)
static var rollingPaperList: [RollingPaper]?
// 유저디폴트에 값 저장
rollingPaperList = []
// 유저디폴트 내 값 불러오기
var list = rollingPaperList
// 유저디폴트 변경사항 옵저빙
$rollingPaperList.sink { _ in } receiveValue: { rollingPaperList in
...
}.store(in: Set<AnyCancellable>)
UserNotification을 통한 푸시 알림 관리
- MyUserNotification의 인스턴스를 이용해 UserNotification을 관리할 수 있게 했습니다.
- CalendarView에서 생성되는 TaskModel의 데이터와 연계하여 푸시 알림을 생성할 수 있게 했습니다.
func getPermission(){
// 어플 뱃지, 소리, 푸시에 대한 permission 요청
center.requestAuthorization(options: [.badge, .sound, .alert]){(granted, error) in
if granted{
print("✅ 사용자가 푸시 알림을 승인합니다.")
DispatchQueue.main.async{
MyUserDefaults.shared.setValue(key: "notification", value: granted)
}
} else{
if let theError = error{
MyUserDefaults.shared.setValue(key: "notification", value: granted)
print("🔥 사용자가 푸시 알림을 거부합니다. \(theError.localizedDescription)")
}
}
}
isPermitted = MyUserDefaults.shared.getValue(key: "notification") as? Bool ?? false
}
/// CREAT : TaskModel을 입력 받아 해당 deadline에 알림을 생성합니다.
/// - Parameter TaskModel: 알림을 받을 TaskModel
/// UserNotification과 TaskModel은 ID를 공유하게 됩니다.
func create(_ taskModel: TaskModel){
if isPermitted{
content.title = "\(taskModel.title)이(가) \(taskModel.timeInterval.rawValue)입니다."
content.body = "알람: " + taskModel.title
content.sound = UNNotificationSound.default
var deadLine = taskModel.deadLine
...생략
// 알림 예정 시간
let confirmDeadLine = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: deadLine)
// 해당 시간으로 notification trigger 생성
let trigger = UNCalendarNotificationTrigger(dateMatching: confirmDeadLine, repeats: isRepeat)
// notification에 대한 request 생성
let request = UNNotificationRequest(identifier: taskModel.id, content: content, trigger: trigger)
// 해당 request를 NotificationCenter에 추가
center.add(request, withCompletionHandler: nil)
print("알람이 설정됩니다. dateComponents: \(taskModel.deadLine) \(taskModel.timeInterval)")
} else{
print("푸시 알림이 거부된 상태입니다.")
}
}
#### 변경 TaskModel에 대해 Notification request update
/// UPDATE : TaskModel을 입력 받아 해당 ID를 갖고 있는 기존 알림을 제거하고 변경된 TaskModel로 알림을 생성합니다.
/// - Parameter TaskModel: 내용이 변경된 TaskModel
func update(_ taskModel: TaskModel){
if isPermitted{
delete(id: taskModel.id)
create(taskModel)
} else{
print("푸시 알림이 거부된 상태입니다.")
}
}
#### 삭제된 TaskModel에 대해 Notification request 삭제
/// DELETE : ID를 입력받아 해당 ID를 가진 예정된 알림을 제거합니다.
/// - Parameter id: 삭제할 TaskModel의 ID
func delete(id: String){
center.removePendingNotificationRequests(withIdentifiers: [id])
// 해당 ID의 request가 없을 경우 무시
print("알람이 삭제됩니다. TaskModel ID: \(id)")
}
/// EditTaskViewModel.swift
func update() -> Bool {
let taskModel = TaskModel(id: taskId ?? UUID().uuidString, title: taskTitle, deadLine: taskDeadLine, color: taskColor, ticket: taskTicket,timeInterval: timeInterval)
let result = MyFileManager.shared.update(at: .diary, fileName: "task-\(taskModel.id).json", taskModel)
MyUserNotifications.shared.update(taskModel)
...생략
}
/// CalendarViewModel.swift
func deleteTask(id: String) -> Bool {
let result = MyFileManager.shared.delete(at: .diary, fileName: "task-\(id).json")
//알림 삭제
MyUserNotifications.shared.delete(id: id)
...생략
}
AVAudioPlayer를 이용한 음원 재생
//MySoundSetting.swift
func play() {
// 번들에서 url 불러오기
guard let url = Bundle.main.url(forResource: self.urlName, withExtension: self.extensionName) else {
print("🔥 url을 불러오지 못했습니다.")
return
}
// 해당 url의 음원 재생하는 플레이어 생성(오버레이를 위해)
do {
player = try AVAudioPlayer(contentsOf: url)
} catch let error {
print("🔥 음원을 불러오는데 오류가 발생했습니다.\(error.localizedDescription)")
}
// 소리 종류에 따라 설정 변경
switch soundType {
case .BGM:
player?.numberOfLoops = -1
player?.setVolume(0.5, fadeDuration: 0)
default:
player?.setVolume(0.75, fadeDuration: 0)
}
//볼륨 설정
// 소리 설정이 활성 상태면 음원 재생
if self.isEnable{
player?.play()
}
}
- 음원별로 player를 각각 생성하지 않으면 음원이 overlay되지 않음
// MySoundSetting.swift
extension MySoundSetting {
// 사이드메뉴 버튼 클릭 효과음 인스턴스
static let clickSideMenu = MySoundSetting(url: "clickSideMenu", extension: "wav", .SFX)
...
}
// SideMenuView.swift
struct SideMenuView: View {
...
Button(action:{
isOpen.toggle()
offset = Menu.minOffset
// 사이드메뉴 버튼 클릭 시 clickSideMenu 음원 재생
MySoundSetting.clickSideMenu.play()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1){
selection = menu.viewSelection
}
})
...
}