はじめに
こんにちは!Life is Tech ! iPhoneメンターのKentyです。
Life is Tech ! メンターによるAdvent Calendarのトップバッターを務めさせていただきます!しかも、今年からは公式開催!今日から25日まで他分野で活躍するメンターの記事が読めると思うと楽しみです!記念すべき初日は、Swift でアラーム時計の作り方を伝授していきます。簡単につくれるような感じはしますがちょっとした落とし穴があるので解決方法、乗り越え方を丁寧にこの記事では解説していきます。
今回製作するアプリではユーザーがUIDatePicker
を使用して起こしてほしい時間を指定することでアラーム(目覚まし)をセットできるようにします。アラームまでの時間は現在の時刻を表示させます。至ってシンプルなアラーム時計です。また今回はMVCアーキテクチャに基づいて実装していきます。
(アプリの動作確認は必ず実機で行ってください)
Swiftファイル
- Alarm.swift
- CurrentTime.swift
- SetViewController.swift
- SleepingViewController.swift
画面構成
関連付け
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
では現在の時間が表示されるUILabel
とSetViewController
に戻るための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()
を呼びperformSegueでSleepingViewController
への画面移動も行います。
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
の中に書いていきます。
Alarm.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 background
をYES
にすればUIApplicationExitsOnSuspend
がtrue
になります。後はアラームをセットするときにユーザーにホームに戻らないように通知するだけです。今回の記事では割愛しますがもしデメリットである「ホームに戻ると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()
}