ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI에서 타이머 구현하기 (feat. User Notification)
    SwiftUI 2023. 8. 22. 08:56

    안녕하세요. 그린입니다 🍏

    이번에서 SwiftUI에서 타이머를 구현하면서 시간이 다되면 Local Notification까지 받아보는 기능을 구현해볼께요 🙋🏻

     

    우선 모델부터 설계해볼께요!

     


    Time Model 구현

    설정한 타이머 시간을 시 / 분 / 초로 나눠서 각 타이머가 돌 수 있도록 하면 좋을것 같네요.

    그러기 위해선 각 프로퍼티들이 필요하고 시 / 분 / 초로 나눈 시간을 변환하여 초로 만들어주는 프로퍼티들도 필요할것 같아요.

     

    struct Time {
      var hours: Int
      var minutes: Int
      var seconds: Int
      
      var convertedSeconds: Int {
        return (hours * 3600) + (minutes * 60) + seconds
      }
      
      static func fromSeconds(_ seconds: Int) -> Time {
        let hours = seconds / 3600
        let minutes = (seconds % 3600) / 60
        let remainingSeconds = (seconds % 3600) % 60
        return Time(hours: hours, minutes: minutes, seconds: remainingSeconds)
      }
    }
    
    extension Int {
      var formattedTimeString: String {
        let time = Time.fromSeconds(self)
        let hoursString = String(format: "%02d", time.hours)
        let minutesString = String(format: "%02d", time.minutes)
        let secondsString = String(format: "%02d", time.seconds)
        
        return "\(hoursString) : \(minutesString) : \(secondsString)"
      }
      
      var formattedSettingTime: String {
        let currentDate = Date()
        let settingDate = currentDate.addingTimeInterval(TimeInterval(self))
        
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ko_KR")
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul")
        dateFormatter.dateFormat = "HH:mm"
        
        let formattedTime = dateFormatter.string(from: settingDate)
        return formattedTime
      }
    }

     

    앞서 말했듯 시 / 분 / 초의 프로퍼티를 가집니다.

     

    그리고 실제 타이머 기능을 위해 해당 시 / 분 / 초를 변환하고 합해 하나의 변환된 초 시간을 가집니다.

     

    그리고 추후 뷰에 남은 시 / 분 / 초를 보여주기 위해 fromSeconds 메서드를 통해 남은 초를 받아와 각 시 / 분 / 초 값을 갖는 Time 인스턴스를 반환합니다.

     

    마지막으로, Int 타입을 확장해 남은 시간을 보여주는 String으로 표현해줍니다.

    formattedTimeString은 추후 나오겠지만 남은 타이머 시간을 보여줍니다.

    formattedSettingTime은 설정된 타이머가 울리는 시간을 표현해줘요.

     

    자 이렇게 모델 타입은 간단하게 구성할 수 있어요!

     

    그럼 이어서 우리는 설정된 타이머 시간이 다되면 Local Notification을 보내 사용자에게 알려주기로 했습니다.

     

    그걸 위해 Service를 구현해볼께요 🙋🏻



    Notification Service 구현하기

    먼저 바로 이전 포스팅에서 Notification을 좀 더 자세히 다룬 포스팅이 있습니다.

    여기서는 간단히 구현을 확인하며 넘어가니, 좀 더 이해가 필요하다면 참고하셔도 좋습니다!

     

    SwiftUI에서 Notification 사용하기

    안녕하세요. 그린입니다 🍏 이번 포스팅에서는 SwiftUI에서 Notification을 받아 처리하는 방법에 대해 학습해보겠습니다 🙋🏻 흔히, 다른 뷰에서 이벤트가 발생하면 전역적으로 NotificationCenter를

    green1229.tistory.com

     

    자 그럼 구현 코드를 먼저볼께요!

     

    import UserNotifications
    
    struct NotificationService {
      func sendNotification() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
          if granted {
            let content = UNMutableNotificationContent()
            content.title = "타이머 종료!"
            content.body = "설정한 타이머가 종료되었습니다."
            content.sound = UNNotificationSound.default
            
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
            let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
            
            UNUserNotificationCenter.current().add(request)
          }
        }
      }
    }
    
    class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
      func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
      ) {
        completionHandler([.banner, .sound])
      }
    }

     

    노티피케이션은 소리와 얼럿으로 알려줄거에요.

    granted 즉, 알림을 허용해둔 승인 상태라면 작동합니다.

    타이틀과 본문 그리고 소리를 각자 맞게 설정해줍니다.

     

    그리고 트리거를 두어 1초 후 바로 울리도록 설정했어요.

    해당 timerInterval은 0보다 커야 합니다.

    0의 값을 주입해주면 런타임 에러가 나요.

     

    그리고 UNUserNotificationCenterDelegate를 채택하는 NotificationDelegate를 만듭니다.

    해당 역할은 앱이 실행 중일때 알림이 도착하면 채택하여 구현한 메서드가 호출되고 사용자에게 배너와 소리로 알림을 표시하도록 결정합니다.

     

    여기서 하나 더 설정이 필요해요!

     

    import UIKit
    
    class AppDelegate: NSObject, UIApplicationDelegate {
      var notifDelegate = NotificationDelegate()
      
      func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
      ) -> Bool {
        UNUserNotificationCenter.current().delegate = notifDelegate
        return true
      }
    }

     

    AppDelegate를 구현하여 실제 앱이 런칭되었을때 만들어준 딜리게이트를 사용하도록 넣어줍니다.

     

    그리고 App 파일을 가볼까요?

    import SwiftUI
    
    @main
    struct TimerApp: App {
      @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
      
      var body: some Scene {
        WindowGroup {
          TimerView()
        }
      }
    }

     

    여기서 SwiftUI 앱에서 UIApplicationDelegate 프로토콜을 준수하는 AppDelegate 클래스를 연결하고 구성하는데 사용하는 어트리뷰트인 UIApplicationDelegateAdaptor를 선언해줍니다.

    즉, SwiftUI 앱에서 UIKit의 UIApplicationDelegate와 상호작용할 수 있는것이죠.

     

    필요한 이유는 App 구조체가 때론 UIKit의 기능이나 더 로우 레벨의 iOS 시스템 이벤트를 처리해야할 경우가 있어요.

    지금 노티피케이션 딜리게이트가 그런 경우이죠!

    이렇게 연결이 되었으니 이제 SwiftUI 앱이 실행되면 이 어트리뷰트를 사용해 설정한 AppDelegate 객체가 생성되고 앱 라이프 사이클과 시스템 이벤트와 같은 UIKit 관련 기능들도 처리할 수 있게 됩니다.

     

    이렇게 하면 노티피케이션을 받기 위한 서비스 구현이 완성됩니다!

     

    이제 그럼 뷰를 만들기전 뷰모델을 구성해볼께요 😁


    ViewModel 구현하기

    import Foundation
    
    class TimerViewModel: ObservableObject {
      @Published var isDisplaySetTimeView: Bool
      @Published var time: Time
      @Published var timer: Timer?
      @Published var timeRemaining: Int
      @Published var isPaused: Bool
      var notificationService: NotificationService
      
      init(
        isDisplaySetTimeView: Bool = true,
        time: Time = .init(hours: 0, minutes: 0, seconds: 0),
        timer: Timer? = nil,
        timeRemaining: Int = 0,
        isPaused: Bool = false,
        notificationService: NotificationService = .init()
      ) {
        self.isDisplaySetTimeView = isDisplaySetTimeView
        self.time = time
        self.timer = timer
        self.timeRemaining = timeRemaining
        self.isPaused = isPaused
        self.notificationService = notificationService
      }
    }
    
    extension TimerViewModel {
      func settingBtnTapped() {
        isDisplaySetTimeView = false
        timeRemaining = time.convertedSeconds
        startTimer()
      }
      
      func cancelBtnTapped() {
        stopTimer()
        isDisplaySetTimeView = true
      }
      
      func pauseOrRestartBtnTapped() {
        if isPaused {
          startTimer()
        } else {
          timer?.invalidate()
          timer = nil
        }
        isPaused.toggle()
      }
    }
    
    private extension TimerViewModel {
      func startTimer() {
        guard timer == nil else { return }
        
        timer = Timer.scheduledTimer(
          withTimeInterval: 1,
          repeats: true
        ) { _ in
          if self.timeRemaining > 0 {
            self.timeRemaining -= 1
          } else {
            self.stopTimer()
            self.notificationService.sendNotification()
          }
        }
      }
      
      func stopTimer() {
        timer?.invalidate()
        timer = nil
      }
    }

     

    구현은 간단하니 하나씩 보겠습니다.

     

    isDisplaySetTimeView는 타이머를 설정하는 뷰의 노출을 의미합니다.

    기본적으로 처음 해당 화면을 진입하면 해당 뷰가 노출되어 타이머를 설정하고 설정이 되면 실제 타이머가 작동하여 시간이 흐르는 타이머 뷰가 나타날거에요.

     

    그리고, 타이머 기능을 위해 만들어둔 Time 모델 타입과 Timer그리고 남은 시간 등을 계산하고 표현하기 위한 프로퍼티들을 만듭니다.

    빼먹지 말아야할 노티피케이션 서비스도 주입받아야 하구요!

     


    이렇게 프로퍼티들의 설정을 해줬으면 기능을 두 분류로 나눠 구현할 수 있어요.

     

    하나는 유저 인터랙션에 의해 들어오는 이벤트들을 모아 확장시켜줍니다.

    총 3가지가 있는데요.

     

    타이머 시작 버튼이 눌렸을때 남은 시간을 변환해 표기해주고 타이머를 시작합니다.

    취소 버튼을 누르면 타이머를 멈추고 다시 타이머 설정 화면을 보여줍니다.

    일시정지 및 재개 버튼을 누르면 그에 따라 타이머를 일시정지 하거나 다시 시작해줍니다.

     

    두번째로 private한 기능으로 타이머가 실제 시작되거나 종료되는 기능을 담습니다.

     

    타이머 시작 메서드는 Timer를 1초 간격으로 계속 반복하여 남은 타이머 시간이 있으면 1씩 줄이고, 끝나면 타이머를 정지하고 노티피케이션을 구현하도록 설정합니다.

     

    타이머 종료 메서드는 타이머를 무효화 시키고 만들어준 timer를 nil로 초기화 시킵니다.

     

    이렇게 나름 간단하게 기능들을 ViewModel로 정의할 수 있어요!

     

    마지막으로 View 구현을 볼까요?


    View 구현하기

    import SwiftUI
    
    struct TimerView: View {
      @StateObject var timerViewModel = TimerViewModel()
      
      var body: some View {
        if timerViewModel.isDisplaySetTimeView {
          SetTimerView(timerViewModel: timerViewModel)
        } else {
          TimerOperationView(timerViewModel: timerViewModel)
        }
      }
    }
    
    // MARK: - 타이머 설정 뷰
    private struct SetTimerView: View {
      @ObservedObject private var timerViewModel: TimerViewModel
      
      fileprivate init(timerViewModel: TimerViewModel) {
        self.timerViewModel = timerViewModel
      }
      
      fileprivate var body: some View {
        VStack {
          TitleView()
          
          Spacer()
            .frame(height: 50)
          
          TimePickerView(timerViewModel: timerViewModel)
          
          Spacer()
            .frame(height: 30)
          
          TimerCreateBtnView(timerViewModel: timerViewModel)
          
          Spacer()
        }
      }
    }
    
    // MARK: - 타이틀 뷰
    private struct TitleView: View {
      fileprivate var body: some View {
        HStack {
          Text("타이머")
            .font(.system(size: 30, weight: .bold))
            .foregroundColor(.black)
          
          Spacer()
        }
        .padding(.horizontal, 20)
        .padding(.top, 30)
      }
    }
    
    // MARK: - 타이머 설정 뷰
    private struct TimePickerView: View {
      @ObservedObject private var timerViewModel: TimerViewModel
      
      fileprivate init(timerViewModel: TimerViewModel) {
        self.timerViewModel = timerViewModel
      }
      
      fileprivate var body: some View {
        VStack {
          Rectangle()
            .fill(Color(red: 0.85, green: 0.85, blue: 0.85))
            .frame(height: 1)
          
          HStack {
            Picker("Hour", selection: $timerViewModel.time.hours) {
              ForEach(0..<24) { hour in
                Text("\(hour)시")
              }
            }
            
            Picker("Minute", selection: $timerViewModel.time.minutes) {
              ForEach(0..<60) { minute in
                Text("\(minute)분")
              }
            }
            
            Picker("Second", selection: $timerViewModel.time.seconds) {
              ForEach(0..<60) { second in
                Text("\(second)초")
              }
            }
          }
          .labelsHidden()
          .pickerStyle(.wheel)
          
          Rectangle()
            .fill(Color(red: 0.85, green: 0.85, blue: 0.85))
            .frame(height: 1)
        }
      }
    }
    
    // MARK: - 타이머 생성 버튼 뷰
    private struct TimerCreateBtnView: View {
      @ObservedObject private var timerViewModel: TimerViewModel
      
      fileprivate init(timerViewModel: TimerViewModel) {
        self.timerViewModel = timerViewModel
      }
      
      fileprivate var body: some View {
        Button(
          action: {
            timerViewModel.settingBtnTapped()
          },
          label: {
            Text("설정하기")
              .font(.system(size: 18, weight: .bold))
              .foregroundColor(Color(red: 0.13, green: 0.81, blue: 0.47))
          }
        )
      }
    }
    
    // MARK: - 타이머 작동 뷰
    private struct TimerOperationView: View {
      @ObservedObject private var timerViewModel: TimerViewModel
      
      fileprivate init(timerViewModel: TimerViewModel) {
        self.timerViewModel = timerViewModel
      }
      
      fileprivate var body: some View {
        VStack {
          ZStack {
            VStack {
              Text("\(timerViewModel.timeRemaining.formattedTimeString)")
                .font(.system(size: 28))
                .foregroundColor(.black)
                .monospaced()
              
              HStack(alignment: .bottom) {
                Image(systemName: "bell.fill")
                
                Text("\(timerViewModel.time.convertedSeconds.formattedSettingTime)")
                  .font(.system(size: 16))
                  .foregroundColor(.black)
                  .padding(.top, 10)
              }
            }
            
            Circle()
              .stroke(Color(red: 1, green: 0.75, blue: 0.52), lineWidth: 6)
              .frame(width: 350)
          }
          
          Spacer()
            .frame(height: 10)
          
          HStack {
            Button(
              action: {
                timerViewModel.cancelBtnTapped()
              },
              label: {
                Text("취소")
                  .font(.system(size: 14))
                  .foregroundColor(.black)
                  .padding(.vertical, 25)
                  .padding(.horizontal, 22)
                  .background(
                    Circle()
                      .fill(Color(red: 0.85, green: 0.85, blue: 0.85).opacity(0.3))
                  )
              }
            )
            
            Spacer()
            
            Button(
              action: {
                timerViewModel.pauseOrRestartBtnTapped()
              },
              label: {
                Text(timerViewModel.isPaused ? "계속진행" : "일시정지")
                  .font(.system(size: 14))
                  .foregroundColor(.black)
                  .padding(.vertical, 25)
                  .padding(.horizontal, 7)
                  .background(
                    Circle()
                      .fill(Color(red: 1, green: 0.75, blue: 0.52).opacity(0.3))
                  )
              }
            )
          }
          .padding(.horizontal, 20)
        }
      }
    }

     

    복잡해보이지만 뷰를 나눠서 많아보일뿐, 어려운것은 없습니다.

     

    최상단에서는 isDisplaySetTimerView라는 VM의 타이머 설정 화면 노출 프로퍼티를 통해 두 부분으로 나눠 뷰를 구성합니다.

     

    타이머를 설정하는 SetTimerView와 타이머가 작동되고 있는 TimeOperationView

     

    먼저 SetTimerView부터 볼까요?


    SetTimerView

    내부에 타이틀이 들어가구요.

    그리고 핵심인 Picker를 이용해 설정한 타이머의 시 / 분 / 초를 받는 뷰를 구현합니다.

    여기서 생성된 시 / 분 / 초는 Time 모델 타입의 시 / 분 / 초의 값으로 넣어줍니다.

     

    그리고, 타이머 생성을 위한 뷰를 두어 버튼이 눌리면 타이머 설정을 하고 시작합니다.

     

    다음으로 타이머가 작동하고 있을때의 뷰를 보시죠!


    TimeOperationView

    핵심적인 부분만 보면, 남은 시간은 formattedTimeString으로 01:12:34 처럼 표시되도록 보여줍니다.

    그리고 타이머가 종료되는 시간을 formattedSettingTime으로 변환하여 표현해주죠.

     

    그리고 하단에 취소와 계속진행/일시정지 버튼을 두어 타이머의 동작을 관리해주도록 뷰를 구성해줍니다.

     

    뷰는 정말 간단한죠!?

     

    그럼 동작을 한번 볼까요?



    타이머 작동시키기 🕡

     

    잘되는것같죠!?

    노티도 잘오구요~

     


    스유에서 간단히 타이머를 구현해봤는데요.

    필요하다면 해당 예제 코드들은 제 깃헙에 있으니 편하게 보셔도 됩니다!

    https://github.com/GREENOVER/playground/tree/main/Timer

Designed by Tistory.