The title is a mouth-full, but so is creating cross-platform frameworks. In this post, I’d like to show you how to create a Swift framework for iOS, watchOS, and tvOS and get them distributed via Carthage and CocoaPods. It’s a technique I use to share frameworks across all my apps and with the community. Note this will only target iOS 8 above because of dynamic frameworks. Ready?
Creating the Project
First, let’s create an empty project. When I say empty, I literally mean empty. From Xcode, choose a template under “Other > Empty”:
From here, you can start creating your targets per platform. You can do this under “File > New > Target”. Choose the “Cocoa Touch Framework” template under “iOS > Framework & Library”. You can call it “MyModule iOS”. Do not check “Include Unit Tests”, we will do this later.
Now do the same for “watchOS > Framework & Library” and “tvOS > Framework & Library”.
Next, create an empty folder called “Sources” and add it to the project. This is where all your code will go. This convention is meant to be forward-compatible with the Swift Package Manager when Swift 3 comes out 😉
So far, your project should look something like this:
The Info.plist Files
Now that we have our foundation to our project, it’s time to fix it up so the platforms play nice together against the same code base. Let’s take care of the “Info.plist” files. Go into each platform folder created above and start appending the platform name after the “Info.plist” files. For example for iOS, rename the file to “Info-iOS.plist”.
Once you have done this for each platform, move them all into the “Sources” folder:
Now you can add the .plist files into the project by right-clicking on your “Sources” folder in Xcode and select “Add files”. Uncheck “Copy items if needed”, select “Create groups”, and make sure none of the Target Memberships are selected. Your Xcode project should look like this so far:
Now we need to update the “Build Settings” to point to the respective .plist file name and location for each platform target. So for the iOS target, go to “Build Settings > Packaging > Info.plist File”. From here, put in the relative path to the .plist file with the appended platform name you did earlier:
Finally for the .plist files, delete the entry under “Build Phases > Copy Bundle Sources”. This was just a side-effect of adding the files into the Xcode project, but we don’t need to copy the bundle since it is taken care of in the previous step when we updated the path in the build settings. Here is the entry you must delete for each platform target:
The Header Files
Unfortunately, we have to live with Objective-C for awhile, so let’s handle our header file so Objective-C projects can consume our Swift framework and be cool again. Go to the “.h” file Xcode created for you under the iOS folder and remove the platform name from the names in the source code:
Above, I removed “_iOS” from “ZamzamKitData_iOSVersionNumber” and “ZamzamKitData_iOSVersionString”. Save the file then rename it to remove ” iOS” from the file name. Next drag it into the “Sources” folder.
Go to Finder and you’ll notice it’s not really in the “Sources” folder, but still in the iOS target folder. So manually move it to the “Sources” folder from Finder. This will break your project, so go back to Xcode and update the location AND while you’re at it select all of the “Target Memberships” and select “Public”:
Now you can delete the platform folders from the project and “Move to Trash” when prompted. Our code will go in the “Sources” folder going forward, not these target folders. Remember, your framework targets are still available to us, we just don’t need the folders Xcode created for us. At this point, your project should look a lot cleaner:
Go ahead and add a Swift code file in the “Sources” folder to try it out. You’ll be able to toggle which “Target Memberships” this code file is for (iOS, watchOS, tvOS, or all of them).
The Build Settings
Let’s update our “Build Settings” to accommodate the cross-platform architecture we created. For each of the platform targets, go to “Build Settings > Packaging > Product Name” and remove the appended platform name, so it will be an identical name for all the platforms so they are packaged as one product:
For Carthage support, you’ll have to make your targets “Shared”. To do this “Manage Schemes” and check the “Shared” areas:
These next steps aren’t necessary, but I highly recommend them:
- Set “Require Only App-Extension-Safe API” to “Yes”. This will allow your framework to be used in extensions like the Today Widget, which have tighter restrictions. If you do something in your code that breaks this restriction, you’ll get a compile error right away so you can think of a different approach to your code. This is better than later finding out that you need to use your framework in an extension and have to re-architect some parts of your code.
- This is more of a business/management decision, but for my apps I usually support a minimum of iOS 8.4, watchOS 2.0, and tvOS 9.0. The reason is because iOS 8.4 has some goodies not available in previous version, such as support for Apple Watch and security updates. Plus this is just some of the perks of developing for the Apple ecosystem instead of Android 😉. Check out your app stats and don’t end up supporting older version just for one or two people. This setting should be configured under your “Project > Info > Deployment Target”. This will be inherited to the target frameworks. However, for the watchOS and tvOS targets, you’ll have to go “Build Settings > Deployment > watchOS/tvOS Deployment Target” and set it to 2.0/9.0. Don’t worry though, you’ll be coding against the latest SDK versions across the board using the “Base SDK” setting. You are just supporting older versions with the “Deployment Target” and will get warned by the compiler if something in your code is not supported in an older version you’re trying to support.
The Meta Data
Let’s create a “Metadata” folder and add some miscellaneous files such as a read me, license, podspec, etc. This is what I have:
When you add these files to your project, make sure to remove them from the “Build Phases > Compile Sources” and “Build Phases > Copy Bundle Resources” since they don’t need to be compiled.
Are you still with me? Trust me, the end game is worth it… just a little bit longer…
Save your project as a workspace by going to “File > Save As Workspace”. Call it the same as your project and save it in the root of your project folder. Now close the project and open this new workspace.
Also for convenience, add a Playground file so you can sketch some ideas out while dreaming up some code. Go to “File > New > Playground” and call it the same name as your workspace. Close the playground and add it to your workspace as a sibling, not a child, of your project.
Add a new target to your Xcode project. I like to add these templates for unit testing and sample demos:
- iOS > Test > iOS Unit Testing Bundle
- iOS > Application > Tabbed Application
- watchOS > Application > WatchKit App
- tvOS > Test > TV Unit Testing Bundle
The Big Picture
I commend you for reading this far! Here’s how your workspace should look like:
I created some empty folders in the “Sources” folder as a convention for my frameworks, but of course add your own flavor.
Finally, add your workspace to git or some source control and add any dependencies you’d like your framework to use. Check out this excellent blog post for details on how to do that.
See below how you can select which platform to target per file:
Also notice you can even have more granular control within the code using Swift Conditional Compilation *if needed*. I advise against it since segmenting your file into different platforms is not very elegant and can be messy. Instead, use protocol extensions to segment code 💡
It was a long journey, but now you’re ready to rock some code and support multiple platforms with a single code base. When adding new code files, just select the “Target Memberships” you’d like to support for that particular code file. And don’t forget to unit test… 😉