メリークリスマス!
我が家には無事にサンタさんがプレゼントを運んでくれました。お忙しい中、ありがとうございました。
我が家の子どもたちはサンタが家に来るのをとても楽しみにしていた為、サンタの現在位置を可視化しサンタが近づいている感を体感させることで更に気持ちを盛り上げたいと思い、サンタが近づいている感を体感できるアプリを作ってみることにしました。
はじめに
実際に作り始めたのがクリスマスイブの早朝で制作時間が限られていた為、細かいことを考えず、子どもたちが楽しめたらそれでいいという基準で作成しました。
作ったもの
サンタさんが目的地に向かって進んでいるように見えるアプリです。アプリ起動中はジングルベルが流れて、クリスマス感を高めてくれます。
目的地の緯度経度を取得
Google マップで目的地を検索して、対象の箇所で右クリックを押すと、目的地の緯度経度が表示されます。そのままクリップボードへのコピーも行えます。
今回は仮にサンタの目的地を広島駅しています。
サンタさんのデフォルトの座標として、フィンランド・サンタクロース村の緯度経度も取得しておきます。
実装
Constant
目的地の座標とサンタクロースの座標をConstantクラスに記述しておきます。
import MapKit
class Constant {
// 家の座標(仮に広島駅の座標)
static var homeCoordinate = CLLocationCoordinate2D(latitude: 34.39783518267202,
longitude: 132.47571119130762)
// フィンランド・サンタクロース村の座標
static var santaClausTownCoordinate = CLLocationCoordinate2D(latitude: 66.54369554169479,
longitude: 25.847604995753933)
}
MapView
SwiftUIにMap
というViewはあるのですが、今回はMKMapView
を使用することにしたので、MKMapView
をラップしたMapView
を作成しました。
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
@Binding var currentSantaCoordinate: CLLocationCoordinate2D?
@State private var santaAnnotation = MKPointAnnotation()
private let homeAnnotationData = AnnotationData(title: "ホーム",
coordinate: Constant.homeCoordinate,
imageName: "house.circle.fill")
private let santaAnnotationData = AnnotationData(title: "サンタ",
// 初期位置はフィンランド サンタクロース村
coordinate: Constant.santaClausTownCoordinate,
imageName: "img_santa")
// 最初に表示する地域、目的地の周辺
private var defaultRegion: MKCoordinateRegion {
return MKCoordinateRegion(center: homeAnnotationData.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.01,
longitudeDelta: 0.01))
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
// Delegateの設定
mapView.delegate = context.coordinator
// 表示する地域を設定
mapView.setRegion(defaultRegion, animated: false)
// 家のアノテーションを追加
let homeAnnotation = MKPointAnnotation()
homeAnnotation.title = homeAnnotationData.title
homeAnnotation.coordinate = homeAnnotationData.coordinate
mapView.addAnnotation(homeAnnotation)
// サンタのアノテーションを追加
santaAnnotation.coordinate = santaAnnotationData.coordinate
santaAnnotation.title = santaAnnotationData.title
mapView.addAnnotation(santaAnnotation)
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
guard let currentSantaCoordinate else {
// サンタの座標がnilの場合は、サンタのアノテーションを削除
mapView.removeAnnotation(santaAnnotation)
return
}
// サンタの座標を更新
santaAnnotation.coordinate = currentSantaCoordinate
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
init(_ parent: MapView) {
self.parent = parent
}
private let parent: MapView
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// HomeAnnotationの場合
if annotation.title == parent.homeAnnotationData.title {
let annotationView = MKMarkerAnnotationView(annotation: annotation,
reuseIdentifier: parent.homeAnnotationData.title)
annotationView.glyphImage = UIImage(systemName: parent.homeAnnotationData.imageName)
return annotationView
}
// SantaAnnotationの場合
if annotation.title == parent.santaAnnotationData.title {
let annotationView = MKAnnotationView(annotation: annotation,
reuseIdentifier: parent.santaAnnotationData.title)
annotationView.image = UIImage(named: parent.santaAnnotationData.imageName)
return annotationView
}
return nil
}
}
}
AnnotationData
MapKit
で特定の場所にインジケータや詳細を追加する時にAnnotationを作成します。そのAnnotationのデータ用の構造体を作っておきました。
extension MapView {
struct AnnotationData {
let title: String
let coordinate: CLLocationCoordinate2D
let imageName: String
}
}
- title: タイトル
- coordinate: 座標(緯度経度)
- imageName: Annotationで使用する画像の名前
makeUIView
MapView
が生成される時に呼び出されるメソッドで、マップ上に最初に表示する地域の設定や、MKMapView
にアノテーションを追加しています。サンタのアノテーションに関しては、座標の値が変わるのでインスタンス変数で宣言しているものを追加しています。
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
// Delegateの設定
mapView.delegate = context.coordinator
// 表示する地域を設定
mapView.setRegion(defaultRegion, animated: false)
// 家のアノテーションを追加
let homeAnnotation = MKPointAnnotation()
homeAnnotation.title = homeAnnotationData.title
homeAnnotation.coordinate = homeAnnotationData.coordinate
mapView.addAnnotation(homeAnnotation)
// サンタのアノテーションを追加
santaAnnotation.coordinate = santaAnnotationData.coordinate
santaAnnotation.title = santaAnnotationData.title
mapView.addAnnotation(santaAnnotation)
return mapView
}
updateUIView
MapView
が更新される度に呼び出されるメソッドで、バインディングで受け取ったサンタの座標がnil
の場合は、サンタのアノテーションを削除して、値がある場合は、その座標にサンタのアノテーションの座標を変更させています。
func updateUIView(_ mapView: MKMapView, context: Context) {
guard let currentSantaCoordinate else {
// サンタの座標がnilの場合は、サンタのアノテーションを削除
mapView.removeAnnotation(santaAnnotation)
return
}
// サンタの座標を更新
santaAnnotation.coordinate = currentSantaCoordinate
}
mapView(_:viewFor:)
Coordinator
クラス内で呼んでいるMKMapViewDelegate
のメソッドで、アノテーションのViewを返すメソッドです。
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// HomeAnnotationの場合
if annotation.title == parent.homeAnnotationData.title {
let annotationView = MKMarkerAnnotationView(annotation: annotation,
reuseIdentifier: parent.homeAnnotationData.title)
annotationView.glyphImage = UIImage(systemName: parent.homeAnnotationData.imageName)
return annotationView
}
// SantaAnnotationの場合
if annotation.title == parent.santaAnnotationData.title {
let annotationView = MKAnnotationView(annotation: annotation,
reuseIdentifier: parent.santaAnnotationData.title)
annotationView.image = UIImage(named: parent.santaAnnotationData.imageName)
return annotationView
}
return nil
}
家のアノテーションの場合は、MKMarkerAnnotationView
で表示して、glyphImage
に家の画像を設定しています。
サンタのアノテーションの場合は、マーカーのような形状ではなく画像のみで表示したかった為、MKAnnotationView
で表示してサンタの画像を設定しています。
JingleBellsPlayer
ジングルベルを再生する為だけのプレイヤーです。
import AVFoundation
class JingleBellsPlayer {
init() {
guard let url = Bundle.main.url(forResource: "jingle_bells", withExtension: "mp3"),
let player = try? AVAudioPlayer(contentsOf: url)
// 気持ちを高めるためにジングルベルは必須要素なので生成出来ない場合はfatalError
else { fatalError("音楽を再生出来ません") }
player.numberOfLoops = -1
self.player = player
}
private let player: AVAudioPlayer
func play() {
if player.isPlaying { return }
player.play()
}
}
player.numberOfLoops = -1
でジングルベルが無限ループされるようにしています。
また再生用のplay()
メソッドも用意しました。
ContentView
import SwiftUI
import MapKit
struct ContentView: View {
@State private var currentSantaCoordinate: CLLocationCoordinate2D? = Constant.santaClausTownCoordinate
private let player = JingleBellsPlayer()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
/// サンタが到着するDate
private let santaArriveDate: Date? = {
let formatter = ISO8601DateFormatter()
formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
let santaArriveDateSting = "2022-12-24T23:00:00+09:00"
return formatter.date(from: santaArriveDateSting)
}()
var body: some View {
MapView(currentSantaCoordinate: $currentSantaCoordinate)
.ignoresSafeArea()
.onReceive(timer) { _ in
moveSanta()
}
.onAppear {
player.play()
}
}
}
MapView
にサンタの現在の座標をバインディング出来るように@State
変数を用意しています。あまり関係はないですが、デフォルトはサンタクロース村の座標を設定しています。
santaArriveDate
は、我が家ではクリスマスイブの23時に到着することになっているので、2022-12-24T23:00:00+09:00
をDate
型に変換してものにしています。
今回はTimer
を使用して、1
秒毎にonRevieve
の処理を実行出来るようにしました。onRecieve
内ではサンタの位置を移動させるmoveSanta()
メソッドを呼ぶようにしています。
また、Viewが表示されたonAppear
のタイミングでジングルベルが流れるようにしています。
moveSanta
サンタの座標を移動させる為の処理です。
// MARK: - Private Function
extension ContentView {
private func moveSanta() {
guard let santaArriveDate else { return }
// 現在のDate
let currentDate = Date()
// 現在Dateとサンタ到着Dateの差分(秒)
let remainingTimeSeconds = santaArriveDate.timeIntervalSince(currentDate)
guard remainingTimeSeconds >= 0 else {
// サンタが到着したのでサンタの座標をnilにする
// この時間には子どもは起きていないので深く考えない
currentSantaCoordinate = nil
return
}
// 残り時間(秒)を10000で割ったものを目的地までの残り角度とする
// 目的地まで近づいた感が出せれば良いので深く考えない
let movingDegrees = remainingTimeSeconds / 10000
let currentSantaLatitude = Constant.homeCoordinate.latitude - movingDegrees
let currentSantaLongitude = Constant.homeCoordinate.longitude - movingDegrees
currentSantaCoordinate = CLLocationCoordinate2D(latitude: currentSantaLatitude,
longitude: currentSantaLongitude)
}
}
現在のDate
とサンタ到着Date
の差分の秒を計算しています。差分が0
未満の場合は、すでに到着したとみなして、currentSantaCoordinate
をnil
にしています。
差分が0
以上の場合は、差分の秒を10000
で割った値をサンタの目的地までの残り角度として、目的地の緯度経度から減算しています。
最終的に計算されたサンタの緯度経度をcurrentSantaCoordinate
に渡しています。
これでサンタが目的地に向かって進んでいるように見えるアプリが出来ました。
おわりに
子どもが喜べばいいので、細かいことは気にせずに作成しました。
サンタとメッセージのやり取りできたら面白いだろうなとか子どもがわがまま言うたびにサンタを後退させることの出来る機能があれば面白そうとか思ったりしました。
このアプリで無事に子どもの気持ちを盛り上げることに成功しました!
サンタさん、また来年も子どもに笑顔を届けてください。よろしくお願い致します。