地図を回転する(アンドロイド地図アプリの開発 その5)

地図アプリ開発の続きです。前回、次回からは実機でと書いてしまったのですが、地図を回転表示するところで大きくはまって、相変わらずのエミュレータによる開発です(´;ω;`)。
結局プログラムで何やっているのかをちゃんと理解せずに、見よう見まねでやってきたのでハマると抜け出せないという悪循環でした。そもそも、Androidアプリ開発が初めての超初心者がいきなり地図アプリなんて高度なことをするからこんなことになるのですが、わかっちゃいるけど、ほかにアプリで作りたいものもないので、ろくに泳ぎもできない人がいきなり大海原に放り込まれて、溺れかけながら必死でもがいているようなものですね。
ただ、いろいろなサイトを参照しながら、ようやくどういう処理をしているのかがおぼろげながらわかってきました。遠くに島影が見えてきたという感でしょうか。ともかく、今回やったことを書いてみます。

どうやって地図を描いているか

そもそも、これがわかってないのが大問題でした。実は、参考にさせていただいた、伊勢在住のプログラマーさんのサイトは、mapsforgeのサンプルプログラムで最もシンプルなGettingStarted.javaというサンプルプログラムをベースに作られていて、それをそのまま、地図を回転するサンプルプログラムRotateMapViewer.javaに持っていこうとしてはまってしまいました。
まずは、mapsforgeはどうやって地図を描くかということですが、簡単に書くと次の4段階で描いているようです。

  1. MapViewというViewクラスを利用する。
  2. 地図データをタイル状に分割して描くためのTileCashを作成する。
  3. 地図データをTileRendererLayerにレイヤー化する。
  4. MapViewにTileRendererLayerをAddする。

まだ理解が浅いので100%の自信はないですが、プログラムを読む限りはこんな感じだと思います。ここから先は、理解が追い付いてないので詳しいことがわかってないのですが、mapsforgeのサンプルプログラムのRotateMapView.javaを読んでもどこにも地図を描くという処理が書いてないのです。確かにOvalayMapViewerというクラスを継承しているのですが、こいつは、地図上に線やら、バルーンやらを書き込んでいく処理を行っているので、地図そのものを描く処理はここにも書いてません。そもそも、サンプルプログラムがクラスの塊でどこからどう処理が飛んでいるのかさっぱりわからないので、非常に理解に苦しみました。いろいろなサンプルのソースコードを見比べてようやくたどり着いた結論が上記になりました。
また、TileCashを作成するところですが、GettingStarged.javaでは、

TileCache tileCache = AndroidUtil.createTileCache(this, "mapcache", mapView.getModel().displayModel.getTileSize(), 1f, mapView.getModel().frameBufferModel.getOverdrawFactor());

と1つの変数になっていますが、他のサンプルではRotateMapViewr.javaも含めて

protected List<TileCache> tileCaches = new ArrayList<TileCache>();
~省略~
protected void createTileCaches() {
        this.tileCaches.add(AndroidUtil.createTileCache(this, getPersistableId(),
                this.mapView.getModel().displayModel.getTileSize(), this.getScreenRatio(),
                this.mapView.getModel().frameBufferModel.getOverdrawFactor()));
    }

のように配列を使っていました。この部分もとってもわかるのに苦労したのですが、GettingStarted.javaの次にシンプルそうなSimplestMapView.javaを読むとMapViewerTemplate.javaを継承していたので、こいつを探して(サンプルプログラムのフォルダにはなく、mapsforge-map-androidのフォルダにあった)読み比べてようやくわかりました。そこで、TileCashが配列になっていないのが悪いのかと思って、配列の処理に変えてみたのですがうまくいきませんでした。
結局、回転表示がうまくいかない結論として分かったことは、Viewの使い方が違うということでした。

Viewをどう処理するか

Viewの使い方も、GettingStarted.javaと他のプログラムでは異なっていました。GettingStarted.javaでは、

private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        AndroidGraphicFactory.createInstance(this.getApplication());

        mapView = new MapView(this);
        setContentView(mapView);

のように、直接mapViewを書き込んでいます(上から6-7行目)。だいたい、アンドロイドアプリの教科書とかにも標準的なViewの実装方法としては、
setContentView(R.id.layout.activity_main)
と書いてあることが多いと思います。この2つの違いは、前者がmapViewというViewのみを表示する設定になっているのに対し、後者はactivity_main.xmlというレイアウトファイルの中身を表示するという設定になっていることで、Viewの設定をmapViewのみにして一生懸命地図を回そうとしてはまっていました。RotateMapViewerのレイアウトファイルを見ると、

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/mainView"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <org.mapsforge.map.android.rotation.RotateView
        android:id="@+id/rotateView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent">

        <org.mapsforge.map.android.view.MapView
            android:id="@+id/mapView"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent" />
    </org.mapsforge.map.android.rotation.RotateView>

~以下略~

となっていて、ViewとしてRotateViewというのを配置していて、その下にMapViewを配置しています。つまりレイアウトファイルを使っていて、そこにMapViewをはめ込んでいるようです。一方、GettingStarted.javaのコメントには

/*
         * A MapView is an Android View (or ViewGroup) that displays a mapsforge map. You can have
         * multiple MapViews in your app or even a single Activity. Have a look at the mapviewer.xml
         * on how to create a MapView using the Android XML Layout definitions. Here we create a
         * MapView on the fly and make the content view of the activity the MapView. This means
         * that no other elements make up the content of this activity.
         */

とあって、MapViewはXML Layoutに配置するときはmapviewer.xmlを見てみよ。ここではMapViewをオンザフライでcontent viewに作成しているので他の要素は表示されない。という意味のことが書いてありました。あまりコメントを読んでなかったのでこれに気づくのに随分時間がかかりました。で、mapviewer.xmlを見てみると

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/mainView"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <org.mapsforge.map.android.view.MapView
        android:id="@+id/mapView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" />

のようになっていて、MapViewを配置しているのがわかる。ここで、なんとなくわかったのは、レイアウトファイルにMapViewを配置し、それをsetContentViewするとマップが表示されるだろう、回転表示をするためにはさらにその上階層にRotateViewを配置して回転するコマンドを実行すればよいのでは? ということでした。
そこで、また、GettingStarted.javaのプログラムに戻って、

private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        AndroidGraphicFactory.createInstance(getApplication());
        mapView = new MapView(this);
        setContentView(mapView);

となっていたのを、変更し

private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AndroidGraphicFactory.createInstance(getApplication());
        //mapView = new MapView(this);
        //setContentView(mapView);
       mapView = (MapView)this.findViewById(R.id.mapView);

のように、レイアウトファイルを使うように変更してみました。レイアウトファイルはmapviewer.xmlを使いました。すると地図が無事に表示されました。

f:id:alasixOsaka:20190706113405j:plain
レイアウトファイルを使って地図を表示
なんとなく地図の見た目が違うような気がしますが、あまり気にせず、回転できるかを確認します。レイアウトファイルのMapViewを配置するところを、RotateMapViewer.xmlを参考に書き換えて、例によってロングタップの処理を追記しその処理として、地図の回転コマンドを書いてみました。伊勢在住のプログラマーさんには本当に感謝の言葉しかありません。あのブログがなかったらおそらくここまでたどり着いてなかったでしょう。

private boolean setMarker() {
        RotateView rotateView = (RotateView) findViewById(R.id.rotateView);
        rotateView.setHeading(rotateView.getHeading() - 45f);
        rotateView.postInvalidate();
        return true;
    }

前のプログラムをそのまま流用したので、オブジェクトの名前がseMarkerのままになっているがそこはご愛敬で。処理としては、RotateViewをレイアウトファイル中のrotateViewに対応させて、現在の回転角をgetHeading()で取得し、右に45度回転させるという処理になっている。RotateMapView.javaの記述をそのままコピーしているだけです。その他の部分で色々と処理をしているのですが、結局地図を回転する処理そのものには関係ないようです。最後のところで書きますが、このRotateViewを使うと見た目を整えるのに色々処理が必要なようです。

f:id:alasixOsaka:20190706114314j:plain
地図を右45°回転したところ
45fの部分を変更すれば好きな回転角で表示が可能。ということは、磁気センサーなり、GPSで現在の方位を所得して、ここに書き込んでやれば地図が正面を向く表示に変えられるはずだ。ようやくゴールが見えてきた。
この回転表示では、スケールバーやらズームイン、ズームアウトのボタンが消えてしまうので、その辺の表示も整える必要があるが、とりあえず最大の難所は突破した。
MainActivityの全文は下記の通り。結局Cashは配列でなくてもよかったようだ。

public class MainActivity extends AppCompatActivity {
    // Name of the map file in device storage
    private static final String MAP_FILE = "berlin.map";

    private final static int PERMISSION_REQUEST_CODE = 1;


    private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AndroidGraphicFactory.createInstance(getApplication());
        //mapView = new MapView(this);
        //setContentView(mapView);
        mapView = (MapView)this.findViewById(R.id.mapView);
        /*
         * We then make some simple adjustments, such as showing a scale bar and zoom controls.
         */
        mapView.setClickable(true);
        mapView.getMapScaleBar().setVisible(true);
        mapView.setBuiltInZoomControls(true);

        /*
         * To avoid redrawing all the tiles all the time, we need to set up a tile cache with an
         * utility method.
         */

        final int permission = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE);
        if (permission != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
        } else {
            displayMap();
        }
    }
    @Override
    protected void onDestroy() {
        /*
         * Whenever your activity exits, some cleanup operations have to be performed lest your app
         * runs out of memory.
         */
        mapView.destroyAll();
        AndroidGraphicFactory.clearResourceMemoryCache();
        super.onDestroy();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        switch(requestCode) {
            case PERMISSION_REQUEST_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    displayMap();
                    return;
                }
            default:
        }

        // アプリを終了
        this.finish();
    }

    private void displayMap() {

        try {

            TileCache tileCache = AndroidUtil.createTileCache(this, "mapcache", mapView.getModel().displayModel.getTileSize(), 1f, mapView.getModel().frameBufferModel.getOverdrawFactor());
            File mapFile = new File(getExternalStorageDirectory().getPath(), MAP_FILE);
            MapDataStore mds = new MapFile(mapFile);
            TileRendererLayer trl = new TileRendererLayer(tileCache, mds, mapView.getModel().mapViewPosition, AndroidGraphicFactory.INSTANCE) {
                @Override
                public boolean onLongPress(LatLong tapLatLong, Point layerXY, Point tapXY) {
                    return setMarker();
                }
            };

            trl.setXmlRenderTheme(InternalRenderTheme.DEFAULT);

            mapView.getLayerManager().getLayers().add(trl);

            //mapView.setCenter(new LatLong(34.491297, 136.709685)); // 伊勢市駅
            mapView.setCenter(new LatLong(52.517037, 13.38886));
            mapView.setZoomLevel((byte) 12);
        }catch (Exception e) {
            /*
             * In case of map file errors avoid crash, but developers should handle these cases!
             */
            e.printStackTrace();
        }
    }
    private boolean setMarker() {
        RotateView rotateView = (RotateView) findViewById(R.id.rotateView);
        rotateView.setHeading(rotateView.getHeading() - 45f);
        rotateView.postInvalidate();
        return true;
    }
}

また、レイアウトファイルActivity_Main.xml

<?xml version="1.0" encoding="utf-8"?>
<!--suppress ALL -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="16dp"
    android:paddingLeft="16dp"
    android:paddingRight="16dp"
    android:paddingTop="16dp"
    tools:context=".com.example.mapsforgeRotate4.MainActivity">

    <org.mapsforge.map.android.rotation.RotateView
        android:id="@+id/rotateView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent">

        <org.mapsforge.map.android.view.MapView
            android:id="@+id/mapView"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent" />
    </org.mapsforge.map.android.rotation.RotateView>

    <TextView
        android:id="@+id/attribution"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="@dimen/attribution_margin"
        android:gravity="center"
        android:singleLine="true"
        android:text="@string/copyright_osm"
        android:textColor="#000"
        android:textSize="@dimen/attribution_size" />

</RelativeLayout>

これ以外にAndroidManifest.xmlに外部ファイル読み込みのパーミッションを記述し、buildGradleにmapsforgeのライブラリをインポートするための依存関係の記述が必要だが、今回は省略。

今回の結論

  • 地図を回転するときは、レイアウトファイルを使い、RotateViewというViewクラスを使う。
  • RotateMapViewクラスの下にMapViewクラスのViewを配置する。
  • TileCashは配列にする必要はない。
  • 地図を回転すると単純な表示に比べて見た目を整えるのに工夫が必要?(詳細は次回以降で)