Creating an SPM Package for SwiftUI

Jan 5, 2020 · Follow on Twitter and Mastodon

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.

Background

I have several open source projects and was excited when Apple last WWDC announced that SPM was coming to iOS. CocoaPods and Carthage are great dependency managers, but have problems that SPM can solve since it’s integrated into Xcode.

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. SPM may provide you with some unexpected 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.

Creating the package

Let’s start by creating a Swift package. In Terminal, create a new folder for the package, navigate to it, then create a new 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 here
  • Tests - 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

Since the package will use gestures, it’s not really applicable for tvOS and macOS, 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 a class in which all the swipe gesture logic will go - SwipeGesture.

In SwiftUI, we could build this as a view modifier, a function etc. but since we’re going use UISwipeGestureRecognizer and UIViewRepresentable, which will wrap a UIView in a 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) {}
}

Since SwipeGesture should support four swipe directions (up, down, left, right), let’s create a struct that can contain four distinct actions, with an Action typealias:

import Foundation

public extension SwipeGesture {

    typealias Action = () -> Void
    
    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
    }
}

Let’s add an initializer that lets people define actions when creating a SwipeGesture, using empty default values to reduce the amount of actions that people must provide:

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

    ...
}

Let’s create the content of our SwipeGesture, which will be a UIView with gestures. Let’s create this view 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 a view, 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 use addGestureRecognizer, but it requires a target and selector. Since SwipeGesture is a struct, we can’t use it as a target.

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)
    }
}

Returning this coordinator in makeCoordinator causes the makeUIView context to get a coordinator of this type. This lets us set the target and selectors with this coordinator.

Before we proceed, let’s add a UIView extension that simplifies adding gestures to a 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 nice compared to SwiftUI’s onTapGesture and onLongPressGesture.

Let’s create a similar view 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)
    }
}

With this code, we 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") }
    )
)

We now have a fully functional swipe gesture view. Before we try it out in a demo app, let’s do something even more fun - unit testing!

Add unit tests

I use Quick & 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.

Press Cmd+U to launch the test runner. It should run without problems and all tests pass.

Publishing the package

The last part is to publish the package. This can easily involve as much work as creating the package, since you should put effort into:

  • A great name that communicates what it’s all about.
  • A great readme that explains what it does, how it’s used etc.
  • A stunning logo to…well, you don’t have to, but it’s pretty fun.
  • A basic demo app to make it easy for users to test 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.

Adding the package to a new app

Let’s create a new iOS app test the package. 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 dependencies, you can 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 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 earlier list, as well as other useful workflow utilities.

You can have a look at my various projects to see how I generally handle this.