Skip to content

Commit fbaaa57

Browse files
authored
Filterable attributions (#2370)
1 parent 2ae693c commit fbaaa57

21 files changed

+535
-316
lines changed

Apps/Apps.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ That initializer doesn't require to wrap arguments in `Argument` cases. For exam
2121
* Add a way to specify image expression options.
2222
* Bump core maps version to 11.9.0-beta.1 and common sdk to 24.9.0-beta.1
2323
* Add new experimental APIs to control precipitation rendering. Snow and Rain are available now with an `@_spi(Experimental)` import prefix.
24+
* Add a way to filter attribution menu items.
2425

2526
## 11.8.0 - 11 November, 2024
2627

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import Foundation
2+
import UIKit
3+
@_implementationOnly import MapboxCommon_Private
4+
5+
/// API for attribution menu configuration
6+
/// Restricted API. Please contact Mapbox to discuss your use case if you intend to use this property.
7+
@_spi(Restricted)
8+
public class AttributionMenu {
9+
private let urlOpener: AttributionURLOpener
10+
private let feedbackURLRef: Ref<URL?>
11+
private let geofencingIsActiveRef: Ref<Bool>
12+
13+
/// Filters attribution menu items based on the provided closure.
14+
public var filter: ((AttributionMenuItem) -> Bool)?
15+
16+
init(
17+
urlOpener: AttributionURLOpener,
18+
feedbackURLRef: Ref<URL?>,
19+
filter: ((AttributionMenuItem) -> Bool)? = nil,
20+
geofencingIsActiveRef: Ref<Bool> = Ref { __GeofencingUtils.isActive() }
21+
) {
22+
self.urlOpener = urlOpener
23+
self.filter = filter
24+
self.feedbackURLRef = feedbackURLRef
25+
self.geofencingIsActiveRef = geofencingIsActiveRef
26+
}
27+
}
28+
29+
extension AttributionMenu {
30+
var isMetricsEnabled: Bool {
31+
get { UserDefaults.standard.MGLMapboxMetricsEnabled }
32+
set { UserDefaults.standard.MGLMapboxMetricsEnabled = newValue }
33+
}
34+
35+
var isGeofencingEnabled: Bool {
36+
get { __GeofencingUtils.getUserConsent() }
37+
set { __GeofencingUtils.setUserConsent(isConsentGiven: newValue) { expected in
38+
if let error = expected.error { Log.error("Error: \(error) when setting geofencing user consent.") }
39+
} }
40+
}
41+
42+
internal func menu(from attributions: [Attribution]) -> AttributionMenuSection {
43+
var elements = [AttributionMenuElement]()
44+
let items = attributions.compactMap { attribution in
45+
switch attribution.kind {
46+
case .actionable(let url):
47+
return AttributionMenuItem(title: attribution.localizedTitle, id: .copyright, category: .main) { [weak self] in
48+
self?.urlOpener.openAttributionURL(url)
49+
}
50+
case .nonActionable:
51+
return AttributionMenuItem(title: attribution.localizedTitle, id: .copyright, category: .main)
52+
case .feedback:
53+
guard let feedbackURL = feedbackURLRef.value else { return nil }
54+
return AttributionMenuItem(title: attribution.localizedTitle, id: .contribute, category: .main) { [weak self] in
55+
self?.urlOpener.openAttributionURL(feedbackURL)
56+
}
57+
}
58+
}
59+
let menuSubtitle: String?
60+
if items.count == 1, let item = items.first, item.action == nil {
61+
menuSubtitle = item.title
62+
} else {
63+
menuSubtitle = nil
64+
elements.append(contentsOf: items.map(AttributionMenuElement.item))
65+
}
66+
67+
elements.append(.section(telemetryMenu))
68+
69+
if geofencingIsActiveRef.value || !isGeofencingEnabled {
70+
elements.append(.section(geofencingMenu))
71+
}
72+
73+
elements.append(.item(privacyPolicyItem))
74+
elements.append(.item(cancelItem))
75+
76+
let mainTitle = Bundle.mapboxMaps.localizedString(
77+
forKey: "SDK_NAME",
78+
value: "Powered by Mapbox",
79+
table: Ornaments.localizableTableName
80+
)
81+
82+
return AttributionMenuSection(title: mainTitle, subtitle: menuSubtitle, category: .main, elements: elements)
83+
}
84+
85+
private var cancelItem: AttributionMenuItem {
86+
let cancelTitle = NSLocalizedString("ATTRIBUTION_CANCEL",
87+
tableName: Ornaments.localizableTableName,
88+
bundle: .mapboxMaps,
89+
value: "Cancel",
90+
comment: "Title of button for dismissing attribution action sheet")
91+
92+
return AttributionMenuItem(title: cancelTitle, style: .cancel, id: .cancel, category: .main) { }
93+
}
94+
95+
private var privacyPolicyItem: AttributionMenuItem {
96+
let privacyPolicyTitle = NSLocalizedString("ATTRIBUTION_PRIVACY_POLICY",
97+
tableName: Ornaments.localizableTableName,
98+
bundle: .mapboxMaps,
99+
value: "Mapbox Privacy Policy",
100+
comment: "Privacy policy action in attribution sheet")
101+
102+
return AttributionMenuItem(title: privacyPolicyTitle, id: .privacyPolicy, category: .main) { [weak self] in
103+
self?.urlOpener.openAttributionURL(Attribution.privacyPolicyURL)
104+
}
105+
}
106+
107+
private var telemetryMenu: AttributionMenuSection {
108+
let telemetryTitle = TelemetryStrings.telemetryTitle
109+
let telemetryURL = URL(string: Ornaments.telemetryURL)!
110+
let message: String
111+
let participateTitle: String
112+
let declineTitle: String
113+
114+
if isMetricsEnabled {
115+
message = TelemetryStrings.telemetryEnabledMessage
116+
participateTitle = TelemetryStrings.telemetryEnabledOnMessage
117+
declineTitle = TelemetryStrings.telemetryEnabledOffMessage
118+
} else {
119+
message = TelemetryStrings.telemetryDisabledMessage
120+
participateTitle = TelemetryStrings.telemetryDisabledOnMessage
121+
declineTitle = TelemetryStrings.telemetryDisabledOffMessage
122+
}
123+
124+
return AttributionMenuSection(title: telemetryTitle, actionTitle: TelemetryStrings.telemetryName, subtitle: message, category: .telemetry, elements: [
125+
AttributionMenuItem(title: TelemetryStrings.telemetryMore, id: .telemetryInfo, category: .telemetry) { [weak self] in
126+
self?.urlOpener.openAttributionURL(telemetryURL)
127+
},
128+
AttributionMenuItem(title: declineTitle, id: .disable, category: .telemetry) { [weak self] in
129+
self?.isMetricsEnabled = false
130+
},
131+
AttributionMenuItem(title: participateTitle, style: .cancel, id: .enable, category: .telemetry) { [weak self] in
132+
self?.isMetricsEnabled = true
133+
}
134+
].map(AttributionMenuElement.item))
135+
}
136+
137+
private var geofencingMenu: AttributionMenuSection {
138+
let telemetryTitle = GeofencingStrings.geofencingTitle
139+
let message = GeofencingStrings.geofencingMessage
140+
let participateTitle: String
141+
let declineTitle: String
142+
143+
if __GeofencingUtils.getUserConsent() {
144+
participateTitle = GeofencingStrings.geofencingEnabledOnMessage
145+
declineTitle = GeofencingStrings.geofencingEnabledOffMessage
146+
} else {
147+
participateTitle = GeofencingStrings.geofencingDisabledOnMessage
148+
declineTitle = GeofencingStrings.geofencingDisabledOffMessage
149+
}
150+
151+
return AttributionMenuSection(title: telemetryTitle, actionTitle: GeofencingStrings.geofencingName, subtitle: message, category: .geofencing, elements: [
152+
AttributionMenuItem(title: declineTitle, id: .disable, category: .geofencing) { [weak self] in
153+
self?.isGeofencingEnabled = false
154+
},
155+
AttributionMenuItem(title: participateTitle, style: .cancel, id: .enable, category: .geofencing) { [weak self] in
156+
self?.isGeofencingEnabled = true
157+
}
158+
].map(AttributionMenuElement.item))
159+
}
160+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Foundation
2+
import UIKit
3+
4+
/// A menu item entry in the attribution list.
5+
@_spi(Restricted)
6+
public struct AttributionMenuItem {
7+
8+
/// Denotes a category(section) that item belongs to.
9+
public struct Category: RawRepresentable {
10+
public let rawValue: String
11+
12+
public init(rawValue: String) {
13+
self.rawValue = rawValue
14+
}
15+
16+
/// Main(root) category
17+
public static let main = Category(rawValue: "com.mapbox.maps.attribution.main")
18+
19+
/// Category for opting in/out of telemetry
20+
public static let telemetry = Category(rawValue: "com.mapbox.maps.attribution.telemetry")
21+
22+
/// Category for opting in/out of geofencing
23+
public static let geofencing = Category(rawValue: "com.mapbox.maps.attribution.geofencing")
24+
}
25+
26+
/// Denotes an identifier of an item
27+
public struct ID: RawRepresentable {
28+
public let rawValue: String
29+
30+
public init(rawValue: String) {
31+
self.rawValue = rawValue
32+
}
33+
34+
/// Item attributing a copyright
35+
public static let copyright = ID(rawValue: "com.mapbox.maps.attribution.copyright")
36+
37+
/// Represents an item opening a contribution form
38+
public static let contribute = ID(rawValue: "com.mapbox.maps.attribution.contribute")
39+
40+
/// Opens privacy policy page
41+
public static let privacyPolicy = ID(rawValue: "com.mapbox.maps.attribution.privacyPolicy")
42+
43+
/// Opens page with the info about Mapbox telemetry
44+
public static let telemetryInfo = ID(rawValue: "com.mapbox.maps.attribution.telemetryInfo")
45+
46+
/// Item that enables a certain option, typically associated with a category
47+
/// e.g. `category: .telemetry, id: .enable`
48+
public static let enable = ID(rawValue: "com.mapbox.maps.attribution.enable")
49+
50+
/// Item that disables a certain option, typically associated with a category
51+
/// e.g. `category: .telemetry, id: .disable`
52+
public static let disable = ID(rawValue: "com.mapbox.maps.attribution.disable")
53+
54+
/// Item that dismisses the attribution menu
55+
public static let cancel = ID(rawValue: "com.mapbox.maps.attribution.cancel")
56+
}
57+
58+
/// Title of the attribution menu item
59+
public let title: String
60+
61+
/// Identifier of the item
62+
public let id: ID
63+
64+
/// Category of the item
65+
public let category: Category
66+
67+
let action: (() -> Void)?
68+
let style: Style
69+
70+
init(title: String, style: Style = .default, id: ID, category: Category, action: (() -> Void)? = nil) {
71+
self.title = title
72+
self.id = id
73+
self.category = category
74+
self.action = action
75+
self.style = style
76+
}
77+
}
78+
79+
extension AttributionMenuItem {
80+
enum Style {
81+
case `default`
82+
case cancel
83+
84+
var uiActionStyle: UIAlertAction.Style {
85+
switch self {
86+
case .default: return .default
87+
case .cancel: return .cancel
88+
}
89+
}
90+
}
91+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Foundation
2+
import UIKit
3+
4+
indirect enum AttributionMenuElement {
5+
case section(AttributionMenuSection)
6+
case item(AttributionMenuItem)
7+
}
8+
9+
internal struct AttributionMenuSection {
10+
var title: String
11+
var actionTitle: String?
12+
var subtitle: String?
13+
var category: AttributionMenuItem.Category
14+
var elements: [AttributionMenuElement]
15+
16+
init(title: String, actionTitle: String? = nil, subtitle: String? = nil, category: AttributionMenuItem.Category, elements: [AttributionMenuElement]) {
17+
self.title = title
18+
self.actionTitle = actionTitle
19+
self.subtitle = subtitle
20+
self.category = category
21+
self.elements = elements
22+
}
23+
24+
mutating func filter(_ filter: (AttributionMenuItem) -> Bool) {
25+
elements = elements.compactMap { element in
26+
switch element {
27+
case .item(let item):
28+
return filter(item) ? .item(item) : nil
29+
case .section(var section):
30+
section.filter(filter)
31+
return .section(section)
32+
}
33+
}
34+
}
35+
}

Sources/MapboxMaps/Foundation/MapView+Attribution.swift

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,11 @@ extension MapView: AttributionDialogManagerDelegate {
55
func viewControllerForPresenting(_ attributionDialogManager: AttributionDialogManager) -> UIViewController? {
66
parentViewController?.topmostPresentedViewController
77
}
8+
}
89

9-
func attributionDialogManager(_ attributionDialogManager: AttributionDialogManager, didTriggerActionFor attribution: Attribution) {
10-
switch attribution.kind {
11-
case .actionable(let url):
12-
Log.debug("Open url: \(url))", category: "Attribution")
13-
attributionUrlOpener.openAttributionURL(url)
14-
case .feedback:
15-
let url = mapboxFeedbackURL()
16-
Log.debug("Open url: \(url))", category: "Attribution")
17-
attributionUrlOpener.openAttributionURL(url)
18-
case .nonActionable:
19-
break
20-
}
21-
}
22-
23-
internal func mapboxFeedbackURL(accessToken: String = MapboxOptions.accessToken) -> URL {
24-
let cameraState = self.mapboxMap.cameraState
10+
internal extension MapboxMap {
11+
func mapboxFeedbackURL(accessToken: String = MapboxOptions.accessToken) -> URL {
12+
let cameraState = self.cameraState
2513

2614
var components = URLComponents(string: "https://apps.mapbox.com/feedback/")!
2715
components.fragment = String(format: "/%.5f/%.5f/%.2f/%.1f/%i",
@@ -38,7 +26,7 @@ extension MapView: AttributionDialogManagerDelegate {
3826

3927
let sdkVersion = Bundle.mapboxMapsMetadata.version
4028

41-
if let styleURIString = mapboxMap.styleURI?.rawValue,
29+
if let styleURIString = styleURI?.rawValue,
4230
let styleURL = URL(string: styleURIString),
4331
styleURL.scheme == "mapbox",
4432
styleURL.host == "styles" {

Sources/MapboxMaps/Foundation/MapView.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import MetalKit
77
// swiftlint:disable:next type_body_length
88
open class MapView: UIView, SizeTrackingLayerDelegate {
99

10+
/// Handles attribution menu customization
11+
/// Restricted API. Please contact Mapbox to discuss your use case if you intend to use this property.
12+
@_spi(Restricted)
13+
public private(set) var attributionMenu: AttributionMenu!
14+
1015
open override class var layerClass: AnyClass { SizeTrackingLayer.self }
1116

1217
// `mapboxMap` depends on `MapInitOptions`, which is not available until
@@ -383,10 +388,15 @@ open class MapView: UIView, SizeTrackingLayerDelegate {
383388
mapboxMap: mapboxMap,
384389
cameraAnimationsManager: internalCamera)
385390

386-
// Initialize the attribution manager
391+
// Initialize the attribution manager and menu
392+
attributionMenu = AttributionMenu(
393+
urlOpener: attributionUrlOpener,
394+
feedbackURLRef: Ref { [weak mapboxMap] in mapboxMap?.mapboxFeedbackURL() }
395+
)
387396
attributionDialogManager = AttributionDialogManager(
388397
dataSource: mapboxMap,
389-
delegate: self)
398+
delegate: self,
399+
attributionMenu: attributionMenu)
390400

391401
// Initialize/Configure ornaments manager
392402
ornaments = OrnamentsManager(

Sources/MapboxMaps/Ornaments/InfoButtonOrnament.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ internal class InfoButtonOrnament: UIView {
1515
}
1616
}
1717

18-
internal var isMetricsEnabled: Bool {
19-
return UserDefaults.standard.MGLMapboxMetricsEnabled
20-
}
21-
2218
internal weak var delegate: InfoButtonOrnamentDelegate?
2319

2420
internal init() {

0 commit comments

Comments
 (0)