flutter_custom_cluster

A highly customizable clustering solution for Google Maps in Flutter with custom icon painters and efficient performance.

Features

  • 🎯 Efficient Clustering: Optimized algorithm for smooth map interactions
  • 🎨 Custom Icons: Flexible painter system for custom cluster and marker designs
  • âš¡ Performance: Built-in icon caching and optimized rendering
  • 🔧 Configurable: Extensive configuration options for clustering behavior
  • 💪 Type-Safe: Generic API for your custom data types
  • 📱 Production-Ready: Battle-tested clustering logic

Getting started

Add to your pubspec.yaml:

dependencies:
  flutter_custom_cluster: ^0.1.0

Usage

1. Create Your Custom Item Class

import 'package:flutter_custom_cluster/flutter_custom_cluster.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class GymLocation extends ClusterableItem {
  final String name;
  final String address;
  final int gymId;

  GymLocation({
    required String id,
    required LatLng position,
    required this.name,
    required this.address,
    required this.gymId,
  }) : super(id: id, position: position);
}

2. Initialize the Cluster Manager

class MapScreen extends StatefulWidget {
  @override
  State<MapScreen> createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> {
  late ClusterManager<GymLocation> _clusterManager;
  Set<Marker> _markers = {};
  double _currentZoom = 12.0;

  final List<GymLocation> _locations = [
    GymLocation(
      id: '1',
      position: LatLng(41.2995, 69.2401),
      name: 'Gym 1',
      address: 'Tashkent',
      gymId: 1,
    ),
    // Add more locations...
  ];

  @override
  void initState() {
    super.initState();
    _clusterManager = ClusterManager<GymLocation>(
      items: _locations,
      config: ClusterConfig(
        radius: 150,
        minZoom: 0,
        maxZoom: 19,
        minClusterSize: 2,
      ),
    );
    _updateMarkers();
  }

  Future<void> _updateMarkers() async {
    final markers = await _clusterManager.getMarkers(
      zoom: _currentZoom,
      onMarkerTap: (id, item) {
        print('Tapped: ${item.name}');
        // Show details, navigate, etc.
      },
      onClusterTap: (id, cluster) {
        print('Cluster with ${cluster.clusterSize} items');
        // Zoom in to cluster
      },
    );

    setState(() {
      _markers = markers;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GoogleMap(
        initialCameraPosition: CameraPosition(
          target: LatLng(41.2995, 69.2401),
          zoom: 12,
        ),
        markers: _markers,
        onCameraMove: (position) {
          _currentZoom = position.zoom;
        },
        onCameraIdle: _updateMarkers,
      ),
    );
  }
}

Customization

Custom Configuration

final manager = ClusterManager<GymLocation>(
  items: locations,
  config: ClusterConfig(
    radius: 200,           // Cluster radius in pixels
    minZoom: 3,            // Start clustering at zoom 3
    maxZoom: 18,           // Stop clustering at zoom 18
    minClusterSize: 3,     // Minimum items to form a cluster
  ),
);

Custom Icon Painter

Create your own painter for complete control over cluster appearance:

class MyCustomPainter extends ClusterIconPainter {
  @override
  Future<Uint8List> paintCluster(ClusterItem cluster) async {
    final recorder = ui.PictureRecorder();
    final canvas = ui.Canvas(recorder);
    
    // Your custom painting logic here
    // Draw shapes, load images, add text, etc.
    
    final image = await recorder.endRecording().toImage(120, 120);
    final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
    return bytes!.buffer.asUint8List();
  }

  @override
  Future<Uint8List> paintItem(ClusterableItem item) async {
    // Paint individual item markers
  }
}

// Use your custom painter
final manager = ClusterManager<GymLocation>(
  items: locations,
  painter: MyCustomPainter(),
);

Asset-Based Icons

class AssetClusterPainter extends ClusterIconPainter {
  @override
  Future<Uint8List> paintCluster(ClusterItem cluster) async {
    // Load your asset
    final data = await rootBundle.load('assets/cluster_bg.png');
    final bytes = data.buffer.asUint8List();
    final image = await decodeImageFromList(bytes);
    
    // Draw asset and add cluster count
    final recorder = ui.PictureRecorder();
    final canvas = ui.Canvas(recorder);
    
    canvas.drawImage(image, Offset.zero, Paint());
    
    // Add text showing cluster size
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${cluster.clusterSize}',
        style: TextStyle(color: Colors.white, fontSize: 24),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(canvas, Offset(50, 50));
    
    final finalImage = await recorder.endRecording().toImage(200, 200);
    final pngBytes = await finalImage.toByteData(format: ui.ImageByteFormat.png);
    return pngBytes!.buffer.asUint8List();
  }

  @override
  Future<Uint8List> paintItem(ClusterableItem item) async {
    final data = await rootBundle.load('assets/marker.png');
    return data.buffer.asUint8List();
  }
}

Custom Colors

Use the default painter with custom colors:

final manager = ClusterManager<GymLocation>(
  items: locations,
  painter: DefaultClusterPainter(
    clusterColor: Colors.blue,
    itemColor: Colors.red,
    textColor: Colors.white,
    borderColor: Colors.white,
    clusterRadius: 50,
    itemRadius: 30,
    fontSize: 24,
  ),
);

Advanced Usage

Filter by Bounds

final markers = await manager.getMarkers(
  zoom: currentZoom,
  bounds: LatLngBounds(
    southwest: LatLng(40.0, 68.0),
    northeast: LatLng(42.0, 70.0),
  ),
);

Force Recalculation

final markers = await manager.getMarkers(
  zoom: currentZoom,
  forceRecalculate: true,
);

Get Statistics

final stats = manager.getStats();
print('Clusters: ${stats['clusters']}');
print('Individual items: ${stats['items']}');
print('Total markers: ${stats['total']}');

Cache Management

// Clear icon cache if needed
manager.clearCache();

// Force recalculation of clusters
manager.invalidate();

Configuration Options

Option Type Default Description
radius int 150 Cluster radius in pixels
minZoom int 0 Minimum zoom level (0-19)
maxZoom int 19 Maximum zoom level (0-19)
extent int 512 Tile extent (must be power of 2)
nodeSize int 64 KD-tree leaf node size
minClusterSize int 2 Minimum items to form cluster

Performance Tips

  1. Use appropriate radius: Larger radius = fewer clusters = better performance
  2. Set reasonable zoom limits: Don't cluster at very high zoom levels
  3. Cache icons: The package automatically caches generated icons
  4. Debounce updates: Use debouncing for camera movement updates
import 'package:easy_debounce/easy_debounce.dart';

onCameraIdle: () {
  EasyDebounce.debounce(
    'map-update',
    Duration(milliseconds: 300),
    () => _updateMarkers(),
  );
}

Migration from Fluster

If you're coming from Fluster, the migration is straightforward:

Before (Fluster):

final fluster = Fluster<LocationMarker>(
  minZoom: 0,
  maxZoom: 19,
  radius: 150,
  points: locations,
);

final clusters = fluster.clusters(bounds, zoom);

After (flutter_custom_cluster):

final manager = ClusterManager<LocationMarker>(
  items: locations,
  config: ClusterConfig(
    minZoom: 0,
    maxZoom: 19,
    radius: 150,
  ),
);

final markers = await manager.getMarkers(
  zoom: zoom,
  bounds: bounds,
);

Example

Check out the example directory for a complete working app demonstrating:

  • Basic clustering setup
  • Custom painters
  • Asset-based icons
  • Marker interactions
  • Zoom controls

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Acknowledgments

  • Inspired by Fluster and Supercluster
  • Built for the Flutter community

Libraries

google_maps_flexible_cluster
A highly customizable clustering solution for Google Maps in Flutter.