maplibre_flutter_gpu 0.0.1 copy "maplibre_flutter_gpu: ^0.0.1" to clipboard
maplibre_flutter_gpu: ^0.0.1 copied to clipboard

MapLibre maps for Flutter, rendered with Flutter GPU.

example/lib/main.dart

import 'dart:async' show unawaited;

import 'package:flutter/material.dart';
import 'package:maplibre_flutter_gpu/maplibre_flutter_gpu.dart';

import 'landmarks.dart';

void main() => runApp(const WorldLandmarksApp());

class WorldLandmarksApp extends StatelessWidget {
  const WorldLandmarksApp({super.key});

  @override
  Widget build(context) => MaterialApp(
    title: 'World Landmarks',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff176b5b)),
      useMaterial3: true,
    ),
    home: const WorldLandmarksPage(),
  );
}

class WorldLandmarksPage extends StatefulWidget {
  const WorldLandmarksPage({super.key});

  @override
  State<WorldLandmarksPage> createState() => _WorldLandmarksPageState();
}

class _WorldLandmarksPageState extends State<WorldLandmarksPage> {
  MapLibreMapController? _controller;
  WorldLandmark? _selected;
  var _positions = const <_LandmarkPosition>[];
  var _zoom = 1.35;
  var _styleReady = false;

  void _onMapCreated(MapLibreMapController controller) {
    _controller = controller;
    controller.addListener(_projectLandmarks);
    _projectLandmarks();
  }

  void _onStyleLoaded() {
    if (!mounted) return;
    setState(() => _styleReady = true);
  }

  void _projectLandmarks() {
    final controller = _controller;
    if (controller == null || !mounted) return;
    setState(() {
      _zoom = controller.cameraPosition?.zoom ?? _zoom;
      _positions = [
        for (final landmark in worldLandmarks)
          _LandmarkPosition(
            landmark: landmark,
            offset: controller.toScreenOffset(landmark.location),
          ),
      ];
    });
  }

  Future<void> _visit(WorldLandmark landmark) async {
    final controller = _controller;
    if (controller == null) return;
    setState(() => _selected = landmark);
    await WidgetsBinding.instance.endOfFrame;
    if (!mounted || !identical(controller, _controller)) return;

    // Refresh the camera snapshot after overlays and pointer gestures settle.
    await controller.queryCameraPosition();
    final update = CameraUpdate.newLatLngZoom(landmark.location, landmark.zoom);
    await controller.animateCamera(
      update,
      duration: const Duration(milliseconds: 900),
    );
  }

  Future<void> _showLandmarkList() async {
    final landmark = await showModalBottomSheet<WorldLandmark>(
      context: context,
      showDragHandle: true,
      isScrollControlled: true,
      builder: (context) => SafeArea(
        child: FractionallySizedBox(
          heightFactor: 0.72,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Padding(
                padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
                child: Text(
                  'World Landmarks',
                  style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
                ),
              ),
              Expanded(
                child: ListView.builder(
                  itemCount: worldLandmarks.length,
                  itemBuilder: (context, index) {
                    final landmark = worldLandmarks[index];

                    return ListTile(
                      leading: CircleAvatar(
                        backgroundColor: landmark.color.withValues(alpha: 0.14),
                        child: Text(
                          landmark.symbol,
                          style: const TextStyle(fontSize: 20),
                        ),
                      ),
                      title: Text(landmark.name),
                      subtitle: Text(landmark.country),
                      trailing: const Icon(Icons.arrow_forward),
                      selected: _selected == landmark,
                      onTap: () => Navigator.pop(context, landmark),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
    if (landmark != null && mounted) {
      await _visit(landmark);
    }
  }

  @override
  void dispose() {
    _controller?.removeListener(_projectLandmarks);
    super.dispose();
  }

  @override
  Widget build(context) => Scaffold(
    appBar: AppBar(
      title: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('World Landmarks', overflow: TextOverflow.ellipsis),
          Text(
            _selected == null
                ? 'Choose a world landmark'
                : '${_selected!.symbol} ${_selected!.country}・'
                      '${_selected!.name}',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
          ),
        ],
      ),
      actions: [
        IconButton(
          tooltip: _styleReady ? 'Open world landmark list' : 'Loading map',
          onPressed: _styleReady ? () => unawaited(_showLandmarkList()) : null,
          icon: const Icon(Icons.public),
        ),
        const SizedBox(width: 4),
      ],
    ),
    body: LayoutBuilder(
      builder: (context, constraints) => Stack(
        children: [
          Positioned.fill(
            child: MapLibreMap(
              styleString: MapLibreStyles.openfreemapLiberty,
              initialCameraPosition: const CameraPosition(
                target: LatLng(20, 10),
                zoom: 1.35,
              ),
              trackCameraPosition: true,
              onMapCreated: _onMapCreated,
              onStyleLoadedCallback: _onStyleLoaded,
            ),
          ),
          for (final position in _positions)
            if (_isOnScreen(position.offset, constraints.biggest))
              _LandmarkMarker(
                position: position,
                selected: _selected == position.landmark,
                zoom: _zoom,
                onTap: () => unawaited(_visit(position.landmark)),
              ),
        ],
      ),
    ),
  );

  bool _isOnScreen(Offset offset, Size size) =>
      offset.dx >= -60 &&
      offset.dx <= size.width + 60 &&
      offset.dy >= -70 &&
      offset.dy <= size.height + 60;
}

class _LandmarkMarker extends StatelessWidget {
  const _LandmarkMarker({
    required this.position,
    required this.selected,
    required this.zoom,
    required this.onTap,
  });

  final _LandmarkPosition position;
  final bool selected;
  final double zoom;
  final VoidCallback onTap;

  @override
  Widget build(context) {
    final landmark = position.landmark;
    final scale = landmarkMarkerScale(zoom);
    final size = (selected ? 54.0 : 46.0) * scale;
    final fontSize = (selected ? 27.0 : 23.0) * scale;

    return Positioned(
      left: position.offset.dx - size / 2,
      top: position.offset.dy - size,
      child: Semantics(
        button: true,
        label: 'Fly to ${landmark.name}, ${landmark.country}',
        child: Tooltip(
          message: '${landmark.country}・${landmark.name}',
          child: InkResponse(
            onTap: onTap,
            radius: size / 2,
            child: Container(
              width: size,
              height: size,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: Theme.of(context).colorScheme.surface,
                shape: BoxShape.circle,
                border: Border.all(
                  color: landmark.color,
                  width: selected ? 4 : 2,
                ),
                boxShadow: const [
                  BoxShadow(
                    color: Color(0x33000000),
                    blurRadius: 5,
                    offset: Offset(0, 2),
                  ),
                ],
              ),
              child: Text(
                landmark.symbol,
                style: TextStyle(fontSize: fontSize),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _LandmarkPosition {
  const _LandmarkPosition({required this.landmark, required this.offset});

  final WorldLandmark landmark;
  final Offset offset;
}