我们要实现地图功能,首先会想到用现有的地图库来做,比如高德、百度地图的 SDK,又或者开源方案:js 的 leaflet、flutter 的 flutter_map。用这些方案没有什么问题,但有点杀鸡用牛刀,而且毕竟这些地图库针对的是地理地图(geographical),用来做非地理地图(non-geographical)坐标系还需要做转换。考虑到非地理地图并不是那么复杂,我们完全可以自己实现。
实现可交互平面地图主要麻烦的点在于识别缩放、拖拽手势对地图进行 transform,对于 flutter 来说却不是什么问题,因为 flutter 提供的 InteractiveViewer 就能很好的解决。最简单的情况下,我们只需要把底图和覆盖物放到 InteractiveViewer 里就能实现地图的基本功能。但通常来说 marker 不应该随地图一起缩放,我们可以用 InteractiveViewer 的 transformationController 监听当前 scale,然后把 marker 反方向 scale 即可。
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: Scaffold(body: Body())));
}
const mapUrl =
'https://uploadstatic.mihoyo.com/ys-obc/2022/01/05/75379475/d258137dc0e84fc8acbf77b7dc7115da_1941568151557226408.jpeg?x-oss-process=image/format,webp';
const mapSize = [4096.0, 4096.0];
const mapOrigin = [1849.0, 1779.0];
const markerSize = 32.0;
final marker = jsonDecode(
'{"id":3,"name":"传送锚点","icon":"https://uploadstatic.mihoyo.com/ys-obc/2022/11/25/75379475/7d650757c10b70cb42189f7fca0c9778_7547860493386240880.png","children":[],"points":[{"id":21594,"x":889.8559699306456,"y":-803.7845201911994},{"id":21593,"x":-662.8577760502653,"y":-1235.21133097975},{"id":21592,"x":242,"y":1063.5},{"id":21591,"x":605.2144440125662,"y":-306.6422600955998},{"id":21590,"x":334.92853900930504,"y":-196.14226009560002},{"id":21589,"x":505.85701697812874,"y":-152.9288852030761},{"id":21588,"x":569.714033956257,"y":38.786609856600535},{"id":21587,"x":-277.69813182939333,"y":807.4834479069223},{"id":21586,"x":174.07146099069496,"y":370.5690403823987},{"id":21585,"x":3.1429219813896907,"y":459.85770940264865},{"id":21584,"x":-187.78587448302028,"y":608.286564103973},{"id":21583,"x":-253.42891854537356,"y":390.78659460572453},{"id":21582,"x":-172.07143047045383,"y":166.07114529867567},{"id":21581,"x":99.78555598743378,"y":14.5},{"id":21580,"x":-92.92847796882324,"y":-5.5},{"id":21579,"x":-179.71441349232555,"y":-101.85566548987526},{"id":21578,"x":-86.35742703443793,"y":-151.78659460572453},{"id":21577,"x":-433.2130479492562,"y":-355.2948922638252},{"id":21576,"x":-522.7854949469518,"y":-201.42882419957277},{"id":21575,"x":-656.4285084890644,"y":-293.85566548987526},{"id":21574,"x":-648.6429219813897,"y":-67.28452019119959},{"id":21573,"x":-927.0714609906948,"y":135.21334439077236},{"id":21572,"x":-1379.35707801861,"y":382.6422600955998},{"id":21571,"x":-1204.857427034438,"y":730.5}]}',
);
class Body extends StatefulWidget {
const Body({super.key});
@override
State<Body> createState() => _BodyState();
}
class _BodyState extends State<Body> {
final transformation = TransformationController();
final StreamController<double> stream = StreamController.broadcast();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final size = MediaQuery.of(context).size;
// 初始显示地图全貌
transformation.value.scale(
max(size.width / mapSize[0], size.height / mapSize[1]),
);
setState(() {});
});
transformation.addListener(() {
stream.sink.add(transformation.value[0]);
});
}
@override
Widget build(BuildContext context) {
return InteractiveViewer(
transformationController: transformation,
constrained: false,
maxScale: 1.5,
minScale: 0.1,
child: SizedBox(
width: mapSize[0],
height: mapSize[1],
child: Stack(children: [
const Image(image: NetworkImage(mapUrl)),
...marker['points'].map(
(i) => Positioned(
left: i['x'] + mapOrigin[0] - markerSize / 2,
top: i['y'] + mapOrigin[1] - markerSize,
width: markerSize,
height: markerSize,
child: StreamBuilder(
stream: stream.stream,
builder: (context, snapshot) {
final scale = snapshot.data ?? transformation.value[0];
return Transform.scale(
scale: 1 / scale,
alignment: Alignment.bottomCenter,
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () {
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(content: Text('onPressed ${i['id']}')),
);
},
child: Image.network(marker['icon']),
),
);
},
),
),
),
]),
),
);
}
}
dartpad 在线演示:www.dartpad.dev/?id=b4d39e2…
可以看到,实现的效果还是很不错的,流程度、交互体验完全没问题。当然,这只是在数据量不大的情况下。如果底图很大,显然是不能直接整张显示的,内存会吃不消,这时候就得考虑用瓦片地图;还有 marker 也不能太多,就我的测试,web 版数量在 100 个的时候就开始掉帧了,这个表现其实不算差,用点聚合可以缓解,但要获得最好的性能就得考虑用 canvas。
这里我提供另外一个同样基于 InteractiveViewer,性能更好的例子:github.com/qiuxiang/ge…
InteractiveViewer 作为 flutter sdk 提供的 widget 体验并不差,但也不能说很完美,主要是手势动画不可调,缩放手势缺乏动画过度,对滚轮支持也不好。要获得更好的体验只能重新实现手势识别以及过度动画,不过这就是另一个话题了。