Create an SPM Package for SwiftUI

Jan 5, 2020 · Follow on Twitter and Mastodon swiftuispm

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

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 UISwipeGestureRecognizers and UIViews, 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.

Discussions

Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying on Twitter or Mastodon..

Follow for more

If you found this interesting, follow the Twitter and Mastodon accounts for more content like this, and to be notified when new content is published.