Taizoo's Tech note

技術系の備忘録

Android BottomNavigationを追加する

今回は、既存の画面にBottomNavigationを追加します。
元々はマップのみが表示される画面ですが、右図のようにBottomNavigationを追加していきたいと思います。 f:id:Taizoo:20220218155955p:plain

手順

ざっくりとした流れは以下の通りです。

  1. menu.xmlを追加
  2. layoutの修正
  3. NavigationBarのタップイベント処理の追加

メニューのxmlを追加

リソースフォルダ下にbottom_nav_menu.xmlを追加します。
※ファイル名はなんでもOK。
f:id:Taizoo:20220218165644p:plain

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/navigation_game"
        android:icon="@drawable/baseline_sports_esports_24"
        android:title="@string/title_game" />

    <item
        android:id="@+id/navigation_edit"
        android:icon="@drawable/outline_edit_24"
        android:title="@string/title_edit" />

</menu>

アイコンは、Material Iconsからダウンロードしてきました。

layoutの修正

<修正前>

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:map="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/map"
    android:name="com.google.android.gms.maps.SupportMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MapsActivity" />

<修正後>
BottomNavigationViewを追加しています。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        android:id="@+id/map"
        android:name="com.google.android.gms.maps.SupportMapFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/nav_view"
        tools:context=".MapsActivity" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        app:layout_constraintTop_toBottomOf="@+id/map"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>

Designで表示するとこのようになります。
f:id:Taizoo:20220218162534p:plain

最後にNavigationBarのイベント処理を追加していきます。

まず、OnItemSelectedListenerのimplementsを追加します。

public class MapsActivity extends FragmentActivity implements
        OnMapReadyCallback, PositionService.OnPositionListener, GoogleMap.OnMarkerClickListener,
        NavigationBarView.OnItemSelectedListener {

次に、onCreate()で、リスナーを登録します。

BottomNavigationView navView = findViewById(R.id.nav_view);
navView.setOnItemSelectedListener(this);

そして、onNavigationItemSelectedメソッドを実装して、イベントを処理します。 今回は、BottomNavigationに、GameとEditのメニューを設置しており、ゲームモードと編集モードを切り替えられるようにしました。

@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
    if (R.id.navigation_game == item.getItemId()) {
        mode = MODE_GAME;
    } else {
        mode = MODE_EDIT;
    }

    return true;
}

ソース

ソース全体は、Githubに登録しています。
GitHub - TaizooTech/SampleMap

参考

BottomNavigationView入門 - Qiita
AndroidでBottomNavigationBarを実装してみる - Re:30からはじめるエンジニア生活(仮)
Google Fonts

AndroidStudioからGithubへ登録する

今回はAndroidStudioからプロジェクトをGithubへ登録する方法を勉強していきます。Githubのアカウントだけは既に所有していたので、アカウント作成方法は割愛します。

目次

環境

Android Studio Bumblebee 2021.1.1 Patch 1

GitとGithub

そもそも理解が曖昧だったので、、

バージョン管理を有効化

①メニュー > VCS > Enable Version Control Integration...
②プルダウンで"Git"を選択し、"OK"をクリック f:id:Taizoo:20220213124547p:plain

Githubとの連携

f:id:Taizoo:20220213152058p:plain

"Generate"をクリックすると、下図のようにブラウザが起動してGithubのページが表示されます。ページに従いトークンを発行し、先程のToken欄にコピペします。 その後、"Add Account"ボタンをクリックします。 f:id:Taizoo:20220213125241p:plain

正常に識別されると、下図のようにShare by欄に自分のGithubのアカウントが表示されます。
f:id:Taizoo:20220213125445p:plain:w300

登録するソースを選択し、コミット

コミットに成功すると、下図のようにGithubに登録されたソースを確認できます。 f:id:Taizoo:20220213130003p:plain

実際に登録したプロジェクトは、以下です。

github.com

ほとんど参考サイトのとおりに作業ができました。
ありがとうございます。

参考

blog.codecamp.jp

Android Stutdio ライブラリプロジェクトを作る

前回は、GoogleMapの吹き出しをカスタマイズしました。
実際に子供と遊ぶことで、色々な改善点が見えてきました。
そこで今回は、今後の改修とGitでの管理を見据えてソースを少し整理していきます。

目次

方針

  • 現状、Activityクラスに位置情報に関連する処理が実装されており煩雑なので、位置情報関連処理をActivityクラスから分離する
  • 位置情報関連処理を今後使い回せるようライブラリ化する

流れ

  1. ライブラリプロジェクトを追加
  2. ライブラリプロジェクトへの参照を追加
  3. ライブラリプロジェクトの実装

やってみる

①ライブラリモジュールの追加

f:id:Taizoo:20220213052456p:plain

②ライブラリモジュールへの参照を追加

下図のようにsetting.gradleとbuild.gradleにそれぞれ1行追記すればOK。 なお、setting.gradleへの追記は、ライブラリプロジェクトを追加した際に、AndroidStudioが自動でやってくれました。 f:id:Taizoo:20220213062516p:plain

③ライブラリモジュールの実装

ライブラリモジュール(postionLib)に位置情報関連とセンサー関連の処理を移動させることで、appモジュール内がスッキリました。
f:id:Taizoo:20220213062743p:plain:w300

まとめ

今回は、ライブラリプロジェクトを作成し、ソースの構造を整理しました。 基本的には、

  • ライブラリモジュールの追加
  • ライブラリモジュールへの参照追加

の2ステップでできました。
ソースが少し整ったので、次はGithubへの登録してみたいと思います。

Android GoogleMapのInfoWindow(吹き出し)をカスタマイズする その2

前回は、InfoWindowをカスタマイズし、吹き出しに画像とタイトルと説明を表示できるようにしました。今回は、吹き出しの画像、タイトル、説明を自由に入力できるようにしていきます。

f:id:Taizoo:20220212184534p:plain:w300

方針

  • 長押しクリックイベントをキャッチしたら、画像、タイトル、説明を入力するためのダイアログを表示する
  • 画像は、スピナーで選択できるようにする
  • ダイアログでOKをタップすると、アイコンが追加され、ダイアログで入力した情報が吹き出しに反映されるようにする

ソースコード

先に概要です。

  • 長押しクリックイベントをキャッチしたら、ダイアログを表示します。
  • 画像とタイトルと説明を入力するためのダイアログを表示するため、DialogFragmentをextendsしたMyDialogFragmentクラスを作ります。
  • ダイアログのレイアウトは、custom_dialog.xmlに定義します。
  • ダイアログで入力した内容は、MyDialogFragment#onDialogResult()のコールバッグにより返却します。
  • 画像を選択するためのスピナーをコントロールするために、BaseAdapterをextendsしたSpinnerAdapterクラスを作ります。
  • スピナーのレイアウトは、spinner_layout.xmlに定義します。

MyDialogFragment.java

public class MyDialogFragment extends DialogFragment {

    private OnDialogFragmentListener listener;

    public interface OnDialogFragmentListener {
        void onDialogResult(int selectedItemResourceId, String title, String snippet);
    }

    public void setDialogFragmentListener(OnDialogFragmentListener listener) {
        this.listener = listener;
    }

    @NonNull
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {

        // カスタムダイアログのビューを生成
        View dialogView = requireActivity().getLayoutInflater().inflate(R.layout.custom_dialog, null);

        // 吹き出しに表示する画像を選択するスピナーを生成
        SpinnerAdapter adapter = new SpinnerAdapter(getActivity());
        Spinner spinner = dialogView.findViewById(R.id.sp_icon);
        spinner.setAdapter(adapter);

        // ダイアログの作成
        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setView(dialogView)
                .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        // ダイアログでOKをクリックした場合の操作
                        if (null != listener) {
                            // リスナー登録されている場合、リソースIDとタイトルと説明の情報を返却する
                            listener.onDialogResult(
                                (int)adapter.getItem(spinner.getSelectedItemPosition()),
                                ((EditText) dialogView.findViewById(R.id.et_title)).getText().toString(),
                                ((EditText) dialogView.findViewById(R.id.et_snipet)).getText().toString()
                            );
                        }
                    }
                })
                .setNegativeButton("Cancel", null);

        return builder.create();
    }
}

SpinnerAdapter.java

public class SpinnerAdapter extends BaseAdapter {

    private final LayoutInflater inflater;
    private final int[] imageIDs;
    private final String[] itemNames;

    public SpinnerAdapter(Context context) {
        inflater = LayoutInflater.from(context);

        // スピナーに登録する画像の名前リストをstring.xmlから取得
        String[] spinnerImages = context.getResources().getStringArray(R.array.spinner_image_names);
        itemNames = context.getResources().getStringArray(R.array.spinner_item_names);

        // 画像のリソースIDリストを取得
        imageIDs = new int[spinnerImages.length];
        for (int i=0; i < spinnerImages.length; i++) {
            imageIDs[i] = context.getResources().getIdentifier(
                    spinnerImages[i],
                    "drawable",
                    context.getPackageName());
        }
    }

    @Override
    public int getCount() {
        return imageIDs.length;
    }

    @Override
    public Object getItem(int position) {
        return imageIDs[position];
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {

        if (null == convertView) {
            convertView = inflater.inflate(R.layout.spinner_layout, null);
        }

        ((ImageView) convertView.findViewById(R.id.iv_item)).setImageResource(imageIDs[position]);
        ((TextView) convertView.findViewById(R.id.tv_item)).setText(itemNames[position]);
        return convertView;
    }
}

custom_dialog.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="8dp"
    android:paddingTop="8dp">

    <Spinner
        android:id="@+id/sp_icon"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/et_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="@string/item_title_hint"
        android:inputType="textPersonName"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/sp_icon" />

    <EditText
        android:id="@+id/et_snipet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="@string/item_snipet_hint"
        android:inputType="textPersonName"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/et_title" />

</androidx.constraintlayout.widget.ConstraintLayout>

spinner_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <!-- アイコンを表示するイメージビュー -->
    <ImageView
        android:id="@+id/iv_item"
        android:layout_width="32dp"
        android:layout_height="32dp" />

    <TextView
        android:id="@+id/tv_item"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:paddingLeft="16dp"
        android:textSize="24sp" />

</LinearLayout>

MapsActivity.java

public class MapsActivity extends FragmentActivity implements OnMapReadyCallback {

    private GoogleMap mMap;
    private ActivityMapsBinding binding;
    private Marker marker;
    private FusedLocationProviderClient flpClient = null;
    private MySensorManager mySensorManager = null;
    private LocationCallback locationCallback = null;
    private LatLng tmpLatLng;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMapsBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager()
                .findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);

        // 位置情報が変更された際に、通知を受け取るコールバックメソッドを定義
        locationCallback = new LocationCallback() {
            @Override
            public void onLocationResult(@NonNull LocationResult locationResult) {
                super.onLocationResult(locationResult);
                Location location = locationResult.getLastLocation();
                LatLng latlng = new LatLng(location.getLatitude(), location.getLongitude());
                mMap.moveCamera(CameraUpdateFactory.newLatLng(latlng));
                marker.setPosition(latlng);
                marker.setRotation(mySensorManager.getAzimuth());
            }
        };

        mySensorManager = new MySensorManager(this);
    }

    @Override
    protected void onResume() {
        super.onResume();
        startPositioning();
    }

    @Override
    protected void onPause() {
        super.onPause();
        stopPositioning();
    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;
        LatLng sydney = new LatLng(-34, 151);
        marker = mMap.addMarker(new MarkerOptions().position(sydney).title("Marker in Sydney"));
        marker.setIcon(BitmapDescriptorFactory.fromResource(R.drawable.marker));
        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney));

        mMap.setInfoWindowAdapter(new GoogleMap.InfoWindowAdapter() {

            @Override
            public View getInfoContents(@NonNull Marker marker) {

                // info_window_layout.xml のビューを生成
                View view = getLayoutInflater().inflate(R.layout.info_window_layout, null);

                // イメージビューを取得
                ImageView imgView = view.findViewById(R.id.imageView);

                InfoContents contents = (InfoContents) marker.getTag();
                if (null == contents) {
                    return null;
                }

                imgView.setImageResource(contents.resourceId);
                ((TextView) view.findViewById(R.id.tv_title)).setText(contents.title);
                ((TextView) view.findViewById(R.id.tv_snipet)).setText(contents.snipet);

                return view;
            }

            @Nullable
            @Override
            public View getInfoWindow(@NonNull Marker marker) {
                return null;
            }
        });

        // 長押しクリックイベントをセット
        mMap.setOnMapLongClickListener(new GoogleMap.OnMapLongClickListener() {
            @Override
            public void onMapLongClick(@NonNull LatLng latLng) {

                tmpLatLng = latLng;

                // アイコン選択ダイアログを表示
                MyDialogFragment dialogFragment = new MyDialogFragment();
                dialogFragment.setDialogFragmentListener(listener);
                dialogFragment.show(getSupportFragmentManager(), "custom_dialog");
            }
        });
    }

    private final MyDialogFragment.OnDialogFragmentListener listener =
            new MyDialogFragment.OnDialogFragmentListener() {
        @Override
        public void onDialogResult(int selectedItemResourceId, String title, String snippet) {
            // 長押しクリックイベントをキャッチしたらマーカーを追加
            Marker itemMarker = mMap.addMarker(new MarkerOptions().position(tmpLatLng));

            InfoContents contents = new InfoContents();
            contents.resourceId = selectedItemResourceId;
            contents.title = title;
            contents.snipet = snippet;

            itemMarker.setTag(contents);
        }
    };

    @Override
    public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (1 != requestCode) {
            return;
        }

        // ユーザが許可してくれた場合は、位置情報の取得を開始する
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            startPositioning();
        } else {
            Toast.makeText(this, "Permission Error.", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 位置情報の取得を開始する。
     */
    private void startPositioning() {

        // 位置情報へのアクセス許可チェック
        if (!checkPermission()) {
            return;
        }

        // 位置情報のリクエストを生成する
        LocationRequest request = LocationRequest.create();
        request.setInterval(1000);
        request.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        // 位置情報の更新をリクエストする
        flpClient = LocationServices.getFusedLocationProviderClient(this);
        flpClient.requestLocationUpdates(request, locationCallback, null);

        // センサーの取得を開始
        mySensorManager.startSensor();
    }

    /**
     * 位置情報の取得を停止する。
     */
    private void stopPositioning() {
        if (null != flpClient) {
            flpClient.removeLocationUpdates(locationCallback);
        }

        if (null != mySensorManager) {
            mySensorManager.stopSensor();
        }
    }

    /**
     * 位置情報へのアクセスが許可されているかチェックする。<br>
     * 許可されていてない場合、パーミッションリクエストを行う。
     * @return ture:許可 / false:未許可
     */
    private boolean checkPermission() {
        // アクセス許可チェック
        if (checkSelfPermission(ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
            return true;
        }

        // パーミッションリクエスト
        requestPermissions(new String[] {ACCESS_FINE_LOCATION},1);
        return false;
    }

    /**
     * 吹き出しに表示するコンテンツを保持するクラス
     */
    private class InfoContents {
        private int resourceId;
        private String title;
        private String snipet;

    }
}

今回のポイント

今回のポイントは、次の三点です。
①ダイアログでの入力内容をコールバックで通知する。

        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setView(dialogView)
                .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        // ダイアログでOKをクリックした場合の操作
                        if (null != listener) {
                            // リスナー登録されている場合、リソースIDとタイトルと説明の情報を返却する
                            listener.onDialogResult(
                                (int)adapter.getItem(spinner.getSelectedItemPosition()),
                                ((EditText) dialogView.findViewById(R.id.et_title)).getText().toString(),
                                ((EditText) dialogView.findViewById(R.id.et_snipet)).getText().toString()
                            );
                        }
                    }
                })
                .setNegativeButton("Cancel", null);

②ダイアログの入力内容をMarkerのタグで保持する。

    private final MyDialogFragment.OnDialogFragmentListener listener =
            new MyDialogFragment.OnDialogFragmentListener() {
        @Override
        public void onDialogResult(int selectedItemResourceId, String title, String snippet) {
            // 長押しクリックイベントをキャッチしたらマーカーを追加
            Marker itemMarker = mMap.addMarker(new MarkerOptions().position(tmpLatLng));

            InfoContents contents = new InfoContents();
            contents.resourceId = selectedItemResourceId;
            contents.title = title;
            contents.snipet = snippet;

            itemMarker.setTag(contents);
        }
    };

吹き出しを描画するタイミング(getInfoContents())で、Markerのタグから入力内容を取得し、吹き出しに反映する。

            @Override
            public View getInfoContents(@NonNull Marker marker) {

                // info_window_layout.xml のビューを生成
                View view = getLayoutInflater().inflate(R.layout.info_window_layout, null);

                // イメージビューを取得
                ImageView imgView = view.findViewById(R.id.imageView);

                InfoContents contents = (InfoContents) marker.getTag();
                if (null == contents) {
                    return null;
                }

                imgView.setImageResource(contents.resourceId);
                ((TextView) view.findViewById(R.id.tv_title)).setText(contents.title);
                ((TextView) view.findViewById(R.id.tv_snipet)).setText(contents.snipet);

                return view;
            }

実行結果

これで以下のことができるようになりました。

  • マップをロングタップすると、ダイアログが表示される
  • 画像をスピナーで選択できる
  • ダイアログで入力した内容が吹き出しに反映される
f:id:Taizoo:20220212185543p:plainf:id:Taizoo:20220212185414p:plainf:id:Taizoo:20220212185302p:plain

今後の課題

本アプリは、子供(4才)と一緒に宝探しゲームをすることを目的に作っています。 そのため、アイコンはRPGチックなものをチョイスしています。 試しに公園で試してみたところ、以下の改善点が見つかりました。
次回以降改善していきたいと思います。

  1. 自分の向きに応じてマーカーを回転させるより、地図を回転させたほうがわかりやすそう
  2. ゲームとしてイベントが少なすぎる(面白くない)
    →ジオフェンスで音を鳴らす等イベントを増やす
  3. 常に自分の位置がマップの中心位置になるので、アイテムを設置しにくい
    →マップ中心座標の更新ON/OFFをスイッチできるようにする
  4. 航空写真も表示できたが色々と便利
    →通常の地図と航空写真をスイッチできるようにする

また、ソース全文を載せると長くなってしまうので、今後はGitに登録するようにしたい。

参考

[Android] Spinner をカスタマイズして画像リストを表示する

リファレンス

ダイアログ  |  Android デベロッパー  |  Android Developers

素材について

ぴぽや https://pipoya.net/

Android GoogleMapのInfoWindow(吹き出し)をカスタマイズする

今回は、

  • GoogleMapのタッチイベントを拾ってアイコンを表示する
  • InfoWindow(吹き出し)をカスタマイズする

の2点をやっていきたいと思います。

方針

  • 長押しクリックイベントは、GoogleMap.setOnMapLongClickListener()を使って拾う
  • 吹き出しのカスタマイズは、独自のレイアウトを定義する
  • レイアウトは、左側にアイコン、右側にタイトルと説明を表示する

ソースコード

先にレイアウトです。
info_window_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <!-- アイコンを表示するイメージビュー -->
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <!-- タイトル用のテキストビュー -->
        <TextView
            android:id="@+id/tv_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_horizontal"
            android:text="This is title."
            android:textColor="#E91E63"
            android:textSize="16sp"
            android:textStyle="bold" />

        <!-- 説明用のテキストビュー -->
        <TextView
            android:id="@+id/tv_snipet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_horizontal"
            android:text="This is snipet."
            android:textColor="@color/black" />
    </LinearLayout>
</LinearLayout>

続いて、ソースコードです。

package com.example.sampleaddmarker;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.ImageView;

import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.OnMapReadyCallback;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
import com.example.sampleaddmarker.databinding.ActivityMapsBinding;

public class MapsActivity extends FragmentActivity implements OnMapReadyCallback {

    private GoogleMap mMap;
    private Marker marker;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(ActivityMapsBinding.inflate(getLayoutInflater()).getRoot());
        SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager()
                .findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);
    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;
        mMap.moveCamera(CameraUpdateFactory.newLatLng(new LatLng(-34, 151)));
        mMap.setInfoWindowAdapter(new GoogleMap.InfoWindowAdapter() {

            @Override
            public View getInfoContents(@NonNull Marker marker) {
                // info_window_layout.xml のビューを生成
                View view = getLayoutInflater().inflate(R.layout.info_window_layout, null);

                // イメージビューを取得
                ImageView imgView = view.findViewById(R.id.imageView);

                // イメージビューにアイコンをセット
                imgView.setImageResource(R.drawable.common_google_signin_btn_icon_dark_focused);

                return view;
            }

            @Nullable
            @Override
            public View getInfoWindow(@NonNull Marker marker) {
                return null;
            }
        });

        // 長押しクリックイベントをセット
        mMap.setOnMapLongClickListener(new GoogleMap.OnMapLongClickListener() {
            @Override
            public void onMapLongClick(@NonNull LatLng latLng) {
                // 長押しクリックイベントをキャッチしたらマーカーを追加
                marker = mMap.addMarker(new MarkerOptions().position(latLng));
            }
        });
    }
}

今回のポイント

GoogleMap.InfoWindowAdapter

吹き出しをカスタマイズするためには、InfoWindowAdapterを実装して、setInfoWindowAdapter()にセットする必要があります。 具体的には、getInfoContents()とgetInfoWindow()を実装します。 getInfoWindow()は、情報ウィンドウ全体をカスタマイズできるのに対し、getInfoContents()はウィンドウのコンテンツのみをカスタマイズできます。

今回は、getInfoContents()を使用して、吹き出しの中のコンテンツのみをカスタマイズしています。 独自に定義したレイアウト(info_window_layout.xml)をinflateしています。

@Override
public View getInfoContents(@NonNull Marker marker) {
    // info_window_layout.xml のビューを生成
    View view = getLayoutInflater().inflate(R.layout.info_window_layout, null);

    // イメージビューを取得
    ImageView imgView = view.findViewById(R.id.imageView);

    // イメージビューにアイコンをセット
    imgView.setImageResource(R.drawable.common_google_signin_btn_icon_dark_focused);

    return view;
}

実行結果

これで、マップを長押しすると、アイコンが追加されるようになりました。
また、アイコンをタップすると、カスタマイズした吹き出しが表示されるようになりました。 f:id:Taizoo:20220206082051p:plain:w300

参考にしたサイト

google maps for AndroidのinfoWindowをカスタマイズする - Qiita

リファレンス

Info Windows  |  Maps SDK for Android  |  Google Developers

GoogleMap.OnMapLongClickListener  |  Google Play services  |  Google Developers

Android GoogleMapのマーカーを端末の向きに応じて回転させる

前回は、GoogleMapに現在位置を表示するところまでやりました。
今回は、センサーを使用して端末が向いている向きに応じてマーカーを回転させたいと思います。

方針

  • 向いている方向がわかりやすいようにマップに表示するマーカーをカスタマイズする。
  • 端末の向き(方位角)は、SensorManagerを使用して取得した地磁気と加速度の値を利用して求める。
  • センサーを扱う処理は、MySensorManagerクラスに集約し、getAzumith()で最新の方位角を取得できるようにする。
  • 前回作成したMapsActivityクラスからMySensorManagerクラスを利用する。

ソースコード

/**
 * MySensorManagerクラス
 */
public class MySensorManager implements SensorEventListener {

    private final SensorManager sensorManager;
    private float[] gravity = new float[3];
    private float[] geomagnetic = new float[3];
    private float azimuth = 0.0f;

    /**
     * コンストラクタ
     * @param context コンテキスト
     */
    public MySensorManager(Context context) {
        // SensorManagerのインスタンス取得
        sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
    }

    @Override
    public void onSensorChanged(SensorEvent event) {

        // センサーのタイプに応じて値を取得
        switch (event.sensor.getType()) {
            case Sensor.TYPE_MAGNETIC_FIELD:
                geomagnetic = event.values.clone();
                break;
            case Sensor.TYPE_ACCELEROMETER:
                gravity = event.values.clone();
                break;
        }

        // 地磁気と加速度の両方の値が揃っていない場合、方位角の算出処理をスキップ
        if (null == geomagnetic || null == gravity) {
            return;
        }

        float[] R  = new float[16];     // 回転行列Rの値を格納する
        float[] value = new float[3];   // 方位角、ピッチ、ロールの回転角を格納する配列

        // 地磁気と加速度の値から回転行列を求める
        SensorManager.getRotationMatrix(R, null, gravity, geomagnetic);

        // 回転行列に基づいて方位角と傾きを算出
        SensorManager.getOrientation(R, value);

        // 方位角をラジアンから度に変換
        azimuth = (float) (value[0] * 180 / Math.PI);
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {}

    /**
     * センサー値の取得を開始する。
     */
    public void startSensor() {
        // 地磁気センサー値の取得を開始
        sensorManager.registerListener(
                this,
                sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD),
                SensorManager.SENSOR_DELAY_NORMAL);

        // 加速度センサー値の取得を開始
        sensorManager.registerListener(
                this,
                sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
                SensorManager.SENSOR_DELAY_NORMAL);
    }

    /**
     * センサー値の取得を停止する。
     */
    public void stopSensor() {
        sensorManager.unregisterListener(this);
    }

    /**
     * 方位角を取得する。
     * @return 方位角
     */
    public float getAzimuth() {
        return azimuth;
    }
}

ソースのざっくり解説

  1. MySensorManagerクラスは、SensorEventListenerをimplements
  2. コンストラクタで、SensorManagerインスタンスを取得
  3. startSensor()で、地磁気と加速度の取得を開始
  4. stopSensor()で、地磁気と加速度の取得を停止
  5. onSensorChanged()は、センサー値が変更した際に呼び出される
    onSensorChanged()にて、取得したセンサー値から方位角を算出
  6. getAzumith()で、方位角を返却

今回のポイント

マーカーのアイコンをカスタマイズ

向きがわかりやすいようにアイコンをカスタマイズします。
イコン画像をdrawableフォルダに配置することで、オリジナル画像をマーカーとして使うことができます。 BitmapDescriptorFactory.fromResource()で指定してます。
f:id:Taizoo:20220123125444p:plain

@Override
public void onMapReady(GoogleMap googleMap) {
    mMap = googleMap;
    LatLng sydney = new LatLng(-34, 151);
    marker = mMap.addMarker(new MarkerOptions().position(sydney).title("Marker in Sydney"));
    marker.setIcon(BitmapDescriptorFactory.fromResource(R.drawable.icon));
    mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney));
}

方位角の算出

方位角は、onSensorChanged()のメソッド内で計算しています。 今回は、ソースコードのコメントに私自身の解釈で説明を記載しました。
正直、地磁気と加速度の値から回転行列を求め、回転行列から方位角が算出される仕組みまでは理解していません。下記の参考サイトを参考にさせていただきました。
個人的なポイントとしては、MySensorManagerクラスにセンサー関連の処理を集約させることで、MapsActivityクラスのコード量を減らしているところが工夫した点です。

Marker.setRotation()でアイコンの向きをセット

方位角は、MySensorManager.getAzimuth()で取得し、setRotation()でセットしています。

locationCallback = new LocationCallback() {
    @Override
    public void onLocationResult(@NonNull LocationResult locationResult) {
        super.onLocationResult(locationResult);
        Location location = locationResult.getLastLocation();
        LatLng latlng = new LatLng(location.getLatitude(), location.getLongitude());
        mMap.moveCamera(CameraUpdateFactory.newLatLng(latlng));
        marker.setPosition(latlng);
        marker.setRotation(mySensorManager.getAzimuth());
    }
};

実行結果

実行すると、このように向きに応じてマーカーが回転するようになりました。 デフォルトのマーカーよりも移動している感じが格段にわかりやすくなりました。 f:id:Taizoo:20220123172108p:plain なお、マーカーのアイコンは、icon8の素材を使用させていただいています。

参考にしたサイト

端末の向きと傾きを取得する方法 - 加速度センサーと地磁気センサーの利用 - Androidプログラミングの基礎 - Android 開発入門
Androidで世界座標系の加速度と方位を取得する - Qiita

リファレンス

Sensor  |  Android Developers
SensorManager  |  Android Developers
SensorEventListener  |  Android Developers

icon8

https://icons8.com/
GPSデバイス icon by Icons8

FusedLocationProviderClientで取得した現在位置をGoogleMapに表示する

前回は、アプリにGoogleMapを表示するところまでやってみました。
今回は、Google Play ServiceのFusedLocationProviderClientを使って、GoogleMapに現在位置を表示します。

目次

ソースコード

/**
 * MapsActivityクラス
 */
public class MapsActivity extends FragmentActivity implements OnMapReadyCallback {

    private GoogleMap mMap;
    private ActivityMapsBinding binding;
    private Marker marker;
    private FusedLocationProviderClient flpClient = null;
    private LocationCallback locationCallback = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMapsBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager()
                .findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);

        // 位置情報が変更された際に、通知を受け取るコールバックメソッドを定義
        locationCallback = new LocationCallback() {
            @Override
            public void onLocationResult(@NonNull LocationResult locationResult) {
                super.onLocationResult(locationResult);
                Location location = locationResult.getLastLocation();
                LatLng latlng = new LatLng(location.getLatitude(), location.getLongitude());
                marker.setPosition(latlng);
                mMap.moveCamera(CameraUpdateFactory.newLatLng(latlng));
            }
        };
    }

    @Override
    protected void onResume() {
        super.onResume();
        startPositioning();
    }

    @Override
    protected void onPause() {
        super.onPause();
        stopPositioning();
    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;
        LatLng sydney = new LatLng(-34, 151);
        marker = mMap.addMarker(new MarkerOptions().position(sydney).title("Marker in Sydney"));
        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney));
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (1 != requestCode) {
            return;
        }

        // ユーザが許可してくれた場合は、位置情報の取得を開始する
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            startPositioning();
        } else {
            Toast.makeText(this, "Permission Error.", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 位置情報の取得を開始する。
     */
    private void startPositioning() {

        // 位置情報へのアクセス許可チェック
        if (!checkPermission()) {
            return;
        }

        // 位置情報の取得を開始する
        flpClient = LocationServices.getFusedLocationProviderClient(this);
        LocationRequest request = LocationRequest.create();
        request.setInterval(1000);
        request.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        // 位置情報が変更された際は、locationCallbackで受け取る
        flpClient.requestLocationUpdates(request, locationCallback, null);
    }

    /**
     * 位置情報の取得を停止する。
     */
    private void stopPositioning() {
        if (null != flpClient) {
            flpClient.removeLocationUpdates(locationCallback);
        }
    }

    /**
     * 位置情報へのアクセスが許可されているかチェックする。<br>
     * 許可されていてない場合、パーミッションリクエストを行う。
     * @return ture:許可 / false:未許可
     */
    private boolean checkPermission() {
        // アクセス許可チェック
        if (checkSelfPermission(ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
            return true;
        }

        // パーミッションリクエスト
        requestPermissions(new String[] {ACCESS_FINE_LOCATION},1);
        return false;
    }
}

ソースのざっくり解説

  1. onCreate()で位置情報を受け取るLocationCallbackを定義
  2. onResume()で位置情報の更新を開始(startPositioning()呼び出し)
  3. onStop()で位置情報の更新を停止
  4. startPostioning()で
    1. 位置情報のパーミッションチェック
    2. 未許可の場合はパーミッションリクエス
    3. 許可の場合は、FusedLocationProviderClientを使って、位置情報の更新を開始
  5. onRequestPermissionsResult()でパーミッションリクエストの結果を処理
    1. 許可されていれば再度startPositioning()呼び出し
  6. 位置情報が更新されると、1 で定義したLocationCallbackのonLocationResult()が呼ばれて、マップとアイコンを更新 という感じです。

今回のポイント

パーミッションをチェック&リクエストする

今回の本題ではありませんが、端末の位置情報にアクセスするためには、ユーザに位置情報へのアクセスを許可してもらう必要があります。 やることは2つです。

  1. AndroidManifest.xmlパーミッションの記載を追加
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  2. ユーザにパーミッションリクエストを行う
    パーミッションチェックを行っていない状態で、FusedLocationProviderClientを使って位置情報の更新を開始しようとするとエラーになります。
    そこで、checkPermission()を呼び出し、位置情報へのアクセスの許可状態のチェックと許可されていない場合は、リクエストを行います。
    f:id:Taizoo:20220122150054p:plain:w300
    ユーザの選択結果をonRequestPermissionsResult()で受け取り処理しています。

FusedLocationProviderClientを使って位置情報の更新をリクエストする

private void startPositioning() {

    // 位置情報へのアクセス許可チェック
    if (!checkPermission()) {
        return;
    }

    // 位置情報のリクエストを生成する
    LocationRequest request = LocationRequest.create();
    request.setInterval(1000);
    request.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

    // 位置情報の更新をリクエストする
    flpClient = LocationServices.getFusedLocationProviderClient(this);
    flpClient.requestLocationUpdates(request, locationCallback, null);
}
  • FusedLocationProviderClientを使うためには、GooglePlayServiceのLocationAPIを使用します。
    LocationAPIを使用するためには、build.gradle(:app)に以下を追加します。
implementation 'com.google.android.gms:play-services-location:19.0.1'
  • [LocationServices].getFusedLocationProviderClient()でFusedLocationProviderClientインスタンスを取得します。
  • requestLocationUpdates()で位置情報の更新を要求します。
  • 位置情報が更新されると、onLocationResult()が呼び出されます。

取得した現在位置でマップの中心座標とアイコンの座標を変更する

locationCallback = new LocationCallback() {
    @Override
    public void onLocationResult(@NonNull LocationResult locationResult) {
        super.onLocationResult(locationResult);
        Location location = locationResult.getLastLocation();
        LatLng latlng = new LatLng(location.getLatitude(), location.getLongitude());
        marker.setPosition(latlng);
        mMap.moveCamera(CameraUpdateFactory.newLatLng(latlng));
    }
};
  • onLocationResult()でLocationResultを受け取ることができます。
  • 受け取ったLocationResultから緯度経度を取得し、LatLngオブジェクトを生成します。
  • 生成したLatLngオブジェクトを、マーカーの座標とマップの中心座標にセットします。

実行結果

実行するとこのように現在位置が表示されます。
f:id:Taizoo:20220122152741p:plain:w300

リファレンス

LatLng  |  Google Play services  |  Google Developers
LocationServices  |  Google Play services  |  Google Developers
LocationResult  |  Google Play services  |  Google Developers
LocationRequest  |  Google Play services  |  Google Developers
LocationCallback  |  Google Play services  |  Google Developers
FusedLocationProviderClient  |  Google Play services  |  Google Developers