Taizoo's Tech note

技術系の備忘録

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/