Create an SPM Package for SwiftUI
In this post, we’ll create a package for the Swift Package Manager. The result will be a package that adds more gestures to SwiftUI.
At the end of this post, we’ll have a fully functional package, but it will lack some features that an open-source project should have, such as support for CocoaPods, Carthage, Fastlane, Bitrise etc.
Background
I have several open source projects and was very excited when Apple last WWDC announced that SPM was coming to iOS. CocoaPods and Carthage are great dependency managers, but have problems that SPM has the potential to solve, since it’s integrated into Xcode.
As this is written, SPM is young and has some childhood problems. As your package evolves and you add a demo project, support for 3rd party dependency managers, CI integrations etc. you may run into some problems.
Therefore, let’s focus on the most essential features in this post. Let’s save these problems for later, and take them on as we add more features to the package.
Create the package
Let’s start by creating a Swift package. In the Terminal, create a new folder for the package, navigate to it and create the package within it:
mkdir SwiftUIGestures
cd SwiftUIGestures
swift package init
This will create a couple of files and folders:
.gitignore
- a standard, but badly configured ignore file. Read more here.Package.swift
- a manifest file that describes your package.Readme.md
- a basic readme for the package.Sources
- Source files go hereTests
- Unit tests go here
Unlike apps, this is a package and not an Xcode project. If you ask Xcode to open the folder, it will open the package, which has a different structure than Xcode projects.
Before we add functionality to the package, let’s remove two files that are generated by SPM and that we will not use: SwiftUIGestures.swift
and SwiftUIGesturesTests.swift
.
Specify supported platforms
Since the package will use SwiftUI, we can only target iOS 13, tvOS 13, watchOS 6 and macOS 10.10 or later. If we don’t, we would get the following build error:
'...' is only available in iOS 13.0 or newer
However, since the package will use gestures, it’s not really applicable for tvOS and macOS either, so let’s specify that it can only be used on platforms that support SwiftUI and swipe gestures.
Open Package.json
and add the following code below name
:
platforms: [
.iOS(.v13)
]
This tells the package that it can only be used on iOS 13 and later. Save the file, let Xcode refresh the package and you will now be able to build for SwiftUI.
Create a swipe gesture
It’s time to start adding functionality to the package. Let’s start by creating the class in which all the swipe gesture logic will go - SwipeGesture
.
In SwiftUI, we can build this gesture as a view modifier, a function etc. but since we’re going use UISwipeGestureRecognizer
s and UIView
s, let’s build it as a View
instead, or rather as a UIViewRepresentable
, which will wrap a UIView
in a SwiftUI View
:
import SwiftUI
import UIKit
public struct SwipeGesture: UIViewRepresentable {
typealias Context = UIViewRepresentableContext<SwipeGesture>
public func makeUIView(context: Context) -> UIView {
// TODO: Coming soon
}
public func updateUIView(_ uiView: UIView, context: Context) {}
}
This view should trigger individual actions when it’s swiped in different directions. Let’s define such an action as a typealias
:
import Foundation
public extension SwipeGesture {
typealias Action = () -> Void
}
Since SwipeGesture
should support four swipe directions (up, down, left, right), let’s create a struct that can contain four distinct actions:
import Foundation
public extension SwipeGesture {
struct Actions {
init(
up: @escaping Action = {},
left: @escaping Action = {},
right: @escaping Action = {},
down: @escaping Action = {}) {
self.up = up
self.left = left
self.right = right
self.down = down
}
let up: Action
let left: Action
let right: Action
let down: Action
}
}
Now, let’s add a SwipeGesture
initializer that lets people define actions when the gesture is created. Let’s also add empty default values to reduce the amount of code people have to write when they only want to use one or some of these actions:
public struct SwipeGesture: UIViewRepresentable {
init(
up: @escaping Action = {},
left: @escaping Action = {},
right: @escaping Action = {},
down: @escaping Action = {}) {
self.actions = Actions(up: up, left: left, right: right, down: down)
}
private let actions: Actions
...
}
Now, let’s create the content of our SwipeGesture
, which will be a UIView
with gestures added to it. Let’s create it inside makeUIView
:
public func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
// TODO: Add gestures to the view
return view
}
The view has a clear background and no content, since it should not be considered to be a view per se, but rather a swipe gesture capture area that can be added to any other view.
It’s time to add gestures. Since we have a UIView
, we can do it by calling addGestureRecognizer
on the view. There’s only one problem. addGestureRecognizer
requires a target and a selector, and since SwipeGesture
is a struct, we can’t use it as target or in the selector.
To fix this, let’s define a Coordinator
inside SwipeGesture
and return it in makeCoordinator
:
public extension SwipeGesture {
class Coordinator: NSObject {
public init(gesture: SwipeGesture) {
self.gesture = gesture
}
private let gesture: SwipeGesture
@objc public func swipeLeft() { gesture.actions.left() }
@objc public func swipeRight() { gesture.actions.right() }
@objc public func swipeUp() { gesture.actions.up() }
@objc public func swipeDown() { gesture.actions.down() }
}
func makeCoordinator() -> SwipeGesture.Coordinator {
Coordinator(gesture: self)
}
}
The coordinator takes a swipe gesture and defines selector-compatible functions that call actions on this gesture. Returning it in makeCoordinator
makes the context
get a coordinator
of the this type.
We can now use this coordinator as target and selector when we add gestures to the view. Let’s first create a UIView
extension that simplifies adding gestures to the view:
extension UIView {
func addSwipeGesture(
_ direction: UISwipeGestureRecognizer.Direction,
target: Any?,
action: Selector) {
let swipe = UISwipeGestureRecognizer(target: target, action: action)
swipe.direction = direction
addGestureRecognizer(swipe)
}
}
We can now use this extension to easily add gestures in makeUIView
:
public func makeUIView(context: Context) -> UIView {
let coord = context.coordinator
let view = UIView(frame: .zero)
view.backgroundColor = .clear
view.addSwipeGesture(.up, target: coord, action: #selector(coord.swipeUp))
view.addSwipeGesture(.left, target: coord, action: #selector(coord.swipeLeft))
view.addSwipeGesture(.right, target: coord, action: #selector(coord.swipeRight))
view.addSwipeGesture(.down, target: coord, action: #selector(coord.swipeDown))
return view
}
That’s it! We now have a SwiftUI view that can be used to capture swipe gestures on any view it is added to. Next, let’s add this swipe gesture to a SwiftUI View
.
Add swipe gesture to any SwiftUI View
Since SwipeGesture
is a View
, you could just add it as an overlay
to any other view:
Color.red
.overlay(
SwipeGesture(
up: { print("UP") },
left: { print("LEFT") },
right: { print("RIGHT") },
down: { print("DOWN") }
)
)
)
However, this is not very nice compared to SwiftUI’s onTapGesture
and onLongPressGesture
. Let’s create such a modifier for this gesture as well:
import SwiftUI
extension View {
func onSwipeGesture(
up: @escaping SwipeGesture.Action = {},
left: @escaping SwipeGesture.Action = {},
right: @escaping SwipeGesture.Action = {},
down: @escaping SwipeGesture.Action = {}) -> some View {
let gesture = SwipeGesture(up: up, left: left, right: right, down: down)
return overlay(gesture)
}
}
In the code above, we create a View
extension that creates a GestureView
and adds it as an overlay to the view. You can now add a SwipeGesture
to any View
like this:
Color.red
.onSwipeGesture(
up: { print("UP") },
left: { print("LEFT") },
right: { print("RIGHT") },
down: { print("DOWN") }
)
)
That’s it! We now have a fully functional swipe gesture view. However, we won’t be able to try it out until we have a demo project. Until then, let’s do something even more fun - unit testing.
Add unit tests
I use Quick and Nimble for unit testing in most of my projects, but let’s reduce the complexity of this post by using XCTests
. Let’s add a tiny test that tests the UIView+Gestures
extension we defined earlier:
import UIKit
extension UIView {
func addGesture(_ direction: UISwipeGestureRecognizer.Direction, target: Any?, action: Selector) {
let swipe = UISwipeGestureRecognizer(target: target, action: action)
swipe.direction = direction
addGestureRecognizer(swipe)
}
}
In Tests/SwiftUIGestureTests
, create a UIView+GesturesTests.swift
file with this code:
import XCTest
@testable import SwiftUIGestures
final class UIView_GesturesTests: XCTestCase {
func testVisualEffectViewIsCorrectlyAdded() {
let view = UIView()
let obj = TestClass()
view.addGesture(.up, target: obj, action: #selector(obj.doStuff))
let gestures = view.gestureRecognizers
XCTAssertEqual(gestures?.count, 1)
XCTAssertTrue(gestures?[0] is UISwipeGestureRecognizer)
}
}
private class TestClass: NSObject {
@objc func doStuff() {}
}
We need the @testable import
since the extension is internal and can only be accessed within the library, as well as in testable imports.
Now press Cmd+U
to launch the test runner. It should run without problems and all tests should pass. As a good tester, you know that you always need more tests, but that’s for another time.
Publish the package
The last part now is to publish the package. This can easily be as much work as creating the package, since you should put effort into:
A great name
that communicates what the package is all about.A great readme
that explains the package, how it’s used etc.A stunning logo
to…well, you don’t have to, but it’s fun.A basic demo app
to make it easy for users to give your package a try.Documentation
to make it easy for people to learn how to use your package.
For now, though, just push it up to GitHub so that we can try it our in a real Xcode project. You can then add it with SPM, using the branch name, since we don’t have a version yet.
As soon as you create a version tag, e.g. 0.1
, you can ask SPM to use versions instead of branch names. This gives you additional capabilities to define version ranges to auto-bump to.
Add the package to a new app
Let’s create a new iOS app and see if the package works. Create a SwiftUI project and navigate to the SPM dependency manager under Project/Swift Packages
.
Add a new dependency and enter the url to your repo. If you haven’t created a repo of your own, you can use this url instead: https://github.com/danielsaidi/SwiftUIKit
.
After Xcode has synced SPM dependencies, you can now import
the library and use it in your app:
import YourPackageName // or SwiftUIKit if you use my library
...
Color.red
.onSwipeGesture(
up: { print("UP") },
left: { print("LEFT") },
right: { print("RIGHT") },
down: { print("DOWN") }
)
)
If you run the project and swipe the red part of the screen, you should now get a printout of the direction you swiped. This means that everything worked well and that the package works.
Congratulations - you have just created your first SPM Package! 🥳
Going further
You now have a working SPM package, but it still lacks a bunch of features, like the things I mentioned in the earlier list, as well as:
- Fastlane support
- Carthage support
- CocoaPods support
- Bitrise integration
- Terminal support for automated tasks
You can have a look at my various projects to see how I generally handle this.