Skip to content

MarkerImage.scale is never applied — marker images size differently on iOS and Android #133

Description

@jkasprzyk17

Problem

MarkerImage.scale is public API — it is in the Nitro spec (package/src/native/specs/overlays.ts:7),
re-exported from package/src/types/index.ts, documented in the README, and used by the example
app — but no provider ever reads it when sizing or decoding an image.

It is referenced in exactly four native places, and all four are cache keys or change-detection
hashes:

File Line Use
package/ios/MarkerImageLoader.swift 47-48 image cache key
package/ios/MarkerDescriptor+Fingerprint.swift 14 displayed-identity hash
package/android/.../MarkerIconFactory.kt 166 icon cache key
package/android/.../MarkerDescriptor+DisplayedIdentity.kt 16 displayed-identity hash

The two functions that actually decide how big a marker image is drawn both bail out when
width/height are missing and never look at scale:

// package/ios/MarkerImageLoader.swift:103
private static func resize(_ uiImage: UIImage, image: MarkerImage) -> UIImage {
  guard let width = image.width, let height = image.height else {
    return uiImage          // scale is not consulted here or anywhere below
  }
// package/android/.../MarkerIconFactory.kt:509
private fun resizeBitmap(source: Bitmap, image: MarkerImage): Bitmap {
  val width = image.width ?: return source
  val height = image.height ?: return source

So width/height are the only working sizing path; scale is inert.

Reproduction

<Marker
  coordinate={{ latitude: 52.2297, longitude: 21.0122 }}
  image={{
    // 50×82 px bitmap authored for a 25×41 dp pin
    uri: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
    scale: 2,
  }}
/>

Expected: a 25×41 dp pin on both platforms.

Actual:

  • iOS (MapKit and Google alike) — UIImage(data:) produces an image with scale = 1.0, and
    resize returns early, so the pin draws at 50×82 pt — 2× too large, on every device.
  • Android — the decode runs with inScaled = false (MarkerIconFactory.kt:356) and
    resizeBitmap returns early, so the 50×82 px bitmap maps 1:1 onto physical pixels: 25×41 dp on
    an xhdpi (2×) phone, 16.7×27.3 dp on xxhdpi (3×), 50×82 dp on mdpi.

On a 3× phone the same descriptor therefore renders roughly 3× larger on iOS than on Android.
Neither platform honours the size the author asked for, and the Android result additionally depends
on the device's density.

Second symptom on Android: with width/height absent, displaySizePx returns null
(MarkerIconFactory.kt:148-161), applyAnchor falls back to a 0×0 size (:66-71), and
effectiveGoogleMapsAnchor short-circuits on a zero size
(MarkerDescriptor+MarkerOptions.kt:20-22) — so centerOffset is silently dropped for those
markers too.

Why this survived

  • Every existing scale test asserts pass-through, never rendering:
    resolveMarkerImage.test.ts:103-148 checks the object is returned unchanged and cached;
    descriptorEquality.test.ts:118-121 checks that a changed scale invalidates the descriptor.
    Both pass against a field that does nothing.
  • The require() path masks it. Image.resolveAssetSource always returns width/height in dp,
    so bundled assets size correctly and scale is redundant there. Only an explicitly constructed
    MarkerImage depends on it — i.e. the remote-URL case, where React Native cannot know the size.
  • The example app passes all three together
    (example/examples/customMarkerImages.ts:13-18: { width: 25, height: 41, scale: 2 }), so the
    Custom markers scenario looks correct and hides the gap.

The field has never worked: it landed with the original marker-image feature (#25, 9a7cab6) and
was only ever wired into the cache key.

Suggested fix

Implement it: display size = decoded pixel size ÷ scale, with an explicit width/height still
winning.

  • iOS — decode at the requested scale rather than resizing afterwards:
    UIImage(data: data, scale: CGFloat(image.scale ?? 1)) in loadRemote, and the equivalent for
    the file:// / path branches of loadLocal. UIImage.size is then already in points, so
    NitroImageAnnotationView (:67), GMSMarker.icon (GoogleMarkerVisualApplier.swift:119) and
    anchorImageSize (:137-149) all start reporting the right size with no further change.
  • Android — when width/height are absent and scale is present, target px =
    source px × density ÷ scale in resizeBitmap (:509-523), and displaySizePx (:148-161) has
    to return that same size so the anchor/centerOffset math stops degenerating.
  • The generated struct already carries the field, so this is a native-only change — no spec edit
    and no nitrogen regen.

Both sizing helpers are private today, so the arithmetic wants its own file under the repo's
existing Type+operation convention — MarkerImage+displaySize.swift and
MarkerImage+displaySize.kt — unit-tested in package/iosTests and package/android/src/test
alongside the existing MarkerDisplayedIdentityTest.

The alternative is to delete scale from the spec and the README. That is a breaking change to a
public type and it removes the only way to size a remote image whose intrinsic size the app does not
know, so I would not choose it.

Either way README.md:358 needs updating — it currently offers width / height / scale as
interchangeable knobs:

Retina assets: pass require() and let Metro resolve @2x/@3x; optional explicit
width/height/scale on MarkerImage.

Observed on 1.2.1 (ccbc2a8), both providers, iOS and Android.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingkotlinThe Kotlin / Android native layer (package/android)platform: androidAffects Androidplatform: iosAffects iOSswiftThe Swift / iOS native layer (package/ios)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions