Flutterのマップパッケージ3選(Google Map, Flutter Map, Mapbox)
Flutter Advent Calendar 2024 の 24日目🎅🎄
Flutterのマップパッケージを紹介します。
packages | 概要 | 料金 |
---|---|---|
google_maps_flutter | Google Map SDK | 無料(ネイティブアプリの利用のみ) |
flutter_map | 地図のラスター/ベクターデータを指定して好みのマップを利用できる | 無料 |
mapbox_maps_flutter | Mapbox SDK | 従量課金 |
本記事のサンプルコードはこちらで確認できます。
google_maps_flutter
その1 | その2 | その3 |
---|---|---|
Marker
Marker
をGoogleMap
のmarkers
にセットするとマーカーピンが表示されます。
Marker(
markerId: MarkerId(id),
icon: BitmapDescriptor.defaultMarker,
position: LatLng(
34.70239193591162,
135.4958750992668,
),
onTap: () {},
);
BitmapDescriptor
はデフォルトは赤色ですが、それ以外の色も用意されています。
Marker(
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
...
);
BitmapDescriptorの種類
/// Convenience hue value representing red.
static const double hueRed = 0.0;
/// Convenience hue value representing orange.
static const double hueOrange = 30.0;
/// Convenience hue value representing yellow.
static const double hueYellow = 60.0;
/// Convenience hue value representing green.
static const double hueGreen = 120.0;
/// Convenience hue value representing cyan.
static const double hueCyan = 180.0;
/// Convenience hue value representing azure.
static const double hueAzure = 210.0;
/// Convenience hue value representing blue.
static const double hueBlue = 240.0;
/// Convenience hue value representing violet.
static const double hueViolet = 270.0;
/// Convenience hue value representing magenta.
static const double hueMagenta = 300.0;
/// Convenience hue value representing rose.
static const double hueRose = 330.0;
カスタマイズ
表示したいマーカーをBitmapDescriptorに変換する必要があります。
Widget → 画像 → BitmapDescriptor
に変換して、マーカーピンとしてセットします。
サンプルコードでは、CafeMarker
のWidgetを構築したものを画像にし、BitmapDescriptorに変換してセットします。
データはGoogle Places APIから取得したデータを利用しました。
CafeMarker
import 'package:flutter/material.dart';
class CafeMarker extends StatelessWidget {
const CafeMarker({
super.key,
required this.id,
required this.globalKey,
required this.title,
required this.rating,
required this.isSelected,
});
final String id;
final GlobalKey globalKey;
final String title;
final double rating;
final bool isSelected;
Widget build(BuildContext context) {
final baseWidth = 120.0 * (isSelected ? 1.5 : 1);
final markerSize = Size(baseWidth, baseWidth);
final iconSize = 40.0 * (isSelected ? 1.5 : 1);
final radius = iconSize * 1.2;
final textWidth = markerSize.width * 1.9;
final fontSize = 32.0 * (isSelected ? 1.1 : 1);
const fontColor = Colors.black;
const fontBorderColor = Colors.white;
return RepaintBoundary(
key: globalKey,
child: SizedBox(
width: textWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: CircleAvatar(
backgroundColor: Colors.redAccent,
radius: radius,
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_cafe,
color: Colors.white,
size: iconSize,
),
Flexible(
child: Text(
rating.toStringAsFixed(1),
style: TextStyle(
height: 1.2,
color: Colors.white,
fontSize: fontSize * 0.9,
),
),
),
],
),
),
),
),
SizedBox(
width: textWidth,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: fontColor,
fontSize: fontSize,
height: 1.3,
shadows: const [
Shadow(
offset: Offset(-1.5, -1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(1.5, -1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(1.5, 1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(-1.5, 1.5),
color: fontBorderColor,
),
],
),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
カスタムマーカーを表示
class GoogleMapPage extends StatefulWidget {
const GoogleMapPage({super.key});
State<GoogleMapPage> createState() => _State();
}
class _State extends State<GoogleMapPage> {
List<CafeMarker> _cafeMarkers = [];
List<({Place place, BitmapDescriptor bitmap})> _cafeBitmapDescriptors = [];
CafeMarker? _selectedCafeMarker;
({Place place, BitmapDescriptor bitmap})? _selectedCafeBitmapDescriptor;
...
void initState() {
super.initState();
Future(() async {
final places = await fetchPlaces();
setState(() {
...
/// カスタムマーカー作成
_cafeMarkers = places
.map(
(e) => CafeMarker(
id: e.placeId,
globalKey: GlobalKey(),
title: e.detail.name,
rating: e.rating,
isSelected: false,
),
)
.toList();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = <({Place place, BitmapDescriptor bitmap})>[];
for (final widget in _cafeMarkers) {
final bitmap = await createCustomMarker(widget.globalKey);
if (bitmap == null) {
continue;
}
final id = widget.id;
final place = places.firstWhereOrNull((e) => e.placeId == id);
if (place == null) {
continue;
}
result.add(
(place: place, bitmap: bitmap),
);
}
setState(() {
_cafeBitmapDescriptors = result;
});
});
});
});
}
Widget build(BuildContext context) {
final markers = _cafeBitmapDescriptors.map((e) {
final place = e.place;
final isSelected = _selectedMarkerId?.value == place.placeId;
return Marker(
markerId: MarkerId(place.placeId),
zIndex: isSelected ? 100 : place.rating,
icon: isSelected
? _selectedCafeBitmapDescriptor?.bitmap ?? e.bitmap
: e.bitmap,
position: LatLng(
place.geometry.location.lat,
place.geometry.location.lng,
),
onTap: () {
setState(() {
final markerId = MarkerId(place.placeId);
final isEnabled = _selectedMarkerId != markerId;
_selectedMarkerId = isEnabled ? markerId : null;
_selectedCafeMarker = isEnabled
? CafeMarker(
id: place.placeId,
globalKey: GlobalKey(),
title: place.detail.name,
rating: place.rating,
isSelected: true,
)
: null;
final selectedCafeMarker = _selectedCafeMarker;
if (selectedCafeMarker != null) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final bitmap =
await createCustomMarker(selectedCafeMarker.globalKey);
if (bitmap == null) {
return;
}
setState(() {
_selectedCafeBitmapDescriptor = (
place: place,
bitmap: bitmap,
);
});
});
} else {
_selectedCafeBitmapDescriptor = null;
}
});
},
);
}).toSet();
return Scaffold(
body: Stack(
children: [
// Widgetとして表示しないと画像化できないため
if (_selectedCafeMarker != null) _selectedCafeMarker!,
..._cafeMarkers,
GoogleMap(
markers: markers,
...
),
...
],
),
);
}
}
Widgetを画像に変換する
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
Future<BitmapDescriptor?> createCustomMarker(
GlobalKey key, {
double? width,
double? height,
}) async {
try {
final context = key.currentContext;
if (context == null) {
return null;
}
final boundary = context.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) {
return null;
}
final image = await boundary.toImage();
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
return null;
}
if (!context.mounted) {
return null;
}
return BytesMapBitmap(
byteData.buffer.asUint8List(),
width: width,
height: height,
imagePixelRatio: MediaQuery.maybeDevicePixelRatioOf(context),
);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
debugPrint(e.toString());
return null;
}
}
fetchPlaces
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:map_sample/core/entities/place.dart';
import 'package:map_sample/core/gen/assets.gen.dart';
Future<List<Place>> fetchPlaces() async {
final data = await rootBundle.loadString(Assets.json.spots);
final jsonList = jsonDecode(data) as List<dynamic>;
final result =
jsonList.map((e) => Place.fromJson(e as Map<String, dynamic>)).toList();
return result;
}
Polyline
Polyline
をGoogleMap
のpolylines
にセットするとルートが表示されます。
データはGoogle Places APIから取得したデータを利用しました。
Polyline(
polylineId: const PolylineId('route1'),
points: [LatLng(34.70205, 135.49614), LatLng(34.70204, 135.49616), ...],
color: Colors.blueAccent,
width: 8,
),
サンプルコードでは、大阪駅からカフェまでのルートを表示します。
大阪駅からカフェまでのルートを表示
class GoogleMapPage extends StatefulWidget {
const GoogleMapPage({super.key});
State<GoogleMapPage> createState() => _State();
}
class _State extends State<GoogleMapPage> {
...
Set<Polyline> _polylines = {};
void initState() {
super.initState();
Future(() async {
final routes = await fetchRoute();
setState(() {
/// ルート
_polylines = {
Polyline(
polylineId: const PolylineId('route1'),
points: routes.map((e) => LatLng(e.first, e.last)).toList(),
color: Colors.blueAccent,
width: 8,
),
};
});
});
}
Widget build(BuildContext context) {
...
return Scaffold(
body: Stack(
children: [
...
GoogleMap(
polylines: _polylines,
...
),
...
],
),
);
}
}
fetchRoute
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:map_sample/core/gen/assets.gen.dart';
Future<List<List<double>>> fetchRoute() async {
final data = await rootBundle.loadString(Assets.json.route);
final jsonList = jsonDecode(data) as List<dynamic>;
final result = jsonList
.map((e) => (e as List<dynamic>).map((d) => d as double).toList())
.toList();
return result;
}
Polygon
Polygon
をGoogleMap
のpolygons
にセットすると、指定範囲の色付けができます。
Polygon(
polygonId: PolygonId(e.id),
points: [LatLng(34.7086, 135.4945), LatLng(34.708, 135.4947), ...],
strokeWidth: 4,
fillColor: Colors.green.withOpacity(0.2),
strokeColor: Colors.green,
),
サンプルコードでは、大阪駅周辺の範囲を緑色で表示します。
GeoJsonファイルは法務省登記所備付地図データを利用しました。
大阪駅周辺の範囲を緑色で表示
class GoogleMapPage extends StatefulWidget {
const GoogleMapPage({super.key});
State<GoogleMapPage> createState() => _State();
}
class _State extends State<GoogleMapPage> {
...
Set<Polyline> _polylines = {};
void initState() {
super.initState();
Future(() async {
final geoJsons = await fetchGeoJson();
setState(() {
/// ポリゴン
_polygons = geoJsons
.map(
(e) => Polygon(
polygonId: PolygonId(e.id),
points: e.geoPoints
.map((p) => LatLng(p.latitude, p.longitude))
.toList(),
strokeWidth: 4,
fillColor: Colors.green.withOpacity(0.2),
strokeColor: Colors.green,
),
)
.toSet();
});
});
}
Widget build(BuildContext context) {
...
return Scaffold(
body: Stack(
children: [
...
GoogleMap(
polygons: _polygons,
...
),
...
],
),
);
}
}
fetchGeoJson
import 'package:flutter/services.dart';
import 'package:geojson/geojson.dart';
import 'package:geopoint/geopoint.dart';
import 'package:map_sample/core/gen/assets.gen.dart';
typedef Result = ({
String id,
List<GeoPoint> geoPoints,
});
Future<List<Result>> fetchGeoJson() async {
final data = await rootBundle.loadString(Assets.json.a271276R);
final geo = GeoJson();
await geo.parse(data);
final results = <Result>[];
for (var i = 0; i < geo.features.length; i++) {
final feature = geo.features[i];
final id = feature.properties?['ID'] as String? ?? '';
final polygon = geo.polygons[i];
final geoPoints =
polygon.geoSeries.expand((element) => element.geoPoints).toList();
results.add((id: id, geoPoints: geoPoints));
}
return results;
}
flutter_map
その1 |
---|
TileLayer
のurlTemplate
にラスターデータとなるURLを指定すると、マップが表示されます。
FlutterMap(
children: [
TileLayer(
urlTemplate: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
),
],
),
OpenStreetMapのWikiにラスタータイルのプロバイダーリストがまとめられています。
Marker
Marker
をMarkerLayer
にセットするとマーカーピンが表示されます。
FlutterMap
はWidgetでマーカーピンを構築できるので、簡単にカスタマイズできて便利です。
Marker(
point: LatLng(
34.70239193591162,
135.4958750992668,
),
child: GestureDetector(
onTap: () {
...
},
child: Icon(
Icons.pin_drop_sharp,
),
),
);
大阪駅周辺のカフェを表示
class FlutterMapPage extends StatefulWidget {
const FlutterMapPage({super.key});
State<FlutterMapPage> createState() => _State();
}
class _State extends State<FlutterMapPage> {
final _mapController = MapController();
List<Place> _places = [];
int? _selectedMarkerId;
void initState() {
super.initState();
Future(() async {
final places = await fetchPlaces();
setState(() {
/// デフォルトマーカー
_places = places;
});
});
}
Widget build(BuildContext context) {
final markers = _places.mapIndexed((index, e) {
final isSelected = _selectedMarkerId == index;
final point = LatLng(
e.geometry.location.lat,
e.geometry.location.lng,
);
const fontColor = Colors.black;
const fontBorderColor = Colors.white;
const fontSize = 10.0;
return Marker(
point: point,
width: 120,
height: 72,
child: GestureDetector(
onTap: () {
final currentZoom = _mapController.camera.zoom;
_mapController.move(point, currentZoom);
setState(() {
_selectedMarkerId = _selectedMarkerId != index ? index : null;
});
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.pin_drop_sharp,
size: 40,
color: isSelected ? Colors.deepPurple : Colors.redAccent,
),
Flexible(
child: Text(
e.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: fontColor,
fontSize: fontSize,
height: 1.3,
shadows: [
Shadow(
offset: Offset(-1.5, -1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(1.5, -1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(1.5, 1.5),
color: fontBorderColor,
),
Shadow(
offset: Offset(-1.5, 1.5),
color: fontBorderColor,
),
],
),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}).toList();
return Scaffold(
body: Stack(
children: [
FlutterMap(
children: [
TileLayer(
urlTemplate:
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
),
MarkerLayer(
markers: markers,
),
],
),
],
),
);
}
}
Polyline
Polyline
をPolylineLayer
にセットするとルートを表示できます。
Polyline(
points: [LatLng(34.70205, 135.49614), LatLng(34.70204, 135.49616), ...],
color: Colors.blueAccent,
strokeWidth: 8,
),
大阪駅からカフェまでのルートを表示
class FlutterMapPage extends StatefulWidget {
const FlutterMapPage({super.key});
State<FlutterMapPage> createState() => _State();
}
class _State extends State<FlutterMapPage> {
List<Polyline> _polylines = [];
void initState() {
super.initState();
Future(() async {
final routes = await fetchRoute();
setState(() {
/// ルート
_polylines = [
Polyline(
points: routes.map((e) => LatLng(e.first, e.last)).toList(),
color: Colors.blueAccent,
strokeWidth: 8,
),
];
});
});
}
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
children: [
...
PolylineLayer(
polylines: _polylines,
),
],
),
],
),
);
}
}
Polygon
Polygon
をPolygonLayer
にセットすると、指定範囲の色付けができます。
Polygon(
color: Colors.green.withOpacity(0.2),
borderColor: Colors.green,
borderStrokeWidth: 4,
points: [LatLng(34.7086, 135.4945), LatLng(34.708, 135.4947), ...],
),
大阪駅周辺の範囲を緑色で表示
class FlutterMapPage extends StatefulWidget {
const FlutterMapPage({super.key});
State<FlutterMapPage> createState() => _State();
}
class _State extends State<FlutterMapPage> {
List<Polygon> _polygons = [];
void initState() {
super.initState();
Future(() async {
final geoJsons = await fetchGeoJson();
setState(() {
/// ポリゴン
_polygons = geoJsons
.map(
(e) => Polygon(
color: Colors.green.withOpacity(0.2),
borderColor: Colors.green,
borderStrokeWidth: 4,
points: e.geoPoints
.map(
(geoPoint) => LatLng(
geoPoint.latitude,
geoPoint.longitude,
),
)
.toList(),
),
)
.toList();
});
});
}
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
children: [
...
PolygonLayer(
polygons: _polygons,
),
],
),
],
),
);
}
}
mapbox_maps_flutter
その1 |
---|
MapWidget
のonMapCreated
から得られるMapboxMap
オブジェクトを利用して、マーカーなどの表示を実現できます。
MapWidget(
onMapCreated: (mapboxMap) async {
// mapBoxオブジェクトを利用してマップ上の操作を制御する。
),
),
Marker
PointAnnotationManager
を利用してマーカーを表示します。
PointAnnotationOptions
にデータを指定し、PointAnnotationManager
のcreateMulti
にセットすると表示できます。
マーカーは画像をバイト配列に変換して設定します。また、マーカーにテキストを表示するだけであれば簡単にできます。
マーカーの画像はこちらからダウンロードしました。
final manager =
await mapboxMap.annotations.createPointAnnotationManager();
final bytes = await rootBundle.load(Assets.images.redMarker.path);
final image = bytes.buffer.asUint8List();
final options = [
PointAnnotationOptions(
geometry: Point(
coordinates: Position(
135.4958750992668, // 経度
34.70239193591162, // 緯度
),
),
textField: '大阪駅',
textSize: 10,
textOffset: [0, 2.5],
textLineHeight: 1.3,
textJustify: TextJustify.CENTER,
image: image,
),
];
await manager.createMulti(options);
マーカーのタップイベントはOnPointAnnotationClickListener
を継承した実体クラスを作る必要があります。実体クラスでタップイベントを実装すると引数のバケツリレーが発生して面倒なのでコールバック関数でタップイベントを検知できるようにします。
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
class AnnotationClickListener extends OnPointAnnotationClickListener {
AnnotationClickListener({
required this.onClick,
});
final void Function(PointAnnotation annotation) onClick;
void onPointAnnotationClick(PointAnnotation annotation) {
onClick(annotation);
}
}
manager.addOnPointAnnotationClickListener(
AnnotationClickListener(
onClick: (annotation) {
// タップイベントの処理はここに書く
},
),
);
大阪駅周辺のカフェを表示
class MapboxPage extends StatefulWidget {
const MapboxPage({super.key});
State<MapboxPage> createState() => _State();
}
class _State extends State<MapboxPage> {
late final MapboxMap mapboxMap;
final defaultLatLng = Point(
coordinates: Position(
135.4958750992668,
34.70239193591162,
),
);
final defaultZoom = 14.0;
Widget build(BuildContext context) {
/// マーカー設定
Future<void> setupAnnotation(List<Place> places) async {
final manager =
await mapboxMap.annotations.createPointAnnotationManager();
final bytes = await rootBundle.load(Assets.images.redMarker.path);
final image = bytes.buffer.asUint8List();
final options = places
.map(
(e) => PointAnnotationOptions(
geometry: Point(
coordinates: Position(
e.geometry.location.lng,
e.geometry.location.lat,
),
),
textField: e.name,
textSize: 10,
textOffset: [0, 2.5],
textLineHeight: 1.3,
textJustify: TextJustify.CENTER,
image: image,
),
)
.toList();
await manager.createMulti(options);
manager.addOnPointAnnotationClickListener(
AnnotationClickListener(
onClick: (annotation) {
mapboxMap.flyTo(
CameraOptions(center: annotation.geometry),
MapAnimationOptions(duration: 500, startDelay: 0),
);
},
),
);
}
return Scaffold(
body: Stack(
children: [
MapWidget(
cameraOptions: CameraOptions(
center: defaultLatLng,
zoom: defaultZoom,
),
onMapCreated: (mapBox) async {
mapboxMap = mapBox;
/// マーカー
final places = await fetchPlaces();
await setupAnnotation(places);
},
),
],
),
);
}
}
Polyline
PolylineAnnotationManager
を利用してルートを表示します。
PolylineAnnotationOptions
にデータを指定し、PolylineAnnotationManager
のcreateMulti
にセットすると表示できます。
final manager =
await mapboxMap.annotations.createPolylineAnnotationManager();
final options = [
PolylineAnnotationOptions(
geometry: LineString(
coordinates: [
LatLng(135.49614, 34.70205),
LatLng(135.49616, 34.70204),
...
],
),
lineColor: Colors.blueAccent.value,
lineWidth: 6,
),
];
await manager.createMulti(options);
大阪駅からカフェまでのルートを表示
class MapboxPage extends StatefulWidget {
const MapboxPage({super.key});
State<MapboxPage> createState() => _State();
}
class _State extends State<MapboxPage> {
late final MapboxMap mapboxMap;
Widget build(BuildContext context) {
/// ルート設定
Future<void> setupRoutes(List<List<double>> routes) async {
final manager =
await mapboxMap.annotations.createPolylineAnnotationManager();
final options = [
PolylineAnnotationOptions(
geometry: LineString(
coordinates: routes
.map(
(e) => Position(e.last, e.first),
)
.toList(),
),
lineColor: Colors.blueAccent.value,
lineWidth: 6,
),
];
await manager.createMulti(options);
}
return Scaffold(
body: Stack(
children: [
MapWidget(
...
onMapCreated: (mapBox) async {
mapboxMap = mapBox;
/// ルート
final routes = await fetchRoute();
await setupRoutes(routes);
},
),
],
),
);
}
}
Polygon
PolygonAnnotationManager
を利用してルートを表示します。
PolygonAnnotationOptions
にデータを指定し、PolygonAnnotationManager
のcreateMulti
にセットすると表示できます。
PolygonAnnotationOptions(
geometry: Polygon(
coordinates: [
[LatLng(135.4945, 34.7086), LatLng(135.4947, 34.708), ...],
],
),
fillColor: Colors.green.withOpacity(0.5).value,
fillOutlineColor: Colors.green.value,
),
大阪駅周辺の範囲を緑色で表示
class MapboxPage extends StatefulWidget {
const MapboxPage({super.key});
State<MapboxPage> createState() => _State();
}
class _State extends State<MapboxPage> {
late final MapboxMap mapboxMap;
Widget build(BuildContext context) {
/// ポリゴン設定
Future<void> setupPolygon(List<Result> polygonList) async {
final manager =
await mapboxMap.annotations.createPolygonAnnotationManager();
final options = [
PolygonAnnotationOptions(
geometry: Polygon(
coordinates: polygonList
.map(
(e) => e.geoPoints
.map(
(geoPoint) =>
Position(geoPoint.longitude, geoPoint.latitude),
)
.toList(),
)
.toList(),
),
fillColor: Colors.green.withOpacity(0.5).value,
fillOutlineColor: Colors.green.value,
),
];
await manager.createMulti(options);
}
return Scaffold(
body: Stack(
children: [
MapWidget(
...
onMapCreated: (mapBox) async {
mapboxMap = mapBox;
/// ポリゴン
final geoJsons = await fetchGeoJson();
await setupPolygon(geoJsons);
},
),
],
),
);
}
}
終わりに
今回紹介したパッケージ以外にもマップパッケージはあります。
maplibre_gl
はもぐもぐさんが詳しいのでぜひ聞いてみてください(無茶振り)
ここまで読んでいただきありがとうございました!
Discussion