Swift Package Managerに対応したライブラリの作成方法

こんにちは。@zrn-nsです。

Xcode12からSwift Package Managerでリソース（画像等）が扱えるようになりました。

個人的にこれまでライブラリ開発を行ったことがなかったため、試しにiOS向けのライブラリを作成してみました。

# 今回作成するライブラリについて

今回はリソースをもち、かつなるべくシンプルなライブラリを作成したかったので、**不織布マスクがオーバーレイされた円形画像ビューを表示する**ライブラリを作成します。

何言ってるんだ？って感じかもしれないですが、動作イメージはこんな感じです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/55838217-c382-455f-e844-4ba98dcc03ee.png" width="200px"/>

どんな画像、サイズでも自動で円形表示でき、その上不織布マスクを追加できます。この時代にもってこいのライブラリですね。

# 検証環境

macOS BigSur 11.1
Xcode12.3

# プロジェクトの作成

新規プロジェクトから作成します。
Xcodeを開いて、「Create a new Xcode project」を選択するとテンプレート一覧が表示されます。
「Multiplatform」タブに「Swift Package」が存在するので、それを選択します。
![スクリーンショット 2021-01-23 19.27.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/1862e767-2b02-19c9-7e23-d84450c178e7.png)

今回プロジェクト名は「MyFirstSwiftPackage」としました。

プロジェクトが作成されると、↓のような構成でプロジェクトが作成されました。（構成はXcodeのバージョンによって異なります）
![スクリーンショット 2021-01-23 20.10.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/e8ad98fb-b033-69bb-331c-3609a77e21cc.png)

# プロジェクトの構成

各ファイル/ディレクトリの役割は下記のような感じです。

### README.md

プロジェクトの説明を記述するためのREADMEファイルです。
Markdownで記述しておくと、Githubのトップページや、SwiftPackageManagerでそのパッケージを導入したときに表示してくれたりします。

### Package.swift

Swift Packageの設定を行うためのファイルです。
そのパッケージ自体の情報や、そのパッケージが依存するパッケージの情報を記述します。
詳細については次のセクションで解説します。

### `/Sources`ディレクトリ

**プログラムやリソース**を入れるためのディレクトリです。
このディレクトリの配下のプログラムは自動的にコンパイル対象として認識され、リソースファイル（xibやstoryboard、Asset Catalogなど）は適切に処理されバイナリに追加されます。

### `/Tests`ディレクトリ

その名の通り、テストコードを入れるためのディレクトリです。`/Sources/[ProjectName]`以下にテストを記述しておくと、`CMD+U`ですべてのテストを自動実行してくれます。


# Package.swiftの設定

まずSwift Packageの設定を行います。(公式ドキュメントは[こちら](https://developer.apple.com/documentation/swift_packages/package))

Package.swiftを下記のように記述します。

```swift:Package.swift
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
    name: "MyFirstSwiftPackage",
    platforms: [.iOS(.v9)],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "MyFirstSwiftPackage",
            targets: ["MyFirstSwiftPackage"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyFirstSwiftPackage",
            dependencies: [],
            exclude: ["Demo"]),
        .testTarget(
            name: "MyFirstSwiftPackageTests",
            dependencies: ["MyFirstSwiftPackage"]),
    ]
)
```

名前などについては明らかなので説明を省きますが、要所の説明をすると、

```swift
// swift-tools-version:5.3
```

Swift Toolsのバージョン指定です。基本的に変える必要はありません。
Packageの指定方法がSwift Toolsのバージョンに依存しているので、ここで指定したバージョンより古いSwift Toolsを使っている場合、このライブラリをビルドすることができません。

```swift
platforms: [.iOS(.v9)],
```

このSwift Packageを導入する事ができる環境を絞る事ができます。
linux, macOS, watchOSなどを設定することもできますが、それぞれの環境で使えるフレームワークが異なるため注意が必要です。
今回はiOS以外での使用を想定していないので、明示的にiOSを指定します。

```swift
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "MyFirstSwiftPackage",
            targets: ["MyFirstSwiftPackage"]),
    ],
```

実行ファイルやライブラリなどの成果物を定義します。複数定義することもできるようです。

```swift
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
    ],
```

依存するライブラリを定義できます。
外部のライブラリを使用する場合、ここに依存するライブラリを定義する事ができます。

```swift
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyFirstSwiftPackage",
            dependencies: []),
        .testTarget(
            name: "MyFirstSwiftPackageTests",
            dependencies: ["MyFirstSwiftPackage"]),
    ]
```

ターゲットを指定します。
テンプレートを生成した時点で、メインターゲットとテストターゲットがそれぞれ作成されます。
dependenciesには、前項のdependenciesで追加した依存ライブラリのうち、そのターゲットで使うライブラリを設定します。

# 画像リソースを追加する

画像リソースは`/Sources`に入れる事になっていますが、[公式ドキュメント](https://developer.apple.com/documentation/swift_packages/bundling_resources_with_a_swift_package)によると、ソースコードとリソースを分離するため、リソースを入れるディレクトリは`/Sources/[ProjectName]/Resources` のようにディレクトリを作成してその中に入れることが推奨されているようです。

そこで今回は、
- プログラムを入れるディレクトリを `/Sources/[ProjectName]/Classes`
- リソースを入れるディレクトリを `/Sources/[ProjectName]/Resources`
というふうに区別しようと思います。

### ディレクトリを作成する

プロジェクトナビゲータで`/Sources/[ProjectName]`ディレクトリを右クリックして「New Folder」を選択し、`Classes`, `Resources`ディレクトリを作成します。

### 画像リソースの追加

さらに、Resourcesディレクトリを右クリックし「AssetCatalog」を選択します。ファイル名は自由ですが、便宜上「Media.xcassets」とします。
↓の画像をダウンロード後、Media.xcassetsに追加します。追加した画像の名前は「mask」とします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/fc4bb639-cb90-85fd-69b1-4213eb6581b5.png" width="200px" />

# ソースコードを追加

次にソースコードを記述します。

`/Sources/[ProjectName]/Classes` ディレクトリを右クリックし、「New File」から「Swift File」を選択します。ファイル名は「CircleMaskImageView.swift」とします。
ファイルを開き、中身を下記のように記述します。

```swift:CircleMaskImageView.swift
import UIKit

public class CircleMaskImageView: UIView {

    public func set(image: UIImage) {
        imageView.image = image
    }

    // MARK: - initializer
    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    private func commonInit() {
        imageView.contentMode = .scaleAspectFit
        imageView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(imageView)
        addConstraints([
            leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
            trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
            topAnchor.constraint(equalTo: imageView.topAnchor),
            bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
        ])

        maskImageView.contentMode = .scaleAspectFit
        maskImageView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(maskImageView)
        addConstraints([
            centerXAnchor.constraint(equalTo: maskImageView.centerXAnchor),
            centerYAnchor.constraint(equalTo: maskImageView.centerYAnchor),
            maskImageView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5)
        ])

        layer.masksToBounds = true
    }

    public override func draw(_ rect: CGRect) {
        super.draw(rect)

        layer.cornerRadius = Self.calculateCornerRadius(indicatedArea: bounds)
    }

    // MARK: - private
    private let imageView: UIImageView = .init()
    private let maskImageView: UIImageView = .init(image: UIImage(named: "mask", in: Bundle.module, compatibleWith: nil)!)
}

internal extension CircleMaskImageView {
    static func calculateCornerRadius(indicatedArea: CGRect) -> CGFloat {
        min(indicatedArea.width, indicatedArea.height) / 2
    }
}
```

注意点として、**画像を取得する際、明示的にBundleを指定する必要がありました。**
SwiftPMでBundleを取得するには、`Bundle.module`を指定すれば良いようです。
[Bundling Resources with a Swift Package | Apple Developer Documentation](https://developer.apple.com/documentation/swift_packages/bundling_resources_with_a_swift_package)

またここで、実行先にMacを指定したままでプログラムを実行しようとすると、下記のようなエラーが出ます。
`UIKit is not available when building for macOS. Consider using #if !os(macOS) to conditionally import this framework.`
これは読んでの通り、macOSではUIKitを使えないために発生するためのエラーです。記述されている通りマクロを使用して分岐をしてもいいのですが、そもそもこのライブラリでmacOSをサポートするつもりはないので、ここでは分岐を書かず、`Package.swift`に設定を追加することでビルドできる環境をiOSのみに絞ることにします。

# テストを追加する

今度はテストを追加します。
`/Tests/[ProjectName]/`ディレクトリを右クリックし、「New File」からSwiftファイルを選択します。ファイル名は便宜上`CircleMaskImageViewTests.swift`とします。

ファイルの中身は下記のようにしました。

```swift:CircleMaskImageViewTests.swift
import UIKit
import XCTest
@testable import MyFirstSwiftPackage

final class CircleMaskImageViewTests: XCTestCase {

    func test_CircleImageView_calculateCornerRadus() {
        XCTAssertEqual(CircleMaskImageView.calculateCornerRadius(indicatedArea: CGRect(x: 0, y: 0, width: 300, height: 200)), CGFloat(100), "widthとheightのうち、小さい方の半分のサイズが返される（width/2）")
        XCTAssertEqual(CircleMaskImageView.calculateCornerRadius(indicatedArea: CGRect(x: 0, y: 0, width: 300, height: 400)), CGFloat(150), "widthとheightのうち、小さい方の半分のサイズが返される（height/2）")
    }
}
```

実行先として適当なiOS Simulatorを選択してCMD+Uを押下することで、テストが実行され、テスト結果が表示されます。


# 動作確認用にDemoプロジェクトを追加する

Swift Packageは別プロジェクトに追加して使うものなので、このライブラリ単体だと動作確認ができません。
完全に切り分けた別プロジェクトを作成して、そちらからこのライブラリを導入することで動作確認する事もできるのですが、パッケージを更新するためにコミットしないといけなかったりして、色々と面倒です。
そこで今回は、**Swift Package内にDemo用のプロジェクトを用意して、リアルタイムにデバッグができる環境を整えよう**と思います。

## Demoプロジェクトを追加

macのメニューバーからFile -> New -> Projectの順に選択。
iOSタブからAppを選びます。Project Nameは今回は「Demo」としました。

![スクリーンショット 2021-01-24 15.08.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/46c3ca61-a2ec-6747-742b-db4c802bfdfa.png)

保存先は今回作成しているパッケージのルートディレクトリを指定します。
「Create Git repository on my Mac」のチェックは外しておきます。

![スクリーンショット 2021-01-24 15.09.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/5e0a1776-c372-e52f-a815-f3ec6134bfe2.png)

XcodeでDemoプロジェクトが開くので、Swift Packageのルートディレクトリをプロジェクトルートにドラッグドロップすると、パッケージがDemoプロジェクトに追加されます。

![スクリーンショット 2021-01-24 15.13.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/5607ebcf-b669-491b-d0ed-e3278119520d.png)
![スクリーンショット 2021-01-24 15.14.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/9f0a2668-4bf6-0e56-798c-2b09ef997f83.png)

DemoプロジェクトのDemoターゲットを選択し「＋」を押下します。

![スクリーンショット 2021-01-24 15.21.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/460e92dd-b5ef-407b-adbd-5556d5449b2e.png)

今回作成しているSwiftPackageを選択し、依存に追加します。

![スクリーンショット 2021-01-24 15.21.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/79b51fa7-6569-f3a9-bd67-46af44fe4094.png)

## Demoプロジェクトにリソースを追加する

Demoプロジェクトで動作確認に利用する画像リソースを追加します。

Assets.xcassetsを開き、こちらの画像を追加します。ファイル名は「reiwa_woman」とします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/8bf0ef5a-2d0b-cdd2-9c47-a72161718ca7.png" width="200px" />

## Demoプロジェクトで動作確認を行う

動作確認用のソースコードを追加します。
ViewController.swiftを開き、内容を下記のように変更します。

```swift:ViewController.swift
import UIKit
import MyFirstSwiftPackage

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.

        view.backgroundColor = .lightGray

        let circleMaskImageView = CircleMaskImageView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
        circleMaskImageView.set(image: UIImage(named: "reiwa_woman")!)
        view.addSubview(circleMaskImageView)
    }
}
```

実行先として適当なシミュレータを選択してプログラムを実行すると、下記のように動作確認ができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/86b5d40c-42f1-4858-5766-39ee02440363.png" width="200px"/>

Demoプロジェクトを開いた状態でSwiftPackage側のソースを変更することもできるので、動作確認をしながら開発を進めることも可能です。

# 終わりに

今回作成したライブラリのソースコードは、こちら↓に上げてあります。
[zrn-ns/MyFirstSwiftPackage](https://github.com/zrn-ns/MyFirstSwiftPackage)

実際に作成してみると、特別な対応はPackage.swiftの作成だけなので、案外簡単に対応できる事が分かりました。

Demoプロジェクトの作成については試行錯誤の結果なので、これが正しい方法なのか正直自信が無いので、もしうまく動かないパターンがあれば教えていただけると助かります。

# 謝辞

画像リソースは[「いらすとや」様](https://www.irasutoya.com/2020/06/blog-post_51.html)から拝借させていただきました。