- Naptune ์ฑ์ ํผ๋กํ ํ๋์ธ๋ค์ ์ํด ์ค๊ณ๋ ํ๋ผ์ด๋น ์์
์๋น์ค๋ก, ๋ฎ์ ์๊ฐ์ ์ค์ ํ๊ณ ์น๊ตฌ๋ค๊ณผ ํจ๊ป ๊ณต์ ํ๋ฉฐ ํผ๋ก๋ฅผ ํ๋ณตํ ์ ์๋๋ก ๋์ต๋๋ค. ์ฌ์ฉ์๋ ๋ฎ์ ํ์ด๋จธ์ ์๋ฆผ ๊ธฐ๋ฅ์ ํตํด ํจ์จ์ ์ผ๋ก ํด์์ ์ทจํ๊ณ , ๋ฎ์ ํ์๋ ์น๊ตฌ๋ค๊ณผ์ ํผ๋๋ฐฑ์ ํตํด ๊ธ์ ์ ์ธ ๊ฒฝํ์ ๋ง๋ค์ด๊ฐ ์ ์์ต๋๋ค. Naptune์ ์ผ์์ ์๋์ง๋ฅผ ๋์ฐพ๊ณ , ๋ฎ์ ๋ฌธํ๋ฅผ ์๋กญ๊ฒ ์ ์ํฉ๋๋ค.
- Apple Developer Academy @ POSTECH DELIGHTS-CON ์ต์ข ๋ฐํ
- 2024.07.01 ~ 2024.08.13 (7์ฃผ) - Apple Developer Academy @ POSTECH (MC3 Challenge)
- iOS Developer 2๋ช , Back-end 1๋ช , Design 2๋ช
-
Framework
SwiftUI
,UIKit
,WidgetKit
,ActivityKit
,CallKit
,AVFoundation
,PhotosUI
,UserNotifications
,Combine
,Haptic
,SnapKit
,KDCircularProgress
,AuthenticationService
,FirebaseFireStore
,FirebaseStorage
-
Design Pattern
MVVM
- 1. ๋ฎ์ ์๊ฐ์ ์ค์ ํ๋ ํ์ด๋จธ ๊ธฐ๋ฅ
- 2. ๋ฎ์ ๋ชจ๋์ ์ง์ ํ๋ฉด ์น๊ตฌ์๊ฒ ์๋ฆผ(Notification)์ ๋ณด๋ด ๋ฎ์ ์ํ๋ฅผ ๊ณต์ ํ๋ ๊ธฐ๋ฅ
- 3. ์ ํด์ง ๋ฎ์ ์๊ฐ์ด ์ข
๋ฃ๋๋ฉด CallKit์ ํตํด ์๋ฆผ ์ ํ๋ฅผ ์ ๊ณตํ๋ ๊ธฐ๋ฅ
- 4. ์ผ์ด๋๊ธฐ ๋ฒํผ์ ๋๋ฅด๋ฉด ์ปค์คํ
์นด๋ฉ๋ผ ํ๋ฉด์ผ๋ก ์ด๋ํ๋ ๊ธฐ๋ฅ
- 5. ์น๊ตฌ๋ค์ ๋ฎ์ ๊ธฐ๋ก๊ณผ ์ํ๋ฅผ ํ์ธํ ์ ์๋ ํผ๋ ๊ธฐ๋ฅ
- 6. ๋ค์ด๋๋ฏน ์์ผ๋๋์ ๋ผ์ด๋ธ ์กํฐ๋นํฐ๋ฅผ ํ์ฉํ์ฌ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ชจ๋์์๋ ๋จ์ ๋ฎ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ํ์ธํ๋ ๊ธฐ๋ฅ
Naptune์ ๋ฎ์ ํ์ด๋จธ ๊ธฐ๋ฅ์ ์ฌ์ฉ์๊ฐ ์ํ๋ ๋ฎ์ ์๊ฐ์ ์ง๊ด์ ์ผ๋ก ์ค์ ํ๊ณ , ์ค์ ๋ ์๊ฐ ๋์ ๋จ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ํ์ธํ ์ ์๋๋ก ์ง์ํฉ๋๋ค. ์ด ๊ธฐ๋ฅ์ ์ธํฐ๋ํฐ๋ธํ ์ฌ๋ผ์ด๋์ ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ฅผ ํตํด ์ฌ์ฉ์ ๊ฒฝํ์ ๊ทน๋ํํ์ผ๋ฉฐ, ์๊ฐ์ ํผ๋๋ฐฑ๊ณผ ํ
ํฑ ํผ๋๋ฐฑ์ ๊ฒฐํฉํด ํธ๋ฆฌํ๊ณ ๋ชฐ์
๊ฐ ์๋ ํ์ด๋จธ ์ค์ ํ๊ฒฝ์ ์ ๊ณตํฉ๋๋ค.
ํ์ด๋จธ User Flow
- ํ์ด๋จธ ์ค์ ๋ฐ ์๊ฐ์ ํผ๋๋ฐฑ
- ์ฌ๋ผ์ด๋ ์ธํฐํ์ด์ค๋ฅผ ํ์ฉํด ์ฌ์ฉ์๊ฐ ๋๋๊ทธ๋ก ์์ ๋ฐ ์ข ๋ฃ ์๊ฐ์ ์ค์
- ํ์ฌ ์ค์ ๋ ๋ฎ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ๊ณ์ฐํ๊ณ ํ๋ฉด์ ํ์
- ํ ํฑ ํผ๋๋ฐฑ์ ์ถ๊ฐํ์ฌ ์ฌ๋ผ์ด๋ ์กฐ์ ์ ์ฌ์ฉ์ ๊ฒฝํ ํฅ์
- ์ค์๊ฐ ํ๋ก๊ทธ๋ ์ค ๋ฐ
- KDCircularProgress ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ ๋จ์ ์๊ฐ์ ์๊ฐ์ ์ผ๋ก ํ์
- ์ ๋๋ฉ์ด์ ํจ๊ณผ๋ฅผ ํตํด ์งํ๋ฅ ์ด ์์ฐ์ค๋ฝ๊ฒ ์ ๋ฐ์ดํธ
- SwiftUI๋ฅผ ํ์ฉํ์ฌ ์ฌ๋ผ์ด๋์ ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ฅผ ๊ตฌ์ฑํ ์ฝ๋
@ViewBuilder
func sleepTimeSlider() -> some View {
GeometryReader { proxy in
let width = proxy.size.width
ZStack {
// ์ฌ๋ผ์ด๋ ๋์์ธ๊ณผ ์๊ณ ํ์
ForEach(1...60, id: \.self) { index in
Rectangle()
.fill(index % 5 == 0 ? .white : .gray)
.frame(width: 2, height: index % 5 == 0 ? 10 : 5)
.offset(y: (width - 60) / 2)
.rotationEffect(.init(degrees: Double(index) * 6))
}
// ๋จ์ ์๊ฐ์ ๋ํ๋ด๋ ํ๋ก๊ทธ๋ ์ค ๋ฐ
KDCircularProgressView(progress: $progress)
.frame(width: width - 40, height: width - 40)
}
}
}
- ํ์ด๋จธ ์์๊ณผ ์ข ๋ฃ ๊ด๋ฆฌ
func startTimer() {
timer?.invalidate()
remainingSeconds = getTimeDifference() * 60
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if remainingSeconds > 0 {
remainingSeconds -= 1
} else {
timer?.invalidate()
timer = nil
// ํ์ด๋จธ ์ข
๋ฃ ํ ํ๋
}
}
}
- ๋จ์ ์๊ฐ ์ค์๊ฐ ์ ๋ฐ์ดํธ
func startProgress() {
let duration = Double(remainingSeconds)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
if self.remainingSeconds > 0 {
self.progress = (1 - Double(self.remainingSeconds) / duration) * 360
} else {
timer.invalidate()
}
}
}
KDCircularProgress
๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๋ฐฉ๋ฒ
import SwiftUI
import KDCircularProgress
struct KDCircularProgressView: UIViewRepresentable {
@Binding var progress: Double
func makeUIView(context: Context) -> KDCircularProgress {
let progressView = KDCircularProgress(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
progressView.startAngle = -90
progressView.progressThickness = 0.37
progressView.trackThickness = 0.4
progressView.clockwise = true
progressView.gradientRotateSpeed = 1
progressView.roundedCorners = true
progressView.glowMode = .forward
progressView.glowAmount = 0.8
progressView.set(colors: UIColor(named: "ManboBlue400")!)
progressView.trackColor = UIColor.gray.withAlphaComponent(0.3)
return progressView
}
func updateUIView(_ uiView: KDCircularProgress, context: Context) {
uiView.angle = progress + 5 // Convert percentage to degrees (100% = 360 degrees)
}
}
2. ๋ฎ์ ๋ชจ๋์ ์ง์ ํ๋ฉด ์น๊ตฌ์๊ฒ ์๋ฆผ(Notification)์ ๋ณด๋ด ๋ฎ์ ์ํ๋ฅผ ๊ณต์ ํ๋ ๊ธฐ๋ฅ
Naptune์ ์ฌ์ฉ์๊ฐ ๋ฎ์ ๋ชจ๋์ ์ง์
ํ๋ฉด ์น๊ตฌ๋ค์๊ฒ ํธ์ ์๋ฆผ์ ๋ณด๋ด ๋ฎ์ ์ํ๋ฅผ ์ค์๊ฐ์ผ๋ก ๊ณต์ ํ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. ์๋ฆผ์ Firebase Cloud Messaging(FCM)์ ํตํด ์ ๋ฌ๋๋ฉฐ, ์ฌ์ฉ์์ ์น๊ตฌ ๊ฐ์ ์์
์ธํฐ๋์
์ ๊ฐํํ๋ ๋ฐ ์ค์ ์ ๋์์ต๋๋ค. ์๋ฆผ์๋ ๋ฎ์ ์์ ๋ฐ ์ข
๋ฃ ์๊ฐ์ด ํฌํจ๋์ด ์น๊ตฌ๊ฐ ์ฌ์ฉ์์๊ฒ ์ ํ๋ ๋ฉ์์ง๋ก ์๋ฆผ์ ๋ณด๋ผ ์ ์๋๋ก ์ ๋ํฉ๋๋ค.
KakaoTalk_Video_2025-01-19-09-32-20.mp4
- ํธ์ ์๋ฆผ ์ค์ ๋ฐ ์ ์ก
- Firebase๋ฅผ ํ์ฉํด ์ฌ์ฉ์ ์ธ์ฆ ๋ฐ ํธ์ ์๋ฆผ ํ ํฐ ๊ด๋ฆฌ
- Firebase Functions๋ฅผ ์ฌ์ฉํด ์๋ฆผ ์ ์ก ๋ก์ง ์ฒ๋ฆฌ
- ์๋ฆผ ๋ด์ฉ ๊ตฌ์ฑ
- ์ฌ์ฉ์๊ฐ ์ค์ ํ ๋ฎ์ ์ข ๋ฃ ์๊ฐ๊ณผ ๊ฐ์ธํ๋ ๋ฉ์์ง๋ฅผ ํฌํจํ์ฌ ์น๊ตฌ๋ค์๊ฒ ์ง๊ด์ ์ธ ์ ๋ณด๋ฅผ ์ ๊ณต
- ์๋ฆผ ์ ๋ชฉ: "์น๊ตฌ๊ฐ ์ ๋ค์์ด์!"
- ์๋ฆผ ๋ด์ฉ: "์ค์ 10:30์ ์ ํ๋ก ๋ฎ์ ์ ๊นจ์์ฃผ์ธ์!"
- ์ฌ์ฉ์ ๊ฒฝํ ๊ฐ์
- ๋ฎ์ ๋ชจ๋ ์ง์ ์ ๋ฌด์ ๋ชจ๋ ํด์ ๋ฅผ ์ ๋ํ์ฌ ์๋ฆผ์ ๋์น์ง ์๋๋ก ์๋ด
- ์๋ฆผ ์ ์ก ์ฑ๊ณต ์ฌ๋ถ๋ฅผ ์ค์๊ฐ์ผ๋ก ํ์ธํ์ฌ ์ฌ์ฉ์ ์ ๋ขฐ๋ ์ฆ๋
- ํธ์ ์๋ฆผ ์ ์ก ๋ก์ง
func sendPushNotification(authToken: String?) {
guard let authToken = authToken else {
resultMessage = "No auth token available"
return
}
let functions = Functions.functions(region: "asia-northeast3")
let data: [String: Any] = [
"token": "์ฌ์ฉ์ ๊ณ ์ FCM ํ ํฐ",
"title": "์น๊ตฌ๊ฐ ์ ๋ค์์ด์!",
"body": "\(wakeupTime().formatted(date: .omitted, time: .shortened))์ ์ ํ๋ก ๋ฎ์ ์ ๊นจ์์ฃผ์ธ์!"
]
functions.httpsCallable("sendPushNotification").call(data) { result, error in
if let error = error as NSError? {
print("Error sending push notification: \(error.localizedDescription)")
DispatchQueue.main.async {
resultMessage = "Error sending push notification: \(error.localizedDescription)"
}
return
}
if let response = result?.data as? [String: Any], let responseMessage = response["response"] as? String {
DispatchQueue.main.async {
resultMessage = responseMessage
}
} else {
DispatchQueue.main.async {
resultMessage = "Unknown response"
}
}
}
}
- ๋ฎ์ ๋ชจ๋ ์ง์ ์ ์๋ฆผ ํธ๋ฆฌ๊ฑฐ
func moveNextAndNoti() {
moveNext = true
fetchAuthToken { token in
self.authToken = token
sendPushNotification(authToken: token)
}
}
- ์๋ฆผ์ ์ํ Firebase ์ธ์ฆ ํ ํฐ ํ๋
func fetchAuthToken(completion: @escaping (String?) -> Void) {
if let currentUser = Auth.auth().currentUser {
currentUser.getIDToken { token, error in
if let error = error {
print("Error fetching ID token: \(error.localizedDescription)")
completion(nil)
} else {
completion(token)
}
}
} else {
print("No user is signed in")
completion(nil)
}
}
Naptune ์ฑ์ ๋
ํนํ ๊ธฐ๋ฅ ์ค ํ๋๋ ๋ฎ์ ์๊ฐ์ด ์ข
๋ฃ๋์์ ๋ CallKit
์ ์ฌ์ฉํ์ฌ ์๋ฆผ ์ ํ๋ฅผ ์ ๊ณตํ๋ ๊ฒ์
๋๋ค. ์ด ๊ธฐ๋ฅ์ ์ฌ์ฉ์๊ฐ ๋ฎ์ ์์ ๊นจ์ด๋์ผ ํ ์๊ฐ์ ๋์น์ง ์๋๋ก ๋๋ ๋์์, ์ฌ์ฉ์ ๊ฒฝํ์ ํ๋ถํ๊ฒ ๋ง๋ญ๋๋ค.
- ํ์ด๋จธ ์๊ฐ ์ข ๋ฃ ์ด๋ฒคํธ ๊ฐ์ง
- ์ฑ์ ์ค์ ๋ ๋ฎ์ ์๊ฐ์ด ์ข ๋ฃ๋์์ ๋ startCall() ๋ฉ์๋๋ฅผ ์คํ
- ๋จ์ ์๊ฐ์ด 0 ์ดํ๊ฐ ๋๋ฉด CallManager๊ฐ ํธ์ถ
- CallKit์ ํตํ ์ ํ ์๋ฆผ
- CallManager๋
CallKit
์ ํ์ฉํ์ฌ ๊ฐ์ ์ ํ๋ฅผ ์์ฑ - ์ฌ์ฉ์ ๋๋ฐ์ด์ค์์ ์ค์ ์ ํ๊ฐ ์จ ๊ฒ์ฒ๋ผ ์๋ฆผ์ด ํ์๋๋ฉฐ, ์ด๋ฅผ ํตํด ์ฌ์ฉ์๊ฐ ํ์คํ ์๋ฆผ์ ๋ฐ์ ์ ์์ต๋๋ค.
- CXProvider๋ฅผ ์ฌ์ฉํ ์ ํ ์์ฑ
- CXProvider์ CXCallController๋ฅผ ์ฌ์ฉํ์ฌ ๊ฐ์ ์ ํ๋ฅผ ์์ฑํ๊ณ ๊ด๋ฆฌ
- UUID๋ฅผ ํ์ฉํ์ฌ ๊ฐ ์ ํ ํธ์ถ์ ๊ณ ์ ํ๊ฒ ์๋ณ
- CXCallUpdate๋ฅผ ์ฌ์ฉํ์ฌ ์ ํ์ ์ธ๋ถ ์ ๋ณด(์: ๋ฐ์ ์ ์ด๋ฆ, ํธ๋ค)๋ฅผ ์ค์
final class CallManager: NSObject, CXProviderDelegate {
let provider = CXProvider(configuration: CXProviderConfiguration())
let callController = CXCallController()
static let shared = CallManager()
private override init() {
super.init()
provider.setDelegate(self, queue: nil)
}
func reportIncomingCall(id: UUID, handle: String) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: handle)
provider.reportNewIncomingCall(with: id, update: update) { error in
if let error = error {
print("Error reporting incoming call: \(error.localizedDescription)")
} else {
print("Call Reported")
}
}
}
}
- NapProgress์ ํ์ด๋จธ ์ข ๋ฃ ์ด๋ฒคํธ
func startCall() {
DispatchQueue.main.asyncAfter(deadline: .now(), execute: {
let callManager = CallManager.shared
let id = UUID()
callManager.reportIncomingCall(id: id, handle: "์ฌ์ฐ")
})
}
Naptune ์ฑ์ ๋ฎ์ ํ ์ฌ์ฉ์๊ฐ ๊ธฐ์ ์ํ๋ฅผ ๊ธฐ๋กํ ์ ์๋๋ก ์ปค์คํ
์นด๋ฉ๋ผ ํ๋ฉด์ผ๋ก ์ด๋ํ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. ์ด ๊ธฐ๋ฅ์ ์ฌ์ฉ์๊ฐ ๊ธฐ์ ์ฌ์ง์ ์ดฌ์ํ๊ณ ์ด๋ฅผ ์ฑ ํผ๋์ ์
๋ก๋ํ์ฌ ์น๊ตฌ๋ค๊ณผ ๊ณต์ ํ ์ ์๋ ๋
ํนํ ๊ฒฝํ์ ์ ๊ณตํฉ๋๋ค.
SwiftUI
์ UIViewControllerRepresentable์ ์ฌ์ฉํ์ฌUIKit
์ UIViewController๋ฅผSwiftUI
๋ทฐ์ ํตํฉ- NapPhotoView๊ฐ ์ปค์คํ ์นด๋ฉ๋ผ ํ๋ฉด์ ์ ๊ณตํ๋ฉฐ, ์นด๋ฉ๋ผ ์ ์ด๋ CameraCoordinator์์ ๊ด๋ฆฌ
struct NapPhotoView: UIViewControllerRepresentable {
@Binding var capturedImage: UIImage?
func makeUIViewController(context: Context) -> UIViewController {
let containerVC = UIViewController()
let overlayView = createOverlayView(context: context, containerVC: containerVC)
containerVC.view.addSubview(overlayView)
return containerVC
}
func createOverlayView(context: Context, containerVC: UIViewController) -> UIView {
// ์นด๋ฉ๋ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฐ ์ปค์คํ
UI ๊ตฌ์ฑ
let cameraView = CameraPreview()
let shutterButton = UIButton(type: .system)
shutterButton.addTarget(context.coordinator, action: #selector(CameraCoordinator.shutterButtonTapped), for: .touchUpInside)
// SnapKit์ ์ฌ์ฉํ์ฌ ๋ ์ด์์ ๊ตฌ์ฑ
return cameraView
}
}
AVFoundation
์ ์ฌ์ฉํ์ฌ ์ ๋ฉด/ํ๋ฉด ์นด๋ฉ๋ผ๋ฅผ ์ค์ ํ๊ณ , ์ฌ์ง ์บก์ฒ ๋ฐ ์ค์๊ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ๋ฉด ์ ๊ณต- ์ฌ์ฉ์ ํธ์๋ฅผ ์ํ ์ปค์คํ UI(์ ํฐ ๋ฒํผ, ์นด๋ฉ๋ผ ์ ํ ๋ฒํผ ๋ฑ) ์ ๊ณต
- CameraCoordinator๋ ์นด๋ฉ๋ผ ์ ์ด ๋ก์ง์ ๋ด๋น
class CameraCoordinator: NSObject, AVCapturePhotoCaptureDelegate {
func setupCameraSession() {
guard let frontCamera = getCameraDevice(position: .front) else { return }
let frontCameraInput = try AVCaptureDeviceInput(device: frontCamera)
captureSession.addInput(frontCameraInput)
setupPhotoOutput()
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
if let data = photo.fileDataRepresentation(), let image = UIImage(data: data) {
capturedImage = image // ์บก์ฒ๋ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅ
}
}
}
- AVCapturePhotoOutput์ ํตํด ์ฌ์ง์ ์ดฌ์ํ๊ณ , ์ ๋ฉด ์นด๋ฉ๋ผ์ ์ข์ฐ ๋ฐ์ ๋ ์ด๋ฏธ์ง๋ ์ ์ ํ ์์ ํ์ฌ ์ ์ฅ
- CameraPreview ํด๋์ค๋ ์ค์๊ฐ ์นด๋ฉ๋ผ ํ๋ฉด๊ณผ ์ฌ์ง ์บก์ฒ ํ ๊ฒฐ๊ณผ๋ฅผ ํ์
class CameraPreview: UIView {
let captureSession = AVCaptureSession()
let previewLayer = AVCaptureVideoPreviewLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupCamera()
}
private func setupCamera() {
// ์ ๋ฉด ์นด๋ฉ๋ผ๋ก ์ธ์
๊ตฌ์ฑ
let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
let input = try? AVCaptureDeviceInput(device: frontCamera!)
captureSession.addInput(input!)
captureSession.startRunning()
}
}
- ์ ๊ตํ ์นด๋ฉ๋ผ ์ ์ด ํ์
- SwiftUI๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์นด๋ฉ๋ผ ๊ธฐ๋ฅ์ ์ ๊ณตํ์ง ์์ต๋๋ค. ๋ฐ๋ฉด, UIKit์
AVFoundation
์ ์นด๋ฉ๋ผ ์ธ์ , ์ฌ์ง ์ดฌ์, ์ ํ ๋ฑ ๋ณต์กํ ๊ธฐ๋ฅ์ ์๋ฒฝํ ์ง์ํฉ๋๋ค.
- ์ฑ๋ฅ ์ต์ ํ ๋ฐ ์ ์ฐ์ฑ
- AVCaptureSession๊ณผ AVCapturePhotoOutput์ ํตํด ์ค์๊ฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ ์ฌ์ง ์บก์ฒ๋ฅผ ํจ์จ์ ์ผ๋ก ๊ตฌํ ๊ฐ๋ฅ
- ๋ณต์กํ ์ปค์คํ ๋ ์ด์์(์ ํฐ ๋ฒํผ, ์ ํ ๋ฒํผ ๋ฑ)์ ๊ตฌ์ฑํ๊ธฐ ์ํด UIKit์ UIView์ SnapKit์ ์ฌ์ฉ
- SwiftUI์์ ์๋ฒฝํ ํตํฉ
- UIViewControllerRepresentable์ ์ฌ์ฉํ์ฌ SwiftUI์ ์ฅ์ (๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ, ์ ์ธํ UI)๊ณผ UIKit์ ๊ฐ๋ ฅํ ๊ธฐ๋ฅ์ ๊ฒฐํฉ
Naptune ์ฑ์ ์น๊ตฌ๋ค์ ๋ฎ์ ๊ธฐ๋ก๊ณผ ์ํ๋ฅผ ํ๋์ ํ์ธํ ์ ์๋ ์์
ํผ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. ์ฌ์ฉ์๋ ์น๊ตฌ๋ค์ ๋ฎ์ ์๊ฐ, ์ํ, ๊ทธ๋ฆฌ๊ณ ๊ด๋ จ ์ฝ๋ฉํธ๋ฅผ ํ์ธํ๋ฉฐ ์๋ก์ ๊ฒฝํ์ ๊ณต์ ํ ์ ์์ต๋๋ค. ์ด ๊ธฐ๋ฅ์ Firebase Firestore์ Storage๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๊ด๋ฆฌํ๋ฉฐ, SwiftUI๋ก ์ฌ์ฉ์ ์นํ์ ์ธ UI๋ฅผ ๊ตฌํํ์ต๋๋ค.
- FeedViewModel์์ Firebase Firestore์ posts ์ปฌ๋ ์ ๋ฐ์ดํฐ๋ฅผ ์ค์๊ฐ์ผ๋ก ๊ฐ์ ธ์ SwiftUI ๋ทฐ์ ๋ฐ์
- ๋ฐ์ดํฐ๋ @Published ๋ณ์๋ฅผ ํตํด ์๋ ์ ๋ฐ์ดํธ๋์ด ์ฌ์ฉ์์๊ฒ ์ต์ ์ ๋ณด๋ฅผ ์ ๊ณต
struct Post: Codable {
let id: String
let nickname: String
let profileImageUrl: String
let imageUrl: String
let sleepComent: String
let sleepStatusLevel: Double
let sleepTime: Double
let date: Date
}
- FeedViewModel์ Firestore์์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ posts ๋ฐฐ์ด๋ก ๊ด๋ฆฌ
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
init() {
fetchPosts()
}
func fetchPosts() {
let db = Firestore.firestore()
db.collection("posts")
.order(by: "date", descending: true)
.getDocuments { snapshot, error in
if let error = error {
print("Error getting documents: \(error)")
return
}
self.posts = snapshot?.documents.compactMap { document -> Post? in
try? document.data(as: Post.self)
} ?? []
}
}
}
- FeedRegisterViewModel์ ์ฌ์ฉ์๊ฐ ๋ฑ๋กํ ๋ฐ์ดํฐ๋ฅผ Firebase์ ์ ๋ก๋ํ๋ ๋ก์ง์ ๊ด๋ฆฌ
class FeedRegisterViewModel {
var selectedImage: UIImage?
var sleepComent: String? = ""
var sleepStatusLevel: Double?
func uploadPost(capturedImage: UIImage?, sleepComent: String, sleepStatusLevel: Double, sleepTime: Double) async {
let nickname = UserDefaults.standard.string(forKey: "nickname") ?? "Unknown"
let profileImageUrl = UserDefaults.standard.string(forKey: "profileImageUrl") ?? ""
guard let capturedImage = capturedImage else { return }
guard let imageUrl = await uploadImage(uiImage: capturedImage) else { return }
// ํ์ฌ ๋ก๊ทธ์ธ๋ ์ฌ์ฉ์์ UID ๊ฐ์ ธ์ค๊ธฐ
guard let currentUserID = Auth.auth().currentUser?.uid else {
print("Error: No current user ID found.")
return
}
// Firestore์ ์ ์ฅํ Post ๊ฐ์ฒด ์์ฑ
let post = Post(id: UUID().uuidString, nickname: nickname, profileImageUrl: profileImageUrl, imageUrl: imageUrl, sleepComent: sleepComent, sleepStatusLevel: sleepStatusLevel, sleepTime: sleepTime, date: Date())
// Firestore์ ๋ฌธ์ ID๋ฅผ ์ฌ์ฉ์ UID๋ก ์ค์ ํ๊ณ ๋ฐ์ดํฐ ์ ์ฅ
let postReference = Firestore.firestore().collection("posts").document(UUID().uuidString)
do {
let encodedData = try Firestore.Encoder().encode(post)
try await postReference.setData(encodedData)
} catch {
print("DEBUG: Failed to upload post with error \(error.localizedDescription)")
}
}
func uploadImage(uiImage: UIImage) async -> String? {
guard let imageData = uiImage.jpegData(compressionQuality: 0.5) else { return nil }
let fileName = UUID().uuidString
let reference = Storage.storage().reference(withPath: "/images/\(fileName).jpg")
do {
let _ = try await reference.putDataAsync(imageData)
let url = try await reference.downloadURL()
return url.absoluteString
} catch {
print("DEBUG: Failed to upload image with error \(error.localizedDescription)")
return nil
}
}
}
6. ๋ค์ด๋๋ฏน ์์ผ๋๋์ ๋ผ์ด๋ธ ์กํฐ๋นํฐ๋ฅผ ํ์ฉํ์ฌ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ชจ๋์์๋ ๋จ์ ๋ฎ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ํ์ธํ๋ ๊ธฐ๋ฅ
Naptune ์ฑ์ Dynamic Island์ Live Activities๋ฅผ ํ์ฉํ์ฌ ์ฑ์ด ๋ฐฑ๊ทธ๋ผ์ด๋ ์ํ์์๋ ๋จ์ ๋ฎ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ํ์ธํ ์ ์๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. ์ฌ์ฉ์๋ ์ ์๋ ๋์ iPhone ํ๋ฉด์ ๋ณผ ํ์ ์์ด Dynamic Island๋ ์ ๊ธ ํ๋ฉด์์ ๋ฐ๋ก ๋จ์ ์๊ฐ์ ํ์ธํ ์ ์์ต๋๋ค.
6-1. ActivityKit์ Activity ๊ฐ์ฒด๋ฅผ ์ฌ์ฉํด ๋จ์ ๋ฎ์ ์๊ฐ์ ์ค์๊ฐ์ผ๋ก ์ ๋ฐ์ดํธํฉ๋๋ค.
- NapStatusAttributes์ ContentState๋ฅผ ํตํด ์ํ ๋ฐ์ดํฐ๋ฅผ ๊ด๋ฆฌ
struct NapStatusAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var remainingTime: Int = 0
}
}
- NapStatusLiveActivity๋ Dynamic Island์ ์ ๊ธ ํ๋ฉด์์ ํ์๋ UI๋ฅผ ์ ์
struct NapStatusLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: NapStatusAttributes.self) { context in
ZStack {
RoundedRectangle(cornerRadius: 15)
.fill(LinearGradient(
gradient: Gradient(colors: [Color("์๋จ๋ค์ด๋น"), Color("ํ๋จ๋ค์ด๋น")]),
startPoint: .top,
endPoint: .bottom
))
.opacity(0.5)
VStack {
Text("๋จ์ ๋ฎ์ ์๊ฐ")
.font(.headline)
.foregroundColor(.white)
Text("\(context.state.remainingTime / 3600)์๊ฐ \((context.state.remainingTime % 3600) / 60)๋ถ \((context.state.remainingTime % 3600) % 60)์ด")
.font(.largeTitle)
.foregroundColor(.white)
}
}
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.center) {
Text("Nap - \(context.state.remainingTime / 60)๋ถ ๋จ์")
.font(.headline)
.foregroundColor(.white)
}
DynamicIslandExpandedRegion(.bottom) {
Text("์๊ฐ์ด ๋ค ๋๋ฉด ์๋์ด ์ธ๋ฆฝ๋๋ค.")
.font(.footnote)
.foregroundColor(.gray)
}
} compactLeading: {
Image(systemName: "bolt.fill")
} compactTrailing: {
Text("\(context.state.remainingTime / 60)๋ถ")
} minimal: {
Image(systemName: "bolt.fill")
}
}
}
}
- Activity.update(using:)๋ฅผ ์ฌ์ฉํ์ฌ ๋ผ์ด๋ธ ์ํ๋ฅผ ์ค์๊ฐ์ผ๋ก ๋ณ๊ฒฝ
func updateLiveActivity(remainingTime: Int) {
guard let activity = Activity<NapStatusAttributes>.activities.first else { return }
let updatedContentState = NapStatusAttributes.ContentState(remainingTime: remainingTime)
Task {
await activity.update(using: updatedContentState)
}
}
ยฉ 2024 ์ด์นํ (SeungHyeon Lee). All rights reserved.
-
๋ณธ GitHub ๋ฆฌ๋๋ฏธ๋ ํ ํ๋ก์ ํธ ๋ตํ (Naptune)์ ์๊ฐํ๊ธฐ ์ํด ์์ฑ๋์์ผ๋ฉฐ, ๋ฆฌ๋๋ฏธ์ ๋ชจ๋ ๋ด์ฉ์ ์ด์นํ(SeungHyeon Lee)์ด ์ง์ ์์ฑํ์์ต๋๋ค.
-
์ด ํ๋ก์ ํธ๋ iOS Developer 2๋ช , Back-end 1๋ช , Design 2๋ช ์ผ๋ก ๊ตฌ์ฑ๋ ํ์ด ํ๋ ฅํ์ฌ ์งํํ ๊ฒฐ๊ณผ๋ฌผ์ ๋๋ค. ๊ทธ๋ฌ๋ ๋ฆฌ๋๋ฏธ์ ํฌํจ๋ ๋ชจ๋ ํ ์คํธ, ์ด๋ฏธ์ง ๋ฐฐ์น, ์ค๋ช , ๊ธฐ์ ์คํ ์๊ฐ ๋ฑ์ ์ ์ ์์ ์์ ๋ฐํ๋๋ค.
-
๋ฆฌ๋๋ฏธ์ ๊ด๋ จํ์ฌ ๋ฌธ์๊ฐ ํ์ํ์ ๊ฒฝ์ฐ ์๋ ์ด๋ฉ์ผ๋ก ์ฐ๋ฝํด ์ฃผ์ธ์: [email protected]