post Image
【Swift 4.2】 アラーム時計の作り方

はじめに

こんにちは!Life is Tech ! iPhoneメンターのKentyです。

Life is Tech ! メンターによるAdvent Calendarのトップバッターを務めさせていただきます!しかも、今年からは公式開催!今日から25日まで他分野で活躍するメンターの記事が読めると思うと楽しみです!記念すべき初日は、Swift でアラーム時計の作り方を伝授していきます。簡単につくれるような感じはしますがちょっとした落とし穴があるので解決方法、乗り越え方を丁寧にこの記事では解説していきます。

今回製作するアプリではユーザーがUIDatePickerを使用して起こしてほしい時間を指定することでアラーム(目覚まし)をセットできるようにします。アラームまでの時間は現在の時刻を表示させます。至ってシンプルなアラーム時計です。また今回はMVCアーキテクチャに基づいて実装していきます。

(アプリの動作確認は必ず実機で行ってください)

Swiftファイル

  • Alarm.swift
  • CurrentTime.swift
  • SetViewController.swift
  • SleepingViewController.swift

画面構成

Screen Shot 2018-11-27 at 14.30.42.png

関連付け

SetViewController.swiftでは時間を設定するUIDatePickerとアラーム時計をスタートするためのUIButtonの関連付けを行います。viewDidLoad()PickerMode.timeにすることによって時間のみ選択可能にすることができます。また.setDateで現在の時間を表示させます。

SetViewController.swift

//インスタンスを生成
let alarm = Alarm()

@IBOutlet var sleepTimePicker: UIDatePicker!

override func viewDidLoad() {
    super.viewDidLoad()
    //UIDatePickerを.timeモードにする
    sleepTimePicker.datePickerMode = UIDatePicker.Mode.time
    //現在の時間をDatePickerに表示
    sleepTimePicker.setDate(Date(), animated: false)
}

@IBAction func alarmBtnWasPressed(_ sender: UIButton) {

}

SleepingViewController.swift では現在の時間が表示されるUILabelSetViewControllerに戻るためのUIButtonの関連付けを行います。dismissで戻るようにします。

SleepingViewController.swift

class SleepingViewController: UIViewController {
    //インスタンスを生成
    var currentTime = CurrentTime()

    @IBOutlet var timeLabel: UILabel!

    override func viewDidLoad() {
    }

    @IBAction func closeBtnWasPressed(_ sender: UIButton) {
        //前のViewControllerに戻る
        dismiss(animated: true, completion: nil)
    }
}

現在の時間の取得と表示   

現在の時間を取得してDataFormatterでの文字列化を行います。イニシャライザでは.scheduledTimerでタイマーを作成し、1秒間ごとにupdateCurrentTime()を呼ばせるようにする。updateCurrentTime()では .dateFormatで文字列化の形式を指定、.timeZoneでデバイスに設定されている時間帯を適応しています。日付の取得はDate()を用いる。

CurrentTime.swift

class CurrentTime{

    var timer: Timer?
    var currentTime: String?
    var df = DateFormatter()
    weak var delegate: SleepingViewController?

    init() {
        if timer == nil{
            //タイマーをセット、一秒ごとにupdateCurrentTimeを呼ぶ
            timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateCurrentTime), userInfo: nil, repeats: true)
        }
    }

    @objc private func updateCurrentTime(){
        //フォーマットの指定
        df.dateFormat = "HH:mm"
        //時刻をUNIXから端末のタイムゾーンにする
        df.timeZone = TimeZone.current
        //現在の時間をフォーマットに従って文字列化を行う
        let timezoneDate = df.string(from: Date())
        currentTime = timezoneDate
        delegate?.updateTime(currentTime!)
    }
}

取得した日付を表示するためにSleepingViewController.swiftに以下のメソッドを書きます。

SleepingViewController.swift

func updateTime(_ time:String) {
    timeLabel.text = time
}

アラーム機能 / タイマー

値の引き渡しと画面移動

まずユーザーがUIDatePickerを使用して指定した時間を取得し、selectedWakeUpTimeに代入。またrunTimer()を呼びperformSegueSleepingViewControllerへの画面移動も行います。

SetViewController.swift

@IBAction func alarmBtnWasPressed(_ sender: UIButton) {
    //AlarmにあるselectedWakeUpTimeにユーザーの入力した日付を代入
    alarm.selectedWakeUpTime = sleepTimePicker.date
    //AlarmのrunTimerを呼ぶ
    alarm.runTimer()
    //SleepingViewControllerへの画面移動
    performSegue(withIdentifier: "setToSleeping", sender: nil)
}

アラーム機能

アラーム機能の処理をAlarmで行います。ユーザーが設定した時刻までタイマーでカウントダウンを行うようにします。calculateInterval()でカウントダウンに必要な秒数を計算したあとsecondsに代入します。updateTimer()が一秒に一回呼ばれることによってsecondsを減らしていきます。secondsはユーザーが設定した時刻 – 現在の時間 = 秒数 になります。これを簡単に行えるのが.timeIntervalSinceNow

落とし穴 ① 翌日の時間を設定できない問題

このままでは翌日の時刻を選択したときアラームで使える正しい値が返ってきません。なぜなら日をまたいだ時刻を入れると過去の時間として認識されるためです。実際に.timeIntervalSinceNow のドキュメンテーションを読むと “If the date object is earlier than the current date and time, this property’s value is negative.”と書いてあります。例えば現在の時刻が22:00でユーザーが入力した時刻が翌朝07:00の場合.timeIntervalSinceNowを使用すると07:00は過去の時間として認識されるため-54,000(秒)と返ってきます。アラームのタイマーで使うには正しい9時間分の秒数32,000(秒)が必要になります。解決策は簡単です。もしintervalがマイナス値の場合 一日秒数 – .timeIntervalSinceNow で計算した値をプラス値にして引き算をすれば可能になります。

落とし穴 ② intervalの計算にズレがある

ここで実行してみるとintervalの計算にズレがあることに気づきます。現在の時間が22:00:00でユーザーが入力した時間が22:01の場合はintervalが60秒になるはずなのに対してintervalは52秒などズレが生じます。これはSetViewController.swiftで行っているsleepTimePicker.setDate(Date(), animated: false)が影響しています。.setDate()をしているSetViewControllerが読み込まれた日付(例: 2018/12/01 22:00:30)がUIDatePickerに渡されています。しかしPickerMode.timeにしているため時刻しかユーザー側では変えられないためアラームをセットした時上記の例の場合は 2018/12/01 ユーザーが設定した時刻:ユーザーが設定した時刻:30がselectedWakeUpTimeに代入されています。そのため30秒のズレが生じます。

例: SetViewControllerが読み込まれた時刻が2018/12/01 22:00:30、ユーザーが設定した時刻が30秒後の2018/12/01 22:01:30、.timeIntervalSinceNow が実行された時の時刻が2018/12/01 22:00:40、だった場合20秒ではなく2018/12/01 22:00:40 – 2018/12/01 22:01:30 = 50(秒)になります。

この問題も解決策は簡単でselectedWakeUpTimeの秒数を全体に引き算すればズレをなくすことができます。上記の例だと2018/12/01 22:00:40 – 2018/12/01 22:01:30 = 50(秒) – 30(秒) = 20(秒)になります。

Alarm.swift

class Alarm{
    var selectedWakeUpTime:Date?
    var audioPlayer: AVAudioPlayer!
    var sleepTimer: Timer?
    var seconds = 0

    //アラーム/タイマーを開始
    func runTimer(){
        //calculateIntervalにユーザーが入力した日付を渡す、返り値をsecondsに代入
        seconds = calculateInterval(userAwakeTime: selectedWakeUpTime!)

        if sleepTimer == nil{
             //タイマーをセット、一秒ごとにupdateCurrentTimeを呼ぶ
            sleepTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
        }
    }

    //一秒ごとにsleepTimerに呼ばれる    
    @objc private func updateTimer(){
        if seconds != 0{
            //secondsから-1する
            seconds -= 1
        }else{
            //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
         //TODO:音を鳴らす
        }
    }

    //起きる時間までの秒数を計算
    private func calculateInterval(userAwakeTime:Date)-> Int{
        //タイマーの時間を計算する
        var interval = Int(userAwakeTime.timeIntervalSinceNow)

        if interval < 0{
            //落とし穴 ②の解決策
            interval = 86400 - (0 - interval)
        }
        //落とし穴 ③の解決策
        let calendar =  Calendar.current
        let seconds = calendar.component(.second, from: userAwakeTime)
        return interval - seconds
    }
}

音を鳴らす

音を鳴らす処理をelseの中に書いていきます。
SetViewController.swift

          //secondsが0じゃない場合
         if seconds != 0{
            //secondsから-1する
            seconds -= 1
        }else{
            //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
            //音源のパス
            let soundFilePath = Bundle.main.path(forResource: "", ofType: "")!
            //パスのURL
            let sound:URL = URL(fileURLWithPath: soundFilePath)
            do {
                //AVAudioPlayerを作成
                audioPlayer = try AVAudioPlayer(contentsOf: sound, fileTypeHint:nil)
            } catch {
                print("Could not load file")
            }
            //再生
            audioPlayer.play()
        }
    }

アラームを止める

アラームを止める処理もAlarmに書いていきます。
Alarm.swift

    func stopTimer(){
         //sleepTimerがnilじゃない場合
        if sleepTimer != nil {
           //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
        }else{
            //タイマーを止める
            audioPlayer.stop()
        }
    }

SetViewController.swiftのviewDidAppearで呼びます。
SetViewController.swift

    override func viewDidAppear(_ animated: Bool) {
         //AlarmでsleepTimerがnilじゃない場合
        if alarm.sleepTimer != nil{
            //再生されているタイマーを止める
            alarm.stopTimer()
        }
    }

最大の落とし穴

落とし穴 ④ タイマーが止まる問題

ここまでのコードでアラーム時計は作動します。しかし!デバイスをロックしたりホーム画面に戻った場合アラームが作動しないことが判明します。この問題でこの記事にたどり着いた方も多いのではないでしょうか?原因はBackgroundにアプリが移されるとタイマーが止まってしまうからです。iOSにおいてのForeground vs Backgroundの説明はこちらをご覧ください。
解決方法は多数存在します。ここからはBuilding an Alarm app on iOS – AMBlogに基づいて解説していきます。

UIBackgroundTaskIdentifier method

UIBackgroundTaskIdentifierを使用してバックグラウンドでtimerが作動させる方法があります。しかし、UIBackgroundTaskIdentifierは10分間の猶予しか与えてくれないので10分以内のタイマーなら作動しますがアラーム時計には残念ながら向かないです。
メリット: タイマーをバックグラウンドで作動できる
デメリット: 10分後に停止される

Microphone method

サイレントな音を録音してアプリをバックグラウンドでも処理を続かせるようにする方法があります。この方法はユーザーのマイクロフォン許可がなくても可能ですがホーム画面のステータスバーが録音中の赤いバーになるため「盗聴されている」というユーザーの不安を煽ることになるためいい方法ではありません。
メリット: 音を鳴らせる
デメリット: 赤いバーの存在でUX的に良くない

Push notifications method

Local Notification を時間にセットすればノーティフィケーションを利用して音を鳴らすことができます。しかし、”Sound files must be less than 30 seconds in length. If the sound file is longer than 30 seconds, the system plays the default sound instead.”とUNNotificationSoundに書いてあるように30秒間しか流せません。そのためこの方法はあまり適していないません。
メリット:音を鳴らせる
デメリット:30秒しか鳴らせない

Never sleep method

今回はこの方法で解決します。AppleがiOS 4 を発表した時、マルチタスク機能を搭載しました。それ以前はアプリを閉じてしまうと寸座にkillされる仕様でした。デベロッパーがこれにスムーズに対応できるようアップルはマルチタスクからオプトアウトできるUIApplicationExitsOnSuspendを発表しました。ここでアラーム時計を作るのになぜオプトアウトしないといけないのか疑問になった方いたと思います。iOS 4より以前はアプリを起動した時に端末をロックした場合でも処理が続きます。しかしマルチタスク機能が登場してからこの仕様が変更され、アプリを起動してロックした場合アプリがsuspend状態になります。UIApplicationExitsOnSuspendをYESにさせればiOS4以前の仕様が適応され処理をロック状態でもできることになります。そのかわりホーム画面に戻るとアプリがバックグラウンドに移らずkillされます。
メリット:音を鳴らせない
デメリット:ホームに戻るとアプリが即座にkillされる

Never Sleep Method を使用して解決します。info.plist で Application does not run in backgroundYESにすればUIApplicationExitsOnSuspendtrueになります。後はアラームをセットするときにユーザーにホームに戻らないように通知するだけです。今回の記事では割愛しますがもしデメリットである「ホームに戻るとkill」が気になる場合はkillされる前に必要なデータなどをUserDefaultsに保存してアプリ起動時に読み込むようにすれば問題ないと思います。また引用元のアンドリュー氏によれば上記の方法を組み合わせたりことによって安定性が向上すると示唆している。

落とし穴 ⑤ それでも音がならない問題

このままではタイマーは作動しますが音が鳴りません。AVAudioSessionはSingletonであるためバックグラウンドで再生する場合、.sharedInstance()を使います。

Alarm.swift

    do {
               //AVAudioPlayerを作成
                audioPlayer = try AVAudioPlayer(contentsOf: sound, fileTypeHint:nil)
                // バックグラウンドでもオーディオ再生可能にする
                try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
                try AVAudioSession.sharedInstance().setActive(true)

         } catch {
                print("Could not load file")
            }
             //再生
            audioPlayer.play()
        }

まとめ

Screen Shot 2018-11-27 at 14.30.42.png
Swiftでアラーム時計を製作しました。一見簡単そうではありますが、アラーム時計を作るのは意外と難しいことがわかったと思います。ぜひ参考にしてみてください。
Swiftファイル


『 Swift 』Article List