├── .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 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](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 | MapLibre Logo 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 | ![A screen recording demonstrating the declarative SwiftUI DSL reacting to changes live](demo.gif) 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 --------------------------------------------------------------------------------