Skip to content

[iOS] SymbolLayer silently dropped from style when textFont is set #1122

@oshelot

Description

@oshelot

Summary

On iOS, setting SymbolLayer.textFont causes the entire layer to be silently dropped from the rendered style. The Dart-side addLayer call returns success, but the layer never persists in the style. Visual: the labels never render. Programmatic: subsequent calls to setStyleLayerProperty against that layer id throw PlatformException(0, "Layer ... is not in style").

Android renders the same SymbolLayer correctly with textFont set, on the same mapbox_maps_flutter versions. Android falls back to its default sans-serif when the requested font isn't bundled (I/Mbgl-FontUtils: Couldn't map font family for local ideograph, using sans-serif instead), but the layer renders.

This is the same failure pattern as the lineDasharray LineLayer bug (filed separately) and exhibits the same symptom mutation across versions:

Version Symptom
2.12.0 addLayer returns ok → style.getLayer(id) returns null immediately → labels do not render
2.21.1 addLayer returns ok → style.getLayer(id) returns the layer at the immediate audit → layer disappears from the style a few milliseconds later → labels do not render

The pattern of silent failure is identical for both bugs, suggesting a shared underlying defect in the iOS-side layer-add path that's specific to certain layout properties.

Affected versions

  • mapbox_maps_flutter 2.12.0 — confirmed broken (Sep 2024 era)
  • mapbox_maps_flutter 2.21.1 — confirmed broken with mutated symptom (current latest at time of filing)

Tested on:

  • iPhone 17, iOS 26.3.1, Xcode 26.3
  • Compared against: moto g play 2024, Android 14 (API 34) — works correctly on both versions (with sans-serif fallback)

Flutter:

  • Original: 3.24.5 / Dart 3.5.4
  • Retest: 3.41.6

Minimal reproduction

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';

void main() {
  MapboxOptions.setAccessToken(const String.fromEnvironment('MAPBOX_TOKEN'));
  runApp(const MaterialApp(home: BugRepro()));
}

class BugRepro extends StatefulWidget {
  const BugRepro({super.key});
  @override
  State<BugRepro> createState() => _BugReproState();
}

class _BugReproState extends State<BugRepro> {
  static const _sourceId = 'repro-source';
  static const _layerId = 'repro-symbol-layer';

  @override
  Widget build(BuildContext context) => Scaffold(
        body: MapWidget(
          styleUri: MapboxStyles.SATELLITE_STREETS,
          cameraOptions: CameraOptions(
            center: Point(coordinates: Position(-122.4885, 37.6244)),
            zoom: 15,
          ),
          onStyleLoadedListener: (_) => _onStyleLoaded(),
          onMapCreated: (map) => _map = map,
        ),
      );

  MapboxMap? _map;

  Future<void> _onStyleLoaded() async {
    final map = _map;
    if (map == null) return;

    // A few Point features with a `label` property to render as text.
    final geojson = jsonEncode({
      'type': 'FeatureCollection',
      'features': [
        for (var i = 1; i <= 5; i++)
          {
            'type': 'Feature',
            'properties': {'label': '$i'},
            'geometry': {
              'type': 'Point',
              'coordinates': [-122.4885 + i * 0.0005, 37.6244],
            },
          },
      ],
    });

    await map.style.addSource(GeoJsonSource(id: _sourceId, data: geojson));

    // The bug: setting textFont causes the SymbolLayer to silently
    // disappear on iOS. Comment out the textFont line below and the
    // labels render correctly (in the Mapbox default font).
    await map.style.addLayer(SymbolLayer(
      id: _layerId,
      sourceId: _sourceId,
      textFieldExpression: ['get', 'label'],
      textSize: 14.0,
      textColor: 0xFFFFFFFF,
      textHaloColor: 0xFF000000,
      textHaloWidth: 1.5,
      textAllowOverlap: true,
      textFont: const ['DIN Pro Bold', 'Arial Unicode MS Bold'], // ← REMOVE → bug goes away
    ));

    // Diagnostic — same pattern as the lineDasharray bug:
    final immediate = await map.style.getLayer(_layerId);
    debugPrint('layer present immediately? ${immediate != null}');

    await Future<void>.delayed(const Duration(milliseconds: 100));
    final delayed = await map.style.getLayer(_layerId);
    debugPrint('layer present after 100ms delay? ${delayed != null}');
  }
}

Run with:

flutter run -d <ios-device> --dart-define=MAPBOX_TOKEN=pk.xxx

Expected behavior

The SymbolLayer is added to the style, the labels render on the map (in DIN Pro Bold if the glyph set is available, falling back to a default font if not), and subsequent getLayer(id) calls return the layer. This is what happens on Android with the exact same code.

We don't expect Mapbox to ship the DIN Pro Bold font itself — the textFont array is conventionally a fallback chain, and we'd expect either successful resolution to a fallback font OR a clear error at add time. What we get instead is the entire layer silently disappearing with no error and no callback.

Actual behavior

On iOS, with textFont set to ANY value (we tested ['DIN Pro Bold', 'Arial Unicode MS Bold'], ['Arial Unicode MS Bold'], and ['Open Sans Regular'] — all three reproduce):

  • The labels never render visually.
  • On 2.12.0: the layer present immediately? debug print outputs false.
  • On 2.21.1: the layer present immediately? print outputs true, but layer present after 100ms delay? prints false.

Workaround

Don't set textFont on SymbolLayer cross-platform. The labels will render in the Mapbox default font for the active style — for SATELLITE_STREETS this is a Mapbox-hosted sans-serif which is visually acceptable but does not match the iOS native app's typeface choice.

If matching a specific custom font is a hard requirement: bundle the font as a local glyph PBF set and use style.setStyleGlyphURL(url) instead of the per-layer textFont property. This appears to be the only currently-working path for custom fonts on iOS.

Why the symptom mutation matters

Same as for the related lineDasharray bug filed separately: the audit pattern (getLayer after addLayer) that catches this in user code on 2.12.0 silently passes on 2.21.1, even though the bug is still present. Whatever change was made in the intervening 9 months made the failure asynchronous. Code that defends against the original bug shape no longer detects the new one.

Filing both bugs together (cross-referenced) so a fix can address the underlying cause rather than chasing the surface symptoms one property at a time.

Related

Same set as for the lineDasharray issue:

Spike report context

This issue was filed during a Flutter migration spike for an existing dual-native (Swift + Kotlin) app, alongside the related LineLayer.lineDasharray issue. Both bugs prevent 1:1 visual parity with the native iOS Mapbox SDK. Full report: https://github.com/oshelot/Caddie-AI/blob/kan-252-flutter-spike/flutter-spike/SPIKE_REPORT.md

Happy to provide additional logs, a smaller repro, or test on additional iOS versions if helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions