From 31f0525f15d0a24fd1d4a3401f6ced311b75f149 Mon Sep 17 00:00:00 2001 From: Bart Louwers Date: Tue, 20 Jan 2026 22:59:14 +0100 Subject: [PATCH] ios: add basic support for setting camera roll (#4061) --- platform/darwin/src/MLNMapCamera.h | 3 + platform/darwin/src/MLNMapCamera.mm | 25 ++-- platform/darwin/src/MLNMapSnapshotter.mm | 3 + platform/darwin/test/MLNMapCameraTests.m | 68 ++++++++++ .../Sources/CameraSliderExample.swift | 128 ++++++++++++++++++ .../Sources/MapLibreNavigationView.swift | 3 + platform/ios/src/MLNMapProjection.mm | 1 + platform/ios/src/MLNMapView.mm | 10 +- 8 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 platform/ios/app-swift/Sources/CameraSliderExample.swift diff --git a/platform/darwin/src/MLNMapCamera.h b/platform/darwin/src/MLNMapCamera.h index cc52c83d4..7cc10cf59 100644 --- a/platform/darwin/src/MLNMapCamera.h +++ b/platform/darwin/src/MLNMapCamera.h @@ -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. diff --git a/platform/darwin/src/MLNMapCamera.mm b/platform/darwin/src/MLNMapCamera.mm index 5a15bcc25..ebe8cdb27 100644 --- a/platform/darwin/src/MLNMapCamera.mm +++ b/platform/darwin/src/MLNMapCamera.mm @@ -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 *)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 diff --git a/platform/darwin/src/MLNMapSnapshotter.mm b/platform/darwin/src/MLNMapSnapshotter.mm index b6fc746e2..3f0c2c5be 100644 --- a/platform/darwin/src/MLNMapSnapshotter.mm +++ b/platform/darwin/src/MLNMapSnapshotter.mm @@ -827,6 +827,9 @@ NSArray *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); } diff --git a/platform/darwin/test/MLNMapCameraTests.m b/platform/darwin/test/MLNMapCameraTests.m index d38aadc63..3fb194169 100644 --- a/platform/darwin/test/MLNMapCameraTests.m +++ b/platform/darwin/test/MLNMapCameraTests.m @@ -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 diff --git a/platform/ios/app-swift/Sources/CameraSliderExample.swift b/platform/ios/app-swift/Sources/CameraSliderExample.swift new file mode 100644 index 000000000..e03c0ed1c --- /dev/null +++ b/platform/ios/app-swift/Sources/CameraSliderExample.swift @@ -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 = 0 ... maxPitch + private let rollRange: ClosedRange = -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, range: ClosedRange) -> 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, roll: Binding) { + _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 + } + } + } +} diff --git a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift index 6eadfae05..f0806cbb6 100644 --- a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift +++ b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift @@ -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) diff --git a/platform/ios/src/MLNMapProjection.mm b/platform/ios/src/MLNMapProjection.mm index 405785c3a..bbb6cfb75 100644 --- a/platform/ios/src/MLNMapProjection.mm +++ b/platform/ios/src/MLNMapProjection.mm @@ -61,6 +61,7 @@ { cameraOptions.pitch = camera.pitch; } + cameraOptions.roll = camera.roll; _mbglProjection->setCamera(cameraOptions); } diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index f6fad861c..176f91f82 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -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; }