Skip to content

Commit 0a16a47

Browse files
mapbox-github-ci-writer-3[bot]pjleonard37
authored andcommitted
[Backport release/v0.19] Expose accessibility settings change listener (#10045)
Backport 1bf526acb821a324a30d155bb6188ed034e49176 from #9946. cc @mapbox/maps-ios cc @mapbox/maps-android cc @mapbox/sdk-platform Co-authored-by: Patrick Leonard <pjleonard37@users.noreply.github.com> GitOrigin-RevId: 3e589aae6d8a2fae5589c1d9880a3d2459a12e31
1 parent a6df416 commit 0a16a47

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
@@ -9,6 +9,7 @@ Mapbox welcomes participation and contributions from everyone.
99
### Features ✨ and improvements 🏁
1010
* Introduce new `LineLayer.lineElevationGroundScale` property to scale elevated lines with terrain exaggeration.
1111
* Promote elevated lines properties to stable: `LineLayer.lineZOffset` and `LineLayer.lineElevationReference`.
12+
* 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.
1213

1314
### Bug fixes 🐞
1415
* Fix elevated line bevel join artifacts at sharp corners.

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)