Gluegent Blog

Gluegent Blog

位置情報と生成AIで散歩を楽しくする iOSアプリを作ってみた

  • その他Gluegent製品
  • グルージェントフロー
  • 技術

こんにちは、エンジニアのYuito Hayashiです。

前回の記事では加速度センサーを用いたiOSアプリを作りましたが、今回はGPSセンサーを使ったiOSアプリを作ってみたいと思います。
ただ、マップに現在地を表示するだけでは面白くないので、今回は生成AIも組み合わせて、散歩を楽しくする「散歩クエストアプリ」を作ってみようと思います。

位置情報と生成AIで散歩を楽しくする iOSアプリを作ってみた

位置情報を取得してマップに表示してみる

まずは基本となる、位置情報の取得とマップへの表示から行ってみようと思います。

info.plistの設定

位置情報を扱うためには、ユーザーに許可を求める必要があります。
Xcodeの「Project > TARGETS > info」から以下の設定を追加します。

  • Key:Privacy - Location When In Use Usage Description
  • Value:ユーザーへの説明文を記述

実装

位置情報の取得にはCoreLocationというフレームワークを使用します。
取得した緯度と経度をMapに渡し、現在地にアノテーション(青い丸)を表示させます。
以下のコードから、取得した位置情報をマップへ表示することができます。

import SwiftUI
import CoreLocation
import Combine
import MapKit
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    @Published var location: CLLocation?
    override init() {
        super.init()
        manager.delegate = self
    }
    func start() {
        manager.requestWhenInUseAuthorization()
        manager.startUpdatingLocation()
    }
    func stop() {
        manager.stopUpdatingLocation()
    }
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        location = locations.last
    }
}
struct ContentView: View {
    @StateObject private var locationManager = LocationManager()
    @State private var cameraPosition: MapCameraPosition = .automatic
    var body: some View {
        ZStack(alignment: .bottom) {
            Map(position: $cameraPosition) {
                UserAnnotation()
            }
            if let location = locationManager.location {
                HStack {
                    Text("緯度: \(location.coordinate.latitude)\n" +
                         "経度: \(location.coordinate.longitude)")
                        .multilineTextAlignment(.center)
                        .padding(15)
                        .background(Color.white)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
        }
        .statusBar(hidden: true)
        .onReceive(locationManager.$location) { location in
            guard let location else { return }
            cameraPosition = .region(
                MKCoordinateRegion(
                    center: location.coordinate,
                    span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
                )
            )
        }
        .onAppear { locationManager.start() }
        .onDisappear { locationManager.stop() }
    }
}

動作は実機でも確認できますが、実機の画面を見せると読者の皆様に自宅を晒すことになってしまうので、今回はiOSシミュレーターを使って動作確認をします。
シミュレーターのメニューから「Features > Location > Custom Location」を選択すると、任意の現在地を設定することができます。
今回は弊社のオフィスがある「サイオスビル」の緯度経度を設定してみました。
以下が実行画面です。

現在地をマップに表示するアプリの動作画面
現在地をマップに表示するアプリの動作画面

設定した地点が正しく表示されていることがわかります。

歩いた軌跡を描いてみる、現在地周辺にピンを立ててみる

次に、歩いた道の「軌跡」をマップ上に描画できるように改修してみます。
さらに、ボタンを押すことで現在地周辺のランダムな位置に「目的地」としてピンを立てる機能も追加してみようと思います。

実装

軌跡を表示するために、LocationManagerクラスにtrackという配列を用意し、位置情報が更新されるたびに座標を保存するようにします。
これをMapPolylineに渡すことで通ったルートを線で描画します。
コードは以下のようになります。(一部省略しています。)

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
    @Published var track: [CLLocationCoordinate2D] = []
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let lastLocation = locations.last {
            location = lastLocation
            track.append(lastLocation.coordinate)
        }
    }
}
struct ContentView: View {
    @StateObject private var locationManager = LocationManager()
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var pin: CLLocationCoordinate2D?
    var body: some View {
        ZStack(alignment: .bottom) {
            Map(position: $cameraPosition) {
                UserAnnotation()
                MapPolyline(coordinates: locationManager.track)
                    .stroke(.blue, lineWidth: 5)
                if let pin {
                    Marker("目的地", coordinate: pin)
                }
            }
            Button("ピンを立てる") {
                guard let location = locationManager.location?.coordinate else { return }
                let range = 0.005
                pin = CLLocationCoordinate2D(
                    latitude: location.latitude + Double.random(in: -range...range),
                    longitude: location.longitude + Double.random(in: -range...range)
                )
            }
            .buttonStyle(.borderedProminent)
        }
        .statusBar(hidden: true)
        .onReceive(locationManager.$location) { location in
            guard let location else { return }
            cameraPosition = .region(
                MKCoordinateRegion(
                    center: location.coordinate,
                    span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
                )
            )
        }
        .onAppear { locationManager.start() }
        .onDisappear { locationManager.stop() }
    }
}

シミュレーターのメニューから「Features > Location > CityRun」を選択すると、Appleの本社周辺を走っている状態をシミュレートできます。
以下が実行画面です。

軌跡とピンをマップに表示するアプリの動作画面
軌跡とピンをマップに表示するアプリの動作画面

通ったルートに青い線が描画されていることがわかります。
また、「ピンを立てる」ボタンを押すことで現在地周辺のランダムな位置にピンが立てられることも確認できました。

生成AIを使った散歩クエストアプリを作成する

いよいよ、本題の散歩クエストアプリの作成に取り掛かります。
iOSアプリから緯度と経度をPOSTで送信し、サーバー側で生成AIを用いて「目的地」と「クエスト」を作成し、iOSアプリへ返すような構成にしようと思います。
生成AIにはGemini、サーバー側の処理にはPythonを使用することにします。

事前準備

Gemini APIを使用するため、Google Cloud Platform (GCP) 側で準備が必要です。

  1. Google Cloud Platform (GCP) でプロジェクトを作成し、「Vertex AI API」を有効化する。
  2. サービスアカウントを作成し、JSON形式のキーファイルをダウンロードする。
  3. プロジェクトのフォルダ内に .env ファイルを用意し、以下の情報を記述する。
GCP_PROJECT_ID="GCPのプロジェクトID"
GCP_LOCATION="ロケーション"
GOOGLE_APPLICATION_CREDENTIALS="キーファイルのパス"

バックエンド実装

Geminiに現在地の緯度と経度を渡し、「徒歩10分以内の目的地」と「面白いクエスト内容」を考えてもらいます。
アプリ側で扱いやすいように、JSON形式で返してもらうようにプロンプトとスキーマを組みました。
以下のコードでAPIサーバーを立ち上げることができます。

import os
import json
from dotenv import load_dotenv
from flask import Flask, request, jsonify
from google import genai
from google.genai import types
load_dotenv()
app = Flask(__name__)
PROJECT = os.environ["GCP_PROJECT_ID"]
LOCATION = os.environ["GCP_LOCATION"]
client = genai.Client(
    vertexai=True,
    project=PROJECT,
    location=LOCATION
)
@app.post("/")
def recommend():
    data = request.get_json()
    latitude = float(data["latitude"])
    longitude = float(data["longitude"])
    prompt = f"""
    現在、緯度{latitude},経度{longitude}にいます。
    """
    context = """
    あなたは散歩クエスト作成AIです。
    ここから徒歩で10分以内に行ける距離で目的地を一つ設定し、散歩クエストを作成してください。
    クエストの内容は実現可能なもので、面白い内容にしてください。
    JSONのプロパティには、latitudeに目的地の緯度、longitudeに目的地の経度、questにクエストの内容、placeに目的地名が入るようにしてください。
    """
    response = client.models.generate_content(
        model = "gemini-2.5-flash",
        contents = prompt,
        config = types.GenerateContentConfig(
            system_instruction = context,
            response_mime_type = "application/json",
            response_schema = {
                "type": "object",
                "properties": {
                    "latitude": {"type": "number"},
                    "longitude": {"type": "number"},
                    "quest": {"type": "string"},
                    "place": {"type": "string"}
                },
                "required": ["latitude", "longitude", "quest", "place"],
                "additionalProperties": False
            }
        )
    )
    return jsonify(json.loads(response.text))
if __name__ == "__main__":
    app.run(port=5000, debug=True)

iOSアプリ実装

サーバーと通信するための構造体(Request、Response)とAPIを叩く処理を追加します。
ボタンを押すことで、APIを呼び出し、取得した目的地にピンを立て、クエスト内容を画面に表示させます。
コードは以下のようになります。(一部省略しています。)

struct Request: Codable {
    let latitude: Double
    let longitude: Double
}
struct Response: Codable {
    let latitude: Double
    let longitude: Double
    let quest: String
    let place: String
}
struct ContentView: View {
    @StateObject private var locationManager = LocationManager()
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var pin: CLLocationCoordinate2D?
    @State private var pinTitle: String = ""
    @State private var questText: String = ""
    var body: some View {
        ZStack(alignment: .bottom) {
            Map(position: $cameraPosition) {
                UserAnnotation()
                MapPolyline(coordinates: locationManager.track)
                    .stroke(.blue, lineWidth: 5)
                if let pin {
                    Marker(pinTitle, coordinate: pin)
                }
            }
            VStack{
                if (!questText.isEmpty) {
                    Text(questText)
                        .multilineTextAlignment(.center)
                        .padding(15)
                        .background(Color.white)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
                Button("クエスト生成") {
                    Task {
                        guard let location = locationManager.location else { return }
                        do {
                            let response = try await postLocation(location)
                            let coordinate = CLLocationCoordinate2D(latitude: response.latitude, longitude: response.longitude)
                            pin = coordinate
                            pinTitle = response.place
                            questText = response.quest
                            cameraPosition = .region(MKCoordinateRegion(center: coordinate, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)))
                        } catch {
                            print(error)
                        }
                    }
                }
                .buttonStyle(.borderedProminent)
            }
        }
        .statusBar(hidden: true)
        .onReceive(locationManager.$location) { location in
            guard let location else { return }
            cameraPosition = .region(
                MKCoordinateRegion(
                    center: location.coordinate,
                    span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
                )
            )
        }
        .onAppear { locationManager.start() }
        .onDisappear { locationManager.stop() }
    }
    private func postLocation(_ location: CLLocation) async throws -> Response {
        var request = URLRequest(url: URL(string: "http://127.0.0.1:5000/")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = Request(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
        request.httpBody = try JSONEncoder().encode(body)
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(Response.self, from: data)
    }
}

以下が実行画面です。

散歩クエストアプリの動作画面
散歩クエストアプリの動作画面

「クエスト生成」ボタンを押すことでクエストの作成と目的地のピンが立てられることを確認できました。
しかし、クエストの内容はいいのですが、マップをよく見るとピンの位置が実際の場所からズレていました。
生成AIに座標まで出力させるのは難しかったのかもしれません。
この問題はまたいつか解決してみたいです。

さいごに

今回は位置情報と生成AIを組み合わせて「散歩クエストアプリ」を作成してみました。
スマートフォンのセンサーと生成AIを組み合わせたアプリは初めてだったので、作っていて楽しかったです。
次は、別のセンサーと生成AIを組み合わせたスマホアプリも作ってみたいです。

スマホアプリといえば、「グルージェントフロー(Gluegent Flow)」と「共有アドレス帳」にも便利なスマホアプリがあります。
そちらも是非ダウンロードして使ってみてください。
グルージェントフローのモバイル対応ご案内はこちらから

最後まで読んでいただきありがとうございました!
(Yuito Hayashi)

おまけ

私事ですが、Gluegentの2025年ブログ大賞を受賞し、景品として「生ハムの原木」をいただきました!

送られてきた生ハムの原木
送られてきた生ハムの原木

友達と一緒にいただいたのですが、とても美味しかったです。
ただ、薄くスライスするのにはなかなか苦戦しました。
説明書には、バイオリンを弾くようなイメージでスライスするといいと書かれていたので、扱える楽器がカスタネットとウッドブロックぐらいの自分には難しいのかもしれません。
生ハムがなくなるまでにはきれいにスライスできるようになりたいです。
これからもブログ記事を通して、Gluegentの認知度アップに貢献していきたいと思います
今後ともよろしくお願いします。