├── .github
├── FUNDING.yml
├── pull_request_template.md
└── workflows
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .spi.yml
├── .swiftformat
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ ├── MapLibreSwiftDSL.xcscheme
│ ├── MapLibreSwiftUI-Package.xcscheme
│ └── MapLibreSwiftUI.xcscheme
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.md
├── MIGRATING.md
├── Package.resolved
├── Package.swift
├── README.md
├── SECURITY_POLICY.txt
├── Sources
├── InternalUtils
│ ├── Enums.swift
│ ├── Sample Data.swift
│ └── Utilities.swift
├── MapLibreSwiftDSL
│ ├── Enums.swift
│ ├── Expressions.swift
│ ├── MapControls.swift
│ ├── MapLibre Extensions.swift
│ ├── MapViewContentBuilder.swift
│ ├── ShapeDataBuilder.swift
│ ├── Style Layers
│ │ ├── Background.swift
│ │ ├── Circle.swift
│ │ ├── FillStyleLayer.swift
│ │ ├── Line.swift
│ │ ├── Style Layer.swift
│ │ ├── StyleLayerCollection.swift
│ │ └── Symbol.swift
│ └── Support
│ │ └── DefaultResultBuilder.swift
├── MapLibreSwiftMacros
│ └── MapLibreSwiftMacros.swift
├── MapLibreSwiftMacrosImpl
│ ├── MapLibreSwiftMacrosPlugin.swift
│ └── StyleExpressionMacro.swift
└── MapLibreSwiftUI
│ ├── Documentation.docc
│ └── MapLibreSwiftUI.md
│ ├── Examples
│ ├── Camera.swift
│ ├── FillStyleLayerPreviews.swift
│ ├── Gestures.swift
│ ├── Layers.swift
│ ├── Other.swift
│ ├── Polyline.swift
│ ├── Preview Helpers.swift
│ └── User Location.swift
│ ├── Extensions
│ ├── CoreLocation
│ │ └── CLLocationCoordinate2D.swift
│ ├── MapLibre
│ │ ├── MLNCameraChangeReason.swift
│ │ └── MLNMapViewCameraUpdating.swift
│ ├── MapView
│ │ └── MapViewGestures.swift
│ ├── MapViewCamera
│ │ └── MapViewCameraOperations.swift
│ └── UIKit
│ │ └── UIGestureRecognizing.swift
│ ├── MLNMapViewController.swift
│ ├── MapView.swift
│ ├── MapViewCoordinator.swift
│ ├── MapViewModifiers.swift
│ ├── Models
│ ├── Gesture
│ │ ├── MapGesture.swift
│ │ └── MapGestureContext.swift
│ ├── MapCamera
│ │ ├── CameraChangeReason.swift
│ │ ├── CameraPitchRange.swift
│ │ ├── CameraState.swift
│ │ └── MapViewCamera.swift
│ ├── MapStyleSource.swift
│ └── MapViewProxy.swift
│ └── StaticLocationManager.swift
├── Tests
├── MapLibreSwiftDSLTests
│ ├── PointFeatureTests.swift
│ ├── ShapeSourceTests.swift
│ └── StyleLayerTest.swift
├── MapLibreSwiftMacrosTests
│ └── MapLibreSwiftMacrosTests.swift
└── MapLibreSwiftUITests
│ ├── Examples
│ ├── CameraPreviewTests.swift
│ ├── LayerPreviewTests.swift
│ ├── MapControlsTests.swift
│ └── __Snapshots__
│ │ ├── CameraPreviewTests
│ │ └── testCameraPreview.1.png
│ │ ├── LayerPreviewTests
│ │ ├── testCirclesWithSymbols.1.png
│ │ ├── testRoseTint.1.png
│ │ ├── testRotatedSymbolConst.1.png
│ │ ├── testRotatedSymboleDynamic.1.png
│ │ └── testSimpleSymbol.1.png
│ │ └── MapControlsTests
│ │ ├── testAttributionChangePosition.1.png
│ │ ├── testAttributionOnly.1.png
│ │ ├── testCompassChangePosition.1.png
│ │ ├── testCompassOnly.1.png
│ │ ├── testEmptyControls.1.png
│ │ ├── testLogoChangePosition.1.png
│ │ └── testLogoOnly.1.png
│ ├── Extensions
│ └── CoreLocation
│ │ └── CLLocationCoordinate2D.swift
│ ├── MapView
│ └── MapViewGestureTests.swift
│ ├── MapViewCoordinator
│ └── MapViewCoordinatorCameraTests.swift
│ ├── Models
│ ├── Gesture
│ │ └── MapGestureTests.swift
│ └── MapCamera
│ │ ├── CameraChangeReasonTests.swift
│ │ ├── CameraPitchTests.swift
│ │ ├── CameraStateTests.swift
│ │ ├── MapViewCameraTests.swift
│ │ └── __Snapshots__
│ │ ├── CameraStateTests
│ │ ├── testCenterCameraState.1.txt
│ │ ├── testRect.1.txt
│ │ ├── testTrackingUserLocation.1.txt
│ │ ├── testTrackingUserLocationWithCourse.1.txt
│ │ └── testTrackingUserLocationWithHeading.1.txt
│ │ └── MapViewCameraTests
│ │ ├── testBoundingBox.1.txt
│ │ ├── testCenterCamera.1.txt
│ │ ├── testTrackUserLocationWithCourse.1.txt
│ │ ├── testTrackUserLocationWithHeading.1.txt
│ │ └── testTrackingUserLocation.1.txt
│ └── Support
│ └── XCTestAssertView.swift
└── demo.gif
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [maplibre]
2 | open_collective: maplibre
3 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Issue/Motivation
2 |
3 | What issue is this PR targeting?
4 |
5 | ## Tasklist
6 |
7 | - [ ] Include tests (if applicable) and examples (new or edits)
8 | - [ ] If there are any visual changes as a result, include before/after screenshots and/or videos
9 | - [ ] Add #fixes with the issue number that this PR addresses
10 | - [ ] Update any documentation for affected APIs
11 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | bump_version_scheme:
7 | type: choice
8 | description: 'Bump version scheme'
9 | required: true
10 | default: 'patch'
11 | options:
12 | - 'patch'
13 | - 'minor'
14 | - 'major'
15 |
16 | jobs:
17 | release:
18 | runs-on: ubuntu-latest
19 | env:
20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21 | steps:
22 | - uses: rymndhng/release-on-push-action@v0.28.0
23 | with:
24 | bump_version_scheme: ${{ inputs.bump_version_scheme }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | format-lint:
11 | runs-on: macos-15
12 |
13 | steps:
14 | - name: Checkout maplibre-swiftui-dsl-playground
15 | uses: actions/checkout@v4
16 |
17 | - name: Check format
18 | run: swiftformat . --lint
19 |
20 | test:
21 | runs-on: macos-15
22 | strategy:
23 | matrix:
24 | scheme: [MapLibreSwiftUI-Package]
25 | destination: [
26 | # TODO: Add more destinations (snapshot testing is the problem)
27 | "platform=iOS Simulator,name=iPhone 16,OS=18.1",
28 | # 'platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)',
29 | # "platform=iOS Simulator,name=iPad (10th generation),OS=16.4",
30 | # "platform=iOS Simulator,name=iPhone 15,OS=17.2",
31 | ]
32 | name: ${{ matrix.destination }}
33 |
34 | steps:
35 | - name: Install tools
36 | run: brew update && brew upgrade xcbeautify
37 |
38 | - uses: maxim-lobanov/setup-xcode@v1
39 | with:
40 | xcode-version: latest-stable
41 |
42 | - name: Checkout maplibre-swiftui-dsl-playground
43 | uses: actions/checkout@v4
44 |
45 | - name: Test ${{ matrix.scheme }} on ${{ matrix.destination }}
46 | run: xcodebuild -scheme ${{ matrix.scheme }} test -skipMacroValidation -destination '${{ matrix.destination }}' | xcbeautify && exit ${PIPESTATUS[0]}
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [MapLibreSwiftUI]
5 | platform: ios
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | # file options
2 |
3 | --exclude .build
4 |
5 | # format options
6 |
7 | --header ""
8 | --indent 4
9 | --importgrouping testable-bottom
10 | --maxwidth 120
11 | --swiftversion 5.9
12 |
13 | # rules
14 |
15 | --enable isEmpty
16 | --disable andOperator
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
43 |
49 |
50 |
56 |
57 |
58 |
59 |
61 |
62 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI-Package.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
57 |
63 |
64 |
65 |
66 |
67 |
73 |
74 |
76 |
82 |
83 |
84 |
86 |
92 |
93 |
94 |
96 |
102 |
103 |
104 |
105 |
106 |
116 |
117 |
123 |
124 |
130 |
131 |
132 |
133 |
135 |
136 |
139 |
140 |
141 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
43 |
49 |
50 |
56 |
57 |
58 |
59 |
61 |
62 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant
2 | [](https://github.com/maplibre/maplibre/blob/main/CODE_OF_CONDUCT.md)
3 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributor Guide
2 |
3 | This project is a standard Swift package.
4 |
5 | ## Environment setup
6 |
7 | The only special thing you might need besides Xcode is [`swiftformat`](https://github.com/nicklockwood/SwiftFormat).
8 | We use it to automatically handle basic formatting and to linting
9 | so the code has a standard style.
10 |
11 | Check out the swiftformat [Install Guide](https://github.com/nicklockwood/SwiftFormat?tab=readme-ov-file#how-do-i-install-it)
12 | to add swiftformat to your machine.
13 | Once installed, you can autoformat code using the command:
14 |
15 | ```sh
16 | swiftformat .
17 | ```
18 |
19 | Swiftformat can occasionally poorly resolve a formatting issue (e.g. when you've already line-broken a large comment).
20 | Issues like this are typically easy to manually correct.
21 |
22 | ## Structure
23 |
24 | This package is structured into a few targets. `InternalUtils` is pretty much what it says. `MapLibreSwiftDSL` and
25 | `MapLibreSwiftUI` are published products, and make up the bulk of the project. Finally, `Examples` is a collection of
26 | SwiftUI previews.
27 |
28 | The DSL provides a more Swift-y layer on top of the lower level MapLibre APIs, and features a number of
29 | result builders which enable more modern expressive APIs.
30 |
31 | The SwiftUI layer publishes a SwiftUI view with the end goal of being a universal view that can be adapted to a wide
32 | variety of use cases, much like MapKit's SwiftUI views.
33 |
34 | ## Testing
35 |
36 | You can run the test suite from Xcode using the standard process:
37 | Product menu > Test, or Cmd + U.
38 | If you're having trouble getting a test option / there are no tests,
39 | make sure that the `MapLibreSwiftUI-Package` target is active.
40 |
41 | Most of the unit tests are pretty straightforward, but you may notice a few things besides the vanilla testing tools.
42 | We employ snapshot tests liberally to record the state of objects after some operations.
43 | In case you change something which triggers a snapshot change,
44 | you'll get a test failure with some nominally useful info (usually paths to the new and old snapshot).
45 |
46 | To record a new snapshot, you can either delete the existing snapshot file on disk
47 | or pass a keyword argument `record: true` to the assertion function.
48 | Then re-run the tests and a new snapshot will be generated (tests will fail one last time with a note that a new snapshot was recorded).
49 |
50 | You can learn more about the snapshot testing library we use [here](https://github.com/pointfreeco/swift-snapshot-testing).
51 |
52 | We do not currently have full UI tests.
53 | These are a bit tricky due to the async nature of MapLibre and integrating this into an Xcode UI test is challenging.
54 | If you have any suggestions, we welcome them!
55 |
56 | ## PRs
57 |
58 | If you're using this project and want to improve it, send us a PR!
59 | We have a checklist in our PR template to help reviews go smoothly.
60 |
61 | NOTE: If possible, enable maintainer edits when you open PRs.
62 | If your PR is good and just needs a few minor edits to get merged,
63 | we can often put those finishing touches on for you.
64 | In order to allow maintainer edits,
65 | you need to send PRs from a personal fork (org-owned ones have funky auth).
66 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024, MapLibre contributors.
2 |
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without modification,
6 | are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright notice,
9 | this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 | * Neither the name of MapLibre nor the names of its contributors
14 | may be used to endorse or promote products derived from this software
15 | without specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/MIGRATING.md:
--------------------------------------------------------------------------------
1 | This project has migrated from Stadia Maps to the MapLibre organization!
2 |
3 | Xcode and GitHub normally handle these sorts of changes well,
4 | but sometimes they don't.
5 | So, you'll probably want to be proactive and change your URLs from
6 | `https://github.com/stadiamaps/maplibre-swiftui-dsl-playground`
7 | to `https://github.com/maplibre/swiftui-dsl`.
8 |
9 | If you're building a plain Xcode project, it might actually be easier to remove the Swift Package
10 | and all of its targets and then re-add with the URL.
11 |
12 | Swift Package authors can simply update the URLs.
13 | Note that the package name also changes with the repo name.
14 |
15 | ```swift
16 | .product(name: "MapLibreSwiftDSL", package: "swiftui-dsl"),
17 | .product(name: "MapLibreSwiftUI", package: "swiftui-dsl"),
18 | ```
19 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "3ef0341fce60ebda3bd350b1bafb9cebcd8654136f438ae028750da294ee7bf8",
3 | "pins" : [
4 | {
5 | "identity" : "maplibre-gl-native-distribution",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
8 | "state" : {
9 | "revision" : "3615e3cc81b09b78b58b183660815b0f36107b3b",
10 | "version" : "6.10.0"
11 | }
12 | },
13 | {
14 | "identity" : "mockable",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/Kolos65/Mockable.git",
17 | "state" : {
18 | "revision" : "68f3ed6c4b62afab27a84425494cb61421a61ac1",
19 | "version" : "0.3.1"
20 | }
21 | },
22 | {
23 | "identity" : "swift-custom-dump",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/pointfreeco/swift-custom-dump",
26 | "state" : {
27 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
28 | "version" : "1.3.3"
29 | }
30 | },
31 | {
32 | "identity" : "swift-macro-testing",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/pointfreeco/swift-macro-testing",
35 | "state" : {
36 | "revision" : "0b80a098d4805a21c412b65f01ffde7b01aab2fa",
37 | "version" : "0.6.0"
38 | }
39 | },
40 | {
41 | "identity" : "swift-snapshot-testing",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing",
44 | "state" : {
45 | "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b",
46 | "version" : "1.18.3"
47 | }
48 | },
49 | {
50 | "identity" : "swift-syntax",
51 | "kind" : "remoteSourceControl",
52 | "location" : "https://github.com/swiftlang/swift-syntax",
53 | "state" : {
54 | "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82",
55 | "version" : "510.0.3"
56 | }
57 | },
58 | {
59 | "identity" : "xctest-dynamic-overlay",
60 | "kind" : "remoteSourceControl",
61 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
62 | "state" : {
63 | "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1",
64 | "version" : "1.4.3"
65 | }
66 | }
67 | ],
68 | "version" : 3
69 | }
70 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.10
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import CompilerPluginSupport
5 | import PackageDescription
6 |
7 | let package = Package(
8 | name: "MapLibreSwiftUI",
9 | platforms: [
10 | .iOS(.v15),
11 | .macOS(.v12),
12 | ],
13 | products: [
14 | .library(
15 | name: "MapLibreSwiftUI",
16 | targets: ["MapLibreSwiftUI"]
17 | ),
18 | .library(
19 | name: "MapLibreSwiftDSL",
20 | targets: ["MapLibreSwiftDSL"]
21 | ),
22 | .library(
23 | name: "MapLibreSwiftMacros",
24 | targets: ["MapLibreSwiftMacros"]
25 | ),
26 | ],
27 | dependencies: [
28 | .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.10.0"),
29 | // Macros
30 | .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.0.0" ..< "601.0.0"),
31 | // Testing
32 | .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.3.1"),
33 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.3"),
34 | // Macro Testing
35 | .package(url: "https://github.com/pointfreeco/swift-macro-testing", .upToNextMinor(from: "0.6.0")),
36 | ],
37 | targets: [
38 | .target(
39 | name: "MapLibreSwiftUI",
40 | dependencies: [
41 | .target(name: "InternalUtils"),
42 | .target(name: "MapLibreSwiftDSL"),
43 | .product(name: "MapLibre", package: "maplibre-gl-native-distribution"),
44 | .product(name: "Mockable", package: "Mockable"),
45 | ],
46 | swiftSettings: [
47 | .define("MOCKING", .when(configuration: .debug)),
48 | .enableExperimentalFeature("StrictConcurrency"),
49 | ]
50 | ),
51 | .target(
52 | name: "MapLibreSwiftDSL",
53 | dependencies: [
54 | .target(name: "InternalUtils"),
55 | .product(name: "MapLibre", package: "maplibre-gl-native-distribution"),
56 | "MapLibreSwiftMacros",
57 | ],
58 | swiftSettings: [
59 | .enableExperimentalFeature("StrictConcurrency"),
60 | ]
61 | ),
62 | .target(
63 | name: "InternalUtils",
64 | dependencies: [
65 | .product(name: "MapLibre", package: "maplibre-gl-native-distribution"),
66 | ],
67 | swiftSettings: [
68 | .enableExperimentalFeature("StrictConcurrency"),
69 | ]
70 | ),
71 |
72 | // MARK: Macro
73 |
74 | .macro(
75 | name: "MapLibreSwiftMacrosImpl",
76 | dependencies: [
77 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
78 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
79 | ]
80 | ),
81 |
82 | // Library that exposes a macro as part of its API, which is used in client programs.
83 | .target(name: "MapLibreSwiftMacros", dependencies: ["MapLibreSwiftMacrosImpl"]),
84 |
85 | // MARK: Tests
86 |
87 | .testTarget(
88 | name: "MapLibreSwiftUITests",
89 | dependencies: [
90 | "MapLibreSwiftUI",
91 | .product(name: "Mockable", package: "Mockable"),
92 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
93 | ]
94 | ),
95 | .testTarget(
96 | name: "MapLibreSwiftDSLTests",
97 | dependencies: [
98 | "MapLibreSwiftDSL",
99 | ]
100 | ),
101 |
102 | // MARK: Macro Tests
103 |
104 | .testTarget(
105 | name: "MapLibreSwiftMacrosTests",
106 | dependencies: [
107 | "MapLibreSwiftMacrosImpl",
108 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
109 | .product(name: "MacroTesting", package: "swift-macro-testing"),
110 | ]
111 | ),
112 | ]
113 | )
114 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # MapLibreSwiftUI
6 |
7 | Swift DSLs for [MapLibre Native](https://github.com/maplibre/maplibre-native), a free open-source renderer
8 | for interactive vector maps, to enable better integration with SwiftUI and generally enable easier use of MapLibre.
9 |
10 | **NOTE: This package has migrated from Stadia Maps to the MapLibre organization 🎉**
11 | If you previously installed this package, refer to [MIGRATING.md](MIGRATING.md).
12 |
13 | 
14 |
15 | This package is a reimagining of the MapLibre API with a modern DSLs for SwiftUI.
16 | The pre-1.0 status means only that we are not yet committed to API stability yet,
17 | since we care deeply about finding the best way to express things for SwfitUI users.
18 | The package is robust for the subset of the MapLibre iOS API that it supports.
19 | Any breaking API changes will be reflected in release notes.
20 |
21 | ## Goals
22 |
23 | 1. Primary: Make common use cases easy and [make complicated ones possible](Sources/MapLibreSwiftUI/Examples/Other.swift)
24 | * Easy integration of MapLibre into a modern SwiftUI app
25 | * Add [markers](Sources/MapLibreSwiftUI/Examples/Gestures.swift), [polylines](Sources/MapLibreSwiftUI/Examples/Polyline.swift) and similar annotations
26 | * Interaction with features through [gestures](Sources/MapLibreSwiftUI/Examples/Gestures.swift)
27 | * Clustering (common use case that's rather difficult for first timers)
28 | * [Overlays](Sources/MapLibreSwiftUI/Examples/)
29 | * Dynamic styling
30 | * [Camera control](Sources/MapLibreSwiftUI/Examples/Camera.swift)
31 | * Turn-by-turn Navigation (see the showcase integrations below)
32 | * Animation
33 | 2. Prevent most common classes of mistakes that users make with the lower level APIs (ex: adding the same source twice)
34 | 3. Deeper SwiftUI integration (ex: SwiftUI callout views)
35 |
36 | ## Quick start
37 |
38 | ### In a normal Xcode project
39 |
40 | If you're building an app using an Xcode project,
41 | the easiest way to add package dependencies is in the File menu.
42 | Search for the package using the repository URL: `https://github.com/maplibre/swiftui-dsl`.
43 |
44 | ### In a Swift package
45 |
46 | Add the following to the main dependencies section of your `Package.swift`.
47 |
48 | ```swift
49 | .package(url: "https://github.com/maplibre/swiftui-dsl", branch: "main"),
50 | ```
51 |
52 | Then, for each target add either the DSL (for just the DSL) or both (for the SwiftUI view):
53 |
54 | ```swift
55 | .product(name: "MapLibreSwiftDSL", package: "swiftui-dsl"),
56 | .product(name: "MapLibreSwiftUI", package: "swiftui-dsl"),
57 | ```
58 |
59 | ### Simple example: polyline rendering
60 |
61 | Then, you can use it in a SwiftUI view body like this:
62 |
63 | ```swift
64 | import MapLibre
65 | import MapLibreSwiftDSL
66 | import SwiftUI
67 | import CoreLocation
68 |
69 | struct PolylineMapView: View {
70 | // You'll need a MapLibre Style for this to work.
71 | // You can use https://demotiles.maplibre.org/style.json for basic testing.
72 | // For a list of commercially supported tile providers, check out https://wiki.openstreetmap.org/wiki/Vector_tiles#Providers.
73 | // These providers all have their own "house styles" as well as custom styling.
74 | // You can create your own style or modify others (subject to license restrictions) using https://maplibre.org/maputnik/.
75 | let styleURL: URL
76 |
77 | // Just a list of waypoints (ex: a route to follow)
78 | let waypoints: [CLLocationCoordinate2D]
79 |
80 | var body: some View {
81 | MapView(styleURL: styleURL,
82 | camera: .constant(.center(waypoints.first!, zoom: 14)))
83 | {
84 | // Define a data source.
85 | // It will be automatically if a layer references it.
86 | let polylineSource = ShapeSource(identifier: "polyline") {
87 | MLNPolylineFeature(coordinates: waypoints)
88 | }
89 |
90 | // Add a polyline casing for a stroke effect
91 | LineStyleLayer(identifier: "polyline-casing", source: polylineSource)
92 | .lineCap(.round)
93 | .lineJoin(.round)
94 | .lineColor(.white)
95 | .lineWidth(interpolatedBy: .zoomLevel,
96 | curveType: .exponential,
97 | parameters: NSExpression(forConstantValue: 1.5),
98 | stops: NSExpression(forConstantValue: [14: 6, 18: 24]))
99 |
100 | // Add an inner (blue) polyline
101 | LineStyleLayer(identifier: "polyline-inner", source: polylineSource)
102 | .lineCap(.round)
103 | .lineJoin(.round)
104 | .lineColor(.systemBlue)
105 | .lineWidth(interpolatedBy: .zoomLevel,
106 | curveType: .exponential,
107 | parameters: NSExpression(forConstantValue: 1.5),
108 | stops: NSExpression(forConstantValue: [14: 3, 18: 16]))
109 | }
110 | }
111 | }
112 | ```
113 |
114 | Check out more [Examples](Sources/MapLibreSwiftUI/Examples) to go deeper.
115 |
116 | **NOTE: This currently only works on iOS, as the dynamic framework doesn't yet include macOS.**
117 |
118 | ## How can you help?
119 |
120 | The first thing you can do is try it out!
121 | Check out the [Examples](Sources/MapLibreSwiftUI/Examples) for inspiration,
122 | swap it into your own SwiftUI app, or check out some showcase integrations for inspiration.
123 | Putting it "through the paces" is the best way for us to converge on the "right" APIs as a community.
124 | Your use case probably isn't supported today, in which case you can either open an issue or contribute a PR.
125 |
126 | The code has a number of TODOs, most of which can be tackled by any intermediate Swift programmer.
127 | The important issues should all be tracked in GitHub.
128 | We also have a `#maplibre-swiftui` channel in the
129 | [OpenStreetMap US Slack](https://slack.openstreetmap.us/).
130 | (For nonspecific questions about MapLibre on iOS, there's also a `#maplibre-ios` channel).
131 |
132 | The skeleton is already in place for several of the core concepts, including style layers and sources, but
133 | these are incomplete. You can help by opening a PR that fills these in.
134 | For example, if you wanted to fill out the API for the line style layer,
135 | head over to [the docs](https://maplibre.org/maplibre-native/ios/api/Classes/MGLLineStyleLayer.html)
136 | and just start filling out the remaining properties and modifiers.
137 |
138 | ## Showcase integrations
139 |
140 | ### Ferrostar
141 |
142 | [Ferrostar](https://github.com/stadiamaps/ferrostar) has a MapLibre UI module as part of its Swift Package.
143 | That was actually the impetus for building this package,
144 | and the core devs are eating their own dogfood.
145 | See the [SwiftUI customization](https://stadiamaps.github.io/ferrostar/swiftui-customization.html)
146 | part of the Ferrostar user guide for details on how to customize the map.
147 |
148 | ### MapLibre Navigation iOS
149 |
150 | This package also helps to bridge the gap between MapLibre Navigation iOS and SwiftUI!
151 | Thanks to developers from [HudHud](https://hudhud.sa/en) for their contributions which made this possible!
152 |
153 | Add the [Swift Package](https://github.com/maplibre/maplibre-navigation-ios) to your project.
154 | Then add some code like this:
155 |
156 | ```swift
157 | import MapboxCoreNavigation
158 | import MapboxNavigation
159 | import MapLibreSwiftUI
160 |
161 | extension NavigationViewController: MapViewHostViewController {
162 | public typealias MapType = NavigationMapView
163 | }
164 |
165 |
166 | @State var route: Route?
167 | @State var navigationInProgress: Bool = false
168 |
169 | @ViewBuilder
170 | var mapView: some View {
171 | MapView(makeViewController: NavigationViewController(dayStyleURL: self.styleURL), styleURL: self.styleURL, camera: self.$mapStore.camera) {
172 | // TODO: Your customizations here; add more layers or whatever you like!
173 | }
174 | .unsafeMapViewControllerModifier { navigationViewController in
175 | navigationViewController.delegate = self.mapStore
176 | if let route = self.route, self.navigationInProgress == false {
177 | let locationManager = SimulatedLocationManager(route: route)
178 | navigationViewController.startNavigation(with: route, locationManager: locationManager)
179 | self.navigationInProgress = true
180 | } else if self.route == nil, self.navigationInProgress == true {
181 | navigationViewController.endNavigation()
182 | self.navigationInProgress = false
183 | }
184 |
185 | navigationViewController.mapView.showsUserLocation = self.showUserLocation && self.mapStore.streetView == .disabled
186 | }
187 | .cameraModifierDisabled(self.route != nil)
188 | }
189 | ```
190 |
--------------------------------------------------------------------------------
/SECURITY_POLICY.txt:
--------------------------------------------------------------------------------
1 | For an up-to-date policy refer to
2 | https://github.com/maplibre/maplibre/blob/main/SECURITY_POLICY.txt
3 |
--------------------------------------------------------------------------------
/Sources/InternalUtils/Enums.swift:
--------------------------------------------------------------------------------
1 | public protocol MLNRawRepresentable {
2 | associatedtype T: RawRepresentable
3 |
4 | var mlnRawValue: T { get }
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/InternalUtils/Sample Data.swift:
--------------------------------------------------------------------------------
1 | // Sample data
2 | import CoreLocation
3 |
4 | public let samplePedestrianWaypoints = [
5 | CLLocationCoordinate2D(latitude: 37.807770999999995, longitude: -122.41970699999999),
6 | CLLocationCoordinate2D(latitude: 37.807680999999995, longitude: -122.42041599999999),
7 | CLLocationCoordinate2D(latitude: 37.807623, longitude: -122.42040399999999),
8 | CLLocationCoordinate2D(latitude: 37.807587, longitude: -122.420678),
9 | CLLocationCoordinate2D(latitude: 37.807527, longitude: -122.420666),
10 | CLLocationCoordinate2D(latitude: 37.807514, longitude: -122.420766),
11 | CLLocationCoordinate2D(latitude: 37.807475, longitude: -122.420757),
12 | CLLocationCoordinate2D(latitude: 37.807438, longitude: -122.42073599999999),
13 | CLLocationCoordinate2D(latitude: 37.807403, longitude: -122.420721),
14 | CLLocationCoordinate2D(latitude: 37.806951999999995, longitude: -122.420633),
15 | CLLocationCoordinate2D(latitude: 37.806779999999996, longitude: -122.4206),
16 | CLLocationCoordinate2D(latitude: 37.806806, longitude: -122.42069599999999),
17 | CLLocationCoordinate2D(latitude: 37.806781, longitude: -122.42071999999999),
18 | CLLocationCoordinate2D(latitude: 37.806754999999995, longitude: -122.420746),
19 | CLLocationCoordinate2D(latitude: 37.806739, longitude: -122.420761),
20 | CLLocationCoordinate2D(latitude: 37.806701, longitude: -122.42105699999999),
21 | CLLocationCoordinate2D(latitude: 37.806616999999996, longitude: -122.42171599999999),
22 | CLLocationCoordinate2D(latitude: 37.806562, longitude: -122.42214299999999),
23 | CLLocationCoordinate2D(latitude: 37.806464999999996, longitude: -122.422123),
24 | CLLocationCoordinate2D(latitude: 37.806453, longitude: -122.42221699999999),
25 | CLLocationCoordinate2D(latitude: 37.806439999999995, longitude: -122.42231),
26 | CLLocationCoordinate2D(latitude: 37.806394999999995, longitude: -122.422585),
27 | CLLocationCoordinate2D(latitude: 37.806305, longitude: -122.423289),
28 | CLLocationCoordinate2D(latitude: 37.806242999999995, longitude: -122.423773),
29 | CLLocationCoordinate2D(latitude: 37.806232, longitude: -122.423862),
30 | CLLocationCoordinate2D(latitude: 37.806152999999995, longitude: -122.423846),
31 | CLLocationCoordinate2D(latitude: 37.805687999999996, longitude: -122.423755),
32 | CLLocationCoordinate2D(latitude: 37.805385, longitude: -122.42369),
33 | CLLocationCoordinate2D(latitude: 37.805371, longitude: -122.423797),
34 | CLLocationCoordinate2D(latitude: 37.805306, longitude: -122.42426999999999),
35 | CLLocationCoordinate2D(latitude: 37.805259, longitude: -122.42463699999999),
36 | CLLocationCoordinate2D(latitude: 37.805192, longitude: -122.425147),
37 | CLLocationCoordinate2D(latitude: 37.805184, longitude: -122.42521199999999),
38 | CLLocationCoordinate2D(latitude: 37.805096999999996, longitude: -122.425218),
39 | CLLocationCoordinate2D(latitude: 37.805074999999995, longitude: -122.42539699999999),
40 | CLLocationCoordinate2D(latitude: 37.804992, longitude: -122.425373),
41 | CLLocationCoordinate2D(latitude: 37.804852, longitude: -122.425345),
42 | CLLocationCoordinate2D(latitude: 37.804657, longitude: -122.42530599999999),
43 | CLLocationCoordinate2D(latitude: 37.804259, longitude: -122.425224),
44 | CLLocationCoordinate2D(latitude: 37.804249, longitude: -122.425339),
45 | CLLocationCoordinate2D(latitude: 37.804128, longitude: -122.425314),
46 | CLLocationCoordinate2D(latitude: 37.804109, longitude: -122.425461),
47 | CLLocationCoordinate2D(latitude: 37.803956, longitude: -122.426678),
48 | CLLocationCoordinate2D(latitude: 37.803944, longitude: -122.42677599999999),
49 | CLLocationCoordinate2D(latitude: 37.803931, longitude: -122.42687699999999),
50 | CLLocationCoordinate2D(latitude: 37.803736, longitude: -122.42841899999999),
51 | CLLocationCoordinate2D(latitude: 37.803695, longitude: -122.428411),
52 | ]
53 |
--------------------------------------------------------------------------------
/Sources/InternalUtils/Utilities.swift:
--------------------------------------------------------------------------------
1 | import CommonCrypto
2 | import Foundation
3 | import MapLibre
4 |
5 | // DISCUSS: Is this the best way to do this?
6 | /// Generic function that copies a struct and operates on the modified value.
7 | ///
8 | /// Declarative DSLs frequently use this pattern in Swift (think Views in SwiftUI), but there is no
9 | /// generic way to do this at the language level.
10 | public func modified(_ value: T, using modifier: (inout T) -> Void) -> T {
11 | var copy = value
12 | modifier(©)
13 | return copy
14 | }
15 |
16 | /// Adds a source to the style if a source with the given
17 | /// ID does not already exist. Returns the source
18 | /// on the map for the given ID.
19 | public func addSourceIfNecessary(_ source: MLNSource, to mlnStyle: MLNStyle) -> MLNSource {
20 | if let existingSource = mlnStyle.source(withIdentifier: source.identifier) {
21 | return existingSource
22 | } else {
23 | mlnStyle.addSource(source)
24 | return source
25 | }
26 | }
27 |
28 | public extension UIImage {
29 | /// Computes a SHA256 hash of the image data.
30 | ///
31 | /// This is used internally to generate identifiers for images that can be used in the MapLibre GL
32 | /// style which uniquely identify `UIImage`s to the renderer.
33 | func sha256() -> String {
34 | if let imageData = cgImage?.dataProvider?.data as? Data {
35 | return imageData.digest.hexString
36 | }
37 | return ""
38 | }
39 | }
40 |
41 | extension Data {
42 | var digest: Data {
43 | let digestLength = Int(CC_SHA256_DIGEST_LENGTH)
44 | var hash = [UInt8](repeating: 0, count: digestLength)
45 |
46 | withUnsafeBytes { bufferPointer in
47 | let bytesPointer = bufferPointer.bindMemory(to: UInt8.self).baseAddress!
48 |
49 | _ = CC_SHA256(bytesPointer, CC_LONG(count), &hash)
50 | }
51 |
52 | return Data(bytes: hash, count: digestLength)
53 | }
54 |
55 | var hexString: String {
56 | let hexString = map { String(format: "%02.2hhx", $0) }.joined()
57 | return hexString
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Enums.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 |
4 | // This file exists for convenience until / unless
5 | // this is merged into the MapLibre Native Swift module OR Swift gains the
6 | // ability to re-export types from dependencies.
7 |
8 | public enum LineCap {
9 | case butt
10 | case round
11 | case square
12 | }
13 |
14 | extension LineCap: MLNRawRepresentable {
15 | public var mlnRawValue: MLNLineCap {
16 | switch self {
17 | case .butt: .butt
18 | case .round: .round
19 | case .square: .square
20 | }
21 | }
22 | }
23 |
24 | public enum LineJoin {
25 | case bevel
26 | case miter
27 | case round
28 | }
29 |
30 | extension LineJoin: MLNRawRepresentable {
31 | public var mlnRawValue: MLNLineJoin {
32 | switch self {
33 | case .bevel: .bevel
34 | case .miter: .miter
35 | case .round: .round
36 | }
37 | }
38 | }
39 |
40 | public enum MLNVariableExpression {
41 | case featureAccumulated
42 | case featureAttributes
43 | case featureIdentifier
44 | case geometryType
45 | case heatmapDensity
46 | case lineProgress
47 | case zoomLevel
48 | }
49 |
50 | extension MLNVariableExpression {
51 | var nsExpression: NSExpression {
52 | switch self {
53 | case .featureAccumulated:
54 | .featureAccumulatedVariable
55 | case .featureAttributes:
56 | .featureAttributesVariable
57 | case .featureIdentifier:
58 | .featureIdentifierVariable
59 | case .geometryType:
60 | .geometryTypeVariable
61 | case .heatmapDensity:
62 | .heatmapDensityVariable
63 | case .lineProgress:
64 | .lineProgressVariable
65 | case .zoomLevel:
66 | .zoomLevelVariable
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Expressions.swift:
--------------------------------------------------------------------------------
1 | // Helpers to construct MapLibre GL expressions
2 | import Foundation
3 | import MapLibre
4 |
5 | // TODO: Parameters and stops need nicer interfaces
6 | // TODO: Expression should be able to accept other expressions like variable getters. Probably should be a protocol?
7 | public func interpolatingExpression(
8 | expression: MLNVariableExpression,
9 | curveType: MLNExpressionInterpolationMode,
10 | parameters: NSExpression?,
11 | stops: NSExpression
12 | ) -> NSExpression {
13 | NSExpression(forMLNInterpolating: expression.nsExpression,
14 | curveType: curveType,
15 | parameters: parameters,
16 | stops: stops)
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/MapControls.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | public protocol MapControl {
5 | /// Overrides the position of the control. Default values are control-specfiic.
6 | var position: MLNOrnamentPosition? { get set }
7 | /// Overrides the offset of the control.
8 | var margins: CGPoint? { get set }
9 | /// Overrides whether the control is hidden.
10 | var isHidden: Bool { get set }
11 |
12 | @MainActor func configureMapView(_ mapView: MLNMapView)
13 | }
14 |
15 | public extension MapControl {
16 | /// Sets position of the control.
17 | func position(_ position: MLNOrnamentPosition) -> Self {
18 | var result = self
19 |
20 | result.position = position
21 |
22 | return result
23 | }
24 |
25 | /// Sets the position offset of the control.
26 | func margins(_ margins: CGPoint) -> Self {
27 | var result = self
28 |
29 | result.margins = margins
30 |
31 | return result
32 | }
33 |
34 | /// Hides the control.
35 | func hidden(_: Bool) -> Self {
36 | var result = self
37 |
38 | result.isHidden = true
39 |
40 | return result
41 | }
42 | }
43 |
44 | public struct CompassView: MapControl {
45 | public var position: MLNOrnamentPosition?
46 | public var margins: CGPoint?
47 | public var isHidden: Bool = false
48 |
49 | public func configureMapView(_ mapView: MLNMapView) {
50 | if let position {
51 | mapView.compassViewPosition = position
52 | }
53 |
54 | if let margins {
55 | mapView.compassViewMargins = margins
56 | }
57 |
58 | mapView.compassView.isHidden = isHidden
59 | }
60 |
61 | public init() {}
62 | }
63 |
64 | public struct LogoView: MapControl {
65 | public var position: MLNOrnamentPosition?
66 | public var margins: CGPoint?
67 | public var isHidden: Bool = false
68 | public var image: UIImage?
69 |
70 | public init() {}
71 |
72 | public func configureMapView(_ mapView: MLNMapView) {
73 | if let position {
74 | mapView.logoViewPosition = position
75 | }
76 |
77 | if let margins {
78 | mapView.logoViewMargins = margins
79 | }
80 |
81 | mapView.logoView.isHidden = isHidden
82 |
83 | if let image {
84 | mapView.logoView.image = image
85 | }
86 | }
87 | }
88 |
89 | public extension LogoView {
90 | /// Sets the logo image (defaults to the MapLibre logo).
91 | func image(_ image: UIImage?) -> Self {
92 | var result = self
93 |
94 | result.image = image
95 |
96 | return result
97 | }
98 | }
99 |
100 | public struct AttributionButton: MapControl {
101 | public var position: MLNOrnamentPosition?
102 | public var margins: CGPoint?
103 | public var isHidden: Bool = false
104 |
105 | public func configureMapView(_ mapView: MLNMapView) {
106 | if let position {
107 | mapView.attributionButtonPosition = position
108 | }
109 |
110 | if let margins {
111 | mapView.attributionButtonMargins = margins
112 | }
113 |
114 | mapView.attributionButton.isHidden = isHidden
115 | }
116 |
117 | public init() {}
118 | }
119 |
120 | @resultBuilder
121 | public enum MapControlsBuilder: DefaultResultBuilder {
122 | public static func buildExpression(_ expression: MapControl) -> [MapControl] {
123 | [expression]
124 | }
125 |
126 | public static func buildExpression(_ expression: [MapControl]) -> [MapControl] {
127 | expression
128 | }
129 |
130 | public static func buildExpression(_: Void) -> [MapControl] {
131 | []
132 | }
133 |
134 | public static func buildBlock(_ components: [MapControl]...) -> [MapControl] {
135 | components.flatMap { $0 }
136 | }
137 |
138 | public static func buildArray(_ components: [MapControl]) -> [MapControl] {
139 | components
140 | }
141 |
142 | public static func buildArray(_ components: [[MapControl]]) -> [MapControl] {
143 | components.flatMap { $0 }
144 | }
145 |
146 | public static func buildEither(first components: [MapControl]) -> [MapControl] {
147 | components
148 | }
149 |
150 | public static func buildEither(second components: [MapControl]) -> [MapControl] {
151 | components
152 | }
153 |
154 | public static func buildOptional(_ components: [MapControl]?) -> [MapControl] {
155 | components ?? []
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/MapLibre Extensions.swift:
--------------------------------------------------------------------------------
1 |
2 | // Various quality-of-life extensions to MapLibre APIs.
3 | import MapLibre
4 |
5 | // TODO: Upstream this?
6 | public extension MLNPolyline {
7 | /// Constructs a polyline (aka LineString) from a list of coordinates.
8 | convenience init(coordinates: [CLLocationCoordinate2D]) {
9 | self.init(coordinates: coordinates, count: UInt(coordinates.count))
10 | }
11 | }
12 |
13 | public extension MLNPointFeature {
14 | convenience init(coordinate: CLLocationCoordinate2D, configure: ((MLNPointFeature) -> Void)? = nil) {
15 | self.init()
16 | self.coordinate = coordinate
17 |
18 | configure?(self)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/MapViewContentBuilder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @resultBuilder
4 | public enum MapViewContentBuilder: DefaultResultBuilder {
5 | public static func buildExpression(_ expression: StyleLayerDefinition) -> [StyleLayerDefinition] {
6 | [expression]
7 | }
8 |
9 | public static func buildExpression(_ expression: [StyleLayerDefinition]) -> [StyleLayerDefinition] {
10 | expression
11 | }
12 |
13 | public static func buildExpression(_: Void) -> [StyleLayerDefinition] {
14 | []
15 | }
16 |
17 | public static func buildBlock(_ components: [StyleLayerDefinition]...) -> [StyleLayerDefinition] {
18 | components.flatMap { $0 }
19 | }
20 |
21 | public static func buildArray(_ components: [StyleLayerDefinition]) -> [StyleLayerDefinition] {
22 | components
23 | }
24 |
25 | public static func buildArray(_ components: [[StyleLayerDefinition]]) -> [StyleLayerDefinition] {
26 | components.flatMap { $0 }
27 | }
28 |
29 | public static func buildEither(first components: [StyleLayerDefinition]) -> [StyleLayerDefinition] {
30 | components
31 | }
32 |
33 | public static func buildEither(second components: [StyleLayerDefinition]) -> [StyleLayerDefinition] {
34 | components
35 | }
36 |
37 | public static func buildOptional(_ components: [StyleLayerDefinition]?) -> [StyleLayerDefinition] {
38 | components ?? []
39 | }
40 |
41 | // MARK: Custom Handler for StyleLayerCollection type.
42 |
43 | public static func buildExpression(_ styleCollection: StyleLayerCollection) -> [StyleLayerDefinition] {
44 | styleCollection.layers
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/ShapeDataBuilder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | /// A description of a data source for layers in a MapLibre style.
5 | public protocol Source {
6 | /// A string that uniquely identifies the source in the style to which it is added.
7 | var identifier: String { get }
8 |
9 | func makeMGLSource() -> MLNSource
10 | }
11 |
12 | /// A description of the data underlying an ``MLNShapeSource``.
13 | public enum ShapeData {
14 | /// A URL from which to load GeoJSON.
15 | case geoJSONURL(URL)
16 | /// Generic shapes. These will NOT preserve any attributes.
17 | case shapes([MLNShape])
18 | // Features which retain attributes when styled, filtered via a predicate, etc.
19 | case features([MLNShape & MLNFeature])
20 | }
21 |
22 | public struct ShapeSource: Source {
23 | public let identifier: String
24 | public let options: [MLNShapeSourceOption: Any]?
25 | let data: ShapeData
26 |
27 | public init(
28 | identifier: String,
29 | options: [MLNShapeSourceOption: Any]? = nil,
30 | @ShapeDataBuilder _ makeShapeDate: () -> ShapeData
31 | ) {
32 | self.identifier = identifier
33 | self.options = options
34 | data = makeShapeDate()
35 | }
36 |
37 | public func makeMGLSource() -> MLNSource {
38 | // TODO: Options! These should be represented via modifiers like .clustered()
39 | switch data {
40 | case let .geoJSONURL(url):
41 | MLNShapeSource(identifier: identifier, url: url, options: options)
42 | case let .shapes(shapes):
43 | MLNShapeSource(identifier: identifier, shapes: shapes, options: options)
44 | case let .features(features):
45 | MLNShapeSource(identifier: identifier, features: features, options: options)
46 | }
47 | }
48 | }
49 |
50 | @resultBuilder
51 | public enum ShapeDataBuilder: DefaultResultBuilder {
52 | public static func buildExpression(_ expression: MLNShape) -> [MLNShape] {
53 | [expression]
54 | }
55 |
56 | public static func buildExpression(_ expression: [MLNShape]) -> [MLNShape] {
57 | expression
58 | }
59 |
60 | public static func buildExpression(_: Void) -> [MLNShape] {
61 | []
62 | }
63 |
64 | public static func buildBlock(_ components: [MLNShape]...) -> [MLNShape] {
65 | components.flatMap { $0 }
66 | }
67 |
68 | public static func buildArray(_ components: [MLNShape]) -> [MLNShape] {
69 | components
70 | }
71 |
72 | public static func buildArray(_ components: [[MLNShape]]) -> [MLNShape] {
73 | components.flatMap { $0 }
74 | }
75 |
76 | public static func buildEither(first components: [MLNShape]) -> [MLNShape] {
77 | components
78 | }
79 |
80 | public static func buildEither(second components: [MLNShape]) -> [MLNShape] {
81 | components
82 | }
83 |
84 | public static func buildOptional(_ components: [MLNShape]?) -> [MLNShape] {
85 | components ?? []
86 | }
87 |
88 | // Convert the collected MLNShape array to ShapeData
89 | public static func buildFinalResult(_ components: [MLNShape]) -> ShapeData {
90 | let features = components.compactMap { $0 as? MLNShape & MLNFeature }
91 | if features.count == components.count {
92 | return .features(features)
93 | } else {
94 | return .shapes(components)
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/Background.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import InternalUtils
3 | import MapLibre
4 | import MapLibreSwiftMacros
5 |
6 | @MLNStyleProperty("backgroundColor", supportsInterpolation: true)
7 | @MLNStyleProperty("backgroundOpacity", supportsInterpolation: true)
8 | public struct BackgroundLayer: StyleLayer {
9 | public let identifier: String
10 | public var insertionPosition: LayerInsertionPosition = .below(.all)
11 | public var isVisible: Bool = true
12 | public var maximumZoomLevel: Float? = nil
13 | public var minimumZoomLevel: Float? = nil
14 |
15 | public init(identifier: String) {
16 | self.identifier = identifier
17 | }
18 |
19 | public func makeMLNStyleLayer() -> MLNStyleLayer {
20 | let result = MLNBackgroundStyleLayer(identifier: identifier)
21 |
22 | result.backgroundColor = backgroundColor
23 | result.backgroundOpacity = backgroundOpacity
24 |
25 | return result
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/Circle.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import InternalUtils
3 | import MapLibre
4 | import MapLibreSwiftMacros
5 |
6 | @MLNStyleProperty("radius", supportsInterpolation: true)
7 | @MLNStyleProperty("color", supportsInterpolation: false)
8 | @MLNStyleProperty("strokeWidth", supportsInterpolation: true)
9 | @MLNStyleProperty("strokeColor", supportsInterpolation: false)
10 | public struct CircleStyleLayer: SourceBoundVectorStyleLayerDefinition {
11 | public let identifier: String
12 | public let sourceLayerIdentifier: String?
13 | public var insertionPosition: LayerInsertionPosition = .above(.all)
14 | public var isVisible: Bool = true
15 | public var maximumZoomLevel: Float? = nil
16 | public var minimumZoomLevel: Float? = nil
17 |
18 | public var source: StyleLayerSource
19 | public var predicate: NSPredicate?
20 |
21 | public init(identifier: String, source: Source) {
22 | self.identifier = identifier
23 | self.source = .source(source)
24 | sourceLayerIdentifier = nil
25 | }
26 |
27 | public init(identifier: String, source: MLNSource, sourceLayerIdentifier: String? = nil) {
28 | self.identifier = identifier
29 | self.source = .mglSource(source)
30 | self.sourceLayerIdentifier = sourceLayerIdentifier
31 | }
32 |
33 | public func makeStyleLayer(style: MLNStyle) -> StyleLayer {
34 | let styleSource = addSource(to: style)
35 |
36 | return CircleStyleLayerInternal(definition: self, mglSource: styleSource)
37 | }
38 |
39 | // MARK: - Modifiers
40 | }
41 |
42 | private struct CircleStyleLayerInternal: StyleLayer {
43 | private var definition: CircleStyleLayer
44 | private let mglSource: MLNSource
45 |
46 | public var identifier: String { definition.identifier }
47 | public var insertionPosition: LayerInsertionPosition {
48 | get { definition.insertionPosition }
49 | set { definition.insertionPosition = newValue }
50 | }
51 |
52 | public var isVisible: Bool {
53 | get { definition.isVisible }
54 | set { definition.isVisible = newValue }
55 | }
56 |
57 | public var maximumZoomLevel: Float? {
58 | get { definition.maximumZoomLevel }
59 | set { definition.maximumZoomLevel = newValue }
60 | }
61 |
62 | public var minimumZoomLevel: Float? {
63 | get { definition.minimumZoomLevel }
64 | set { definition.minimumZoomLevel = newValue }
65 | }
66 |
67 | init(definition: CircleStyleLayer, mglSource: MLNSource) {
68 | self.definition = definition
69 | self.mglSource = mglSource
70 | }
71 |
72 | public func makeMLNStyleLayer() -> MLNStyleLayer {
73 | let result = MLNCircleStyleLayer(identifier: identifier, source: mglSource)
74 |
75 | result.sourceLayerIdentifier = definition.sourceLayerIdentifier
76 | result.circleRadius = definition.radius
77 | result.circleColor = definition.color
78 |
79 | result.circleStrokeWidth = definition.strokeWidth
80 | result.circleStrokeColor = definition.strokeColor
81 |
82 | result.predicate = definition.predicate
83 |
84 | return result
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/FillStyleLayer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import InternalUtils
3 | import MapLibre
4 | import MapLibreSwiftMacros
5 |
6 | // TODO: Other properties and their modifiers
7 | @MLNStyleProperty("fillColor", supportsInterpolation: true)
8 | @MLNStyleProperty("fillOutlineColor", supportsInterpolation: true)
9 | @MLNStyleProperty("fillOpacity", supportsInterpolation: true)
10 | public struct FillStyleLayer: SourceBoundVectorStyleLayerDefinition {
11 | public let identifier: String
12 | public let sourceLayerIdentifier: String?
13 | public var insertionPosition: LayerInsertionPosition = .above(.all)
14 | public var isVisible: Bool = true
15 | public var maximumZoomLevel: Float? = nil
16 | public var minimumZoomLevel: Float? = nil
17 |
18 | public var source: StyleLayerSource
19 | public var predicate: NSPredicate?
20 |
21 | public init(identifier: String, source: Source) {
22 | self.identifier = identifier
23 | self.source = .source(source)
24 | sourceLayerIdentifier = nil
25 | }
26 |
27 | public init(identifier: String, source: MLNSource, sourceLayerIdentifier: String? = nil) {
28 | self.identifier = identifier
29 | self.source = .mglSource(source)
30 | self.sourceLayerIdentifier = sourceLayerIdentifier
31 | }
32 |
33 | public func makeStyleLayer(style: MLNStyle) -> StyleLayer {
34 | let styleSource = addSource(to: style)
35 |
36 | return FillStyleLayerInternal(definition: self, mglSource: styleSource)
37 | }
38 | }
39 |
40 | private struct FillStyleLayerInternal: StyleLayer {
41 | private var definition: FillStyleLayer
42 | private let mglSource: MLNSource
43 |
44 | public var identifier: String { definition.identifier }
45 | public var insertionPosition: LayerInsertionPosition {
46 | get { definition.insertionPosition }
47 | set { definition.insertionPosition = newValue }
48 | }
49 |
50 | public var isVisible: Bool {
51 | get { definition.isVisible }
52 | set { definition.isVisible = newValue }
53 | }
54 |
55 | public var maximumZoomLevel: Float? {
56 | get { definition.maximumZoomLevel }
57 | set { definition.maximumZoomLevel = newValue }
58 | }
59 |
60 | public var minimumZoomLevel: Float? {
61 | get { definition.minimumZoomLevel }
62 | set { definition.minimumZoomLevel = newValue }
63 | }
64 |
65 | init(definition: FillStyleLayer, mglSource: MLNSource) {
66 | self.definition = definition
67 | self.mglSource = mglSource
68 | }
69 |
70 | public func makeMLNStyleLayer() -> MLNStyleLayer {
71 | let result = MLNFillStyleLayer(identifier: identifier, source: mglSource)
72 |
73 | result.fillColor = definition.fillColor
74 | result.fillOutlineColor = definition.fillOutlineColor
75 | result.fillOpacity = definition.fillOpacity
76 |
77 | result.predicate = definition.predicate
78 |
79 | return result
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/Line.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import InternalUtils
3 | import MapLibre
4 | import MapLibreSwiftMacros
5 |
6 | // TODO: Other properties and their modifiers
7 | @MLNStyleProperty("lineColor", supportsInterpolation: true)
8 | @MLNRawRepresentableStyleProperty("lineCap")
9 | @MLNRawRepresentableStyleProperty("lineJoin")
10 | @MLNStyleProperty("lineWidth", supportsInterpolation: true)
11 | public struct LineStyleLayer: SourceBoundVectorStyleLayerDefinition {
12 | public let identifier: String
13 | public let sourceLayerIdentifier: String?
14 | public var insertionPosition: LayerInsertionPosition = .above(.all)
15 | public var isVisible: Bool = true
16 | public var maximumZoomLevel: Float? = nil
17 | public var minimumZoomLevel: Float? = nil
18 |
19 | public var source: StyleLayerSource
20 | public var predicate: NSPredicate?
21 |
22 | public init(identifier: String, source: Source) {
23 | self.identifier = identifier
24 | self.source = .source(source)
25 | sourceLayerIdentifier = nil
26 | }
27 |
28 | public init(identifier: String, source: MLNSource, sourceLayerIdentifier: String? = nil) {
29 | self.identifier = identifier
30 | self.source = .mglSource(source)
31 | self.sourceLayerIdentifier = sourceLayerIdentifier
32 | }
33 |
34 | public func makeStyleLayer(style: MLNStyle) -> StyleLayer {
35 | let styleSource = addSource(to: style)
36 |
37 | return LineStyleLayerInternal(definition: self, mglSource: styleSource)
38 | }
39 | }
40 |
41 | private struct LineStyleLayerInternal: StyleLayer {
42 | private var definition: LineStyleLayer
43 | private let mglSource: MLNSource
44 |
45 | public var identifier: String { definition.identifier }
46 | public var insertionPosition: LayerInsertionPosition {
47 | get { definition.insertionPosition }
48 | set { definition.insertionPosition = newValue }
49 | }
50 |
51 | public var isVisible: Bool {
52 | get { definition.isVisible }
53 | set { definition.isVisible = newValue }
54 | }
55 |
56 | public var maximumZoomLevel: Float? {
57 | get { definition.maximumZoomLevel }
58 | set { definition.maximumZoomLevel = newValue }
59 | }
60 |
61 | public var minimumZoomLevel: Float? {
62 | get { definition.minimumZoomLevel }
63 | set { definition.minimumZoomLevel = newValue }
64 | }
65 |
66 | init(definition: LineStyleLayer, mglSource: MLNSource) {
67 | self.definition = definition
68 | self.mglSource = mglSource
69 | }
70 |
71 | public func makeMLNStyleLayer() -> MLNStyleLayer {
72 | let result = MLNLineStyleLayer(identifier: identifier, source: mglSource)
73 |
74 | result.lineColor = definition.lineColor
75 | result.lineCap = definition.lineCap
76 | result.lineWidth = definition.lineWidth
77 | result.lineJoin = definition.lineJoin
78 |
79 | result.predicate = definition.predicate
80 |
81 | return result
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/Style Layer.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 |
4 | /// A layer reference specifying which layer we should insert a new layer above.
5 | public enum LayerReferenceAbove: Equatable {
6 | /// A specific layer, referenced by ID.
7 | case layer(layerId: String)
8 | /// The group of all layers currently in the style.
9 | case all
10 | }
11 |
12 | /// A layer reference specifying which layer we should insert a new layer below.
13 | public enum LayerReferenceBelow: Equatable {
14 | /// A specific layer, referenced by ID.
15 | case layer(layerId: String)
16 | /// The group of all layers currently in the style.
17 | case all
18 | /// The group of symbol layers currently in the style.
19 | case symbols
20 | }
21 |
22 | /// Specifies a preference for where the layer should be inserted in the hierarchy.
23 | public enum LayerInsertionPosition: Equatable {
24 | /// The layer should be inserted above the layer with ID ``layerID``.
25 | ///
26 | /// If no such layer exists, the layer will be added above others and an error will be logged.
27 | case above(LayerReferenceAbove)
28 | /// The layer should be inserted below the layer with ID ``layerID``.
29 | ///
30 | /// If no such layer exists, the layer will be added above others and an error will be logged.
31 | case below(LayerReferenceBelow)
32 | }
33 |
34 | /// Internal style enum that wraps a source reference.
35 | ///
36 | /// We need to hold on to this so that the coordinator can add the source to the style if necessary.
37 | public enum StyleLayerSource {
38 | case source(Source)
39 | case mglSource(MLNSource)
40 | }
41 |
42 | public extension StyleLayerSource {
43 | var identifier: String {
44 | switch self {
45 | case let .mglSource(s): s.identifier
46 | case let .source(s): s.identifier
47 | }
48 | }
49 | }
50 |
51 | /// A description of layer in a MapLibre style.
52 | ///
53 | /// If you think this looks very similar to ``MLNStyleLayer``, you're spot on. While the final result objects
54 | /// built here eventually are such, introducing a separate protocol helps keep things Swifty (in particular,
55 | /// it removes the requirement to use classes; idiomatic DSL builders use structs).
56 | public protocol StyleLayerDefinition {
57 | /// A string that uniquely identifies the style layer in the style to which it is added.
58 | var identifier: String { get }
59 |
60 | /// Whether this layer is displayed.
61 | var isVisible: Bool { get set }
62 |
63 | /// The minimum zoom level at which the layer gets processed and rendered.
64 | ///
65 | /// This type is optional since the default values in the C++ code base signifying that the
66 | /// property has not been explicitly set is an [implementation detail](https://github.com/maplibre/maplibre-native/blob/d92431b404b80b2e111c00a94eae51bbb45920fa/src/mbgl/style/layer_impl.hpp#L52)
67 | /// that is not currently documented in the public API.
68 | var minimumZoomLevel: Float? { get set }
69 |
70 | /// The maximum zoom level at which the layer gets processed and rendered.
71 | ///
72 | /// This type is optional since the default values in the C++ code base signifying that the
73 | /// property has not been explicitly set is an [implementation detail](https://github.com/maplibre/maplibre-native/blob/d92431b404b80b2e111c00a94eae51bbb45920fa/src/mbgl/style/layer_impl.hpp#L53)
74 | /// that is not currently documented in the public API.
75 | var maximumZoomLevel: Float? { get set }
76 |
77 | /// Specifies a preference for where the layer should be inserted in the hierarchy.
78 | var insertionPosition: LayerInsertionPosition { get set }
79 |
80 | /// Converts a layer definition into a concrete layer which can be added to a style.
81 | ///
82 | /// FIXME: Terrible abstraction alert... This currently assumes that any referenced source definitions
83 | /// have been materialized and added to the style (in the method body if necessary) so that the returned
84 | /// style layer is able to be turned into a MapLibre style layer and added to the view fairly quickly. This
85 | /// is a halfway finished abstraction which seems most likely to be fully implemented as an
86 | /// `addLayerToStyle` or similar method once the implications are all worked out.
87 | func makeStyleLayer(style: MLNStyle) -> StyleLayer
88 | }
89 |
90 | public protocol SourceBoundStyleLayerDefinition: StyleLayerDefinition {
91 | var source: StyleLayerSource { get set }
92 |
93 | var sourceLayerIdentifier: String? { get }
94 | }
95 |
96 | /// Based on MLNVectorStyleLayer
97 | public protocol SourceBoundVectorStyleLayerDefinition: SourceBoundStyleLayerDefinition {
98 | /**
99 | The style layer’s predicate.
100 |
101 | Use the style layer’s predicate to include only the features in the source
102 | layer that satisfy a condition that you define.
103 |
104 | See the *Predicates and Expressions*
105 | guide for details about the predicate syntax supported by this class:
106 | https://maplibre.org/maplibre-native/ios/api/predicates-and-expressions.html
107 | */
108 | var predicate: NSPredicate? { get set }
109 |
110 | func predicate(_ predicate: NSPredicate) -> Self
111 | }
112 |
113 | public extension SourceBoundVectorStyleLayerDefinition {
114 | func predicate(_ predicate: NSPredicate) -> Self {
115 | modified(self) { it in
116 | it.predicate = predicate
117 | }
118 | }
119 | }
120 |
121 | extension SourceBoundStyleLayerDefinition {
122 | func addSource(to style: MLNStyle) -> MLNSource {
123 | let tmpSource: MLNSource
124 |
125 | switch source {
126 | case let .source(s):
127 | let source = s.makeMGLSource()
128 | tmpSource = source
129 | case let .mglSource(s):
130 | tmpSource = s
131 | }
132 |
133 | return addSourceIfNecessary(tmpSource, to: style)
134 | }
135 | }
136 |
137 | public protocol StyleLayer: StyleLayerDefinition {
138 | /// Builds an ``MLNStyleLayer`` using the layer definition.
139 | // DISCUSS: Potential leaky abstraction alert! We don't necessarily (TBD?) need this method public, but we do want the protocol conformance. This should be revisited.
140 | func makeMLNStyleLayer() -> MLNStyleLayer
141 | }
142 |
143 | public extension StyleLayer {
144 | func makeStyleLayer(style _: MLNStyle) -> StyleLayer {
145 | self
146 | }
147 | }
148 |
149 | public extension StyleLayerDefinition {
150 | // MARK: - Common modifiers
151 |
152 | func visible(_ value: Bool) -> Self {
153 | modified(self) { $0.isVisible = value }
154 | }
155 |
156 | func minimumZoomLevel(_ value: Float) -> Self {
157 | modified(self) { $0.minimumZoomLevel = value }
158 | }
159 |
160 | func maximumZoomLevel(_ value: Float) -> Self {
161 | modified(self) { $0.maximumZoomLevel = value }
162 | }
163 |
164 | func renderAbove(_ layerReference: LayerReferenceAbove) -> Self {
165 | modified(self) { $0.insertionPosition = .above(layerReference) }
166 | }
167 |
168 | func renderBelow(_ layerReference: LayerReferenceBelow) -> Self {
169 | modified(self) { $0.insertionPosition = .below(layerReference) }
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/StyleLayerCollection.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol StyleLayerCollection {
4 | @MapViewContentBuilder var layers: [StyleLayerDefinition] { get }
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Style Layers/Symbol.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import InternalUtils
3 | import MapLibre
4 | import MapLibreSwiftMacros
5 |
6 | @MLNStyleProperty("iconRotation", supportsInterpolation: true)
7 | @MLNStyleProperty("iconColor", supportsInterpolation: true)
8 | @MLNStyleProperty("iconAllowsOverlap", supportsInterpolation: false)
9 | @MLNStyleProperty("iconOffset", supportsInterpolation: true)
10 |
11 | @MLNStyleProperty("textColor", supportsInterpolation: true)
12 | @MLNStyleProperty("textFontSize", supportsInterpolation: true)
13 | @MLNStyleProperty("text", supportsInterpolation: false)
14 | @MLNStyleProperty<[String]>("textFontNames", supportsInterpolation: false)
15 | // An enum would probably be better?
16 | @MLNStyleProperty("textAnchor", supportsInterpolation: false)
17 | @MLNStyleProperty("textOffset", supportsInterpolation: true)
18 | @MLNStyleProperty("maximumTextWidth", supportsInterpolation: true)
19 |
20 | @MLNStyleProperty("textHaloColor", supportsInterpolation: true)
21 | @MLNStyleProperty("textHaloWidth", supportsInterpolation: true)
22 | @MLNStyleProperty("textHaloBlur", supportsInterpolation: true)
23 |
24 | public struct SymbolStyleLayer: SourceBoundVectorStyleLayerDefinition {
25 | public let identifier: String
26 | public let sourceLayerIdentifier: String?
27 | public var insertionPosition: LayerInsertionPosition = .above(.all)
28 | public var isVisible: Bool = true
29 | public var maximumZoomLevel: Float? = nil
30 | public var minimumZoomLevel: Float? = nil
31 |
32 | public var source: StyleLayerSource
33 | public var predicate: NSPredicate?
34 |
35 | public init(identifier: String, source: Source) {
36 | self.identifier = identifier
37 | sourceLayerIdentifier = nil
38 | self.source = .source(source)
39 | }
40 |
41 | public init(identifier: String, source: MLNSource, sourceLayerIdentifier: String? = nil) {
42 | self.identifier = identifier
43 | self.sourceLayerIdentifier = sourceLayerIdentifier
44 | self.source = .mglSource(source)
45 | }
46 |
47 | public func makeStyleLayer(style: MLNStyle) -> StyleLayer {
48 | let styleSource = addSource(to: style)
49 |
50 | // Register the images with the map style
51 | for image in iconImages {
52 | style.setImage(image, forName: image.sha256())
53 | }
54 | return SymbolStyleLayerInternal(definition: self, mglSource: styleSource)
55 | }
56 |
57 | public var iconImageName: NSExpression?
58 |
59 | public var iconImages = [UIImage]()
60 |
61 | // MARK: - Modifiers
62 |
63 | public func iconImage(_ image: UIImage) -> Self {
64 | modified(self) { it in
65 | it.iconImageName = NSExpression(forConstantValue: image.sha256())
66 | it.iconImages = [image]
67 | }
68 | }
69 |
70 | public func iconImage(featurePropertyNamed keyPath: String) -> Self {
71 | var copy = self
72 | copy.iconImageName = NSExpression(forKeyPath: keyPath)
73 | return copy
74 | }
75 |
76 | /// Add an icon image that can be dynamic and use UIImages in your app, based on a feature property of the source.
77 | /// For example, your feature could have a property called "icon-name". This name is then resolved against the key
78 | /// in the mappings dictionary and used to find a UIImage to display on the map for that feature.
79 | /// - Parameters:
80 | /// - keyPath: The keypath to the feature property containing the icon to use, for example "icon-name".
81 | /// - mappings: A lookup dictionary containing the keys found in "keyPath" and a UIImage for each keyPath. The key
82 | /// of the mappings dictionary needs to match the value type stored at keyPath, for example `String`.
83 | /// - defaultImage: A UIImage that MapLibre should fall back to if the key in your feature is not found in the
84 | /// mappings table
85 | public func iconImage(
86 | featurePropertyNamed keyPath: String,
87 | mappings: [AnyHashable: UIImage],
88 | default defaultImage: UIImage
89 | ) -> Self {
90 | modified(self) { it in
91 | let attributeExpression = NSExpression(forKeyPath: keyPath)
92 | let mappingExpressions = mappings.mapValues { image in
93 | NSExpression(forConstantValue: image.sha256())
94 | }
95 | let mappingDictionary = NSDictionary(dictionary: mappingExpressions)
96 | let defaultExpression = NSExpression(forConstantValue: defaultImage.sha256())
97 |
98 | it.iconImageName = NSExpression(
99 | forMLNMatchingKey: attributeExpression,
100 | in: mappingDictionary as! [NSExpression: NSExpression],
101 | default: defaultExpression
102 | )
103 | it.iconImages = mappings.values + [defaultImage]
104 | }
105 | }
106 | }
107 |
108 | private struct SymbolStyleLayerInternal: StyleLayer {
109 | private var definition: SymbolStyleLayer
110 | private let mglSource: MLNSource
111 |
112 | public var identifier: String { definition.identifier }
113 | public var insertionPosition: LayerInsertionPosition {
114 | get { definition.insertionPosition }
115 | set { definition.insertionPosition = newValue }
116 | }
117 |
118 | public var isVisible: Bool {
119 | get { definition.isVisible }
120 | set { definition.isVisible = newValue }
121 | }
122 |
123 | public var maximumZoomLevel: Float? {
124 | get { definition.maximumZoomLevel }
125 | set { definition.maximumZoomLevel = newValue }
126 | }
127 |
128 | public var minimumZoomLevel: Float? {
129 | get { definition.minimumZoomLevel }
130 | set { definition.minimumZoomLevel = newValue }
131 | }
132 |
133 | init(definition: SymbolStyleLayer, mglSource: MLNSource) {
134 | self.definition = definition
135 | self.mglSource = mglSource
136 | }
137 |
138 | public func makeMLNStyleLayer() -> MLNStyleLayer {
139 | let result = MLNSymbolStyleLayer(identifier: identifier, source: mglSource)
140 | result.sourceLayerIdentifier = definition.sourceLayerIdentifier
141 |
142 | result.iconImageName = definition.iconImageName
143 | result.iconRotation = definition.iconRotation
144 | result.iconAllowsOverlap = definition.iconAllowsOverlap
145 | result.iconColor = definition.iconColor
146 | result.iconOffset = definition.iconOffset
147 |
148 | result.text = definition.text
149 | result.textColor = definition.textColor
150 | result.textFontSize = definition.textFontSize
151 | result.maximumTextWidth = definition.maximumTextWidth
152 | result.textAnchor = definition.textAnchor
153 | result.textOffset = definition.textOffset
154 | result.textFontNames = definition.textFontNames
155 |
156 | result.textHaloColor = definition.textHaloColor
157 | result.textHaloWidth = definition.textHaloWidth
158 | result.textHaloBlur = definition.textHaloBlur
159 |
160 | result.predicate = definition.predicate
161 |
162 | if let minimumZoomLevel = definition.minimumZoomLevel {
163 | result.minimumZoomLevel = minimumZoomLevel
164 | }
165 |
166 | if let maximumZoomLevel = definition.maximumZoomLevel {
167 | result.maximumZoomLevel = maximumZoomLevel
168 | }
169 |
170 | return result
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Enforces a basic set of result builder definiitons.
4 | ///
5 | /// This is just a tool to make a result builder easier to build, maintain sorting, etc.
6 | protocol DefaultResultBuilder {
7 | associatedtype Component
8 |
9 | static func buildExpression(_ expression: Component) -> [Component]
10 |
11 | static func buildExpression(_ expression: [Component]) -> [Component]
12 |
13 | // MARK: Handle void
14 |
15 | static func buildExpression(_ expression: Void) -> [Component]
16 |
17 | // MARK: Combine elements into an array
18 |
19 | static func buildBlock(_ components: [Component]...) -> [Component]
20 |
21 | // MARK: Handle Arrays
22 |
23 | static func buildArray(_ components: [Component]) -> [Component]
24 |
25 | // MARK: Handle for in loops
26 |
27 | static func buildArray(_ components: [[Component]]) -> [Component]
28 |
29 | // MARK: Handle if statements
30 |
31 | static func buildEither(first components: [Component]) -> [Component]
32 |
33 | static func buildEither(second components: [Component]) -> [Component]
34 |
35 | // MARK: Handle Optionals
36 |
37 | static func buildOptional(_ components: [Component]?) -> [Component]
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftMacros/MapLibreSwiftMacros.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Adds a stored property and modifiers for an attribute that can be styled using a MapLibre style expression.
4 | ///
5 | /// Layout and paint properties may be specified using expresisons.
6 | /// Some expressions may suppeort more types of expressions than others (ex: interpolated).
7 | /// TODO: Figure out where these edges are.
8 | /// TODO: Document different types
9 | @attached(member, names: arbitrary)
10 | public macro MLNStyleProperty(_ named: String, supportsInterpolation: Bool = false) = #externalMacro(
11 | module: "MapLibreSwiftMacrosImpl",
12 | type: "MLNStylePropertyMacro"
13 | )
14 |
15 | // NOTE: This version of the macro cannot be more specific, but it is assumed that T: MLNRawRepresentable.
16 | // This bound should be reintroduced when the packages are re-merged.
17 | @attached(member, names: arbitrary)
18 | public macro MLNRawRepresentableStyleProperty(_ named: String) = #externalMacro(
19 | module: "MapLibreSwiftMacrosImpl",
20 | type: "MLNRawRepresentableStylePropertyMacro"
21 | )
22 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftMacrosImpl/MapLibreSwiftMacrosPlugin.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntaxMacros
3 |
4 | @main
5 | struct MapLibreSwiftMacrosPlugin: CompilerPlugin {
6 | let providingMacros: [Macro.Type] = [
7 | MLNStylePropertyMacro.self,
8 | MLNRawRepresentableStylePropertyMacro.self,
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftMacrosImpl/StyleExpressionMacro.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftSyntax
3 | import SwiftSyntaxBuilder
4 | import SwiftSyntaxMacros
5 |
6 | private let allowedKeys = Set(["supportsInterpolation"])
7 |
8 | private func generateStyleProperty(for attributes: AttributeSyntax, valueType: TypeSyntax,
9 | isRawRepresentable: Bool) throws -> [DeclSyntax]
10 | {
11 | guard let args = attributes.arguments, let exprs = args.as(LabeledExprListSyntax.self), exprs.count >= 1,
12 | let identifierString = exprs.first?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue
13 | else {
14 | fatalError("Compiler bug: this macro did not receive arguments per its public signature.")
15 | }
16 |
17 | let flags = Dictionary(uniqueKeysWithValues: exprs.dropFirst().map { expr in
18 | guard let key = expr.label?.text, allowedKeys.contains(key),
19 | let tokenKind = expr.expression.as(BooleanLiteralExprSyntax.self)?.literal.tokenKind
20 | else {
21 | fatalError("Compiler bug: this macro did not receive arguments per its public signature.")
22 | }
23 | return (key, tokenKind == TokenKind.keyword(.true))
24 | })
25 |
26 | let identifier = TokenSyntax(stringLiteral: identifierString)
27 |
28 | let varDeclSyntax = DeclSyntax("fileprivate var \(identifier): NSExpression? = nil")
29 |
30 | let constantFuncDecl = try generateFunctionDeclSyntax(
31 | identifier: identifier,
32 | valueType: valueType,
33 | isRawRepresentable: isRawRepresentable
34 | )
35 |
36 | let nsExpressionFuncDecl = try FunctionDeclSyntax("public func \(identifier)(expression: NSExpression) -> Self") {
37 | "var copy = self"
38 | "copy.\(identifier) = expression"
39 | "return copy"
40 | }
41 |
42 | let getPropFuncDecl =
43 | try FunctionDeclSyntax("public func \(identifier)(featurePropertyNamed keyPath: String) -> Self") {
44 | "var copy = self"
45 | "copy.\(identifier) = NSExpression(forKeyPath: keyPath)"
46 | "return copy"
47 | }
48 |
49 | guard let constFuncDeclSyntax = DeclSyntax(constantFuncDecl),
50 | let getPropFuncDeclSyntax = DeclSyntax(getPropFuncDecl),
51 | let nsExpressionFuncDeclSyntax = DeclSyntax(nsExpressionFuncDecl)
52 | else {
53 | fatalError("SwiftSyntax bug or implementation error: unable to construct DeclSyntax")
54 | }
55 |
56 | var extra: [DeclSyntax] = []
57 |
58 | if flags["supportsInterpolation"] == .some(true) {
59 | let interpolationFuncDecl =
60 | try FunctionDeclSyntax(
61 | "public func \(identifier)(interpolatedBy expression: MLNVariableExpression, curveType: MLNExpressionInterpolationMode, parameters: NSExpression?, stops: NSExpression) -> Self"
62 | ) {
63 | "var copy = self"
64 | "copy.\(identifier) = interpolatingExpression(expression: expression, curveType: curveType, parameters: parameters, stops: stops)"
65 | "return copy"
66 | }
67 |
68 | guard let interpolationFuncDeclSyntax = DeclSyntax(interpolationFuncDecl) else {
69 | fatalError("SwiftSyntax bug or implementation error: unable to construct DeclSyntax")
70 | }
71 |
72 | extra.append(interpolationFuncDeclSyntax)
73 | }
74 |
75 | return [varDeclSyntax, constFuncDeclSyntax, nsExpressionFuncDeclSyntax, getPropFuncDeclSyntax] + extra
76 | }
77 |
78 | private func generateFunctionDeclSyntax(identifier: TokenSyntax, valueType: TypeSyntax,
79 | isRawRepresentable: Bool) throws -> FunctionDeclSyntax
80 | {
81 | if isRawRepresentable {
82 | try FunctionDeclSyntax("public func \(identifier)(_ value: \(valueType)) -> Self") {
83 | "var copy = self"
84 | "copy.\(identifier) = NSExpression(forConstantValue: value.mlnRawValue.rawValue)"
85 | "return copy"
86 | }
87 | } else {
88 | try FunctionDeclSyntax("public func \(identifier)(_ value: \(valueType)) -> Self") {
89 | "var copy = self"
90 | "copy.\(identifier) = NSExpression(forConstantValue: value)"
91 | "return copy"
92 | }
93 | }
94 | }
95 |
96 | public struct MLNStylePropertyMacro: MemberMacro {
97 | public static func expansion(
98 | of node: AttributeSyntax,
99 | providingMembersOf _: some DeclGroupSyntax,
100 | in _: some SwiftSyntaxMacros.MacroExpansionContext
101 | ) throws -> [DeclSyntax] {
102 | guard let genericArgument = node
103 | .attributeName.as(IdentifierTypeSyntax.self)?
104 | .genericArgumentClause?
105 | .arguments.first?
106 | .argument
107 | else {
108 | fatalError("Compiler bug: this macro is missing a generic type constraint.")
109 | }
110 |
111 | return try generateStyleProperty(for: node, valueType: genericArgument, isRawRepresentable: false)
112 | }
113 | }
114 |
115 | public struct MLNRawRepresentableStylePropertyMacro: MemberMacro {
116 | public static func expansion(
117 | of node: AttributeSyntax,
118 | providingMembersOf _: some DeclGroupSyntax,
119 | in _: some SwiftSyntaxMacros.MacroExpansionContext
120 | ) throws -> [DeclSyntax] {
121 | guard let genericArgument = node
122 | .attributeName.as(IdentifierTypeSyntax.self)?
123 | .genericArgumentClause?
124 | .arguments.first?
125 | .argument
126 | else {
127 | fatalError("Compiler bug: this macro is missing a generic type constraint.")
128 | }
129 |
130 | return try generateStyleProperty(for: node, valueType: genericArgument, isRawRepresentable: true)
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Documentation.docc/MapLibreSwiftUI.md:
--------------------------------------------------------------------------------
1 | # ``MapLibreSwiftUI``
2 |
3 | A SwiftUI framework for Maplibre Native iOS. Provides declarative methods for MapLibre inspired by default SwiftUI functionality.
4 |
5 | ## Overview
6 |
7 | ```swift
8 | struct MyView: View {
9 |
10 | @State var camera: MapViewCamera = .default()
11 |
12 | var body: some View {
13 | MapView(
14 | styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!,
15 | camera: $camera
16 | ) {
17 | // Declarative overlay features.
18 | }
19 | .onTapMapGesture { context in
20 | // Handle tap gesture context
21 | }
22 | }
23 | }
24 | ```
25 |
26 | ## Topics
27 |
28 | ### MapView
29 |
30 | - ``MapView``
31 |
32 | ### MapViewCamera
33 |
34 | - ``MapViewCamera``
35 | - ``CameraState``
36 | - ``CameraPitch``
37 | - ``CameraChangeReason``
38 |
39 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Camera.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import SwiftUI
3 |
4 | struct CameraDirectManipulationPreview: View {
5 | @State private var camera = MapViewCamera.center(switzerland, zoom: 4)
6 |
7 | let styleURL: URL
8 | var onStyleLoaded: (() -> Void)? = nil
9 | var targetCameraAfterDelay: MapViewCamera? = nil
10 |
11 | var body: some View {
12 | MapView(styleURL: styleURL, camera: $camera)
13 | .onStyleLoaded { _ in
14 | onStyleLoaded?()
15 | }
16 | .overlay(alignment: .bottom, content: {
17 | Text("\(String(describing: camera.state))")
18 | .padding()
19 | .foregroundColor(.white)
20 | .background(
21 | Rectangle()
22 | .foregroundColor(.black)
23 | .cornerRadius(8)
24 | )
25 | .padding(.bottom, 42)
26 | })
27 | .task {
28 | if let targetCameraAfterDelay {
29 | try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
30 |
31 | camera = targetCameraAfterDelay
32 | }
33 | }
34 | }
35 | }
36 |
37 | #Preview("Camera Zoom after delay") {
38 | CameraDirectManipulationPreview(
39 | styleURL: demoTilesURL,
40 | targetCameraAfterDelay: .center(switzerland, zoom: 6)
41 | )
42 | .ignoresSafeArea(.all)
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/FillStyleLayerPreviews.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import MapLibreSwiftDSL
3 | import SwiftUI
4 |
5 | @available(iOS 17.0, *)
6 | #Preview {
7 | @Previewable let source = ShapeSource(identifier: "fillSource") {
8 | MLNPolygonFeature(coordinates: austriaPolygon, count: UInt(austriaPolygon.count))
9 | }
10 | MapView(styleURL: demoTilesURL, camera: .constant(.center(austriaPolygon.first!, zoom: 4))) {
11 | FillStyleLayer(identifier: "fillLayer", source: source)
12 | .fillColor(.red)
13 | .fillOpacity(0.5)
14 | .fillOutlineColor(.blue)
15 | }
16 |
17 | .ignoresSafeArea(.all)
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Gestures.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | #Preview("Tappable Circles") {
7 | let tappableID = "simple-circles"
8 | return MapView(styleURL: demoTilesURL) {
9 | // Simple symbol layer demonstration with an icon
10 | CircleStyleLayer(identifier: tappableID, source: pointSource)
11 | .radius(16)
12 | .color(.systemRed)
13 | .strokeWidth(2)
14 | .strokeColor(.white)
15 |
16 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
17 | .iconImage(UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate))
18 | .iconColor(.white)
19 | }
20 | .onTapMapGesture(on: [tappableID], onTapChanged: { _, features in
21 | print("Tapped on \(features.first?.description ?? "")")
22 | })
23 | .ignoresSafeArea(.all)
24 | }
25 |
26 | #Preview("Tappable Countries") {
27 | MapView(styleURL: demoTilesURL)
28 | .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in
29 | print("Tapped on \(features.first?.description ?? "")")
30 | })
31 | .ignoresSafeArea(.all)
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Layers.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | // A collection of points with various
7 | // attributes
8 | @MainActor
9 | let pointSource = ShapeSource(identifier: "points") {
10 | // Uses the DSL to quickly construct point features inline
11 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139))
12 |
13 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in
14 | feature.attributes["icon"] = "missing"
15 | feature.attributes["heading"] = 45
16 | }
17 |
18 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in
19 | feature.attributes["icon"] = "club"
20 | feature.attributes["heading"] = 145
21 | }
22 | }
23 |
24 | @MainActor
25 | let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .clusterRadius: 44]) {
26 | // Uses the DSL to quickly construct point features inline
27 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 48.2082, longitude: 16.3719))
28 |
29 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 48.3082, longitude: 16.3719))
30 |
31 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 48.2082, longitude: 16.9719))
32 |
33 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 48.0082, longitude: 17.9719))
34 | }
35 |
36 | #Preview("Rose Tint") {
37 | MapView(styleURL: demoTilesURL) {
38 | // Silly example: a background layer on top of everything to create a tint effect
39 | BackgroundLayer(identifier: "rose-colored-glasses")
40 | .backgroundColor(.systemPink.withAlphaComponent(0.3))
41 | .renderAbove(.all)
42 | }
43 | .ignoresSafeArea(.all)
44 | }
45 |
46 | #Preview("Simple Symbol") {
47 | MapView(styleURL: demoTilesURL) {
48 | // Simple symbol layer demonstration with an icon
49 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
50 | .iconImage(UIImage(systemName: "mappin")!)
51 | }
52 | .ignoresSafeArea(.all)
53 | }
54 |
55 | #Preview("Rotated Symbols (Const)") {
56 | MapView(styleURL: demoTilesURL) {
57 | // Simple symbol layer demonstration with an icon
58 | SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource)
59 | .iconImage(UIImage(systemName: "location.north.circle.fill")!)
60 | .iconRotation(45)
61 | }
62 | .ignoresSafeArea(.all)
63 | }
64 |
65 | #Preview("Rotated Symbols (Dynamic)") {
66 | MapView(styleURL: demoTilesURL) {
67 | // Simple symbol layer demonstration with an icon
68 | SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource)
69 | .iconImage(UIImage(systemName: "location.north.circle.fill")!)
70 | .iconRotation(featurePropertyNamed: "heading")
71 | }
72 | .ignoresSafeArea(.all)
73 | }
74 |
75 | #Preview("Circles with Symbols") {
76 | MapView(styleURL: demoTilesURL) {
77 | // Simple symbol layer demonstration with an icon
78 | CircleStyleLayer(identifier: "simple-circles", source: pointSource)
79 | .radius(16)
80 | .color(.systemRed)
81 | .strokeWidth(2)
82 | .strokeColor(.white)
83 |
84 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
85 | .iconImage(UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate))
86 | .iconColor(.white)
87 | }
88 | .ignoresSafeArea(.all)
89 | }
90 |
91 | #Preview("Clustered Circles with Symbols") {
92 | MapView(styleURL: demoTilesURL, camera: .constant(MapViewCamera.center(
93 | CLLocationCoordinate2D(latitude: 48.2082, longitude: 16.3719),
94 | zoom: 5,
95 | direction: 0
96 | ))) {
97 | // Clusters pins when they would touch
98 |
99 | // Cluster == YES shows only those pins that are clustered, using .text
100 | CircleStyleLayer(identifier: "simple-circles-clusters", source: clustered)
101 | .radius(16)
102 | .color(.systemRed)
103 | .strokeWidth(2)
104 | .strokeColor(.white)
105 | .predicate(NSPredicate(format: "cluster == YES"))
106 |
107 | SymbolStyleLayer(identifier: "simple-symbols-clusters", source: clustered)
108 | .textColor(.white)
109 | .text(expression: NSExpression(format: "CAST(point_count, 'NSString')"))
110 | .predicate(NSPredicate(format: "cluster == YES"))
111 |
112 | // Cluster != YES shows only those pins that are not clustered, using an icon
113 | CircleStyleLayer(identifier: "simple-circles-non-clusters", source: clustered)
114 | .radius(16)
115 | .color(.systemRed)
116 | .strokeWidth(2)
117 | .strokeColor(.white)
118 | .predicate(NSPredicate(format: "cluster != YES"))
119 |
120 | SymbolStyleLayer(identifier: "simple-symbols-non-clusters", source: clustered)
121 | .iconImage(UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate))
122 | .iconColor(.white)
123 | .predicate(NSPredicate(format: "cluster != YES"))
124 | }
125 | .onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in
126 | print("Tapped on \(features.first?.debugDescription ?? "")")
127 | })
128 | .expandClustersOnTapping(clusteredLayers: [ClusterLayer(
129 | layerIdentifier: "simple-circles-clusters",
130 | sourceIdentifier: "points"
131 | )])
132 | .ignoresSafeArea(.all)
133 | }
134 |
135 | // This example does not work within a package? But it does work when in a real app
136 | // #Preview("Multiple Symbol Icons") {
137 | // MapView(styleURL: demoTilesURL) {
138 | // // Simple symbol layer demonstration with an icon
139 | // SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
140 | // .iconImage(featurePropertyNamed: "icon",
141 | // mappings: [
142 | // "missing": UIImage(systemName: "mappin.slash")!,
143 | // "club": UIImage(systemName: "figure.dance")!,
144 | // ],
145 | // default: UIImage(systemName: "mappin")!)
146 | // .iconColor(.red)
147 | // }
148 | // .ignoresSafeArea(.all)
149 | // }
150 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Other.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | #Preview("Unsafe MapView Modifier") {
7 | MapView(styleURL: demoTilesURL) {
8 | // A collection of points with various
9 | // attributes
10 | let pointSource = ShapeSource(identifier: "points") {
11 | // Uses the DSL to quickly construct point features inline
12 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139))
13 |
14 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in
15 | feature.attributes["icon"] = "missing"
16 | feature.attributes["heading"] = 45
17 | }
18 |
19 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in
20 | feature.attributes["icon"] = "club"
21 | feature.attributes["heading"] = 145
22 | }
23 | }
24 |
25 | // Demonstrates how to use the unsafeMapModifier to set MLNMapView properties that have not been exposed as
26 | // modifiers yet.
27 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
28 | .iconImage(UIImage(systemName: "mappin")!)
29 | }
30 | .unsafeMapViewControllerModifier { viewController in
31 | // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying
32 | // MLNMapView.
33 | // Be careful: if you modify properties that the DSL controls already, they may be overridden!
34 | // This modifier is a temporary solution; let us know your use case(s)
35 | // so we can build safe support into the DSL.
36 | viewController.mapView.logoView.isHidden = false
37 | viewController.mapView.compassViewPosition = .topLeft
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Polyline.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | struct PolylineMapView: View {
7 | let styleURL: URL
8 | let waypoints: [CLLocationCoordinate2D]
9 |
10 | var body: some View {
11 | MapView(styleURL: styleURL,
12 | camera: .constant(.center(waypoints.first!, zoom: 14)))
13 | {
14 | // Define a data source.
15 | // It will be automatically if a layer references it.
16 | let polylineSource = ShapeSource(identifier: "polyline") {
17 | MLNPolylineFeature(coordinates: waypoints)
18 | }
19 |
20 | // Add a polyline casing for a stroke effect
21 | LineStyleLayer(identifier: "polyline-casing", source: polylineSource)
22 | .lineCap(.round)
23 | .lineJoin(.round)
24 | .lineColor(.white)
25 | .lineWidth(interpolatedBy: .zoomLevel,
26 | curveType: .exponential,
27 | parameters: NSExpression(forConstantValue: 1.5),
28 | stops: NSExpression(forConstantValue: [14: 6, 18: 24]))
29 | .renderBelow(.symbols)
30 |
31 | // Add an inner (blue) polyline
32 | LineStyleLayer(identifier: "polyline-inner", source: polylineSource)
33 | .lineCap(.round)
34 | .lineJoin(.round)
35 | .lineColor(.systemBlue)
36 | .lineWidth(interpolatedBy: .zoomLevel,
37 | curveType: .exponential,
38 | parameters: NSExpression(forConstantValue: 1.5),
39 | stops: NSExpression(forConstantValue: [14: 3, 18: 16]))
40 | .renderBelow(.symbols)
41 | }
42 | }
43 | }
44 |
45 | struct Polyline_Previews: PreviewProvider {
46 | static var previews: some View {
47 | PolylineMapView(styleURL: demoTilesURL, waypoints: samplePedestrianWaypoints)
48 | .ignoresSafeArea(.all)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift:
--------------------------------------------------------------------------------
1 | // This file contains helpers that are used in the SwiftUI preview examples
2 | import CoreLocation
3 |
4 | let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470)
5 | public let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")!
6 |
7 | let austriaPolygon: [CLLocationCoordinate2D] = [
8 | CLLocationCoordinate2D(latitude: 49.0200, longitude: 16.9600),
9 | CLLocationCoordinate2D(latitude: 48.9000, longitude: 15.0160),
10 | CLLocationCoordinate2D(latitude: 48.2890, longitude: 13.0310),
11 | CLLocationCoordinate2D(latitude: 47.5237, longitude: 10.4350),
12 | CLLocationCoordinate2D(latitude: 46.4000, longitude: 12.1500),
13 | CLLocationCoordinate2D(latitude: 46.8700, longitude: 16.5900),
14 | CLLocationCoordinate2D(latitude: 48.1234, longitude: 16.9600),
15 | CLLocationCoordinate2D(latitude: 49.0200, longitude: 16.9600), // Closing point (same as start)
16 | ]
17 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Examples/User Location.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibreSwiftDSL
3 | import SwiftUI
4 |
5 | @MainActor
6 | private let locationManager = StaticLocationManager(initialLocation: CLLocation(
7 | coordinate: switzerland,
8 | altitude: 0,
9 | horizontalAccuracy: 1,
10 | verticalAccuracy: 1,
11 | course: 8,
12 | speed: 28,
13 | timestamp: Date()
14 | ))
15 |
16 | #Preview("Track user location") {
17 | MapView(
18 | styleURL: demoTilesURL,
19 | camera: .constant(.trackUserLocation(zoom: 4, pitch: 45)),
20 | locationManager: locationManager
21 | )
22 | .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0))
23 | .ignoresSafeArea(.all)
24 | }
25 |
26 | #Preview("Track user location with Course") {
27 | MapView(
28 | styleURL: demoTilesURL,
29 | camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: 45)),
30 | locationManager: locationManager
31 | )
32 | .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0))
33 | .mapControls {
34 | LogoView()
35 | }
36 | .ignoresSafeArea(.all)
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/CoreLocation/CLLocationCoordinate2D.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 |
3 | // TODO: We can delete chat about this. I'm not 100% on it, even though I want Hashable
4 | // on the MapCameraView (so we can let a user present a MapView with a designated camera from NavigationLink)
5 | extension CLLocationCoordinate2D: @retroactive Hashable {
6 | public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
7 | lhs.latitude == rhs.latitude
8 | && lhs.longitude == rhs.longitude
9 | }
10 |
11 | public func hash(into hasher: inout Hasher) {
12 | hasher.combine(latitude)
13 | hasher.combine(longitude)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | public extension MLNCameraChangeReason {
5 | /// Get the MLNCameraChangeReason from the option set with the largest
6 | /// bitwise value.
7 | var largestBitwiseReason: MLNCameraChangeReason {
8 | // Start at 1
9 | var mask: UInt = 1
10 | var result: UInt = 0
11 |
12 | while mask <= rawValue {
13 | // If the raw value matches the remaining mask.
14 | if rawValue & mask != 0 {
15 | result = mask
16 | }
17 | // Shift all the way until the rawValue has been allocated and we have the true last value.
18 | mask <<= 1
19 | }
20 |
21 | return MLNCameraChangeReason(rawValue: result)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import Foundation
3 | import MapLibre
4 | import Mockable
5 |
6 | // NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment
7 | @Mockable
8 | public protocol MLNMapViewCameraUpdating: AnyObject {
9 | @MainActor var userTrackingMode: MLNUserTrackingMode { get set }
10 | @MainActor func setUserTrackingMode(_ mode: MLNUserTrackingMode, animated: Bool, completionHandler: (() -> Void)?)
11 |
12 | @MainActor var centerCoordinate: CLLocationCoordinate2D { get set }
13 | @MainActor var zoomLevel: Double { get set }
14 | @MainActor var minimumPitch: CGFloat { get set }
15 | @MainActor var maximumPitch: CGFloat { get set }
16 | @MainActor var direction: CLLocationDirection { get set }
17 | @MainActor var camera: MLNMapCamera { get set }
18 | @MainActor var frame: CGRect { get set }
19 | @MainActor func setCamera(_ camera: MLNMapCamera, animated: Bool)
20 | @MainActor func setCenter(_ coordinate: CLLocationCoordinate2D,
21 | zoomLevel: Double,
22 | direction: CLLocationDirection,
23 | animated: Bool)
24 | @MainActor func setZoomLevel(_ zoomLevel: Double, animated: Bool)
25 | @MainActor func setVisibleCoordinateBounds(
26 | _ bounds: MLNCoordinateBounds,
27 | edgePadding: UIEdgeInsets,
28 | animated: Bool,
29 | completionHandler: (() -> Void)?
30 | )
31 | }
32 |
33 | extension MLNMapView: MLNMapViewCameraUpdating {
34 | // No definition
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 | import SwiftUI
4 |
5 | extension MapView {
6 | /// Register a gesture recognizer on the MapView.
7 | ///
8 | /// - Parameters:
9 | /// - mapView: The MLNMapView that will host the gesture itself.
10 | /// - context: The UIViewRepresentable context that will orchestrate the response sender
11 | /// - gesture: The gesture definition.
12 | @MainActor func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) {
13 | switch gesture.method {
14 | case let .tap(numberOfTaps: numberOfTaps):
15 | let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator,
16 | action: #selector(context.coordinator.captureGesture(_:)))
17 | gestureRecognizer.numberOfTapsRequired = numberOfTaps
18 | if numberOfTaps == 1 {
19 | // If a user double taps to zoom via the built in gesture, a normal
20 | // tap should not be triggered.
21 | if let doubleTapRecognizer = mapView.gestureRecognizers?
22 | .first(where: {
23 | $0 is UITapGestureRecognizer && ($0 as! UITapGestureRecognizer).numberOfTapsRequired == 2
24 | })
25 | {
26 | gestureRecognizer.require(toFail: doubleTapRecognizer)
27 | }
28 | }
29 | mapView.addGestureRecognizer(gestureRecognizer)
30 | gesture.gestureRecognizer = gestureRecognizer
31 |
32 | case let .longPress(minimumDuration: minimumDuration):
33 | let gestureRecognizer = UILongPressGestureRecognizer(target: context.coordinator,
34 | action: #selector(context.coordinator
35 | .captureGesture(_:)))
36 | gestureRecognizer.minimumPressDuration = minimumDuration
37 |
38 | mapView.addGestureRecognizer(gestureRecognizer)
39 | gesture.gestureRecognizer = gestureRecognizer
40 | }
41 | }
42 |
43 | /// Runs on each gesture change event and filters the appropriate gesture behavior based on the
44 | /// user definition.
45 | ///
46 | /// Since the gestures run "onChange", we run this every time, event when state changes. The implementer is
47 | /// responsible for
48 | /// guarding
49 | /// and handling whatever state logic they want.
50 | ///
51 | /// - Parameters:
52 | /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the
53 | /// gesture.
54 | /// - sender: The UIGestureRecognizer
55 | @MainActor func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) {
56 | guard let gesture = gestures.first(where: { $0.gestureRecognizer == sender }) else {
57 | assertionFailure("\(sender) is not a registered UIGestureRecongizer on the MapView")
58 | return
59 | }
60 |
61 | if let clusteredLayers {
62 | if let gestureRecognizer = sender as? UITapGestureRecognizer, gestureRecognizer.numberOfTouches == 1 {
63 | let point = gestureRecognizer.location(in: sender.view)
64 | for clusteredLayer in clusteredLayers {
65 | let features = mapView.visibleFeatures(
66 | at: point,
67 | styleLayerIdentifiers: [clusteredLayer.layerIdentifier]
68 | )
69 | if let cluster = features.first as? MLNPointFeatureCluster,
70 | let source = mapView.style?
71 | .source(withIdentifier: clusteredLayer.sourceIdentifier) as? MLNShapeSource
72 | {
73 | let zoomLevel = source.zoomLevel(forExpanding: cluster)
74 |
75 | if zoomLevel > 0 {
76 | mapView.setCenter(cluster.coordinate, zoomLevel: zoomLevel, animated: true)
77 | break // since we can only zoom on one thing, we can abort the for loop here
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
84 | // Process the gesture into a context response.
85 | let context = processContextFromGesture(mapView, gesture: gesture, sender: sender)
86 | // Run the context through the gesture held on the MapView (emitting to the MapView modifier).
87 | switch gesture.onChange {
88 | case let .context(action):
89 | action(context)
90 | case let .feature(action, layers):
91 | let point = sender.location(in: sender.view)
92 | let features = mapView.visibleFeatures(at: point, styleLayerIdentifiers: layers)
93 | action(context, features)
94 | }
95 | }
96 |
97 | /// Convert the sender data into a MapGestureContext
98 | ///
99 | /// - Parameters:
100 | /// - mapView: The mapview that's emitting the gesture.
101 | /// - gesture: The gesture definition for this event.
102 | /// - sender: The UIKit gesture emitting from the map view.
103 | /// - Returns: The calculated context from the sending UIKit gesture
104 | @MainActor func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture,
105 | sender: UIGestureRecognizing) -> MapGestureContext
106 | {
107 | // Build the context of the gesture's event.
108 | let point: CGPoint = switch gesture.method {
109 | case let .tap(numberOfTaps: numberOfTaps):
110 | // Calculate the CGPoint of the last gesture tap
111 | sender.location(ofTouch: numberOfTaps - 1, in: mapView)
112 | case .longPress:
113 | // Calculate the CGPoint of the long process gesture.
114 | sender.location(in: mapView)
115 | }
116 |
117 | return MapGestureContext(gestureMethod: gesture.method,
118 | state: sender.state,
119 | point: point,
120 | coordinate: mapView.convert(point, toCoordinateFrom: mapView))
121 | }
122 | }
123 |
124 | /// Provides the layer identifier and it's source identifier.
125 | public struct ClusterLayer {
126 | public let layerIdentifier: String
127 | public let sourceIdentifier: String
128 |
129 | public init(layerIdentifier: String, sourceIdentifier: String) {
130 | self.layerIdentifier = layerIdentifier
131 | self.sourceIdentifier = sourceIdentifier
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/MapViewCamera/MapViewCameraOperations.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public extension MapViewCamera {
4 | // MARK: Zoom
5 |
6 | /// Set a new zoom for the current camera state.
7 | ///
8 | /// - Parameter newZoom: The new zoom value.
9 | mutating func setZoom(_ newZoom: Double) {
10 | switch state {
11 | case let .centered(onCoordinate, _, pitch, pitchRange, direction):
12 | state = .centered(onCoordinate: onCoordinate,
13 | zoom: newZoom,
14 | pitch: pitch,
15 | pitchRange: pitchRange,
16 | direction: direction)
17 | case let .trackingUserLocation(_, pitch, pitchRange, direction):
18 | state = .trackingUserLocation(zoom: newZoom, pitch: pitch, pitchRange: pitchRange, direction: direction)
19 | case let .trackingUserLocationWithHeading(_, pitch, pitchRange):
20 | state = .trackingUserLocationWithHeading(zoom: newZoom, pitch: pitch, pitchRange: pitchRange)
21 | case let .trackingUserLocationWithCourse(_, pitch, pitchRange):
22 | state = .trackingUserLocationWithCourse(zoom: newZoom, pitch: pitch, pitchRange: pitchRange)
23 | case .rect:
24 | return
25 | case .showcase:
26 | return
27 | }
28 |
29 | lastReasonForChange = .programmatic
30 | }
31 |
32 | /// Increment the zoom of the current camera state.
33 | ///
34 | /// - Parameter newZoom: The value to increment the zoom by. Negative decrements the value.
35 | mutating func incrementZoom(by increment: Double) {
36 | switch state {
37 | case let .centered(onCoordinate, zoom, pitch, pitchRange, direction):
38 | state = .centered(onCoordinate: onCoordinate,
39 | zoom: zoom + increment,
40 | pitch: pitch,
41 | pitchRange: pitchRange,
42 | direction: direction)
43 | case let .trackingUserLocation(zoom, pitch, pitchRange, direction):
44 | state = .trackingUserLocation(
45 | zoom: zoom + increment,
46 | pitch: pitch,
47 | pitchRange: pitchRange,
48 | direction: direction
49 | )
50 | case let .trackingUserLocationWithHeading(zoom, pitch, pitchRange):
51 | state = .trackingUserLocationWithHeading(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange)
52 | case let .trackingUserLocationWithCourse(zoom, pitch, pitchRange):
53 | state = .trackingUserLocationWithCourse(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange)
54 | case .rect:
55 | return
56 | case .showcase:
57 | return
58 | }
59 |
60 | lastReasonForChange = .programmatic
61 | }
62 |
63 | // MARK: Pitch
64 |
65 | /// Set a new pitch for the current camera state.
66 | ///
67 | /// - Parameter newPitch: The new pitch value.
68 | mutating func setPitch(_ newPitch: Double) {
69 | switch state {
70 | case let .centered(onCoordinate, zoom, _, pitchRange, direction):
71 | state = .centered(onCoordinate: onCoordinate,
72 | zoom: zoom,
73 | pitch: newPitch,
74 | pitchRange: pitchRange,
75 | direction: direction)
76 | case let .trackingUserLocation(zoom, _, pitchRange, direction):
77 | state = .trackingUserLocation(zoom: zoom, pitch: newPitch, pitchRange: pitchRange, direction: direction)
78 | case let .trackingUserLocationWithHeading(zoom, _, pitchRange):
79 | state = .trackingUserLocationWithHeading(zoom: zoom, pitch: newPitch, pitchRange: pitchRange)
80 | case let .trackingUserLocationWithCourse(zoom, _, pitchRange):
81 | state = .trackingUserLocationWithCourse(zoom: zoom, pitch: newPitch, pitchRange: pitchRange)
82 | case .rect:
83 | return
84 | case .showcase:
85 | return
86 | }
87 |
88 | lastReasonForChange = .programmatic
89 | }
90 |
91 | // TODO: Add direction set
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift:
--------------------------------------------------------------------------------
1 | import Mockable
2 | import UIKit
3 |
4 | @Mockable
5 | public protocol UIGestureRecognizing: AnyObject {
6 | @MainActor var state: UIGestureRecognizer.State { get }
7 | @MainActor func location(in view: UIView?) -> CGPoint
8 | @MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint
9 | }
10 |
11 | extension UIGestureRecognizer: UIGestureRecognizing {
12 | // No definition
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/MLNMapViewController.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import UIKit
3 |
4 | public protocol MapViewHostViewController: UIViewController {
5 | associatedtype MapType: MLNMapView
6 | @MainActor var mapView: MapType { get }
7 | }
8 |
9 | public final class MLNMapViewController: UIViewController, MapViewHostViewController {
10 | var activity: MapActivity = .standard
11 |
12 | @MainActor
13 | public var mapView: MLNMapView {
14 | view as! MLNMapView
15 | }
16 |
17 | override public func loadView() {
18 | view = MLNMapView(frame: .zero)
19 | view.tag = activity.rawValue
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/MapView.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | /// Identifies the activity this ``MapView`` is being used for. Useful for debugging purposes.
7 | public enum MapActivity: Int {
8 | /// Navigation in a standard window. Default.
9 | case standard = 0
10 | /// Navigation in a CarPlay template.
11 | case carplay = 2025
12 | }
13 |
14 | public struct MapView: UIViewControllerRepresentable {
15 | public typealias UIViewControllerType = T
16 | var cameraDisabled: Bool = false
17 |
18 | @Binding var camera: MapViewCamera
19 |
20 | let makeViewController: () -> T
21 | let styleSource: MapStyleSource
22 | let userLayers: [StyleLayerDefinition]
23 |
24 | var gestures = [MapGesture]()
25 |
26 | var onStyleLoaded: ((MLNStyle) -> Void)?
27 | var onViewProxyChanged: ((MapViewProxy) -> Void)?
28 | var proxyUpdateMode: ProxyUpdateMode?
29 |
30 | var mapViewContentInset: UIEdgeInsets?
31 |
32 | var unsafeMapViewControllerModifier: ((T) -> Void)?
33 |
34 | var controls: [MapControl] = [
35 | CompassView(),
36 | LogoView(),
37 | AttributionButton(),
38 | ]
39 |
40 | private var locationManager: MLNLocationManager?
41 |
42 | var clusteredLayers: [ClusterLayer]?
43 |
44 | let activity: MapActivity
45 |
46 | public init(
47 | makeViewController: @autoclosure @escaping () -> T,
48 | styleURL: URL,
49 | camera: Binding = .constant(.default()),
50 | locationManager: MLNLocationManager? = nil,
51 | activity: MapActivity = .standard,
52 | @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }
53 | ) {
54 | self.makeViewController = makeViewController
55 | styleSource = .url(styleURL)
56 | _camera = camera
57 | userLayers = makeMapContent()
58 | self.locationManager = locationManager
59 | self.activity = activity
60 | }
61 |
62 | public func makeCoordinator() -> MapViewCoordinator {
63 | MapViewCoordinator(
64 | parent: self,
65 | onGesture: { processGesture($0, $1) },
66 | onViewProxyChanged: { onViewProxyChanged?($0) },
67 | proxyUpdateMode: proxyUpdateMode ?? .onFinish
68 | )
69 | }
70 |
71 | public func makeUIViewController(context: Context) -> T {
72 | // Create the map view
73 | let controller = makeViewController()
74 | controller.mapView.delegate = context.coordinator
75 | context.coordinator.mapView = controller.mapView
76 |
77 | // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as
78 | // content insets can trigger a change)
79 | applyModifiers(controller, runUnsafe: false)
80 |
81 | controller.mapView.locationManager = locationManager
82 |
83 | switch styleSource {
84 | case let .url(styleURL):
85 | controller.mapView.styleURL = styleURL
86 | }
87 |
88 | context.coordinator.applyCameraChangeFromStateUpdate(
89 | controller.mapView,
90 | camera: camera,
91 | animated: false
92 | )
93 |
94 | controller.mapView.locationManager = controller.mapView.locationManager
95 |
96 | // Link the style loaded to the coordinator that emits the delegate event.
97 | context.coordinator.onStyleLoaded = onStyleLoaded
98 |
99 | // Add all gesture recognizers
100 | for gesture in gestures {
101 | registerGesture(controller.mapView, context, gesture: gesture)
102 | }
103 |
104 | return controller
105 | }
106 |
107 | public func updateUIViewController(_ uiViewController: T, context: Context) {
108 | context.coordinator.parent = self
109 |
110 | applyModifiers(uiViewController, runUnsafe: true)
111 |
112 | // FIXME: This should be a more selective update
113 | context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView)
114 | context.coordinator.updateLayers(mapView: uiViewController.mapView)
115 |
116 | // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be.
117 | let isStyleLoaded = uiViewController.mapView.style != nil
118 |
119 | if cameraDisabled == false {
120 | context.coordinator.applyCameraChangeFromStateUpdate(
121 | uiViewController.mapView,
122 | camera: camera,
123 | animated: isStyleLoaded
124 | )
125 | }
126 | }
127 |
128 | @MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) {
129 | if let mapViewContentInset {
130 | mapViewController.mapView.automaticallyAdjustsContentInset = false
131 | mapViewController.mapView.contentInset = mapViewContentInset
132 | }
133 |
134 | // Assume all controls are hidden by default (so that an empty list returns a map with no controls)
135 | mapViewController.mapView.logoView.isHidden = true
136 | mapViewController.mapView.compassView.isHidden = true
137 | mapViewController.mapView.attributionButton.isHidden = true
138 |
139 | // Apply each control configuration
140 | for control in controls {
141 | control.configureMapView(mapViewController.mapView)
142 | }
143 |
144 | if runUnsafe {
145 | unsafeMapViewControllerModifier?(mapViewController)
146 | }
147 | }
148 | }
149 |
150 | public extension MapView where T == MLNMapViewController {
151 | @MainActor
152 | init(
153 | styleURL: URL,
154 | camera: Binding = .constant(.default()),
155 | locationManager: MLNLocationManager? = nil,
156 | activity: MapActivity = .standard,
157 | @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }
158 | ) {
159 | self.init(
160 | makeViewController: {
161 | let vc = MLNMapViewController()
162 | vc.activity = activity
163 | return vc
164 | }(),
165 | styleURL: styleURL,
166 | camera: camera,
167 | locationManager: locationManager,
168 | activity: activity,
169 | makeMapContent
170 | )
171 | }
172 | }
173 |
174 | #Preview("Vanilla Map") {
175 | MapView(styleURL: demoTilesURL)
176 | .ignoresSafeArea(.all)
177 |
178 | // For a larger selection of previews,
179 | // check out the Examples directory, which
180 | // has a wide variety of previews,
181 | // organized into (hopefully) useful groups
182 | }
183 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/MapViewModifiers.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 | import MapLibreSwiftDSL
4 | import SwiftUI
5 |
6 | public extension MapView {
7 | /// Perform an action when the map view has loaded its style and all locally added style definitions.
8 | ///
9 | /// - Parameter perform: The action to perform with the loaded style.
10 | /// - Returns: The modified map view.
11 | func onStyleLoaded(_ perform: @escaping (MLNStyle) -> Void) -> MapView {
12 | var newMapView = self
13 | newMapView.onStyleLoaded = perform
14 | return newMapView
15 | }
16 |
17 | /// Allows you to set properties of the underlying MLNMapView directly
18 | /// in cases where these have not been ported to DSL yet.
19 | /// Use this function to modify various properties of the MLNMapView instance.
20 | /// For example, you can enable the display of the user's location on the map by setting `showUserLocation` to true.
21 | ///
22 | /// This is an 'escape hatch' back to the non-DSL world
23 | /// of MapLibre for features that have not been ported to DSL yet.
24 | /// Be careful not to use this to modify properties that are
25 | /// already ported to the DSL, like the camera for example, as your
26 | /// modifications here may break updates that occur with modifiers.
27 | /// In particular, this modifier is potentially dangerous as it runs on
28 | /// EVERY call to `updateUIView`.
29 | ///
30 | /// - Parameter modifier: A closure that provides you with an MLNMapView so you can set properties.
31 | /// - Returns: A MapView with the modifications applied.
32 | ///
33 | /// Example:
34 | /// ```swift
35 | /// MapView()
36 | /// .unsafeMapViewControllerModifier { controller in
37 | /// controller.mapView.showUserLocation = true
38 | /// }
39 | /// ```
40 | ///
41 | func unsafeMapViewControllerModifier(_ modifier: @escaping (T) -> Void) -> MapView {
42 | var newMapView = self
43 | newMapView.unsafeMapViewControllerModifier = modifier
44 | return newMapView
45 | }
46 |
47 | // MARK: Default Gestures
48 |
49 | /// Add an tap gesture handler to the MapView
50 | ///
51 | /// - Parameters:
52 | /// - count: The number of taps required to run the gesture.
53 | /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains
54 | /// information like the latitude and longitude of the tap.
55 | /// - Returns: The modified map view.
56 | func onTapMapGesture(count: Int = 1,
57 | onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView
58 | {
59 | var newMapView = self
60 |
61 | // Build the gesture and link it to the map view.
62 | let gesture = MapGesture(method: .tap(numberOfTaps: count),
63 | onChange: .context(onTapChanged))
64 | newMapView.gestures.append(gesture)
65 |
66 | return newMapView
67 | }
68 |
69 | /// Add an tap gesture handler to the MapView that returns any visible map features that were tapped.
70 | ///
71 | /// - Parameters:
72 | /// - count: The number of taps required to run the gesture.
73 | /// - on layers: The set of layer ids that you would like to check for visible features that were tapped. If no
74 | /// set is provided, all map layers are checked.
75 | /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains
76 | /// information like the latitude and longitude of the tap. Also emits an array of map features that were tapped.
77 | /// Returns an empty array when nothing was tapped on the "on" layer ids that were provided.
78 | /// - Returns: The modified map view.
79 | func onTapMapGesture(count: Int = 1, on layers: Set?,
80 | onTapChanged: @escaping (MapGestureContext, [any MLNFeature]) -> Void) -> MapView
81 | {
82 | var newMapView = self
83 |
84 | // Build the gesture and link it to the map view.
85 | let gesture = MapGesture(method: .tap(numberOfTaps: count),
86 | onChange: .feature(onTapChanged, layers: layers))
87 | newMapView.gestures.append(gesture)
88 |
89 | return newMapView
90 | }
91 |
92 | /// Add a long press gesture handler to the MapView
93 | ///
94 | /// - Parameters:
95 | /// - minimumDuration: The minimum duration in seconds the user must press the screen to run the gesture.
96 | /// - onPressChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc).
97 | /// - Returns: The modified map view.
98 | func onLongPressMapGesture(minimumDuration: Double = 0.5,
99 | onPressChanged: @escaping (MapGestureContext) -> Void) -> MapView
100 | {
101 | var newMapView = self
102 |
103 | // Build the gesture and link it to the map view.
104 | let gesture = MapGesture(method: .longPress(minimumDuration: minimumDuration),
105 | onChange: .context(onPressChanged))
106 | newMapView.gestures.append(gesture)
107 |
108 | return newMapView
109 | }
110 |
111 | /// Add a default implementation for tapping clustered features. When tapped, the map zooms so that the cluster is
112 | /// expanded.
113 | /// - Parameter clusteredLayers: An array of layers to monitor that can contain clustered features.
114 | /// - Returns: The modified MapView
115 | func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> MapView {
116 | var newMapView = self
117 | newMapView.clusteredLayers = clusteredLayers
118 | return newMapView
119 | }
120 |
121 | func mapViewContentInset(_ inset: UIEdgeInsets) -> Self {
122 | var result = self
123 | result.mapViewContentInset = inset
124 | return result
125 | }
126 |
127 | func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self {
128 | var result = self
129 | result.controls = buildControls()
130 | return result
131 | }
132 |
133 | /// The view modifier recieves an instance of `MapViewProxy`, which contains read only information about the current
134 | /// state of the
135 | /// `MapView` such as its bounds, center and insets.
136 | /// - Parameters:
137 | /// - updateMode: How frequently the `MapViewProxy` is updated. Per default this is set to `.onFinish`, so updates
138 | /// are only sent when the map finally completes updating due to animations or scrolling. Can be set to `.realtime`
139 | /// to recieve updates during the animations and scrolling too.
140 | /// - onViewProxyChanged: The closure containing the `MapViewProxy`. Use this to run code based on the current
141 | /// mapView state.
142 | ///
143 | /// Example:
144 | /// ```swift
145 | /// .onMapViewProxyUpdate() { proxy in
146 | /// print("The map zoom level is: \(proxy.zoomLevel)")
147 | /// }
148 | /// ```
149 | ///
150 | func onMapViewProxyUpdate(
151 | updateMode: ProxyUpdateMode = .onFinish,
152 | onViewProxyChanged: @escaping (MapViewProxy) -> Void
153 | ) -> Self {
154 | var result = self
155 | result.onViewProxyChanged = onViewProxyChanged
156 | result.proxyUpdateMode = updateMode
157 | return result
158 | }
159 |
160 | /// Prevent Maplibre-DSL from updating the camera, useful when the underlying ViewController is managing the camera,
161 | /// for example during navigation when Maplibre-Navigation is used.
162 | /// - Parameter disabled: if true, prevents Maplibre-DSL from updating the camera
163 | /// - Returns: The modified MapView
164 | func cameraModifierDisabled(_ disabled: Bool) -> Self {
165 | var view = self
166 | view.cameraDisabled = disabled
167 | return view
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import UIKit
3 |
4 | public class MapGesture: NSObject {
5 | public enum Method: Equatable {
6 | /// A standard tap gesture (UITapGestureRecognizer)
7 | ///
8 | /// - Parameters:
9 | /// - numberOfTaps: The number of taps required for the gesture to trigger
10 | case tap(numberOfTaps: Int = 1)
11 |
12 | /// A standard long press gesture (UILongPressGestureRecognizer)
13 | ///
14 | /// - Parameters:
15 | /// - minimumDuration: The minimum duration of the press in seconds.
16 | case longPress(minimumDuration: Double = 0.5)
17 | }
18 |
19 | /// The Gesture's method, this is used to register it for the correct user interaction on the MapView.
20 | public let method: Method
21 |
22 | /// The onChange action that runs when the gesture changes on the map view.
23 | public let onChange: GestureAction
24 |
25 | /// The underlying gesture recognizer
26 | public weak var gestureRecognizer: UIGestureRecognizer?
27 |
28 | /// Create a new gesture recognizer definition for the MapView
29 | ///
30 | /// - Parameters:
31 | /// - method: The gesture recognizer method
32 | /// - onChange: The action to perform when the gesture is changed
33 | public init(method: Method, onChange: GestureAction) {
34 | self.method = method
35 | self.onChange = onChange
36 | }
37 | }
38 |
39 | public enum GestureAction {
40 | case context((MapGestureContext) -> Void)
41 | case feature((MapGestureContext, [any MLNFeature]) -> Void, layers: Set?)
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import UIKit
3 |
4 | /// The contextual representation of the gesture.
5 | public struct MapGestureContext {
6 | /// The map gesture that produced the context.
7 | public let gestureMethod: MapGesture.Method
8 |
9 | /// The state of the on change event.
10 | public let state: UIGestureRecognizer.State
11 |
12 | /// The location that the gesture occurred on the screen.
13 | public let point: CGPoint
14 |
15 | /// The underlying geographic coordinate at the point of the gesture.
16 | public let coordinate: CLLocationCoordinate2D
17 |
18 | public init(
19 | gestureMethod: MapGesture.Method,
20 | state: UIGestureRecognizer.State,
21 | point: CGPoint,
22 | coordinate: CLLocationCoordinate2D
23 | ) {
24 | self.gestureMethod = gestureMethod
25 | self.state = state
26 | self.point = point
27 | self.coordinate = coordinate
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | @MainActor
5 | public enum CameraChangeReason: Hashable {
6 | case programmatic
7 | case resetNorth
8 | case gesturePan
9 | case gesturePinch
10 | case gestureRotate
11 | case gestureZoomIn
12 | case gestureZoomOut
13 | case gestureOneFingerZoom
14 | case gestureTilt
15 | case transitionCancelled
16 |
17 | /// Initialize a Swift CameraChangeReason from the MLN NSOption.
18 | ///
19 | /// This method will only show the largest bitwise reason contained in MLNCameraChangeReason.
20 | /// If you need a full history of the full bit range, use MLNCameraChangeReason directly
21 | ///
22 | /// - Parameter mlnCameraChangeReason: The camera change reason options list from the MapLibre MapViewDelegate
23 | public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) {
24 | switch mlnCameraChangeReason.largestBitwiseReason {
25 | case .programmatic:
26 | self = .programmatic
27 | case .resetNorth:
28 | self = .resetNorth
29 | case .gesturePan:
30 | self = .gesturePan
31 | case .gesturePinch:
32 | self = .gesturePinch
33 | case .gestureRotate:
34 | self = .gestureRotate
35 | case .gestureZoomIn:
36 | self = .gestureZoomIn
37 | case .gestureZoomOut:
38 | self = .gestureZoomOut
39 | case .gestureOneFingerZoom:
40 | self = .gestureOneFingerZoom
41 | case .gestureTilt:
42 | self = .gestureTilt
43 | case .transitionCancelled:
44 | self = .transitionCancelled
45 | default:
46 | return nil
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | /// The current pitch state for the MapViewCamera
5 | public enum CameraPitchRange: Hashable, Sendable {
6 | /// The user is free to control pitch from it's default min to max.
7 | case free
8 |
9 | /// The user is free to control pitch within the minimum and maximum range.
10 | case freeWithinRange(minimum: Double, maximum: Double)
11 |
12 | /// The pitch is fixed to a certain value.
13 | case fixed(Double)
14 |
15 | /// The range of acceptable pitch values.
16 | ///
17 | /// This is applied to the map view on camera updates.
18 | public var rangeValue: ClosedRange {
19 | switch self {
20 | case .free:
21 | 0 ... 60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)?
22 | case let .freeWithinRange(minimum: minimum, maximum: maximum):
23 | minimum ... maximum
24 | case let .fixed(value):
25 | value ... value
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MapLibre
3 |
4 | /// The CameraState is used to understand the current context of the MapView's camera.
5 | public enum CameraState: Hashable, Equatable, Sendable {
6 | /// Centered on a coordinate
7 | case centered(
8 | onCoordinate: CLLocationCoordinate2D,
9 | zoom: Double,
10 | pitch: Double,
11 | pitchRange: CameraPitchRange,
12 | direction: CLLocationDirection
13 | )
14 |
15 | /// Follow the user's location using the MapView's internal camera.
16 | ///
17 | /// This feature uses the MLNMapView's userTrackingMode to .follow which automatically
18 | /// follows the user from within the MLNMapView.
19 | case trackingUserLocation(zoom: Double, pitch: Double, pitchRange: CameraPitchRange, direction: CLLocationDirection)
20 |
21 | /// Follow the user's location using the MapView's internal camera with the user's heading.
22 | ///
23 | /// This feature uses the MLNMapView's userTrackingMode to .followWithHeading which automatically
24 | /// follows the user from within the MLNMapView.
25 | case trackingUserLocationWithHeading(zoom: Double, pitch: Double, pitchRange: CameraPitchRange)
26 |
27 | /// Follow the user's location using the MapView's internal camera with the users' course
28 | ///
29 | /// This feature uses the MLNMapView's userTrackingMode to .followWithCourse which automatically
30 | /// follows the user from within the MLNMapView.
31 | case trackingUserLocationWithCourse(zoom: Double, pitch: Double, pitchRange: CameraPitchRange)
32 |
33 | /// Centered on a bounding box/rectangle.
34 | case rect(
35 | boundingBox: MLNCoordinateBounds,
36 | edgePadding: UIEdgeInsets = .init(top: 20, left: 20, bottom: 20, right: 20)
37 | )
38 |
39 | /// Showcasing GeoJSON, Polygons, etc.
40 | case showcase(shapeCollection: MLNShapeCollection)
41 | }
42 |
43 | extension CameraState: CustomDebugStringConvertible {
44 | public var debugDescription: String {
45 | switch self {
46 | case let .centered(
47 | onCoordinate: coordinate,
48 | zoom: zoom,
49 | pitch: pitch,
50 | pitchRange: pitchRange,
51 | direction: direction
52 | ):
53 | "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom), pitch: \(pitch), pitchRange: \(pitchRange), direction: \(direction))"
54 | case let .trackingUserLocation(zoom: zoom):
55 | "CameraState.trackingUserLocation(zoom: \(zoom))"
56 | case let .trackingUserLocationWithHeading(zoom: zoom):
57 | "CameraState.trackingUserLocationWithHeading(zoom: \(zoom))"
58 | case let .trackingUserLocationWithCourse(zoom: zoom):
59 | "CameraState.trackingUserLocationWithCourse(zoom: \(zoom))"
60 | case let .rect(boundingBox: boundingBox, edgePadding: edgePadding):
61 | "CameraState.rect(northeast: \(boundingBox.ne), southwest: \(boundingBox.sw), edgePadding: \(edgePadding))"
62 | case let .showcase(shapeCollection: shapeCollection):
63 | "CameraState.showcase(shapeCollection: \(shapeCollection))"
64 | }
65 | }
66 | }
67 |
68 | extension MLNCoordinateBounds: @retroactive Equatable, @retroactive Hashable {
69 | public func hash(into hasher: inout Hasher) {
70 | hasher.combine(ne)
71 | hasher.combine(sw)
72 | }
73 |
74 | public static func == (lhs: MLNCoordinateBounds, rhs: MLNCoordinateBounds) -> Bool {
75 | lhs.ne == rhs.ne && lhs.sw == rhs.sw
76 | }
77 | }
78 |
79 | extension UIEdgeInsets: @retroactive Hashable {
80 | public func hash(into hasher: inout Hasher) {
81 | hasher.combine(left)
82 | hasher.combine(right)
83 | hasher.combine(top)
84 | hasher.combine(bottom)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import Foundation
3 | import MapLibre
4 |
5 | /// The SwiftUI MapViewCamera.
6 | ///
7 | /// This manages the camera state within the MapView.
8 | public struct MapViewCamera: Hashable, Equatable, Sendable {
9 | public enum Defaults {
10 | public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)
11 | public static let zoom: Double = 10
12 | public static let pitch: Double = 0
13 | public static let pitchRange: CameraPitchRange = .free
14 | public static let direction: CLLocationDirection = 0
15 | }
16 |
17 | public var state: CameraState
18 |
19 | /// The reason the camera was changed.
20 | ///
21 | /// This can be used to see if the camera programmatically moved,
22 | /// or manipulated through a user gesture.
23 | public var lastReasonForChange: CameraChangeReason?
24 |
25 | /// A camera centered at 0.0, 0.0. This is typically used as a backup,
26 | /// pre-load for an expected camera update (e.g. before a location provider produces
27 | /// it's first location).
28 | ///
29 | /// - Returns: The constructed MapViewCamera.
30 | public static func `default`() -> MapViewCamera {
31 | MapViewCamera(
32 | state: .centered(
33 | onCoordinate: Defaults.coordinate,
34 | zoom: Defaults.zoom,
35 | pitch: Defaults.pitch,
36 | pitchRange: Defaults.pitchRange,
37 | direction: Defaults.direction
38 | ),
39 | lastReasonForChange: .programmatic
40 | )
41 | }
42 |
43 | /// Center the map on a specific location.
44 | ///
45 | /// - Parameters:
46 | /// - coordinate: The coordinate to center the map on.
47 | /// - zoom: The zoom level.
48 | /// - pitch: Set the camera pitch method.
49 | /// - direction: The course. Default is 0 (North).
50 | /// - Returns: The constructed MapViewCamera.
51 | public static func center(_ coordinate: CLLocationCoordinate2D,
52 | zoom: Double,
53 | pitch: Double = Defaults.pitch,
54 | pitchRange: CameraPitchRange = Defaults.pitchRange,
55 | direction: CLLocationDirection = Defaults.direction,
56 | reason: CameraChangeReason? = nil) -> MapViewCamera
57 | {
58 | MapViewCamera(
59 | state: .centered(
60 | onCoordinate: coordinate,
61 | zoom: zoom,
62 | pitch: pitch,
63 | pitchRange: pitchRange,
64 | direction: direction
65 | ),
66 | lastReasonForChange: reason
67 | )
68 | }
69 |
70 | /// Enables user location tracking within the MapView.
71 | ///
72 | /// This feature uses the MLNMapView's userTrackingMode = .follow
73 | ///
74 | /// - Parameters:
75 | /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike
76 | /// pitch.
77 | /// - pitch: Set the camera pitch method.
78 | /// - Returns: The MapViewCamera representing the scenario
79 | public static func trackUserLocation(zoom: Double = Defaults.zoom,
80 | pitch: Double = Defaults.pitch,
81 | pitchRange: CameraPitchRange = Defaults.pitchRange,
82 | direction: CLLocationDirection = Defaults.direction) -> MapViewCamera
83 | {
84 | // Coordinate is ignored when tracking user location. However, pitch and zoom are valid.
85 | MapViewCamera(
86 | state: .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction),
87 | lastReasonForChange: .programmatic
88 | )
89 | }
90 |
91 | /// Enables user location tracking within the MapView.
92 | ///
93 | /// This feature uses the MLNMapView's userTrackingMode = .followWithHeading
94 | ///
95 | /// - Parameters:
96 | /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike
97 | /// pitch.
98 | /// - pitch: Set the camera pitch method.
99 | /// - Returns: The MapViewCamera representing the scenario
100 | public static func trackUserLocationWithHeading(
101 | zoom: Double = Defaults.zoom,
102 | pitch: Double = Defaults.pitch,
103 | pitchRange: CameraPitchRange = Defaults.pitchRange
104 | ) -> MapViewCamera {
105 | // Coordinate is ignored when tracking user location. However, pitch and zoom are valid.
106 | MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange),
107 | lastReasonForChange: .programmatic)
108 | }
109 |
110 | /// Enables user location tracking within the MapView.
111 | ///
112 | /// This feature uses the MLNMapView's userTrackingMode = .followWithCourse
113 | ///
114 | /// - Parameters:
115 | /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike
116 | /// pitch.
117 | /// - pitch: Set the camera pitch method.
118 | /// - Returns: The MapViewCamera representing the scenario
119 | public static func trackUserLocationWithCourse(
120 | zoom: Double = Defaults.zoom,
121 | pitch: Double = Defaults.pitch,
122 | pitchRange: CameraPitchRange = Defaults.pitchRange
123 | ) -> MapViewCamera {
124 | // Coordinate is ignored when tracking user location. However, pitch and zoom are valid.
125 | MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange),
126 | lastReasonForChange: .programmatic)
127 | }
128 |
129 | /// Positions the camera to show a specific region in the MapView.
130 | ///
131 | /// - Parameters:
132 | /// - box: Set the desired bounding box. This is a one time event and the user can manipulate by moving the map.
133 | /// - edgePadding: Set the edge insets that should be applied before positioning the map.
134 | /// - Returns: The MapViewCamera representing the scenario
135 | public static func boundingBox(
136 | _ box: MLNCoordinateBounds,
137 | edgePadding: UIEdgeInsets = .init(top: 20, left: 20, bottom: 20, right: 20)
138 | ) -> MapViewCamera {
139 | MapViewCamera(state: .rect(boundingBox: box, edgePadding: edgePadding),
140 | lastReasonForChange: .programmatic)
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapStyleSource.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // TODO: Support MLNStyle as well; having a DSL for that would be nice
4 | public enum MapStyleSource {
5 | case url(URL)
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/Models/MapViewProxy.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import Foundation
3 | import MapLibre
4 |
5 | /// A read only representation of the MapView's current View.
6 | ///
7 | /// Provides access to properties and functions of the underlying MLNMapView,
8 | /// but properties only expose their getter, and functions are only available if they
9 | /// do no change the state of the MLNMapView. Writing directly to properties of MLNMapView
10 | /// could clash with swiftui-dsl's state management, which is why modifiying functions
11 | /// and properties are not exposed.
12 | ///
13 | /// You can use `MapView.onMapViewProxyUpdate(_ onViewProxyChanged: @escaping (MapViewProxy) -> Void)` to
14 | /// recieve access to the MapViewProxy.
15 | ///
16 | /// For more information about the properties and functions, see
17 | /// https://maplibre.org/maplibre-native/ios/latest/documentation/maplibre/mlnmapview
18 | @MainActor
19 | public struct MapViewProxy: Hashable, Equatable {
20 | /// The current center coordinate of the MapView
21 | public var centerCoordinate: CLLocationCoordinate2D {
22 | mapView.centerCoordinate
23 | }
24 |
25 | /// The current zoom value of the MapView
26 | public var zoomLevel: Double {
27 | mapView.zoomLevel
28 | }
29 |
30 | /// The current compass direction of the MapView
31 | public var direction: CLLocationDirection {
32 | mapView.direction
33 | }
34 |
35 | public var visibleCoordinateBounds: MLNCoordinateBounds {
36 | mapView.visibleCoordinateBounds
37 | }
38 |
39 | public var mapViewSize: CGSize {
40 | mapView.frame.size
41 | }
42 |
43 | public var contentInset: UIEdgeInsets {
44 | mapView.contentInset
45 | }
46 |
47 | /// The reason the view port was changed.
48 | public let lastReasonForChange: CameraChangeReason?
49 |
50 | private let mapView: MLNMapView
51 |
52 | public func convert(_ coordinate: CLLocationCoordinate2D, toPointTo: UIView?) -> CGPoint {
53 | mapView.convert(coordinate, toPointTo: toPointTo)
54 | }
55 |
56 | public init(mapView: MLNMapView,
57 | lastReasonForChange: CameraChangeReason?)
58 | {
59 | self.mapView = mapView
60 | self.lastReasonForChange = lastReasonForChange
61 | }
62 | }
63 |
64 | public extension MapViewProxy {
65 | /// Generate a basic MapViewCamera that represents the MapView
66 | ///
67 | /// - Returns: The calculated MapViewCamera
68 | func asMapViewCamera() -> MapViewCamera {
69 | .center(centerCoordinate,
70 | zoom: zoomLevel,
71 | direction: direction)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/MapLibreSwiftUI/StaticLocationManager.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibre
3 |
4 | /// A simple class that provides static location updates to a MapLibre view.
5 | ///
6 | /// This is not actually driven by a location manager (such as CLLocationManager) internally, but rather by updates
7 | /// provided one at a time. Beyond the obvious use case in testing and SwiftUI previews, this is also useful if you are
8 | /// doing some processing of raw location data (ex: determining whether to snap locations to a road) and selectively
9 | /// passing the updates on to the map view.
10 | ///
11 | /// You can provide a new location by setting the ``lastLocation`` property.
12 | ///
13 | /// While this class is required to implement authorization status per the underlying protocol,
14 | /// it does not ever actually check whether you have access to Core Location services.
15 | public final class StaticLocationManager: NSObject, @unchecked Sendable {
16 | public var delegate: (any MLNLocationManagerDelegate)?
17 |
18 | public var authorizationStatus: CLAuthorizationStatus = .authorizedAlways {
19 | didSet {
20 | delegate?.locationManagerDidChangeAuthorization(self)
21 | }
22 | }
23 |
24 | // TODO: Investigate what this does and document it
25 | public var headingOrientation: CLDeviceOrientation = .portrait
26 |
27 | public var lastLocation: CLLocation {
28 | didSet {
29 | delegate?.locationManager(self, didUpdate: [lastLocation])
30 | }
31 | }
32 |
33 | public init(initialLocation: CLLocation) {
34 | lastLocation = initialLocation
35 | }
36 | }
37 |
38 | extension StaticLocationManager: MLNLocationManager {
39 | public func requestAlwaysAuthorization() {
40 | // Do nothing
41 | }
42 |
43 | public func requestWhenInUseAuthorization() {
44 | // Do nothing
45 | }
46 |
47 | public func startUpdatingLocation() {
48 | // This has to be async dispatched or else the map view will not update immediately if the camera is set to
49 | // follow the user's location. This leads to some REALLY (unbearably) bad artifacts. We should find a better
50 | // solution for this at some point. This is the reason for the @unchecked Sendable conformance by the way (so
51 | // that we don't get a warning about using non-sendable self; it should be safe though).
52 | DispatchQueue.main.async {
53 | self.delegate?.locationManager(self, didUpdate: [self.lastLocation])
54 | }
55 | }
56 |
57 | public func stopUpdatingLocation() {
58 | // Do nothing
59 | }
60 |
61 | public func startUpdatingHeading() {
62 | // Do nothing
63 | }
64 |
65 | public func stopUpdatingHeading() {
66 | // Do nothing
67 | }
68 |
69 | public func dismissHeadingCalibrationDisplay() {
70 | // Do nothing
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftDSLTests/PointFeatureTests.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 | import XCTest
4 | @testable import MapLibreSwiftDSL
5 |
6 | final class PointFeatureTests: XCTestCase {
7 | private let primeMeridian = CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)
8 |
9 | func testPointFeatureBuilderCoordsOnly() throws {
10 | let feature = MLNPointFeature(coordinate: primeMeridian)
11 |
12 | XCTAssertEqual(feature.coordinate.latitude, primeMeridian.latitude, accuracy: 0.000001)
13 | XCTAssertEqual(feature.coordinate.longitude, primeMeridian.longitude, accuracy: 0.000001)
14 | XCTAssertTrue(feature.attributes.isEmpty)
15 | }
16 |
17 | func testPointFeatureBuilderSetAttributes() throws {
18 | let feature = MLNPointFeature(coordinate: primeMeridian) { feature in
19 | feature.attributes["icon"] = "missing"
20 | }
21 |
22 | XCTAssertEqual(feature.coordinate.latitude, primeMeridian.latitude, accuracy: 0.000001)
23 | XCTAssertEqual(feature.coordinate.longitude, primeMeridian.longitude, accuracy: 0.000001)
24 | XCTAssertEqual(feature.attributes as! [String: AnyHashable], ["icon": "missing"])
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftDSLTests/ShapeSourceTests.swift:
--------------------------------------------------------------------------------
1 | import InternalUtils
2 | import MapLibre
3 | import XCTest
4 | @testable import MapLibreSwiftDSL
5 |
6 | final class ShapeSourceTests: XCTestCase {
7 | func testShapeSourcePolylineShapeBuilder() throws {
8 | // Ideally in a style context, these could be tested at compile time to
9 | // ensure there are no duplicate IDs.
10 | let shapeSource = ShapeSource(identifier: "foo") {
11 | MLNPolyline(coordinates: samplePedestrianWaypoints)
12 | }
13 |
14 | XCTAssertEqual(shapeSource.identifier, "foo")
15 |
16 | switch shapeSource.data {
17 | case let .shapes(shapes):
18 | XCTAssertEqual(shapes.count, 1)
19 | default:
20 | XCTFail("Expected a shape source")
21 | }
22 | }
23 |
24 | func testShapeSourcePolylineFeatureBuilder() throws {
25 | let shapeSource = ShapeSource(identifier: "foo") {
26 | MLNPolylineFeature(coordinates: samplePedestrianWaypoints)
27 | }
28 |
29 | XCTAssertEqual(shapeSource.identifier, "foo")
30 |
31 | switch shapeSource.data {
32 | case let .features(features):
33 | XCTAssertEqual(features.count, 1)
34 | default:
35 | XCTFail("Expected a feature source")
36 | }
37 | }
38 |
39 | func testForInAndCombinationFeatureBuilder() throws {
40 | // ShapeSource now accepts 'for in' building, arrays, and combinations of them
41 | let shapeSource = ShapeSource(identifier: "foo") {
42 | for coordinates in samplePedestrianWaypoints {
43 | MLNPointFeature(coordinate: coordinates)
44 | }
45 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 48.2082, longitude: 16.3719))
46 | }
47 |
48 | XCTAssertEqual(shapeSource.identifier, "foo")
49 |
50 | switch shapeSource.data {
51 | case let .features(features):
52 | XCTAssertEqual(features.count, 48)
53 | default:
54 | XCTFail("Expected a feature source")
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftDSLTests/StyleLayerTest.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import MapLibreSwiftDSL
3 | import XCTest
4 |
5 | final class StyleLayerTest: XCTestCase {
6 | func testBackgroundStyleLayer() throws {
7 | let styleLayer = BackgroundLayer(identifier: "background")
8 | .backgroundColor(.cyan)
9 | .backgroundOpacity(0.4)
10 |
11 | let mglStyleLayer = styleLayer.makeMLNStyleLayer() as! MLNBackgroundStyleLayer
12 |
13 | XCTAssertEqual(mglStyleLayer.backgroundColor, NSExpression(forConstantValue: UIColor.cyan))
14 | XCTAssertEqual(mglStyleLayer.backgroundOpacity.constantValue as! Double, 0.4, accuracy: 0.000001)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftMacrosTests/MapLibreSwiftMacrosTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import MacroTesting
3 | import SwiftSyntaxMacros
4 | import SwiftSyntaxMacrosTestSupport
5 | import XCTest
6 |
7 | #if canImport(MapLibreSwiftMacrosImpl)
8 | import MapLibreSwiftMacrosImpl
9 | #endif
10 |
11 | final class ExpressionTests: XCTestCase {
12 | override func invokeTest() {
13 | #if canImport(MapLibreSwiftMacrosImpl)
14 | withMacroTesting(macros: [
15 | MLNStylePropertyMacro.self,
16 | MLNRawRepresentableStylePropertyMacro.self,
17 | ]) {
18 | super.invokeTest()
19 | }
20 | #endif
21 | }
22 |
23 | // TODO: Non-enum attachment
24 |
25 | func testMLNStylePropertyValid() throws {
26 | #if canImport(MapLibreSwiftMacrosImpl)
27 | assertMacro {
28 | """
29 | @MLNStyleProperty("backgroundColor")
30 | struct Layer {
31 | }
32 | """
33 | } expansion: {
34 | """
35 | struct Layer {
36 |
37 | fileprivate var backgroundColor: NSExpression? = nil
38 |
39 | public func backgroundColor(_ value: UIColor) -> Self {
40 | var copy = self
41 | copy.backgroundColor = NSExpression(forConstantValue: value)
42 | return copy
43 | }
44 |
45 | public func backgroundColor(expression: NSExpression) -> Self {
46 | var copy = self
47 | copy.backgroundColor = expression
48 | return copy
49 | }
50 |
51 | public func backgroundColor(featurePropertyNamed keyPath: String) -> Self {
52 | var copy = self
53 | copy.backgroundColor = NSExpression(forKeyPath: keyPath)
54 | return copy
55 | }
56 | }
57 | """
58 | }
59 |
60 | assertMacro {
61 | """
62 | @MLNStyleProperty("backgroundColor", supportsInterpolation: false)
63 | struct Layer {
64 | }
65 | """
66 | } expansion: {
67 | """
68 | struct Layer {
69 |
70 | fileprivate var backgroundColor: NSExpression? = nil
71 |
72 | public func backgroundColor(_ value: UIColor) -> Self {
73 | var copy = self
74 | copy.backgroundColor = NSExpression(forConstantValue: value)
75 | return copy
76 | }
77 |
78 | public func backgroundColor(expression: NSExpression) -> Self {
79 | var copy = self
80 | copy.backgroundColor = expression
81 | return copy
82 | }
83 |
84 | public func backgroundColor(featurePropertyNamed keyPath: String) -> Self {
85 | var copy = self
86 | copy.backgroundColor = NSExpression(forKeyPath: keyPath)
87 | return copy
88 | }
89 | }
90 | """
91 | }
92 | #else
93 | throw XCTSkip("macros are only supported when running tests for the host platform")
94 | #endif
95 | }
96 |
97 | func testMLNStylePropertyValidWithSupportedExpressions() throws {
98 | #if canImport(MapLibreSwiftMacrosImpl)
99 | assertMacro {
100 | """
101 | @MLNStyleProperty("backgroundColor", supportsInterpolation: true)
102 | struct Layer {
103 | }
104 | """
105 | } expansion: {
106 | """
107 | struct Layer {
108 |
109 | fileprivate var backgroundColor: NSExpression? = nil
110 |
111 | public func backgroundColor(_ value: UIColor) -> Self {
112 | var copy = self
113 | copy.backgroundColor = NSExpression(forConstantValue: value)
114 | return copy
115 | }
116 |
117 | public func backgroundColor(expression: NSExpression) -> Self {
118 | var copy = self
119 | copy.backgroundColor = expression
120 | return copy
121 | }
122 |
123 | public func backgroundColor(featurePropertyNamed keyPath: String) -> Self {
124 | var copy = self
125 | copy.backgroundColor = NSExpression(forKeyPath: keyPath)
126 | return copy
127 | }
128 |
129 | public func backgroundColor(interpolatedBy expression: MLNVariableExpression, curveType: MLNExpressionInterpolationMode, parameters: NSExpression?, stops: NSExpression) -> Self {
130 | var copy = self
131 | copy.backgroundColor = interpolatingExpression(expression: expression, curveType: curveType, parameters: parameters, stops: stops)
132 | return copy
133 | }
134 | }
135 | """
136 | }
137 | #else
138 | throw XCTSkip("macros are only supported when running tests for the host platform")
139 | #endif
140 | }
141 |
142 | func testStyleRawExpressionValid() throws {
143 | #if canImport(MapLibreSwiftMacrosImpl)
144 | assertMacro {
145 | """
146 | @MLNRawRepresentableStyleProperty("backgroundColor")
147 | struct Layer {
148 | }
149 | """
150 | } expansion: {
151 | """
152 | struct Layer {
153 |
154 | fileprivate var backgroundColor: NSExpression? = nil
155 |
156 | public func backgroundColor(_ value: UIColor) -> Self {
157 | var copy = self
158 | copy.backgroundColor = NSExpression(forConstantValue: value.mlnRawValue.rawValue)
159 | return copy
160 | }
161 |
162 | public func backgroundColor(expression: NSExpression) -> Self {
163 | var copy = self
164 | copy.backgroundColor = expression
165 | return copy
166 | }
167 |
168 | public func backgroundColor(featurePropertyNamed keyPath: String) -> Self {
169 | var copy = self
170 | copy.backgroundColor = NSExpression(forKeyPath: keyPath)
171 | return copy
172 | }
173 | }
174 | """
175 | }
176 | #else
177 | throw XCTSkip("macros are only supported when running tests for the host platform")
178 | #endif
179 | }
180 |
181 | func testOptionalArray() throws {
182 | // While this property is not actually implemented this way in the DSL,
183 | // this test verifies that optional array props work correctly.
184 | #if canImport(MapLibreSwiftMacrosImpl)
185 | assertMacro {
186 | """
187 | @MLNStyleProperty<[String]?>("textFontNames", supportsInterpolation: false)
188 | struct Layer {
189 | }
190 | """
191 | } expansion: {
192 | """
193 | struct Layer {
194 |
195 | fileprivate var textFontNames: NSExpression? = nil
196 |
197 | public func textFontNames(_ value: [String]?) -> Self {
198 | var copy = self
199 | copy.textFontNames = NSExpression(forConstantValue: value)
200 | return copy
201 | }
202 |
203 | public func textFontNames(expression: NSExpression) -> Self {
204 | var copy = self
205 | copy.textFontNames = expression
206 | return copy
207 | }
208 |
209 | public func textFontNames(featurePropertyNamed keyPath: String) -> Self {
210 | var copy = self
211 | copy.textFontNames = NSExpression(forKeyPath: keyPath)
212 | return copy
213 | }
214 | }
215 | """
216 | }
217 | #else
218 | throw XCTSkip("macros are only supported when running tests for the host platform")
219 | #endif
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift:
--------------------------------------------------------------------------------
1 | import SnapshotTesting
2 | import XCTest
3 | @testable import MapLibreSwiftUI
4 |
5 | final class CameraPreviewTests: XCTestCase {
6 | func testCameraPreview() {
7 | assertView {
8 | CameraDirectManipulationPreview(
9 | styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!
10 | )
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import MapLibreSwiftDSL
3 | import XCTest
4 | @testable import MapLibreSwiftUI
5 |
6 | final class LayerPreviewTests: XCTestCase {
7 | let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")!
8 |
9 | // A collection of points with various
10 | // attributes
11 | let pointSource = ShapeSource(identifier: "points") {
12 | // Uses the DSL to quickly construct point features inline
13 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139))
14 |
15 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in
16 | feature.attributes["icon"] = "missing"
17 | feature.attributes["heading"] = 45
18 | }
19 |
20 | MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in
21 | feature.attributes["icon"] = "club"
22 | feature.attributes["heading"] = 145
23 | }
24 | }
25 |
26 | @MainActor
27 | func testRoseTint() {
28 | assertView {
29 | MapView(styleURL: demoTilesURL) {
30 | // Silly example: a background layer on top of everything to create a tint effect
31 | BackgroundLayer(identifier: "rose-colored-glasses")
32 | .backgroundColor(.systemPink.withAlphaComponent(0.3))
33 | .renderAbove(.all)
34 | }
35 | }
36 | }
37 |
38 | @MainActor
39 | func testSimpleSymbol() {
40 | assertView {
41 | MapView(styleURL: demoTilesURL) {
42 | // Simple symbol layer demonstration with an icon
43 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
44 | .iconImage(UIImage(systemName: "mappin")!)
45 | }
46 | }
47 | }
48 |
49 | @MainActor
50 | func testRotatedSymbolConst() {
51 | assertView {
52 | MapView(styleURL: demoTilesURL) {
53 | // Simple symbol layer demonstration with an icon
54 | SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource)
55 | .iconImage(UIImage(systemName: "location.north.circle.fill")!)
56 | .iconRotation(45)
57 | }
58 | }
59 | }
60 |
61 | @MainActor
62 | func testRotatedSymboleDynamic() {
63 | assertView {
64 | MapView(styleURL: demoTilesURL) {
65 | // Simple symbol layer demonstration with an icon
66 | SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource)
67 | .iconImage(UIImage(systemName: "location.north.circle.fill")!)
68 | .iconRotation(featurePropertyNamed: "heading")
69 | }
70 | }
71 | }
72 |
73 | @MainActor
74 | func testCirclesWithSymbols() {
75 | assertView {
76 | MapView(styleURL: demoTilesURL) {
77 | // Simple symbol layer demonstration with an icon
78 | CircleStyleLayer(identifier: "simple-circles", source: pointSource)
79 | .radius(16)
80 | .color(.systemRed)
81 | .strokeWidth(2)
82 | .strokeColor(.white)
83 |
84 | SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
85 | .iconImage(UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate))
86 | .iconColor(.white)
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibreSwiftDSL
3 | import SnapshotTesting
4 | import XCTest
5 | @testable import MapLibreSwiftUI
6 |
7 | final class MapControlsTests: XCTestCase {
8 | // NOTE: The map views in this test intentionally have a non-north orientation
9 | // so that the compass will be rendered if present.
10 |
11 | @MainActor
12 | func testEmptyControls() {
13 | assertView {
14 | MapView(
15 | styleURL: demoTilesURL,
16 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
17 | )
18 | .mapControls {
19 | // No controls
20 | }
21 | }
22 | }
23 |
24 | @MainActor
25 | func testLogoOnly() {
26 | assertView {
27 | MapView(
28 | styleURL: demoTilesURL,
29 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
30 | )
31 | .mapControls {
32 | LogoView()
33 | }
34 | }
35 | }
36 |
37 | @MainActor
38 | func testLogoChangePosition() {
39 | assertView {
40 | MapView(
41 | styleURL: demoTilesURL,
42 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
43 | )
44 | .mapControls {
45 | LogoView()
46 | .position(.topLeft)
47 | }
48 | }
49 | }
50 |
51 | @MainActor
52 | func testCompassOnly() {
53 | assertView {
54 | MapView(
55 | styleURL: demoTilesURL,
56 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
57 | )
58 | .mapControls {
59 | CompassView()
60 | }
61 | }
62 | }
63 |
64 | @MainActor
65 | func testCompassChangePosition() {
66 | assertView {
67 | MapView(
68 | styleURL: demoTilesURL,
69 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
70 | )
71 | .mapControls {
72 | CompassView()
73 | .position(.topLeft)
74 | }
75 | }
76 | }
77 |
78 | @MainActor
79 | func testAttributionOnly() {
80 | assertView {
81 | MapView(
82 | styleURL: demoTilesURL,
83 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
84 | )
85 | .mapControls {
86 | AttributionButton()
87 | }
88 | }
89 | }
90 |
91 | @MainActor
92 | func testAttributionChangePosition() {
93 | assertView {
94 | MapView(
95 | styleURL: demoTilesURL,
96 | camera: .constant(.center(CLLocationCoordinate2D(), zoom: 4, direction: 45))
97 | )
98 | .mapControls {
99 | AttributionButton()
100 | .position(.topLeft)
101 | }
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testAttributionChangePosition.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testAttributionChangePosition.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testAttributionOnly.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testAttributionOnly.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import XCTest
3 | @testable import MapLibreSwiftUI
4 |
5 | final class CLLocationCoordinate2DTests: XCTestCase {
6 | func testHashable() {
7 | let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)
8 |
9 | var hasher = Hasher()
10 | coordinate.hash(into: &hasher)
11 | let hashedValue = hasher.finalize()
12 |
13 | XCTAssertEqual(hashedValue, coordinate.hashValue)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import Mockable
3 | import XCTest
4 | @testable import MapLibreSwiftUI
5 |
6 | final class MapViewGestureTests: XCTestCase {
7 | let maplibreMapView = MLNMapView()
8 |
9 | @MainActor
10 | let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!)
11 |
12 | // MARK: Gesture View Modifiers
13 |
14 | func testMapViewOnTapGestureModifier() {
15 | let newMapView = mapView.onTapMapGesture { _ in
16 | // Do nothing
17 | }
18 |
19 | XCTAssertEqual(newMapView.gestures.first?.method, .tap())
20 | }
21 |
22 | func testMapViewOnLongPressGestureModifier() {
23 | let newMapView = mapView.onLongPressMapGesture { _ in
24 | // Do nothing
25 | }
26 |
27 | XCTAssertEqual(newMapView.gestures.first?.method, .longPress())
28 | }
29 |
30 | // MARK: Gesture Processing
31 |
32 | @MainActor func testTapGesture() {
33 | let gesture = MapGesture(method: .tap(numberOfTaps: 2), onChange: .context { _ in
34 | // Do nothing
35 | })
36 |
37 | let mockTapGesture = MockUIGestureRecognizing()
38 |
39 | given(mockTapGesture)
40 | .state.willReturn(.ended)
41 |
42 | given(mockTapGesture)
43 | .location(ofTouch: .value(1), in: .any)
44 | .willReturn(CGPoint(x: 10, y: 10))
45 |
46 | let result = mapView.processContextFromGesture(maplibreMapView,
47 | gesture: gesture,
48 | sender: mockTapGesture)
49 |
50 | XCTAssertEqual(result.gestureMethod, .tap(numberOfTaps: 2))
51 | XCTAssertEqual(result.point, CGPoint(x: 10, y: 10))
52 | // This is what the un-rendered map view returns. We're simply testing it returns something.
53 | XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1)
54 | XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1)
55 | }
56 |
57 | @MainActor func testLongPressGesture() {
58 | let gesture = MapGesture(method: .longPress(minimumDuration: 1), onChange: .context { _ in
59 | // Do nothing
60 | })
61 |
62 | let mockTapGesture = MockUIGestureRecognizing()
63 |
64 | given(mockTapGesture)
65 | .state.willReturn(.ended)
66 |
67 | given(mockTapGesture)
68 | .location(in: .any)
69 | .willReturn(CGPoint(x: 10, y: 10))
70 |
71 | let result = mapView.processContextFromGesture(maplibreMapView,
72 | gesture: gesture,
73 | sender: mockTapGesture)
74 |
75 | XCTAssertEqual(result.gestureMethod, .longPress(minimumDuration: 1))
76 | XCTAssertEqual(result.point, CGPoint(x: 10, y: 10))
77 | // This is what the un-rendered map view returns. We're simply testing it returns something.
78 | XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1)
79 | XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import Mockable
3 | import XCTest
4 |
5 | @testable import MapLibreSwiftUI
6 |
7 | final class MapViewCoordinatorCameraTests: XCTestCase {
8 | var maplibreMapView: MockMLNMapViewCameraUpdating!
9 | var mapView: MapView!
10 | var coordinator: MapView.Coordinator!
11 |
12 | @MainActor
13 | override func setUp() async throws {
14 | maplibreMapView = MockMLNMapViewCameraUpdating()
15 | given(maplibreMapView).frame.willReturn(.zero)
16 | mapView = MapView(styleURL: URL(string: "https://maplibre.org")!)
17 | coordinator = MapView.Coordinator(
18 | parent: mapView,
19 | onGesture: { _, _ in
20 | // No action
21 | },
22 | onViewProxyChanged: { _ in
23 | // No action
24 | }, proxyUpdateMode: .onFinish
25 | )
26 | }
27 |
28 | @MainActor func testUnchangedCamera() async throws {
29 | let coordinate = CLLocationCoordinate2D(latitude: 45.0, longitude: -127.0)
30 | let camera: MapViewCamera = .center(coordinate, zoom: 10)
31 |
32 | given(maplibreMapView)
33 | .setCenter(
34 | .any,
35 | zoomLevel: .any,
36 | direction: .any,
37 | animated: .any
38 | )
39 | .willReturn()
40 |
41 | try await simulateCameraUpdateAndWait {
42 | self.coordinator.applyCameraChangeFromStateUpdate(
43 | self.maplibreMapView, camera: camera, animated: false
44 | )
45 | }
46 |
47 | coordinator.applyCameraChangeFromStateUpdate(
48 | maplibreMapView, camera: camera, animated: false
49 | )
50 |
51 | // All of the actions only allow 1 count of set even though we've run the action twice.
52 | // This verifies the comment above.
53 | verify(maplibreMapView)
54 | .userTrackingMode(newValue: .value(.none))
55 | .setCalled(1)
56 |
57 | verify(maplibreMapView)
58 | .setCenter(
59 | .value(coordinate),
60 | zoomLevel: .value(10),
61 | direction: .value(0),
62 | animated: .value(false)
63 | )
64 | .called(1)
65 |
66 | // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the
67 | // pitch, and then once to set the actual range.
68 | verify(maplibreMapView)
69 | .minimumPitch(newValue: .value(0))
70 | .setCalled(2)
71 |
72 | verify(maplibreMapView)
73 | .maximumPitch(newValue: .value(0))
74 | .setCalled(1)
75 |
76 | verify(maplibreMapView)
77 | .maximumPitch(newValue: .value(60))
78 | .setCalled(1)
79 |
80 | verify(maplibreMapView)
81 | .setZoomLevel(.any, animated: .any)
82 | .called(0)
83 | }
84 |
85 | @MainActor func testCenterCameraUpdate() async throws {
86 | let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)
87 | let newCamera: MapViewCamera = .center(coordinate, zoom: 13)
88 |
89 | given(maplibreMapView)
90 | .setCenter(
91 | .any,
92 | zoomLevel: .any,
93 | direction: .any,
94 | animated: .any
95 | )
96 | .willReturn()
97 |
98 | try await simulateCameraUpdateAndWait {
99 | self.coordinator.applyCameraChangeFromStateUpdate(
100 | self.maplibreMapView, camera: newCamera, animated: false
101 | )
102 | }
103 |
104 | verify(maplibreMapView)
105 | .userTrackingMode(newValue: .value(.none))
106 | .setCalled(1)
107 |
108 | verify(maplibreMapView)
109 | .setCenter(
110 | .value(coordinate),
111 | zoomLevel: .value(13),
112 | direction: .value(0),
113 | animated: .value(false)
114 | )
115 | .called(1)
116 |
117 | // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the
118 | // pitch, and then once to set the actual range.
119 | verify(maplibreMapView)
120 | .minimumPitch(newValue: .value(0))
121 | .setCalled(2)
122 |
123 | verify(maplibreMapView)
124 | .maximumPitch(newValue: .value(0))
125 | .setCalled(1)
126 |
127 | verify(maplibreMapView)
128 | .maximumPitch(newValue: .value(60))
129 | .setCalled(1)
130 |
131 | verify(maplibreMapView)
132 | .setZoomLevel(.any, animated: .any)
133 | .called(0)
134 | }
135 |
136 | @MainActor func testUserTrackingCameraUpdate() async throws {
137 | let newCamera: MapViewCamera = .trackUserLocation()
138 |
139 | given(maplibreMapView)
140 | .setZoomLevel(.any, animated: .any)
141 | .willReturn()
142 |
143 | try await simulateCameraUpdateAndWait {
144 | self.coordinator.applyCameraChangeFromStateUpdate(
145 | self.maplibreMapView, camera: newCamera, animated: false
146 | )
147 | }
148 |
149 | verify(maplibreMapView)
150 | .userTrackingMode(newValue: .value(.follow))
151 | .setCalled(1)
152 |
153 | verify(maplibreMapView)
154 | .setCenter(
155 | .any,
156 | zoomLevel: .any,
157 | direction: .any,
158 | animated: .any
159 | )
160 | .called(0)
161 |
162 | // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the
163 | // pitch, and then once to set the actual range.
164 | verify(maplibreMapView)
165 | .minimumPitch(newValue: .value(0))
166 | .setCalled(2)
167 |
168 | verify(maplibreMapView)
169 | .maximumPitch(newValue: .value(0))
170 | .setCalled(1)
171 |
172 | verify(maplibreMapView)
173 | .maximumPitch(newValue: .value(60))
174 | .setCalled(1)
175 |
176 | verify(maplibreMapView)
177 | .zoomLevel(newValue: .value(10))
178 | .setCalled(1)
179 | }
180 |
181 | @MainActor func testUserTrackingWithCourseCameraUpdate() async throws {
182 | let newCamera: MapViewCamera = .trackUserLocationWithCourse()
183 |
184 | given(maplibreMapView)
185 | .setZoomLevel(.any, animated: .any)
186 | .willReturn()
187 |
188 | try await simulateCameraUpdateAndWait {
189 | self.coordinator.applyCameraChangeFromStateUpdate(
190 | self.maplibreMapView, camera: newCamera, animated: false
191 | )
192 | }
193 |
194 | verify(maplibreMapView)
195 | .userTrackingMode(newValue: .value(.followWithCourse))
196 | .setCalled(1)
197 |
198 | verify(maplibreMapView)
199 | .setCenter(
200 | .any,
201 | zoomLevel: .any,
202 | direction: .any,
203 | animated: .any
204 | )
205 | .called(0)
206 |
207 | // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the
208 | // pitch, and then once to set the actual range.
209 | verify(maplibreMapView)
210 | .minimumPitch(newValue: .value(0))
211 | .setCalled(2)
212 |
213 | verify(maplibreMapView)
214 | .maximumPitch(newValue: .value(0))
215 | .setCalled(1)
216 |
217 | verify(maplibreMapView)
218 | .maximumPitch(newValue: .value(60))
219 | .setCalled(1)
220 |
221 | verify(maplibreMapView)
222 | .zoomLevel(newValue: .value(10))
223 | .setCalled(1)
224 | }
225 |
226 | @MainActor func testUserTrackingWithHeadingUpdate() async throws {
227 | let newCamera: MapViewCamera = .trackUserLocationWithHeading()
228 |
229 | given(maplibreMapView)
230 | .setZoomLevel(.any, animated: .any)
231 | .willReturn()
232 |
233 | try await simulateCameraUpdateAndWait {
234 | self.coordinator.applyCameraChangeFromStateUpdate(
235 | self.maplibreMapView, camera: newCamera, animated: false
236 | )
237 | }
238 |
239 | verify(maplibreMapView)
240 | .userTrackingMode(newValue: .value(.followWithHeading))
241 | .setCalled(1)
242 |
243 | verify(maplibreMapView)
244 | .setCenter(
245 | .any,
246 | zoomLevel: .any,
247 | direction: .any,
248 | animated: .any
249 | )
250 | .called(0)
251 |
252 | // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the
253 | // pitch, and then once to set the actual range.
254 | verify(maplibreMapView)
255 | .minimumPitch(newValue: .value(0))
256 | .setCalled(2)
257 |
258 | verify(maplibreMapView)
259 | .maximumPitch(newValue: .value(0))
260 | .setCalled(1)
261 |
262 | verify(maplibreMapView)
263 | .maximumPitch(newValue: .value(60))
264 | .setCalled(1)
265 |
266 | verify(maplibreMapView)
267 | .zoomLevel(newValue: .value(10))
268 | .setCalled(1)
269 | }
270 |
271 | // TODO: Test Rect & Showcase once we build it!
272 |
273 | @MainActor
274 | private func simulateCameraUpdateAndWait(action: @escaping () -> Void) async throws {
275 | let expectation = XCTestExpectation(description: "Camera update completed")
276 |
277 | Task {
278 | // Execute the provided camera action
279 | action()
280 |
281 | // Simulate the map becoming idle after a short delay
282 | try await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC)
283 | coordinator.cameraUpdateContinuation?.resume(returning: ())
284 |
285 | // Wait for the update task to complete
286 | _ = await coordinator.cameraUpdateTask?.value
287 |
288 | expectation.fulfill()
289 | }
290 |
291 | await fulfillment(of: [expectation], timeout: 1.0)
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import MapLibreSwiftUI
3 |
4 | final class MapGestureTests: XCTestCase {
5 | func testTapGestureDefaults() {
6 | let gesture = MapGesture(method: .tap(),
7 | onChange: .context { _ in
8 |
9 | })
10 |
11 | XCTAssertEqual(gesture.method, .tap())
12 | XCTAssertNil(gesture.gestureRecognizer)
13 | }
14 |
15 | func testTapGesture() {
16 | let gesture = MapGesture(method: .tap(numberOfTaps: 3),
17 | onChange: .context { _ in
18 |
19 | })
20 |
21 | XCTAssertEqual(gesture.method, .tap(numberOfTaps: 3))
22 | XCTAssertNil(gesture.gestureRecognizer)
23 | }
24 |
25 | func testLongPressGestureDefaults() {
26 | let gesture = MapGesture(method: .longPress(),
27 | onChange: .context { _ in
28 |
29 | })
30 |
31 | XCTAssertEqual(gesture.method, .longPress())
32 | XCTAssertNil(gesture.gestureRecognizer)
33 | }
34 |
35 | func testLongPressGesture() {
36 | let gesture = MapGesture(method: .longPress(minimumDuration: 3),
37 | onChange: .context { _ in
38 |
39 | })
40 |
41 | XCTAssertEqual(gesture.method, .longPress(minimumDuration: 3))
42 | XCTAssertNil(gesture.gestureRecognizer)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift:
--------------------------------------------------------------------------------
1 | import MapLibre
2 | import XCTest
3 | @testable import MapLibreSwiftUI
4 |
5 | @MainActor
6 | final class CameraChangeReasonTests: XCTestCase {
7 | func testProgrammatic() {
8 | let mlnReason: MLNCameraChangeReason = [.programmatic]
9 | XCTAssertEqual(CameraChangeReason(mlnReason), .programmatic)
10 | }
11 |
12 | func testTransitionCancelled() {
13 | let mlnReason: MLNCameraChangeReason = [.transitionCancelled]
14 | XCTAssertEqual(CameraChangeReason(mlnReason), .transitionCancelled)
15 | }
16 |
17 | func testResetNorth() {
18 | let mlnReason: MLNCameraChangeReason = [.programmatic, .resetNorth]
19 | XCTAssertEqual(CameraChangeReason(mlnReason), .resetNorth)
20 | }
21 |
22 | func testGesturePan() {
23 | let mlnReason: MLNCameraChangeReason = [.gesturePan]
24 | XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePan)
25 | }
26 |
27 | func testGesturePinch() {
28 | let mlnReason: MLNCameraChangeReason = [.gesturePinch]
29 | XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePinch)
30 | }
31 |
32 | func testGestureRotate() {
33 | let mlnReason: MLNCameraChangeReason = [.gestureRotate]
34 | XCTAssertEqual(CameraChangeReason(mlnReason), .gestureRotate)
35 | }
36 |
37 | func testGestureTilt() {
38 | let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureTilt]
39 | XCTAssertEqual(CameraChangeReason(mlnReason), .gestureTilt)
40 | }
41 |
42 | func testGestureZoomIn() {
43 | let mlnReason: MLNCameraChangeReason = [.gestureZoomIn, .programmatic]
44 | XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomIn)
45 | }
46 |
47 | func testGestureZoomOut() {
48 | let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomOut]
49 | XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomOut)
50 | }
51 |
52 | func testGestureOneFingerZoom() {
53 | let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureOneFingerZoom]
54 | XCTAssertEqual(CameraChangeReason(mlnReason), .gestureOneFingerZoom)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import MapLibreSwiftUI
3 |
4 | final class CameraPitchTests: XCTestCase {
5 | func testFreePitch() {
6 | let pitch: CameraPitchRange = .free
7 | XCTAssertEqual(pitch.rangeValue.lowerBound, 0)
8 | XCTAssertEqual(pitch.rangeValue.upperBound, 60)
9 | }
10 |
11 | func testRangePitch() {
12 | let pitch = CameraPitchRange.freeWithinRange(minimum: 9, maximum: 29)
13 | XCTAssertEqual(pitch.rangeValue.lowerBound, 9)
14 | XCTAssertEqual(pitch.rangeValue.upperBound, 29)
15 | }
16 |
17 | func testFixedPitch() {
18 | let pitch = CameraPitchRange.fixed(41)
19 | XCTAssertEqual(pitch.rangeValue.lowerBound, 41)
20 | XCTAssertEqual(pitch.rangeValue.upperBound, 41)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import SnapshotTesting
3 | import XCTest
4 | @testable import MapLibreSwiftUI
5 |
6 | final class CameraStateTests: XCTestCase {
7 | let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)
8 |
9 | func testCenterCameraState() {
10 | let state: CameraState = .centered(
11 | onCoordinate: coordinate,
12 | zoom: 4,
13 | pitch: 0,
14 | pitchRange: .free,
15 | direction: 42
16 | )
17 | XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4, pitch: 0, pitchRange: .free, direction: 42))
18 | assertSnapshot(of: state, as: .description)
19 | }
20 |
21 | func testTrackingUserLocation() {
22 | let state: CameraState = .trackingUserLocation(zoom: 4, pitch: 0, pitchRange: .free, direction: 12)
23 | XCTAssertEqual(state, .trackingUserLocation(zoom: 4, pitch: 0, pitchRange: .free, direction: 12))
24 | assertSnapshot(of: state, as: .description)
25 | }
26 |
27 | func testTrackingUserLocationWithHeading() {
28 | let state: CameraState = .trackingUserLocationWithHeading(zoom: 4, pitch: 0, pitchRange: .free)
29 | XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4, pitch: 0, pitchRange: .free))
30 | assertSnapshot(of: state, as: .description)
31 | }
32 |
33 | func testTrackingUserLocationWithCourse() {
34 | let state: CameraState = .trackingUserLocationWithCourse(zoom: 4, pitch: 0, pitchRange: .free)
35 | XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4, pitch: 0, pitchRange: .free))
36 | assertSnapshot(of: state, as: .description)
37 | }
38 |
39 | func testRect() {
40 | let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)
41 | let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6)
42 |
43 | let state: CameraState = .rect(boundingBox: .init(sw: southwest, ne: northeast))
44 | XCTAssertEqual(state, .rect(boundingBox: .init(sw: southwest, ne: northeast)))
45 |
46 | assertSnapshot(of: state, as: .description)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift:
--------------------------------------------------------------------------------
1 | import CoreLocation
2 | import MapLibre
3 | import SnapshotTesting
4 | import XCTest
5 | @testable import MapLibreSwiftUI
6 |
7 | final class MapViewCameraTests: XCTestCase {
8 | func testCenterCamera() {
9 | let camera = MapViewCamera.center(
10 | CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4),
11 | zoom: 5,
12 | pitch: 12,
13 | direction: 23
14 | )
15 |
16 | assertSnapshot(of: camera, as: .dump)
17 | }
18 |
19 | func testTrackingUserLocation() {
20 | let pitch: CameraPitchRange = .freeWithinRange(minimum: 12, maximum: 34)
21 | let camera = MapViewCamera.trackUserLocation(zoom: 10, pitchRange: pitch)
22 |
23 | assertSnapshot(of: camera, as: .dump)
24 | }
25 |
26 | func testTrackUserLocationWithCourse() {
27 | let pitchRange: CameraPitchRange = .freeWithinRange(minimum: 12, maximum: 34)
28 | let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitchRange: pitchRange)
29 |
30 | assertSnapshot(of: camera, as: .dump)
31 | }
32 |
33 | func testTrackUserLocationWithHeading() {
34 | let camera = MapViewCamera.trackUserLocationWithHeading(zoom: 10, pitch: 0)
35 |
36 | assertSnapshot(of: camera, as: .dump)
37 | }
38 |
39 | func testBoundingBox() {
40 | let southwest = CLLocationCoordinate2D(latitude: 24.6056011, longitude: 46.67369842529297)
41 | let northeast = CLLocationCoordinate2D(latitude: 24.6993808, longitude: 46.7709285)
42 | let bounds = MLNCoordinateBounds(sw: southwest, ne: northeast)
43 | let camera = MapViewCamera.boundingBox(bounds)
44 |
45 | assertSnapshot(of: camera, as: .dump)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt:
--------------------------------------------------------------------------------
1 | CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 4.0, pitch: 0.0, pitchRange: free, direction: 42.0)
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt:
--------------------------------------------------------------------------------
1 | CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6), edgePadding: UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0))
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt:
--------------------------------------------------------------------------------
1 | CameraState.trackingUserLocation(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.free, 12.0))
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt:
--------------------------------------------------------------------------------
1 | CameraState.trackingUserLocationWithCourse(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.free))
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt:
--------------------------------------------------------------------------------
1 | CameraState.trackingUserLocationWithHeading(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.free))
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt:
--------------------------------------------------------------------------------
1 | ▿ MapViewCamera
2 | ▿ lastReasonForChange: Optional
3 | - some: CameraChangeReason.programmatic
4 | ▿ state: CameraState
5 | ▿ rect: (2 elements)
6 | ▿ boundingBox: MLNCoordinateBounds
7 | ▿ ne: CLLocationCoordinate2D
8 | - latitude: 24.6993808
9 | - longitude: 46.7709285
10 | ▿ sw: CLLocationCoordinate2D
11 | - latitude: 24.6056011
12 | - longitude: 46.67369842529297
13 | ▿ edgePadding: UIEdgeInsets
14 | - bottom: 20.0
15 | - left: 20.0
16 | - right: 20.0
17 | - top: 20.0
18 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt:
--------------------------------------------------------------------------------
1 | ▿ MapViewCamera
2 | - lastReasonForChange: Optional.none
3 | ▿ state: CameraState
4 | ▿ centered: (5 elements)
5 | ▿ onCoordinate: CLLocationCoordinate2D
6 | - latitude: 12.3
7 | - longitude: 23.4
8 | - zoom: 5.0
9 | - pitch: 12.0
10 | - pitchRange: CameraPitchRange.free
11 | - direction: 23.0
12 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt:
--------------------------------------------------------------------------------
1 | ▿ MapViewCamera
2 | ▿ lastReasonForChange: Optional
3 | - some: CameraChangeReason.programmatic
4 | ▿ state: CameraState
5 | ▿ trackingUserLocationWithCourse: (3 elements)
6 | - zoom: 18.0
7 | - pitch: 0.0
8 | ▿ pitchRange: CameraPitchRange
9 | ▿ freeWithinRange: (2 elements)
10 | - minimum: 12.0
11 | - maximum: 34.0
12 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt:
--------------------------------------------------------------------------------
1 | ▿ MapViewCamera
2 | ▿ lastReasonForChange: Optional
3 | - some: CameraChangeReason.programmatic
4 | ▿ state: CameraState
5 | ▿ trackingUserLocationWithHeading: (3 elements)
6 | - zoom: 10.0
7 | - pitch: 0.0
8 | - pitchRange: CameraPitchRange.free
9 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt:
--------------------------------------------------------------------------------
1 | ▿ MapViewCamera
2 | ▿ lastReasonForChange: Optional
3 | - some: CameraChangeReason.programmatic
4 | ▿ state: CameraState
5 | ▿ trackingUserLocation: (4 elements)
6 | - zoom: 10.0
7 | - pitch: 0.0
8 | ▿ pitchRange: CameraPitchRange
9 | ▿ freeWithinRange: (2 elements)
10 | - minimum: 12.0
11 | - maximum: 34.0
12 | - direction: 0.0
13 |
--------------------------------------------------------------------------------
/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift:
--------------------------------------------------------------------------------
1 | import MapLibreSwiftUI
2 | import SnapshotTesting
3 | import SwiftUI
4 | import XCTest
5 |
6 | extension XCTestCase {
7 | func assertView(
8 | named name: String? = nil,
9 | record: Bool = false,
10 | colorScheme: ColorScheme = .light,
11 | frame: CGSize = CGSize(width: 430, height: 932),
12 | expectation _: XCTestExpectation? = nil,
13 | @ViewBuilder content: () -> some View,
14 | file: StaticString = #file,
15 | testName: String = #function,
16 | line: UInt = #line
17 | ) {
18 | let view = content()
19 | .environment(\.colorScheme, colorScheme)
20 | .frame(width: frame.width, height: frame.height)
21 | .background(Color(red: 130 / 255, green: 203 / 255, blue: 114 / 255))
22 |
23 | assertSnapshot(of: view,
24 | as: .image(precision: 0.9, perceptualPrecision: 0.95),
25 | named: name,
26 | record: record,
27 | file: file,
28 | testName: testName,
29 | line: line)
30 | }
31 | }
32 |
33 | // TODO: Figure this out, seems like the exp is being blocked or the map views onStyleLoaded is never run within the test context.
34 | extension Snapshotting {
35 | static func wait(
36 | exp: XCTestExpectation,
37 | timeout: TimeInterval,
38 | on strategy: Self
39 | ) -> Self {
40 | Self(
41 | pathExtension: strategy.pathExtension,
42 | diffing: strategy.diffing,
43 | asyncSnapshot: { value in
44 | Async { callback in
45 | _ = XCTWaiter.wait(for: [exp], timeout: timeout)
46 | strategy.snapshot(value).run(callback)
47 | }
48 | }
49 | )
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maplibre/swiftui-dsl/5bc0284ee86bee29d922c8fac0ea1069dc11227a/demo.gif
--------------------------------------------------------------------------------