Skip to content

Commit e590a58

Browse files
pjleonard37github-actions[bot]
authored andcommitted
Expose accessibility settings change listener (#9946)
Adds experimental API for automatic map symbol scaling based on system accessibility settings (font size preferences). This enables better accessibility for users with visual impairments. Three modes: - .system - Automatic scaling using default mapping (0.8-2.0x range) - .system(mapping) - Custom scale mapping for fine-tuned control - .fixed(scaleFactor) - Fixed scale factor, no dynamic updates GitOrigin-RevId: 1bf526acb821a324a30d155bb6188ed034e49176
1 parent 80897a1 commit e590a58

8 files changed

Lines changed: 433 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Mapbox welcomes participation and contributions from everyone.
55
## main
66
* Introduce new `LineLayer.lineElevationGroundScale` property to scale elevated lines with terrain exaggeration.
77
* Promote elevated lines properties to stable: `LineLayer.lineZOffset` and `LineLayer.lineElevationReference`.
8+
* Introduce experimental `SymbolScaleBehavior` API to automatically scale map symbols (icons and text) based on system accessibility text size settings. Set `MapboxMap.symbolScaleBehavior` property to configure: `.system` (automatic scaling), `.system(mapping:)` (custom mapping function), or `.fixed(scaleFactor:)` (fixed scale, default is 1.0). Valid scale factor range is [0.8, 2.0]. Automatic scaling is opt-in; symbols default to fixed 1.0x scale.
89

910
### Features ✨ and improvements 🏁
1011
* Add `ModelSource` support with `Model`, `ModelMaterialOverride`, and `ModelNodeOverride` to enable interactive 3D models. Material overrides allow customization of color, emissive strength, opacity, and color mix intensity. Node overrides enable control of model part transformations such as rotating doors, landing gear, or propellers. Models can be updated via source-driven approach (modifying `ModelSource.models` directly) or feature-state driven approach (using expressions with feature state for dynamic control). For implementation examples, see `Interactive3DModelFeatureStateExample` (SwiftUI), `Interactive3DModelSourceExample` (UIKit), and `Animated3DModelSourceExample` (SwiftUI).

Examples.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
1FCA58860B3F23CAC57B32CA /* SegmentedToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09006C566A72E52201CA7EFB /* SegmentedToggleView.swift */; platformFilter = ios; };
3434
215230836B6AD1040D3DA547 /* CombineLocationExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EE8F38428A64B5B9D4DBBE /* CombineLocationExample.swift */; platformFilter = ios; };
3535
274074283C558652C5969BB9 /* FavoritesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076D23DADD44F2CA269F947E /* FavoritesManager.swift */; platformFilter = ios; };
36+
DD30C30DDDB84C7FA9AEC91C /* AccessibilityScaleExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD131A61DC14B378EC39AA9 /* AccessibilityScaleExample.swift */; };
37+
1F860D5B445E75772C4C3B6C /* SkyLayerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B8372871DB4BC991737A06 /* SkyLayerExample.swift */; platformFilters = (ios, ); };
38+
1FCA58860B3F23CAC57B32CA /* SegmentedToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09006C566A72E52201CA7EFB /* SegmentedToggleView.swift */; platformFilters = (ios, ); };
39+
215230836B6AD1040D3DA547 /* CombineLocationExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EE8F38428A64B5B9D4DBBE /* CombineLocationExample.swift */; platformFilters = (ios, ); };
40+
274074283C558652C5969BB9 /* FavoritesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076D23DADD44F2CA269F947E /* FavoritesManager.swift */; platformFilters = (ios, ); };
3641
2997D21A7DB20098C6D03D3B /* StandardStyleImportExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640198169EEDFC7CBEFCFCCF /* StandardStyleImportExample.swift */; };
3742
2B44F3E8EF3A50D9AE6B825F /* route.geojson in Resources */ = {isa = PBXBuildFile; fileRef = 450C8D5E4B84428FE51BCA97 /* route.geojson */; };
3843
2C03342240D5487880316518 /* AddOneMarkerSymbolExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DCBE814694CF08A9C2E4A42 /* AddOneMarkerSymbolExample.swift */; platformFilter = ios; };
@@ -267,6 +272,7 @@
267272
455C0B9F01316D0FF38ED62B /* CLLocationCoordinate2D+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLLocationCoordinate2D+Random.swift"; sourceTree = "<group>"; };
268273
45B39AE24486FED5ED30392D /* LocationOverrideExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationOverrideExample.swift; sourceTree = "<group>"; };
269274
46CE3D9C2873C0767DD76D85 /* ClusteringExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClusteringExample.swift; sourceTree = "<group>"; };
275+
CFD131A61DC14B378EC39AA9 /* AccessibilityScaleExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityScaleExample.swift; sourceTree = "<group>"; };
270276
4D5DB9BD5E97D3C0080EC5D3 /* CustomPointAnnotationExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPointAnnotationExample.swift; sourceTree = "<group>"; };
271277
4DCBE814694CF08A9C2E4A42 /* AddOneMarkerSymbolExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOneMarkerSymbolExample.swift; sourceTree = "<group>"; };
272278
4E4FEA4443F430EC8957AB0A /* EditPolygonExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPolygonExample.swift; sourceTree = "<group>"; };
@@ -520,6 +526,7 @@
520526
D60D739017FD720A98C10C68 /* AppearancesExample.swift */,
521527
63A3027A7DA59E090DAD25F1 /* ClipLayerExample.swift */,
522528
46CE3D9C2873C0767DD76D85 /* ClusteringExample.swift */,
529+
CFD131A61DC14B378EC39AA9 /* AccessibilityScaleExample.swift */,
523530
29DD4C2F0049E575A6B5BF66 /* ColorThemeExample.swift */,
524531
7DED80ACD1618F8EE8F95A4A /* CustomGeometrySourceExample.swift */,
525532
C61CC711054A032EE0446036 /* DynamicStylingExample.swift */,
@@ -1025,6 +1032,7 @@
10251032
CA2209956E93ECB18C4C9DEC /* CircleAnnotationExample.swift in Sources */,
10261033
3B4862E6832F23CB115D444A /* ClipLayerExample.swift in Sources */,
10271034
1DAE02D73D16E543777C2025 /* ClusteringExample.swift in Sources */,
1035+
DD30C30DDDB84C7FA9AEC91C /* AccessibilityScaleExample.swift in Sources */,
10281036
5A28C124249725578389175A /* ColorExpressionExample.swift in Sources */,
10291037
4AB9DBA1D6C978B02061F9C6 /* ColorPaletteView.swift in Sources */,
10301038
B0EDC87982D17000DF731503 /* ColorScheme.swift in Sources */,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import SwiftUI
2+
@_spi(Experimental) import MapboxMaps
3+
4+
/// Example demonstrating accessibility scaling for map symbols.
5+
///
6+
/// This example shows three modes:
7+
/// - Fixed: Manual scale control with slider (no system listeners)
8+
/// - System: Automatic scaling based on system text size (opt-in)
9+
/// - Custom: System scaling with custom mapping that dampens large accessibility scales
10+
struct AccessibilityScaleExample: View {
11+
enum ScaleMode: String, CaseIterable {
12+
case fixed = "Fixed"
13+
case system = "System"
14+
case custom = "Custom"
15+
}
16+
17+
@State private var selectedMode: ScaleMode = .fixed
18+
@State private var customScaleValue: Float = 1.0
19+
20+
var body: some View {
21+
MapReader { mapProxy in
22+
Map(initialViewport: .camera(center: .init(latitude: 40.7128, longitude: -74.0060), zoom: 12))
23+
.mapStyle(.standard)
24+
.onMapLoaded { _ in
25+
applySymbolScaleBehavior(to: mapProxy.map)
26+
}
27+
.onChange(of: selectedMode) { _ in
28+
applySymbolScaleBehavior(to: mapProxy.map)
29+
}
30+
.onChange(of: customScaleValue) { _ in
31+
if selectedMode == .fixed {
32+
applySymbolScaleBehavior(to: mapProxy.map)
33+
}
34+
}
35+
}
36+
.ignoresSafeArea(edges: .bottom)
37+
.overlay(alignment: .bottom) {
38+
VStack(spacing: 12) {
39+
// Conditional content (above buttons)
40+
Group {
41+
if selectedMode == .fixed {
42+
VStack(spacing: 8) {
43+
HStack {
44+
Text("Scale factor:")
45+
.font(.caption)
46+
Spacer()
47+
Text(String(format: "%.2fx", customScaleValue))
48+
.font(.caption.monospacedDigit())
49+
}
50+
Slider(value: $customScaleValue, in: 0.8...2.0, step: 0.1)
51+
Text("Manual scale control (no system listeners)")
52+
.font(.caption2)
53+
.foregroundStyle(.secondary)
54+
.multilineTextAlignment(.center)
55+
}
56+
.padding(.horizontal, 16)
57+
.padding(.vertical, 8)
58+
.background(.ultraThinMaterial)
59+
.cornerRadius(8)
60+
.frame(maxWidth: 340)
61+
} else if selectedMode == .system {
62+
Text("Automatic scaling with default mapping.\nChange system text size in Settings to see effect.")
63+
.font(.caption2)
64+
.foregroundStyle(.secondary)
65+
.multilineTextAlignment(.center)
66+
.padding(.horizontal, 16)
67+
.padding(.vertical, 8)
68+
.background(.ultraThinMaterial)
69+
.cornerRadius(8)
70+
.frame(maxWidth: 340)
71+
} else if selectedMode == .custom {
72+
Text("Custom mapping: boosts small scales +10%, dampens large scales.\nChange system text size in Settings to see effect.")
73+
.font(.caption2)
74+
.foregroundStyle(.secondary)
75+
.multilineTextAlignment(.center)
76+
.padding(.horizontal, 16)
77+
.padding(.vertical, 8)
78+
.background(.ultraThinMaterial)
79+
.cornerRadius(8)
80+
.frame(maxWidth: 340)
81+
}
82+
}
83+
.frame(height: selectedMode == .fixed ? 80 : 60)
84+
.opacity(1)
85+
86+
// Mode picker
87+
Picker("Scale Mode", selection: $selectedMode) {
88+
ForEach(ScaleMode.allCases, id: \.self) { mode in
89+
Text(mode.rawValue).tag(mode)
90+
}
91+
}
92+
.pickerStyle(.segmented)
93+
.frame(maxWidth: 340)
94+
}
95+
.padding(.bottom, 70)
96+
.animation(.easeInOut(duration: 0.2), value: selectedMode)
97+
}
98+
}
99+
100+
private func applySymbolScaleBehavior(to mapboxMap: MapboxMap?) {
101+
guard let mapboxMap = mapboxMap else { return }
102+
let behavior = scaleBehaviorForMode()
103+
mapboxMap.symbolScaleBehavior = behavior
104+
}
105+
106+
private func scaleBehaviorForMode() -> SymbolScaleBehavior {
107+
switch selectedMode {
108+
case .fixed:
109+
// Fixed scale: manual control via slider
110+
return .fixed(scaleFactor: Double(customScaleValue))
111+
case .system:
112+
// System: automatic scaling with default mapping
113+
return .system
114+
case .custom:
115+
// Custom: increases low scale values proportionally, dampens large accessibility scales
116+
return .system(mapping: { systemScale in
117+
switch systemScale {
118+
case ..<1.0:
119+
return systemScale * 1.1 // Boost small scales by 10%
120+
case 1.0...1.3:
121+
return systemScale // Keep medium scales unchanged
122+
default:
123+
return 1.3 + (systemScale - 1.3) * 0.4 // Dampen large scales (max ~1.6x)
124+
}
125+
})
126+
}
127+
}
128+
}
129+
130+
#Preview {
131+
AccessibilityScaleExample()
132+
}

Sources/Examples/SwiftUI Examples/Testing Examples/SwiftUIRoot.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ struct SwiftUIExamples {
3838
Example("Color Themes", note: "Showcase the Color Theme API", destination: ColorThemeExample())
3939
},
4040
Examples.Category("🔬 Experimental APIs") {
41+
Example("Accessibility Scale", note: "Automatic map symbol scaling based on system text size preferences.", destination: AccessibilityScaleExample())
4142
Example("Line elevation", note: "Showcase of the Line Elevation API.", destination: ElevatedLineMapView())
4243
},
4344
Examples.Category("Testing Examples") {

Sources/MapboxMaps/Foundation/MapView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ open class MapView: UIView, SizeTrackingLayerDelegate {
412412
self.pixelRatio = CGFloat(resolvedMapInitOptions.mapOptions.pixelRatio)
413413

414414
mapboxMap = makeMapboxMap(resolvedMapInitOptions: resolvedMapInitOptions)
415+
mapboxMap.associatedView = self
415416

416417
notificationCenter.addObserver(
417418
self,

Sources/MapboxMaps/Foundation/MapboxMap.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ public final class MapboxMap: StyleManager {
217217
public lazy var indoor: IndoorManager = .Marshaller.toSwift(__map.getIndoorManager())
218218

219219
deinit {
220+
stopObservingContentSizeCategory()
220221
__map.destroyRenderer()
221222
}
222223

@@ -1100,6 +1101,104 @@ public final class MapboxMap: StyleManager {
11001101
func dispatch(event: CorePlatformEventInfo) {
11011102
__map.dispatch(for: event)
11021103
}
1104+
1105+
// MARK: - Accessibility Scale Support
1106+
1107+
private var isObservingContentSizeCategory = false
1108+
private var currentContentSizeCategory: UIContentSizeCategory = .large
1109+
internal weak var associatedView: UIView?
1110+
1111+
/// Configures how map symbols scale in response to system text size settings.
1112+
///
1113+
/// **Modes:**
1114+
/// - `.fixed(scaleFactor:)` - Fixed scale (default)
1115+
/// - `.system` or `.system(mapping:)` - Automatic scaling (opt-in)
1116+
///
1117+
/// **Examples:**
1118+
/// ```swift
1119+
/// symbolScaleBehavior = .system // Auto-scale
1120+
/// symbolScaleBehavior = .system { min($0 * 1.2, 1.5) } // Custom
1121+
/// ```
1122+
@_spi(Experimental)
1123+
public var symbolScaleBehavior: SymbolScaleBehavior = .fixed(scaleFactor: 1.0) {
1124+
didSet {
1125+
if symbolScaleBehavior.isSystem {
1126+
startObservingContentSizeCategory()
1127+
applyCurrentScale()
1128+
} else if symbolScaleBehavior.isFixed {
1129+
stopObservingContentSizeCategory()
1130+
// Fixed mode: scaleFactor is always present
1131+
if let scaleFactor = symbolScaleBehavior.scaleFactor {
1132+
setScaleFactor(Float(scaleFactor))
1133+
}
1134+
}
1135+
}
1136+
}
1137+
1138+
private func startObservingContentSizeCategory() {
1139+
guard !isObservingContentSizeCategory else { return }
1140+
updateCurrentContentSizeCategory()
1141+
NotificationCenter.default.addObserver(
1142+
self,
1143+
selector: #selector(contentSizeCategoryDidChange),
1144+
name: UIContentSizeCategory.didChangeNotification,
1145+
object: nil
1146+
)
1147+
isObservingContentSizeCategory = true
1148+
}
1149+
1150+
private func stopObservingContentSizeCategory() {
1151+
guard isObservingContentSizeCategory else { return }
1152+
NotificationCenter.default.removeObserver(
1153+
self,
1154+
name: UIContentSizeCategory.didChangeNotification,
1155+
object: nil
1156+
)
1157+
isObservingContentSizeCategory = false
1158+
}
1159+
1160+
@objc private func contentSizeCategoryDidChange(_ notification: Notification) {
1161+
// Extract the new category from the notification's userInfo.
1162+
if let newCategory = notification.userInfo?[UIContentSizeCategory.newValueUserInfoKey] as? UIContentSizeCategory {
1163+
currentContentSizeCategory = newCategory
1164+
} else {
1165+
// Fallback: Read from trait collection if notification doesn't contain the expected key.
1166+
updateCurrentContentSizeCategory()
1167+
}
1168+
applyCurrentScale()
1169+
}
1170+
1171+
private func updateCurrentContentSizeCategory() {
1172+
// Get trait collection from the associated view if available (works in both apps and extensions)
1173+
if let view = associatedView {
1174+
currentContentSizeCategory = view.traitCollection.preferredContentSizeCategory
1175+
return
1176+
}
1177+
1178+
// Fallback to current trait collection
1179+
currentContentSizeCategory = UITraitCollection.current.preferredContentSizeCategory
1180+
}
1181+
1182+
/// Applies current system text size using the configured mapping function.
1183+
/// Only called for System mode (Fixed mode doesn't respond to system changes).
1184+
private func applyCurrentScale() {
1185+
let category = currentContentSizeCategory
1186+
let normalizedScale = SymbolScaleBehavior.normalizedScale(for: category)
1187+
1188+
// Apply mapping function if custom, otherwise use default
1189+
let scaleFactor: Double
1190+
if let mapping = symbolScaleBehavior.mappingFunction {
1191+
scaleFactor = mapping(normalizedScale)
1192+
} else {
1193+
scaleFactor = normalizedScale
1194+
}
1195+
1196+
setScaleFactor(Float(scaleFactor))
1197+
}
1198+
1199+
private func setScaleFactor(_ scaleFactor: Float) {
1200+
__map.setScaleFactorForScaleFactor(scaleFactor)
1201+
}
11031202
}
11041203

11051204
extension MapboxMap: MapboxMapProtocol {}

0 commit comments

Comments
 (0)