🦉
【Flutter】Mapで特定座標に任意のWidgetを表示する
はじめに
Flutter でマップアプリを開発する際、特定の位置に単純なマーカーを配置するだけでなく、任意のウィジェットを表示したいケースがあります。例えば:
- ユーザーの現在地を動的なアニメーションで表示
- 目的地に到着するまでの残り時間をリアルタイムで表示
- 表示内容を動的に変化させて表示
本記事では、Google Maps 上の任意の座標にウィジェットを配置する方法を解説します。
1. マップの表示データ取得
GoogleMap
ウィジェットのonCameraMove
コールバックを使用して、マップの状態を取得します。
GoogleMap(
mapType: MapType.normal,
initialCameraPosition: CameraPosition(
target: initialPosition,
zoom: 16.0,
),
onCameraMove: (CameraPosition position) {
final mapCenter = position.target,
final zoom = position.zoom,
final angle = position.bearing
},
)
2. スクリーン上の位置計算
マップの状態とターゲット座標から、スクリーン上の位置を計算します。
Offset mapToScreenCoordinates({
required Size screenSize,
required LatLng targetPosition,
required LatLng mapCenter,
required double zoom,
required double angle,
}) {
/// ワールドの幅を計算
final double worldWidth = 256 * pow(2, zoom).toDouble();
/// 緯度をy座標に変換(緯度はメルカトル図法で非線形に変換する必要がある)
double fromLatToY(double lat) {
final double latRad = lat * pi / 180;
return log(tan(pi / 4 + latRad / 2));
}
final double centerY = fromLatToY(mapCenter.latitude);
final double targetY = fromLatToY(targetPosition.latitude);
final double centerX = mapCenter.longitude * pi / 180;
final double targetX = targetPosition.longitude * pi / 180;
final double screenX =
(targetX - centerX) * worldWidth / (2 * pi) + screenSize.width / 2;
final double screenY =
(centerY - targetY) * worldWidth / (2 * pi) + screenSize.height / 2;
// 回転を適用
final double rotatedX = screenSize.width / 2 +
(screenX - screenSize.width / 2) * cos(-angle * pi / 180) -
(screenY - screenSize.height / 2) * sin(-angle * pi / 180);
final double rotatedY = screenSize.height / 2 +
(screenX - screenSize.width / 2) * sin(-angle * pi / 180) +
(screenY - screenSize.height / 2) * cos(-angle * pi / 180);
return Offset(rotatedX, rotatedY);
}
3. 任意のウィジェットを配置
計算した位置を使用して、Stack
やOverlay
でマップ上にカスタムウィジェットを配置します。
最後に
基本的にマーカーをカスタマイズしたい場合は、Marker クラスで bitmap を使用する方が良いでしょう。しかし、動的にマーカーを変化させたい場合は、本記事で紹介した方法が有効です。
コード全文
class MapPage extends StatefulWidget {
const MapPage({
super.key,
});
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
final LatLng _tokyoStation = const LatLng(35.681236, 139.767125);
final double _circleSize = 50.0;
GoogleMapController? _googleMapController;
Offset _targetPosition = Offset.zero;
/// ターゲット座標をスクリーン座標に変換する
void _mapToScreenCoordinates({
required LatLng targetPosition,
required LatLng mapCenter,
required Size screenSize,
required double zoom,
required double angle,
}) {
/// ワールドの幅を計算
final double worldWidth = 256 * pow(2, zoom).toDouble();
/// 緯度をy座標に変換(緯度はメルカトル図法で非線形に変換る必要がある)
/// https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%82%AB%E3%83%88%E3%83%AB%E5%9B%B3%E6%B3%95
double fromLatToY(double lat) {
final double latRad = lat * pi / 180;
return log(tan(pi / 4 + latRad / 2));
}
final double centerY = fromLatToY(mapCenter.latitude);
final double targetY = fromLatToY(targetPosition.latitude);
final double centerX = mapCenter.longitude * pi / 180;
final double targetX = targetPosition.longitude * pi / 180;
final double screenX =
(targetX - centerX) * worldWidth / (2 * pi) + screenSize.width / 2;
final double screenY =
(centerY - targetY) * worldWidth / (2 * pi) + screenSize.height / 2;
// 回転を適用
final double rotatedX = screenSize.width / 2 +
(screenX - screenSize.width / 2) * cos(-angle * pi / 180) -
(screenY - screenSize.height / 2) * sin(-angle * pi / 180);
final double rotatedY = screenSize.height / 2 +
(screenX - screenSize.width / 2) * sin(-angle * pi / 180) +
(screenY - screenSize.height / 2) * cos(-angle * pi / 180);
setState(() {
_targetPosition = Offset(rotatedX, rotatedY);
});
}
void dispose() {
_googleMapController?.dispose();
super.dispose();
}
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
return Scaffold(
body: Stack(
children: [
SizedBox(
width: screenSize.width,
height: screenSize.height,
child: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: CameraPosition(
target: _tokyoStation,
zoom: 16.0,
),
onCameraMove: (CameraPosition position) {
_mapToScreenCoordinates(
targetPosition: _tokyoStation,
mapCenter: position.target,
screenSize: screenSize,
zoom: position.zoom,
angle: position.bearing,
);
},
),
),
/// 任意のWidget
Positioned(
top: _targetPosition.dy - _circleSize / 2,
left: _targetPosition.dx - _circleSize / 2,
child:Container(
width: _circleSize,
height: _circleSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.red, width: 10),
),
),
),
/// 任意のWidget
Positioned(
top: _targetPosition.dy - _circleSize / 2 - 80,
left: _targetPosition.dx - _circleSize / 2 - 25,
child: Lottie.asset(
'assets/lottie/demo.json',
width: 100,
height: 100,
),
),
],
),
);
}
}
Discussion