aves/lib/widgets/common/map/leaflet/map.dart
Thibault Deckers 7747e19f73 #3 map page
2021-08-06 15:23:53 +09:00

202 lines
6.6 KiB
Dart

import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/latlng_tween.dart';
import 'package:aves/widgets/common/map/leaflet/scale_layer.dart';
import 'package:aves/widgets/common/map/leaflet/tile_layers.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
class EntryLeafletMap extends StatefulWidget {
final ValueNotifier<ZoomedBounds> boundsNotifier;
final bool interactive;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> markerEntries;
final Size markerSize;
final UserZoomChangeCallback? onUserZoomChange;
const EntryLeafletMap({
Key? key,
required this.boundsNotifier,
required this.interactive,
required this.style,
required this.markerBuilder,
required this.markerCluster,
required this.markerEntries,
required this.markerSize,
this.onUserZoomChange,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _EntryLeafletMapState();
}
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
final MapController _mapController = MapController();
final List<StreamSubscription> _subscriptions = [];
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
ZoomedBounds get bounds => boundsNotifier.value;
// duration should match the uncustomizable Google Maps duration
static const _cameraAnimationDuration = Duration(milliseconds: 400);
static const _zoomMin = 1.0;
// TODO TLAD [map] also limit zoom on pinch-to-zoom gesture
static const _zoomMax = 16.0;
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate;
@override
void initState() {
super.initState();
_subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion()));
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion());
}
@override
void dispose() {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
final markerSize = widget.markerSize;
final markers = clusterByMarkerKey.entries.map((kv) {
final markerKey = kv.key;
final cluster = kv.value;
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
return Marker(
point: latLng,
builder: (context) => GestureDetector(
onTap: () => _moveTo(latLng),
child: widget.markerBuilder(markerKey),
),
width: markerSize.width,
height: markerSize.height,
anchorPos: AnchorPos.align(AnchorAlign.top),
);
}).toList();
return FlutterMap(
options: MapOptions(
center: bounds.center,
zoom: bounds.zoom,
interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none,
),
mapController: _mapController,
children: [
_buildMapLayer(),
ScaleLayerWidget(
options: ScaleLayerOptions(),
),
MarkerLayerWidget(
options: MarkerLayerOptions(
markers: markers,
rotate: true,
rotateAlignment: Alignment.bottomCenter,
),
),
],
);
}
Widget _buildMapLayer() {
switch (widget.style) {
case EntryMapStyle.osmHot:
return const OSMHotLayer();
case EntryMapStyle.stamenToner:
return const StamenTonerLayer();
case EntryMapStyle.stamenWatercolor:
return const StamenWatercolorLayer();
default:
return const SizedBox.shrink();
}
}
void _updateVisibleRegion() {
final bounds = _mapController.bounds;
if (bounds != null) {
boundsNotifier.value = ZoomedBounds(
west: bounds.west,
south: bounds.south,
east: bounds.east,
north: bounds.north,
zoom: _mapController.zoom,
);
}
}
Future<void> _zoomBy(double amount) async {
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
widget.onUserZoomChange?.call(endZoom);
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
}
Future<void> _moveTo(LatLng point) async {
final centerTween = LatLngTween(begin: _mapController.center, end: point);
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
}
Future<void> _animateCamera(void Function(Animation<double> animation) animate) async {
final controller = AnimationController(duration: _cameraAnimationDuration, vsync: this);
final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn);
controller.addListener(() => animate(animation));
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.dispose();
} else if (status == AnimationStatus.dismissed) {
controller.dispose();
}
});
await controller.forward();
}
}