ios: add basic support for setting camera roll (#4061)

This commit is contained in:
Bart Louwers
2026-01-20 22:59:14 +01:00
committed by GitHub
parent 4124ce968b
commit 31f0525f15
8 changed files with 231 additions and 10 deletions

View File

@@ -26,6 +26,9 @@ MLN_EXPORT
/** Heading measured in degrees clockwise from true north. */
@property (nonatomic) CLLocationDirection heading;
/** Roll measured in degrees clockwise around the viewing axis. */
@property (nonatomic) CLLocationDirection roll;
/**
Pitch toward the horizon measured in degrees, with 0 degrees resulting in a
two-dimensional map.

View File

@@ -94,6 +94,7 @@
_altitude = altitude;
_pitch = pitch;
_heading = heading;
_roll = 0;
}
return self;
}
@@ -108,6 +109,7 @@
_altitude = [coder decodeDoubleForKey:@"altitude"];
_pitch = [coder decodeDoubleForKey:@"pitch"];
_heading = [coder decodeDoubleForKey:@"heading"];
_roll = [coder decodeDoubleForKey:@"roll"];
}
return self;
}
@@ -119,14 +121,17 @@
[coder encodeDouble:_altitude forKey:@"altitude"];
[coder encodeDouble:_pitch forKey:@"pitch"];
[coder encodeDouble:_heading forKey:@"heading"];
[coder encodeDouble:_roll forKey:@"roll"];
}
- (id)copyWithZone:(nullable NSZone *)zone
{
return [[[self class] allocWithZone:zone] initWithCenterCoordinate:_centerCoordinate
altitude:_altitude
pitch:_pitch
heading:_heading];
MLNMapCamera *copy = [[[self class] allocWithZone:zone] initWithCenterCoordinate:_centerCoordinate
altitude:_altitude
pitch:_pitch
heading:_heading];
copy.roll = _roll;
return copy;
}
+ (NSSet<NSString *> *)keyPathsForValuesAffectingViewingDistance {
@@ -146,8 +151,8 @@
- (NSString *)description
{
return [NSString stringWithFormat:@"<%@: %p; centerCoordinate = %f, %f; altitude = %.0fm; heading = %.0f°; pitch = %.0f°>",
NSStringFromClass([self class]), (void *)self, _centerCoordinate.latitude, _centerCoordinate.longitude, _altitude, _heading, _pitch];
return [NSString stringWithFormat:@"<%@: %p; centerCoordinate = %f, %f; altitude = %.0fm; heading = %.0f°; pitch = %.0f°; roll = %.0f°>",
NSStringFromClass([self class]), (void *)self, _centerCoordinate.latitude, _centerCoordinate.longitude, _altitude, _heading, _pitch, _roll];
}
- (BOOL)isEqual:(id)other
@@ -165,7 +170,8 @@
return (_centerCoordinate.latitude == otherCamera.centerCoordinate.latitude
&& _centerCoordinate.longitude == otherCamera.centerCoordinate.longitude
&& _altitude == otherCamera.altitude
&& _pitch == otherCamera.pitch && _heading == otherCamera.heading);
&& _pitch == otherCamera.pitch && _heading == otherCamera.heading
&& _roll == otherCamera.roll);
}
- (BOOL)isEqualToMapCamera:(MLNMapCamera *)otherCamera
@@ -179,13 +185,14 @@
&& MLNEqualFloatWithAccuracy(_centerCoordinate.longitude, otherCamera.centerCoordinate.longitude, 1e-8)
&& MLNEqualFloatWithAccuracy(_altitude, otherCamera.altitude, 1e-6)
&& MLNEqualFloatWithAccuracy(_pitch, otherCamera.pitch, 1e-2)
&& MLNEqualFloatWithAccuracy(_heading, otherCamera.heading, 1e-2));
&& MLNEqualFloatWithAccuracy(_heading, otherCamera.heading, 1e-2)
&& MLNEqualFloatWithAccuracy(_roll, otherCamera.roll, 1e-2));
}
- (NSUInteger)hash
{
return (@(_centerCoordinate.latitude).hash + @(_centerCoordinate.longitude).hash
+ @(_altitude).hash + @(_pitch).hash + @(_heading).hash);
+ @(_altitude).hash + @(_pitch).hash + @(_heading).hash + @(_roll).hash);
}
@end

View File

@@ -827,6 +827,9 @@ NSArray<MLNAttributionInfo *> *MLNAttributionInfosFromAttributions(mbgl::MapSnap
if (options.camera.pitch >= 0) {
cameraOptions.pitch = MAX(0, options.camera.pitch);
}
if (options.camera.roll != 0) {
cameraOptions.roll = options.camera.roll;
}
if (cameraOptions != mbgl::CameraOptions()) {
_mbglMapSnapshotter->setCameraOptions(cameraOptions);
}

View File

@@ -104,4 +104,72 @@
XCTAssertEqual(camera.viewingDistance, 10000);
}
- (void)testRollProperty {
MLNMapCamera *camera = [MLNMapCamera camera];
XCTAssertEqual(camera.roll, 0, @"Default camera should have zero roll.");
camera.roll = 45;
XCTAssertEqual(camera.roll, 45, @"Roll should be set correctly.");
camera.roll = -30;
XCTAssertEqual(camera.roll, -30, @"Roll should accept negative values.");
camera.roll = 180;
XCTAssertEqual(camera.roll, 180, @"Roll should accept 180 degrees.");
}
- (void)testRollInCopy {
MLNMapCamera *camera = [MLNMapCamera camera];
camera.roll = 30;
MLNMapCamera *copy = [camera copy];
XCTAssertEqual(copy.roll, 30, @"Copied camera should preserve roll value.");
}
- (void)testRollInEncoding {
CLLocationCoordinate2D coord = CLLocationCoordinate2DMake(40.0, -80.0);
MLNMapCamera *camera = [MLNMapCamera cameraLookingAtCenterCoordinate:coord
altitude:1000
pitch:45
heading:90];
camera.roll = 25;
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:camera requiringSecureCoding:YES error:NULL];
MLNMapCamera *decodedCamera = [NSKeyedUnarchiver unarchivedObjectOfClass:[MLNMapCamera class] fromData:data error:NULL];
XCTAssertEqual(decodedCamera.roll, 25, @"Decoded camera should preserve roll value.");
}
- (void)testRollInEquality {
CLLocationCoordinate2D coord = CLLocationCoordinate2DMake(40.0, -80.0);
MLNMapCamera *camera1 = [MLNMapCamera cameraLookingAtCenterCoordinate:coord
altitude:1000
pitch:45
heading:90];
camera1.roll = 30;
MLNMapCamera *camera2 = [MLNMapCamera cameraLookingAtCenterCoordinate:coord
altitude:1000
pitch:45
heading:90];
camera2.roll = 30;
XCTAssertTrue([camera1 isEqual:camera2], @"Cameras with same roll should be equal.");
XCTAssertTrue([camera1 isEqualToMapCamera:camera2], @"Cameras with same roll should be equal (isEqualToMapCamera).");
camera2.roll = 35;
XCTAssertFalse([camera1 isEqual:camera2], @"Cameras with different roll should not be equal.");
XCTAssertFalse([camera1 isEqualToMapCamera:camera2], @"Cameras with different roll should not be equal (isEqualToMapCamera).");
}
- (void)testRollInHash {
MLNMapCamera *camera1 = [MLNMapCamera camera];
camera1.roll = 45;
MLNMapCamera *camera2 = [MLNMapCamera camera];
camera2.roll = 45;
XCTAssertEqual(camera1.hash, camera2.hash, @"Cameras with same properties including roll should have same hash.");
}
@end

View File

@@ -0,0 +1,128 @@
import CoreLocation
import MapLibre
import SwiftUI
let maxPitch: Double = 90
struct CameraSliderExample: View {
@State private var pitch: Double = 0
@State private var roll: Double = 0
private let pitchRange: ClosedRange<Double> = 0 ... maxPitch
private let rollRange: ClosedRange<Double> = -180 ... 180
var body: some View {
ZStack(alignment: .top) {
OrientationAdjustableMap(pitch: $pitch, roll: $roll)
.edgesIgnoringSafeArea(.all)
VStack(alignment: .leading, spacing: 12) {
sliderRow(label: "Pitch", value: $pitch, range: pitchRange)
sliderRow(label: "Roll", value: $roll, range: rollRange)
}
.padding()
.background(.ultraThinMaterial)
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.15), radius: 6, x: 0, y: 3)
.padding([.top, .horizontal])
}
.navigationTitle("Pitch & Roll")
.navigationBarTitleDisplayMode(.inline)
}
private func sliderRow(label: String, value: Binding<Double>, range: ClosedRange<Double>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(label): \(Int(value.wrappedValue)) deg")
.font(.headline)
Slider(value: value, in: range, step: 1)
.accentColor(MapLibreColors.accent)
}
}
}
private struct OrientationAdjustableMap: UIViewRepresentable {
@Binding var pitch: Double
@Binding var roll: Double
func makeCoordinator() -> Coordinator {
Coordinator(pitch: $pitch, roll: $roll)
}
func makeUIView(context: Context) -> MLNMapView {
let styleJSON = """
{
"version": 8,
"name": "Satellite",
"sources": {
"satellite-source": {
"type": "raster",
"tiles": ["https://versatiles-satellite.b-cdn.net/tiles/orthophotos/{z}/{x}/{y}"],
"tileSize": 256,
"attribution": "Versatiles"
}
},
"layers": [
{
"id": "satellite-layer",
"type": "raster",
"source": "satellite-source"
}
]
}
"""
let mapView = MLNMapView(frame: .zero)
mapView.styleJSON = styleJSON
mapView.delegate = context.coordinator
mapView.maximumPitch = CGFloat(maxPitch)
let center = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
mapView.setCenter(center, zoomLevel: 10, animated: false)
context.coordinator.sync(from: mapView.camera)
return mapView
}
func updateUIView(_ mapView: MLNMapView, context _: Context) {
let currentCamera = mapView.camera
let targetPitch = CGFloat(pitch)
let targetRoll = CGFloat(roll)
let pitchDelta = abs(currentCamera.pitch - targetPitch)
let rollDelta = abs(currentCamera.roll - targetRoll)
if pitchDelta < 0.1, rollDelta < 0.1 {
return
}
let currentZoom = mapView.zoomLevel
let targetAltitude = MLNAltitudeForZoomLevel(currentZoom, targetPitch, currentCamera.centerCoordinate.latitude, mapView.bounds.size)
let updatedCamera = MLNMapCamera(
lookingAtCenter: currentCamera.centerCoordinate,
altitude: targetAltitude,
pitch: targetPitch,
heading: currentCamera.heading
)
updatedCamera.roll = targetRoll
mapView.setCamera(updatedCamera, animated: false)
}
final class Coordinator: NSObject, MLNMapViewDelegate {
@Binding private var pitch: Double
@Binding private var roll: Double
init(pitch: Binding<Double>, roll: Binding<Double>) {
_pitch = pitch
_roll = roll
}
func sync(from camera: MLNMapCamera) {
let newPitch = Double(camera.pitch)
let newRoll = Double(camera.roll)
if abs(pitch - newPitch) >= 0.1 {
pitch = newPitch
}
if abs(roll - newRoll) >= 0.1 {
roll = newRoll
}
}
}
}

View File

@@ -17,6 +17,9 @@ struct MapLibreNavigationView: View {
NavigationLink("SimpleMap") {
SimpleMap().edgesIgnoringSafeArea(.all)
}
NavigationLink("Change Camera Pitch & Roll") {
CameraSliderExample()
}
#if MLN_RENDER_BACKEND_METAL
NavigationLink("CustomStyleLayer (Metal)") {
CustomStyleLayerExample().edgesIgnoringSafeArea(.all)

View File

@@ -61,6 +61,7 @@
{
cameraOptions.pitch = camera.pitch;
}
cameraOptions.roll = camera.roll;
_mbglProjection->setCamera(cameraOptions);
}

View File

@@ -4590,7 +4590,14 @@ static void *windowScreenContext = &windowScreenContext;
CLLocationDirection direction = cameraOptions.bearing ? mbgl::util::wrap(*cameraOptions.bearing, 0., 360.) : self.direction;
CGFloat pitch = cameraOptions.pitch ? *cameraOptions.pitch : *mapCamera.pitch;
CLLocationDistance altitude = MLNAltitudeForZoomLevel(zoomLevel, pitch, centerCoordinate.latitude, self.frame.size);
return [MLNMapCamera cameraLookingAtCenterCoordinate:centerCoordinate altitude:altitude pitch:pitch heading:direction];
auto camera = [MLNMapCamera cameraLookingAtCenterCoordinate:centerCoordinate altitude:altitude pitch:pitch heading:direction];
if (cameraOptions.roll) {
camera.roll = cameraOptions.roll.value();
}
return camera;
}
/// Returns a CameraOptions object that specifies parameters for animating to
@@ -4614,6 +4621,7 @@ static void *windowScreenContext = &windowScreenContext;
{
options.pitch = camera.pitch;
}
options.roll = camera.roll;
return options;
}