🦜

モバイルSDKでもParty Parrot

2023/06/02に公開

はじめに

この記事は「もう一つのParty Parrot」の続きです。Mapbox Maps SDK for AndroidMapbox Maps SDK for iOSおよびMapbox Maps SDK Flutter PluginでParrotをPartyさせる方法について見ていきます。

Partyの開催方法

Mapbox GL JSでは以下のようなコードを記述しました。

  1. ParrotのGIF動画画像をフレームに分解
  2. Mapのロード完了を待つ
  3. Parrotの各フレーム画像をダウンロード
  4. addImageで画像を登録
  5. addSourceでGeoJSONソースを登録
  6. addLayerでシンボルレイヤーを作成
  7. タイマーで50ms毎にシンボルレイヤーの画像を変更

実は、モバイルSDKでも全く同じ手順でPartyできます。ただし、モバイルということで、この記事ではAssetsやバンドルにGeoJSONおよび画像を入れて読み込んでいます。もちろん、これらをネットワーク経由で取得するように実装するのも、もちろんOKです。

それではAndroid、iOS、Flutterの順に実装を見ていきましょう。今回は各プラットフォーム向けにサンプルを作成しました。ただし、挙動を示すための簡単なサンプルで、エラー処理などは行っていません。製品に使用する際にはご注意ください。

Android

サンプルコード

以下のコードがサンプルです。ダウンロードして使用する際にはapp/src/main/res/values/strings.xmlYOUR_MAPBOX_PUBLIC_TOKENの部分に自分のパブリックトークンを設定してください。

自分でプロジェクトを作る際には以下のInstallationガイドをご参照ください。

処理内容

地図を表示する場所にMapViewタグを配置します。サンプルではactivity_main.xmlに以下を指定しています。中心座標等もここで設定していますが、コードから設定することもできます。

<com.mapbox.maps.MapView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:mapbox="http://schemas.android.com/apk/res-auto"
    android:id="@+id/mapView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    mapbox:mapbox_cameraTargetLat="35.6811649"
    mapbox:mapbox_cameraTargetLng="139.763906"
    mapbox:mapbox_cameraZoom="14.0"
    />

次にKotlinのコードを見ていきます。地図のロードにはloadStyle系のメソッドを使用します。ここではloadStyleUriを使用し、第一引数にスタイルのURLを指定しています。第二引数が地図のロードが完了されたときに呼び出されるコールバック関数をしています。

mapView?.getMapboxMap()?.loadStyleUri(Style.LIGHT){
  ...
}

コールバックの中では以下の処理を行います

  1. 画像の登録
  2. GeoJSONソースの登録
  3. シンボルレイヤーの作成
  4. タイマーで50ms毎にシンボルレイヤーの画像を変更

まずは画像の登録です。drawableframe0.pngframe9.pngの画像が入っているので、読み込んでStyle#addImageで登録します。

    it.addImage("parrot0", BitmapFactory.decodeResource(resources, R.drawable.frame0))
    it.addImage("parrot1", BitmapFactory.decodeResource(resources, R.drawable.frame1))
    it.addImage("parrot2", BitmapFactory.decodeResource(resources, R.drawable.frame2))
    it.addImage("parrot3", BitmapFactory.decodeResource(resources, R.drawable.frame3))
    it.addImage("parrot4", BitmapFactory.decodeResource(resources, R.drawable.frame4))
    it.addImage("parrot5", BitmapFactory.decodeResource(resources, R.drawable.frame5))
    it.addImage("parrot6", BitmapFactory.decodeResource(resources, R.drawable.frame6))
    it.addImage("parrot7", BitmapFactory.decodeResource(resources, R.drawable.frame7))
    it.addImage("parrot8", BitmapFactory.decodeResource(resources, R.drawable.frame8))
    it.addImage("parrot9", BitmapFactory.decodeResource(resources, R.drawable.frame9))

次にGeoJSONソースを登録します。party.jsonassetsディレクトリに入っているので、以下のようにGeoJsonSourceとして読み込みます。さらにaddSourceでソースを登録します。

    val source = GeoJsonSource.Builder("party-source").url("asset://party.json").build()
    it.addSource(source)

GeoJSONソースからシンボルレイヤーを作成します。初期画像はparrot0(frame0.png)で、サイズも32x32に縮小します。addLayerでレイヤーを作成します。

    val layer = SymbolLayer("party-layer", "party-source")
    layer.iconImage("parrot0")
    layer.iconSize(0.25)
    it.addLayer(layer)

最後にタイマーで50ms毎にiconImageの値を更新します。ここで、Maps SDKに対する操作は必ずメインスレッドで行わなければならないことに注意してください。これはSDKがスレッドセーフではないことに起因します。たとえば、ワーカースレッドからの操作(例えばレイヤーの追加・削除)を行った場合、データ競合が発生する可能性があります。

    var counter = 0;
    val handler = Handler(Looper.getMainLooper())
    handler.postDelayed(object: Runnable{
        override fun run() {
            handler.postDelayed(this, 50)
            layer.iconImage("parrot${(++counter) % 10}")
        }
    }, 100)

結果は以下のとおりです。

https://youtube.com/shorts/DklRkyxa6C4

おまけ - DSL

AndroidはDSLと呼ばれる記法でもレイヤーの作成が可能です。具体的には以下のように記述します。

mapView?.getMapboxMap()?.loadStyle(
    style(styleUri = Style.LIGHT) {
        +image("parrot0") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame0))
        }
        +image("parrot1") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame1))
        }
        +image("parrot2") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame2))
        }
        +image("frame3") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame3))
        }
        +image("parrot4") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame4))
        }
        +image("parrot5") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame5))
        }
        +image("parrot6") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame6))
        }
        +image("parrot7") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame7))
        }
        +image("parrot8") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame8))
        }
        +image("parrot9") {
            bitmap(BitmapFactory.decodeResource(resources, R.drawable.frame9))
        }

        +geoJsonSource("party-source") {
            url("asset://party.json")
        }

        +symbolLayer("party-layer", "party-source") {
            iconImage("frame0")
            iconSize(0.25)
        }
    }
) {
    var counter = 0;
    val handler = Handler(Looper.getMainLooper())
    handler.postDelayed(object: Runnable{
        override fun run() {
            handler.postDelayed(this, 50)
            val layer = it.getLayer("party-layer") as SymbolLayer
            layer.iconImage("parrot${(++counter) % 10}")
        }
    }, 100)
}

iOS

サンプルコード

以下のコードがサンプルです。ダウンロードして使用する際にはParty Parrot/ViewController.swiftYOUR_MAPBOX_PUBLIC_TOKENの部分に自分のパブリックトークンを設定してください。

自分でプロジェクトを作る際には以下のInstallationガイドをご参照ください。

処理内容

MapViewはコードの中で作成し、ViewControllerの子として表示します。

let resourceOptions = ResourceOptions(accessToken: YOUR_MAPBOX_PUBLIC_TOKEN)
let centerCoordinate = CLLocationCoordinate2D(latitude: 35.6811649, longitude: 139.763906)
let mapInitOptions = MapInitOptions(
    resourceOptions: resourceOptions,
    cameraOptions: CameraOptions(center: centerCoordinate, zoom: 14.0),
    styleURI: .light)
mapView = MapView(frame: view.bounds, mapInitOptions: mapInitOptions)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

self.view.addSubview(mapView)

地図のロード完了イベント発生時に行う処理はonNextのコールバックとして記述します。onNext第一引数で指定されたイベントが発生したときに一回だけ実行します。ちなみに、onNextの他にonEveryがあり、イベントが発生すると毎回実行する処理を記述することができます。

mapView.mapboxMap.onNext(event: .mapLoaded) { _ in
  ...
}

コールバックの中では以下の処理を行います

  1. 画像の登録
  2. GeoJSONソースの登録
  3. シンボルレイヤーの作成
  4. タイマーで50ms毎にシンボルレイヤーの画像を変更

まずは画像の登録です。Assets.xcassetsframe0frame9としての画像が登録されているので、読み込んでaddImageで登録します。

try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame0")!, id: "parrot0")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame1")!, id: "parrot1")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame2")!, id: "parrot2")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame3")!, id: "parrot3")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame4")!, id: "parrot4")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame5")!, id: "parrot5")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame6")!, id: "parrot6")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame7")!, id: "parrot7")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame8")!, id: "parrot8")
try! self.mapView.mapboxMap.style.addImage(UIImage(named: "frame9")!, id: "parrot9")

次にGeoJSONソースを登録します。party.jsonassetsディレクトリに入っているので、以下のようにGeoJsonSourceとして読み込みます。さらにaddSourceでソースを登録します。

guard let featureCollection = try? self.decodeGeoJSON(from: "party") else { return }
var source = GeoJSONSource()
source.data = .featureCollection(featureCollection)
try! self.mapView.mapboxMap.style.addSource(source, id: "party-source")

GeoJSONソースからシンボルレイヤーを作成します。初期画像はparrot0(frame0.png)で、サイズも32x32に縮小します。addLayerでレイヤーを作成します。

var layer = SymbolLayer(id: "party-layer")
layer.source = "party-source"
layer.iconImage = .constant(.name("parrot0"))
layer.iconSize = .constant(0.25)
try! self.mapView.mapboxMap.style.addLayer(layer)

最後にタイマーで50ms毎にiconImageの値を更新します。ここで、Maps SDKに対する操作は必ずメインスレッドで行わなければならないことに注意してください。これはSDKがスレッドセーフではないことに起因します。たとえば、ワーカースレッドからの操作(例えばレイヤーの追加・削除)を行った場合、データ競合が発生する可能性があります。

var counter = 0;
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: { _ in
    counter += 1
    try! self.mapView.mapboxMap.style.updateLayer(withId: "party-layer", type: SymbolLayer.self) { layer in
        layer.iconImage = .constant(.name("parrot\(counter % 10)"))
    }
})

結果は以下のとおりです。

https://www.youtube.com/shorts/VvDBq4lThCo

おまけ - ヘルパー関数

GeoJSONをバンドルから読み込む際に以下のヘルパー関数を使用しました。これはSDKのサンプルのコードを拝借しました。

internal func decodeGeoJSON(from fileName: String) throws -> FeatureCollection? {
    guard let path = Bundle.main.path(forResource: fileName, ofType: "json") else {
        preconditionFailure("File '\(fileName)' not found.")
    }

    let filePath = URL(fileURLWithPath: path)

    var featureCollection: FeatureCollection?

    do {
        let data = try Data(contentsOf: filePath)
        featureCollection = try JSONDecoder().decode(FeatureCollection.self, from: data)
    } catch {
        print("Error parsing data: \(error)")
    }

    return featureCollection
}

Flutter

サンプルコード

以下のコードがサンプルです。ダウンロードして使用する際にはlib/main.dartYOUR_MAPBOX_PUBLIC_TOKENの部分に自分のパブリックトークンを設定してください。

自分でプロジェクトを作る際には以下のInstallationガイドをご参照ください。

処理内容

以下を参考にMapWidgetを作成します。

onMapCreatedに地図がロード完了した際のコールバックを記述します。ここでは以下のように別の関数として定義しています。

_onMapCreated(MapboxMap mapboxMap) async {
  ...
}

コールバックの中では以下の処理を行います

  1. 画像の登録
  2. GeoJSONソースの登録
  3. シンボルレイヤーの作成
  4. タイマーで50ms毎にシンボルレイヤーの画像を変更

まずは画像の登録です。assetsframe0.pngframe9.pngの画像が入っているので、読み込んでaddImageで登録します。

await mapboxMap.style.addStyleImage("parrot0", 1.0, await _getImage("assets/frame0.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot1", 1.0, await _getImage("assets/frame1.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot2", 1.0, await _getImage("assets/frame2.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot3", 1.0, await _getImage("assets/frame3.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot4", 1.0, await _getImage("assets/frame4.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot5", 1.0, await _getImage("assets/frame5.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot6", 1.0, await _getImage("assets/frame6.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot7", 1.0, await _getImage("assets/frame7.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot8", 1.0, await _getImage("assets/frame8.png"), false, [], [], null);
await mapboxMap.style.addStyleImage("parrot9", 1.0, await _getImage("assets/frame9.png"), false, [], [], null);

次にGeoJSONソースを登録します。party.jsonassetsディレクトリに入っているので、以下のようにGeoJsonSourceとして読み込みます。さらにaddSourceでソースを登録します。

var geojson = await rootBundle.loadString('assets/party.json');
var source = GeoJsonSource(id: "party-source", data: geojson);
await mapboxMap.style.addSource(source);

GeoJSONソースからシンボルレイヤーを作成します。初期画像はparrot0(frame0.png)で、サイズも32x32に縮小します。addLayerでレイヤーを作成します。

var layer = SymbolLayer(
  id: "party-layer",
  sourceId: "party-source",
  iconImage: "parrot0",
  iconSize: 0.25,
);
await mapboxMap.style.addLayer(layer);

最後にタイマーで50ms毎にiconImageの値を更新します。

var counter = 0;
Timer.periodic(const Duration(milliseconds: 50), (timer) async {
  layer.iconImage = "parrot${(++counter) % 10}";
  await mapboxMap.style.updateLayer(layer);
});

結果は以下のとおりです。

https://youtube.com/shorts/sdoaAT2orNQ

おまけ - ヘルパー関数

画像をAssetから読み込む際に以下のヘルパー関数を使用しました。これはSDKのサンプルのコードを参考にしました。

Future<MbxImage> _getImage(String path) async {
  final ByteData bytes = await rootBundle.load(path);
  final Uint8List image = bytes.buffer.asUint8List();
  return new MbxImage(width: 128, height: 128, data: image);
}

もっとParty

各サンプルでparty.jsonparty1000.jsonに変更するとたくさんPartyできます!

https://youtube.com/shorts/758RI9-ArNo

https://youtube.com/shorts/A_jsIPf-oG4

https://youtube.com/shorts/Hx1l_LH62d0

まとめ

プラットフォーム毎に多少の違いはあれど、基本的に同じ処理方法でParty Parrotが実装できることがわかりました。Party Parrotに限らず、ソース/レイヤー/ソースに関連する操作はプラットフォーム関係なく同じ様な方法で操作できるように設計されています。

GitHubで編集を提案
マップボックス・ジャパン合同会社

Discussion