Skip to content

fix(camera): correct padding and degenerate bounds fitting - #4351

Open
RobJacobson wants to merge 3 commits into
rnmapbox:mainfrom
RobJacobson:codex/fix-camera-bounds-padding
Open

RobJacobson wants to merge 3 commits into
rnmapbox:mainfrom
RobJacobson:codex/fix-camera-bounds-padding

Conversation

@RobJacobson

@RobJacobson RobJacobson commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #4217.
Fixes #4050. This PR also handles camera fitting for point and zero-width/zero-height bounds reported in #4050. Android expands degenerate bounds by a minimal amount only for fitting and keeps exact bounds for constraints; regression tests cover point, thin, and boundary bounds.

After centering with bottom padding 500, fitting bounds with explicit bottom padding 0 retains the old padding. Bounds fitting uses the deprecated native fitting APIs, which treat their padding argument as extra coordinate padding inside the current viewport. The returned camera does not supply replacement viewport padding.

Use the native fitting overload that accepts CameraOptions, with the resolved viewport padding on that camera and zero additional coordinate padding. This allows explicit zero to clear an edge, keeps existing padding on omitted edges, and preserves explicit or inherited bearing and pitch. Apply the same correction on iOS and Android. The commit contains only the two native camera implementations.

Checklist

  • I've read CONTRIBUTING.md
  • I updated the doc/other generated code with running yarn generate in the root folder
  • I have tested the new feature on /example app.
    • In V11 mode/ios
    • In New Architecture mode/ios
    • In V11 mode/android
    • In New Architecture mode/android
  • I added/updated a sample - if a new feature was implemented (/example)

Generation completed without generated changes. This is a bug fix; the new-feature sample item is not applicable. The reproducer is included below and kept outside the commit. The full /example app was built and run on iOS and Android with React Native 0.84.1, Hermes and Fabric. Local-only compatibility settings were needed for the installed Xcode: the supported iOS deployment target, ARM simulator architecture, and a scene delegate. All setup changes and diagnostic code were removed from the checkout after verification.

Verification

Full /example app verification

The same animated JS reproducer ran in the real example binary with each revision's native camera implementation. It invokes Camera.setCamera with 500 ms animations and samples final idle state after 1400 ms. A temporary _issue4217Padding field in camera/idle event properties reads the SDK's actual viewport padding. Framing is additionally checked through the public getZoom, getCenter, and getPointInView methods. Android expectations account for the existing integer pixel conversion. Temporary diagnostics do not alter camera behavior and are excluded from the commit.

Platform SDK Before After Reset bottom padding
iOS 27 / iPhone 18 Pro 11.20.3 10 checks, 4 failures 10 checks, 0 failures 500 points -> 0
Android API 37 arm64 emulator 11.23.1 10 checks, 4 failures 10 checks, 0 failures 1312 physical pixels (500 points) -> 0

Both unfixed builds fail the padding reset, fresh-frame comparison, repeated padding 80, and partial-edge reset checks. Both fixed builds pass all ten checks, including the initial center command, omitted edges, repeated framing, and explicit/inherited bearing and pitch. On iOS, the reset's zoom changes from 14.066 before to 14.787 after, matching the fresh unpadded fit. On Android, it changes from 14.213 before to 14.819 after, also matching the fresh fit.

Captured full example-app assertions

ios before

BASELINE {"zoom":14.787,"center":[-123.1157,49.2877003],"corners":[[0,745.2127118019727],[402,745.2127118019727],[402,128.79301702367002],[0,128.79301702367002]],"padding":{"left":0,"bottom":0,"right":0,"top":0},"heading":0,"pitch":0}
CENTER_500 {"zoom":15,"center":[-123.12070000000001,49.28269999999998],"corners":[[201.00000000072745,186.99999999854515],[-1,-1],[-1,-1],[-1,-1]],"padding":{"bottom":500,"right":0,"left":0,"top":0},"heading":0,"pitch":0}
PASS: center command applies bottom padding 500
RESET_0 {"zoom":14.066,"center":[-123.1157,49.2877003],"corners":[[79.03776122576221,373.9854955898724],[322.96223877441963,373.9854955898724],[322.96223877441963,0.01797995614606407],[79.03776122576221,0.01797995614606407]],"padding":{"bottom":500,"top":0,"right":0,"left":0},"heading":0,"pitch":0}
FAIL: 500 -> bounds padding 0 clears padding
FAIL: reset fit matches fresh unpadded fit
FAIL: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
FAIL: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=10 failures=4

ios after

BASELINE {"zoom":14.787,"center":[-123.1157,49.2877003],"corners":[[0,745.2127118019727],[402,745.2127118019727],[402,128.79301702367002],[0,128.79301702367002]],"padding":{"left":0,"top":0,"bottom":0,"right":0},"heading":0,"pitch":0}
CENTER_500 {"zoom":15,"center":[-123.12070000000001,49.28269999999998],"corners":[[201.00000000072745,186.99999999854515],[-1,-1],[-1,-1],[-1,-1]],"padding":{"top":0,"left":0,"right":0,"bottom":500},"heading":0,"pitch":0}
PASS: center command applies bottom padding 500
RESET_0 {"zoom":14.787,"center":[-123.1157,49.2877003],"corners":[[0,745.2127118019727],[402,745.2127118019727],[402,128.79301702367002],[0,128.79301702367002]],"padding":{"left":0,"right":0,"bottom":0,"top":0},"heading":0,"pitch":0}
PASS: 500 -> bounds padding 0 clears padding
PASS: reset fit matches fresh unpadded fit
PASS: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
PASS: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=10 failures=0

android before

BASELINE {"zoom":14.819,"center":[-123.11570000000016,49.287700300000225],"corners":[[0,772.1254700577929],[411.0421876517809,772.1254700577929],[411.0421876517809,141.8803872860408],[0,141.8803872860408]],"padding":{"bottom":0,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
CENTER_500 {"zoom":15,"center":[-123.12070000000001,49.282700000000006],"corners":[[205.50000000034782,207.09523809503935],[-0.38095238095238093,-0.38095238095238093],[-0.38095238095238093,-0.38095238095238093],[-0.38095238095238093,-0.38095238095238093]],"padding":{"bottom":1312,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
PASS: center command applies bottom padding 500
RESET_0 {"zoom":14.213,"center":[-123.11570000000006,49.2877003000001],"corners":[[70.45553236195504,414.13765517818416],[340.5444676404796,414.13765517818416],[340.5444676404796,0.05666936948125567],[70.45553236195504,0.05666936948125567]],"padding":{"bottom":1312,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
FAIL: 500 -> bounds padding 0 clears padding
FAIL: reset fit matches fresh unpadded fit
FAIL: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
FAIL: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=10 failures=4

android after

BASELINE {"zoom":14.819,"center":[-123.11570000000016,49.287700300000225],"corners":[[0,772.1254700577929],[411.0421876517809,772.1254700577929],[411.0421876517809,141.8803872860408],[0,141.8803872860408]],"padding":{"bottom":0,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
CENTER_500 {"zoom":15,"center":[-123.12070000000001,49.282700000000006],"corners":[[205.50000000034782,207.09523809503935],[-0.38095238095238093,-0.38095238095238093],[-0.38095238095238093,-0.38095238095238093],[-0.38095238095238093,-0.38095238095238093]],"padding":{"bottom":1312,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
PASS: center command applies bottom padding 500
RESET_0 {"zoom":14.819,"center":[-123.1157,49.2877003],"corners":[[0,772.1254700424895],[411.0421876451727,772.1254700424895],[411.0421876451727,141.88038727073751],[0,141.88038727073751]],"padding":{"bottom":0,"left":0,"right":0,"top":0},"heading":0,"pitch":0}
PASS: 500 -> bounds padding 0 clears padding
PASS: reset fit matches fresh unpadded fit
PASS: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
PASS: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=10 failures=0

Native-only verification

yarn test on the isolated branch: lint completed with no errors (existing warnings), 25 suites and 164 unit tests passed. git diff --check passed.

Fresh before/after native builds execute the issue's sequence (center with bottom padding 500, then fit bounds with padding 0), plus repeated bounds fitting, partial and omitted padding updates, and explicit/inherited bearing and pitch. iOS builds the exact RNMBXCamera.toUpdateItem method extracted from each revision; Android builds the complete CameraStop class and the unchanged bounds helper. Minimal harness support applies the resulting camera directly to a real native map, without animation. SDKs: iOS 11.20.3 on iPhone 18 Pro/iOS 27 simulator; Android 11.23.1 on a local emulator. Before uses base commit 42758736839a432f50227289d08dedb7617d8aab; after uses commit 38f679265dd298eab14d52a40bb980143c9c936b.

Check iOS before iOS after Android before Android after
500 -> bounds padding 0 clears padding Fail Pass Fail Pass
reset fit matches fresh unpadded fit Fail Pass Pass Pass
repeated bounds padding 80 remains 80 Fail Pass Fail Pass
repeated fit keeps same frame Pass Pass Pass Pass
partial update resets supplied edge and preserves omitted edges Fail Pass Fail Pass
bounds without padding preserves edges Pass Pass Pass Pass
explicit bearing Pass Pass Pass Pass
explicit pitch Pass Pass Pass Pass
inherited bearing and pitch Pass Pass Pass Pass

Before: iOS 5/9 passed; Android 6/9 passed. After: 9/9 passed on each platform.

Captured before/after camera-state and assertion output

ios before

RESET bottom=500.0 zoom=14.066 baselineZoom=14.787
FAIL: 500 -> bounds padding 0 clears padding
FAIL: reset fit matches fresh unpadded fit
FAIL: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
FAIL: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=9 failures=4

ios after

RESET bottom=0.0 zoom=14.787 baselineZoom=14.787
PASS: 500 -> bounds padding 0 clears padding
PASS: reset fit matches fresh unpadded fit
PASS: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
PASS: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=9 failures=0

android before

RESET bottom=500.0 zoom=14.819 baselineZoom=14.819
FAIL: 500 -> bounds padding 0 clears padding
PASS: reset fit matches fresh unpadded fit
FAIL: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
FAIL: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=9 failures=3

android after

RESET bottom=0.0 zoom=14.819 baselineZoom=14.819
PASS: 500 -> bounds padding 0 clears padding
PASS: reset fit matches fresh unpadded fit
PASS: repeated bounds padding 80 remains 80
PASS: repeated fit keeps same frame
PASS: partial update resets supplied edge and preserves omitted edges
PASS: bounds without padding preserves edges
PASS: explicit bearing
PASS: explicit pitch
PASS: inherited bearing and pitch
RESULT checks=9 failures=0

Screenshot OR Video

Before/after screenshots were captured from both /example binaries. The full camera-state and assertion output is included above; both fixed screens display RESULT checks=10 failures=0.

Component to reproduce the issue you're fixing

Adapted from the component in #4217 and the structure of BugReportExample.js. It uses local point data and a local style without external tiles or extra libraries. Use it in an app configured for @rnmapbox/maps.

On a fresh map, press Baseline and remember the framing. Then press 1 and 2, waiting for each animation to finish. Before the fix, bottom padding remains 500 after step 2; after the fix it becomes 0 and the framing matches Baseline. Press Repeat twice: bottom padding should stay 80 and the second fit should keep the same framing.

Self-contained React Native component
import React, { useRef } from 'react';
import { View, Button } from 'react-native';
import { MapView, Camera, ShapeSource, CircleLayer } from '@rnmapbox/maps';

const points = [
  [-123.1207, 49.2827],
  [-123.1107, 49.2827],
  [-123.1107, 49.2927],
  [-123.1207, 49.2927],
];
const bounds = { sw: points[0], ne: points[2] };
const shape = {
  type: 'FeatureCollection',
  features: points.map((coordinates, id) => ({
    type: 'Feature', id, properties: {},
    geometry: { type: 'Point', coordinates },
  })),
};
const styleJSON = JSON.stringify({
  version: 8, sources: {},
  layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#ffffff' } }],
});
const padding = (bottom) => ({
  paddingTop: 0, paddingRight: 0, paddingLeft: 0, paddingBottom: bottom,
});

export default function CameraPaddingRepro() {
  const camera = useRef(null);
  const fit = (bottom) => camera.current?.setCamera({
    bounds, padding: padding(bottom), animationDuration: 1000,
  });
  return (
    <View style={{ flex: 1 }}>
      <MapView style={{ flex: 1 }} styleJSON={styleJSON}>
        <Camera ref={camera} defaultSettings={{ centerCoordinate: points[0], zoomLevel: 10 }} />
        <ShapeSource id="points" shape={shape}>
          <CircleLayer id="circles" style={{ circleRadius: 10, circleColor: 'red' }} />
        </ShapeSource>
      </MapView>
      <View style={{ position: 'absolute', bottom: 50, left: 20, right: 20, backgroundColor: 'white' }}>
        <Button title="Baseline: fit with padding 0" onPress={() => fit(0)} />
        <Button title="1. Center with bottom padding 500" onPress={() => camera.current?.setCamera({
          centerCoordinate: points[0], zoomLevel: 15,
          padding: padding(500), animationDuration: 1000,
        })} />
        <Button title="2. Fit with bottom padding 0" onPress={() => fit(0)} />
        <Button title="Repeat: fit with bottom padding 80" onPress={() => fit(80)} />
      </View>
    </View>
  );
}

- Pass resolved viewport padding through native camera options before fitting bounds
- Use zero additional coordinate padding on iOS and Android
- Preserve omitted padding edges and explicit or inherited bearing and pitch
- Verify the padding reset and related cases against real native SDKs on both platforms
@RobJacobson
RobJacobson marked this pull request as ready for review October 13, 2026 05:44
- Expand zero-width or zero-height bounds only for camera fitting.
- Preserve exact source bounds for camera constraints.
- Add regression tests for thin, point, and boundary bounds.
@RobJacobson RobJacobson changed the title fix(camera): apply bounds padding as viewport padding fix(camera): correct padding and degenerate bounds fitting Oct 13, 2026

This branch was successfully deployed

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

Labels

None yet

Projects

None yet

1 participant