跪拜 Guibai
← Back to the summary

Adding Swift Package Manager Support to a Flutter Plugin Without Breaking CocoaPods

Welcome to follow the WeChat official account: FSA Full Stack Action 👋

1. Background

The chat_bottom_container plugin has been published for a while. The iOS side had only been configured with CocoaPods, using a scheme of "pre-compiled xcframework + prepare_command in podspec to download from GitHub Releases". Until recently, someone raised an issue requesting support for Swift Package Manager (hereinafter referred to as SPM): chat_bottom_container#39

After understanding it, I found that from Flutter 3.44 onwards, SPM is enabled by default. My plugin only had a podspec and no Package.swift, so a pure SPM project naturally couldn't find it. The default behavior before and after 3.44 is exactly reversed:

Flutter Version SPM Default Status Manual Switch
< 3.44 Off (opt-in) Must manually execute flutter config --enable-swift-package-manager to turn on
3.44+ Enabled by default Conversely, to turn it off, set enable-swift-package-manager: false (see end of article for details)

By the way, the official CocoaPods repository will become read-only and enter maintenance mode starting from 2026-12-02. SPM is the clear future, and plugins will have to add support sooner or later.

So, let's add it. But before starting, compatibility needs to be considered, so that developers using the new version can smoothly use SPM, and developers using CocoaPods are not affected. Below is the entire process from start to finish, along with a few points I stepped into along the way. The final changes are all in this PR: #40.

2. Implementation

1. Should podspec be deleted?

When adding SPM, should the podspec be replaced? The conclusion is no, it cannot be deleted. Keeping it is the officially recommended practice. There are several reasons:

So the idea is very clear: Adding SPM means "adding" a Package.swift, letting it and the podspec compile the same source code and depend on the same xcframework, rather than choosing one over the other. Unless completely abandoning CocoaPods, it shouldn't be deleted; otherwise, for an already published public plugin, that's a real breaking change.

2. Moving Source Code Location

SPM has strict requirements for directory structure. The source code for a target must be placed under Sources/<target name>/ by default. The original Swift files were in ios/Classes/, which doesn't match the location, so move them first:

ios/
├── chat_bottom_container/
│   ├── Package.swift                          👈 Newly added
│   └── Sources/
│       └── chat_bottom_container/
│           ├── ChatBottomContainerPlugin.swift              👈 Moved from Classes/
│           └── FSAChatBottomContainerGeneratedApis.g.swift  👈 pigeon generated file
├── Frameworks/
│   └── FSAChatBottomContainer.xcframework
└── chat_bottom_container.podspec

Once the source code is moved, the source_files in podspec still points to the old address. If not changed, the CocoaPods side won't find the files. Update it to the new location:

# Before
- s.source_files = 'Classes/**/*'
# After
+ s.source_files = 'chat_bottom_container/Sources/chat_bottom_container/**/*.swift'

3. pigeon's swiftOut

The FSAChatBottomContainerGeneratedApis.g.swift above is generated by pigeon, and its output path is hardcoded in pigeons/bottom_container.dart. After the file is moved, if pigeon is re-run someday, it will generate a file at the old path again, creating an orphan file. This kind of landmine won't explode now, but it will when you've forgotten about it, so fix it together:

// Before
- swiftOut: 'ios/Classes/FSAChatBottomContainerGeneratedApis.g.swift',
// After
+ swiftOut: 'ios/chat_bottom_container/Sources/chat_bottom_container/FSAChatBottomContainerGeneratedApis.g.swift',

4. Writing Package.swift

This step is the core. Package.swift looks like this:

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "chat_bottom_container",
    platforms: [
        .iOS("13.0"),
    ],
    products: [
        .library(name: "chat-bottom-container", targets: ["chat_bottom_container"]),
    ],
    dependencies: [
        // Provided by the Flutter tool at build time (Flutter 3.44+),
        // allowing the plugin target to access the `Flutter` module.
        .package(name: "FlutterFramework", path: "../FlutterFramework"),
    ],
    targets: [
        // Pre-compiled native implementation, hosted on GitHub Releases.
        // Consistent with the vendored_frameworks in podspec,
        // ensuring the SPM and CocoaPods build paths always use the same source.
        .binaryTarget(
            name: "FSAChatBottomContainer",
            url: "https://github.com/LinXunFeng/flutter_chat_packages_pub/releases/download/chat_bottom_container/ios_0.0.1.zip",
            checksum: "b9c380b72010e5d378cc813fbc5cca9b21471b82134e633567a6beee4b5a2a91"
        ),
        .target(
            name: "chat_bottom_container",
            dependencies: [
                "FSAChatBottomContainer",
                .product(name: "FlutterFramework", package: "FlutterFramework"),
            ]
        ),
    ]
)

There are three points here that need emphasis.

First, the pre-compiled artifact uses binaryTarget to reference a remote zip. The CocoaPods side has always relied on podspec's prepare_command to use curl to download the xcframework from GitHub Releases. There's no need to set up a separate system for SPM; just point binaryTarget to the same zip from the same Release. After unzipping this zip, the root directory is FSAChatBottomContainer.xcframework, which perfectly meets the requirements of binaryTarget. No binary needs to be stuffed into the repository.

Second, the checksum is not optional. SPM requires a checksum when referencing a remote binary, otherwise it will directly report an error. This value is calculated using this command:

swift package compute-checksum ios_0.0.1.zip

Third, where does the import Flutter module in the plugin code come from in the SPM scenario? It is obtained through the dependency .package(name: "FlutterFramework", path: "../FlutterFramework"). FlutterFramework is dynamically injected into the project by the Flutter tool at build time. There's a pitfall hidden here: This dependency only exists in Flutter 3.44+. The minimum version limit in the README later is essentially dictated by this.

5. Finishing Touches: Version Alignment and Ignoring Artifacts

The original minimum system version in podspec was 11.0. The SPM / Flutter setup requires a higher one, so both sides are uniformly raised to 13.0:

s.platform = :ios, '13.0'          # podspec
platforms: [ .iOS("13.0") ],       // Package.swift

Also, SPM generates a bunch of local caches and lock files upon building, which shouldn't be committed. Add three lines to ios/.gitignore to ignore them:

.build/
.swiftpm/
Package.resolved

3. Verification

The trickiest part of this change is: Flutter's fallback mechanism can make you think you're testing SPM, when it's actually silently using CocoaPods, leading you to believe the new feature works. So the verification standard isn't "can it compile", but confirming which path it actually took. The goal is to get the same example running in both modes separately, each taking its own path without crossover.

1. CocoaPods Mode: First, ensure no pitfalls for existing users

The highest risk here is for existing users. The source code was moved, and the source_files path was changed. A slight mistake could break the CocoaPods path. First, turn off SPM and run it:

cd packages/chat_bottom_container/example
flutter config --no-enable-swift-package-manager   # Ensure SPM is off
flutter clean
cd ios && rm -rf Pods Podfile.lock && pod install && cd ..
flutter build ios --simulator --debug

To judge that it indeed used CocoaPods, check these points:

2. SPM Mode: Then prove the new feature actually works

Turn SPM back on and do it again:

cd packages/chat_bottom_container/example
flutter config --enable-swift-package-manager      # Enable SPM
flutter clean
cd ios && rm -rf Pods Podfile.lock && cd ..
flutter build ios --simulator --debug

This time, check the following points to confirm it really used SPM and didn't silently fall back:

~/Library/Developer/Xcode/DerivedData/Runner-<random string>/SourcePackages/artifacts/chat_bottom_container/FSAChatBottomContainer/
# Inside is the extracted FSAChatBottomContainer.xcframework

This is very different from CocoaPods: CocoaPods uses prepare_command to download the xcframework into the plugin repository's ios/Frameworks/; SPM puts it into Xcode's DerivedData cache, not into your project directory.

The easiest judgment method: Just check if this plugin is in Podfile.lock. If it is, CocoaPods is managing it; if not, but the app still works normally, then SPM is managing it.

4. Conclusion

Version Compatibility Boundaries

The thing most likely to trip up users is version compatibility, which is also specifically written in the README. The reason lies in the FlutterFramework dependency mentioned earlier, which only exists in Flutter 3.44+. The compatibility situation is summarized in a table:

Integration Method Flutter Version Requirement Description
CocoaPods No special requirement Works out of the box, behavior is exactly the same as before
Swift Package Manager 3.44 and above SPM is enabled by default from 3.44 onwards
Swift Package Manager 3.24–3.43 Although SPM can be manually enabled, FlutterFramework is missing. Please continue using CocoaPods

So existing users don't need to worry: as long as they don't upgrade Flutter and continue using CocoaPods, this change has no impact on them.

Appendix: How to turn off SPM on 3.44

If you've upgraded to 3.44+ but temporarily want to stick with CocoaPods (for example, a dependency hasn't adapted to SPM yet), you can actively turn it off at two levels.

To turn it off for only a single project, add a config: section under flutter: in the app's pubspec.yaml. This will follow the repository and take effect for all collaborators:

flutter:
  config:
    enable-swift-package-manager: false

To turn it off globally for all projects of the current user, use the command line (which is the command run in the "Verification" section for CocoaPods mode):

flutter config --no-enable-swift-package-manager

Note that simply "turning it off" only stops using SPM. The SPM integration previously written into the Xcode project by flutter run remains. To completely clean it out, you must first disable it, then flutter clean, then go into Xcode and delete FlutterGeneratedPluginSwiftPackage from Package Dependencies and Frameworks, Libraries, and Embedded Content, and finally go to Product > Scheme > Edit Scheme → Build → Pre-actions and delete the Run Prepare Flutter Framework Script.

Both Paths Must Use the Same Source

The source code, binaries, and minimum version referenced by SPM and CocoaPods must be completely identical. If each side maintains its own set, a version upgrade will cause the problem of "people using CocoaPods and people using SPM getting two different implementations", which is very troublesome to debug. So source code all points to Sources/, binaries are all locked to the same Release, and the minimum version is all set to 13.0.

This also brings out a pitfall during release: in the future, when the xcframework is updated, the version/URL in podspec and the url + checksum in Package.swift must be changed together. Missing one will cause the two sides to be out of sync. I wrote a comment in Package.swift as a reminder for this.

Checklist for Supporting SPM

If you also have a plugin that needs to support SPM, follow these steps:

  1. Move source code to the Sources/<target>/ layout, and update source_files in podspec accordingly;
  2. Synchronously update the output paths of code generation tools like pigeon, don't leave orphan files;
  3. Add a new Package.swift, use binaryTarget for pre-compiled artifacts pointing to the same Release, and calculate checksum with swift package compute-checksum;
  4. Obtain the Flutter module via FlutterFramework, and set the minimum version to Flutter 3.44+ based on this;
  5. Align the minimum system version on both sides (I set it to 13.0 this time);
  6. Ignore .build/, .swiftpm/, Package.resolved in .gitignore;
  7. Keep the podspec, and clearly state the version compatibility boundaries in the README;
  8. Verify both CocoaPods / SPM modes separately, using Podfile.lock to judge which path was actually taken.

Related Links:

That's all for this record. I hope it helps those who are also adding SPM support.

If this article was helpful to you, please don't hesitate to click and follow my WeChat official account: FSA Full Stack Action, this will be the greatest encouragement for me. The official account has not only iOS technology, but also articles on Android, Flutter, Python, etc. There might be skill points you want to learn about!