不到 100 行,flutter 使用 InteractiveViewer 快速实现可交互的自定义/游戏地图(以原神地图为例)

559 阅读3分钟

我们要实现地图功能,首先会想到用现有的地图库来做,比如高德、百度地图的 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']),
                    ),
                  );
                },
              ),
            ),
          ),
        ]),
      ),
    );
  }
}

demo.gif

dartpad 在线演示:www.dartpad.dev/?id=b4d39e2…

可以看到,实现的效果还是很不错的,流程度、交互体验完全没问题。当然,这只是在数据量不大的情况下。如果底图很大,显然是不能直接整张显示的,内存会吃不消,这时候就得考虑用瓦片地图;还有 marker 也不能太多,就我的测试,web 版数量在 100 个的时候就开始掉帧了,这个表现其实不算差,用点聚合可以缓解,但要获得最好的性能就得考虑用 canvas。

这里我提供另外一个同样基于 InteractiveViewer,性能更好的例子:github.com/qiuxiang/ge…

InteractiveViewer 作为 flutter sdk 提供的 widget 体验并不差,但也不能说很完美,主要是手势动画不可调,缩放手势缺乏动画过度,对滚轮支持也不好。要获得更好的体验只能重新实现手势识别以及过度动画,不过这就是另一个话题了。