【iOS】SnapKitの概要と実装例

この記事は[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku)の18日目の記事となります。

## はじめに

はじめまして。株式会社レコチョクの長島と申します。
2022年4月に新卒で入社し、6ヶ月の研修期間を経て、今はiOSアプリの開発に携わっています。
音楽はダンスミュージックを中心に、最近は電音部という音楽原案キャラクタープロジェクトの曲をよく聞いています。よろしくお願いします。

現在はOJTとして既存アプリのモックを作成する課題を行っているのですが、その際にSnapKitと呼ばれるライブラリを用い、Interface Builderなしで画面を実装する必要があり、一体SnapKitがどういうものなのかを調べる機会がありました。

今回はそれらを調査して得られた結果として、SnapKitの概要と実装例を記事にしようと思います。

### 動作環境

- Xcode 14.0.1
- SnapKit 5.6.0

## SnapKitについて

### 概要

SnapKitはUIKitのAuto Layoutを簡単に実装することができるDSL(Domain Specific Language)です。標準の記法として用いられる`NSLayoutConstraint`や`NSLayoutAnchor`と比較して、制約の記述を簡略化できます。

以下の図は`width`と`height`が100pxの`UIView`を画面の中央に配置したものです。`NSLayoutAnchor`とSnapKitそれぞれでの制約の付け方の比較を行いました。
実際のコードを見れば、SnapKitを使用した場合、`NSLayoutAnchor`と比べて明らかにコードの記述量が少なくなることがわかります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262775/ddc0cca6-ce0f-d7f1-eecc-9255e83b5737.png" alt="SnapKitSample" width="350" />

```swift
// NSLayoutAnchorを利用したコード
override func viewDidLoad() {
    super.viewDidLoad()
    let subView = UIView()
    subView.backgroundColor = .red
    view.addSubview(subView)
    
    subView.translatesAutoresizingMaskIntoConstraints = false
    let width = subView.widthAnchor.constraint(equalToConstant: 100)
    let height = subView.heightAnchor.constraint(equalToConstant: 100)
    let centerX = subView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
    let centerY = subView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
    NSLayoutConstraint.activate([width, height, centerX, centerY])
}
```

```swift
// SnapKitを利用したコード
override func viewDidLoad() {
    super.viewDidLoad()
    let subView = UIView()
    subView.backgroundColor = .red
    view.addSubview(subView)
    
    subView.snp.makeConstraints {
        $0.size.equalTo(100)
        $0.center.equalToSuperview()
    }
}
```

### 導入方法

今回は導入方法としてSwift Package Managerを利用します。Xcodeのバージョンによっては導入方法が異なる場合があるのでご注意ください。

1.メニューバー上の「File」を開き、「Add Packages…」を押下します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262775/04c3c36c-76e9-8229-5ae7-8079cd5db670.png" alt="InstallingSnapKit1" width="350" />

2.画面右上にある検索バーに[SnapKitのGitHub URL](https://github.com/SnapKit/SnapKit)を入力し、「Add Package」を押下します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262775/7def6f78-3f73-fe27-7bcd-797149db1028.png" alt="InstallingSnapKit2" />

3.SnapKitのパッケージが追加され、import可能になります。

```swift
import SnapKit
```

### 実装方法

SnapKitでは`makeConstraints()`というメソッドを用い、クロージャの中に制約を記載します。
基本となる書き方は以下のようになります。

```swift
<対象View>.snp.makeConstraints {
    $0.<対象Viewの属性>.<制約の設定方法>
}
```

対象Viewの属性には、以下のものが用意されています。

```swift
left
right
top
bottom
edges          // top, bottom, left, rightの一括設定
leading
trailing
width
height
size           // width, heightの一括設定
centerX
centerY
center         // centerX, centerYの一括設定
```

制約の設定方法には、以下のような式が用意されています。

```swift
equalTo()                        // 引数に渡す属性と同じ
equalToSuperview()               // 親Viewの属性と同じ
lessThanOrEqualTo()              // 引数に渡す属性と同じかそれ以下
lessThanOrEqualToSuperview()     // 親Viewの属性と同じかそれ以下
greaterThanOrEqualTo()           // 引数に渡す属性と同じかそれ以上
greaterThanOrEqualToSuperview()  // 親Viewの属性と同じかそれ以上  
```

実際に上記の式を組み合わせることで、以下のように制約を設定できます。
```swift
// 対象Viewのcenterに、親Viewのcenterと同じ位置を設定する制約
<対象View>.snp.makeConstraints {
    $0.center.equalToSuperview()
}
```

これらの記述の後に、`offset()`や`inset()`を設定できます。

```swift
// 対象Viewのedgesに、親Viewのedgesに対して100pxのinsetが追加された位置を設定する制約
<対象View>.snp.makeConstraints {
    $0.edges.equalToSuperview().inset(100)
}
```

また、メソッドチェーンという方法で、複数の属性に同時に制約を設定できます。

```swift
<対象View>.snp.makeConstraints {
    // 対象Viewのtop・leftに、親Viewのtop・leftに対して100pxのoffsetが追加された位置を設定する制約
    $0.top.left.equalToSuperview().offset(100)
    // 対象Viewのbottom・rightに、親Viewのbottom・rightに対して50pxのinsetが追加された位置を設定する制約
    $0.bottom.right.equalToSuperview().inset(50)
}
```

### 実例

以下の図のような`UICollectionViewCell`を作成してみます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262775/8421a5bf-778c-4e39-be07-5e8cb3ae6c9f.png" alt="SnapKitCellSample.png" width="500"/>

楽曲の情報が入っている`UICollectionViewCell`であり、以下のような構造になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262775/2912f3a0-32a5-5aef-a046-7793189d6098.png" alt="SnapKitCellSample.png" width="700"/>

- 横方向`UIStackView`(Horizontal): `hStackView`
    - 曲ジャケット`UIImageView`: `jacketImageView`
    - 縦方向の楽曲情報格納`UIStackView`(Vertical): `infoStackView`
        - タイトル`UILabel`: `titleLabel`
        - アーティスト`UILabel`: `artistLabel`
        - ハイレゾ`UILabel`: `hiResLabel`
    - メニュー`UIButton`: `menuButton`

それぞれの要素を作成し、制約を以下のように設定した場合、このようなコードとなりました。

- `hStackView`
    - `top`・`bottom`に、親Viewの`top`・`bottom`と同じ位置を設定する
    - `left`・`right`に、親Viewの`left`・`right`に対して16pxの`inset`が追加された位置を設定する
- `jacketImageView`
    - `size`を50pxに設定する
- `menuButton`
    - `size`を50pxに設定する

```swift
final class MusicCell: UICollectionViewCell {
    private lazy var hStackView: UIStackView = {
        $0.axis = .horizontal
        $0.alignment = .center
        $0.spacing = 16
        return $0
    }(UIStackView(arrangedSubviews: [
        jacketImageView,
        infoStackView,
        menuButton
    ]))

    private let jacketImageView: UIImageView = {
        $0.contentMode = .scaleAspectFit
        return $0
    }(UIImageView())

    private lazy var infoStackView: UIStackView = {
        $0.axis = .vertical
        $0.alignment = .leading
        $0.distribution = .equalSpacing
        return $0
    }(UIStackView(arrangedSubviews: [
        titleLabel,
        artistLabel,
        hiResLabel
    ]))

    private let titleLabel: UILabel = {
        $0.font = .systemFont(ofSize: 18, weight: .init(rawValue: 0.4))
        return $0
    }(UILabel())

    private let artistLabel: UILabel = {
        $0.font = .systemFont(ofSize: 14)
        $0.textColor = .darkGray
        return $0
    }(UILabel())

    private let hiResLabel: UILabel = {
        $0.font = .systemFont(ofSize: 14)
        $0.textColor = .darkGray
        return $0
    }(UILabel())

    private let menuButton: UIButton = {
        $0.setImage(.init(named: "more"), for: .normal)
        $0.tintColor = .darkGray
        return $0
    }(UIButton())
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // setup()の中で制約を設定する作業を行う
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setup() {
        addSubview(hStackView)
        hStackView.snp.makeConstraints {
            $0.top.bottom.equalToSuperview()
            $0.left.right.equalToSuperview().inset(16)
        }
        jacketImageView.snp.makeConstraints {
            $0.size.equalTo(50)
        }
        menuButton.snp.makeConstraints {
            $0.size.equalTo(50)
        }
    }
}
```

## おわりに

SnapKitを用いることで、コードのみで直感的に制約を設定する方法を記載し、実際に`UIStackView`との併用を含めたレイアウトを実装しました。
結果として、以下のような特徴があることがわかりました。

- `makeConstraints()`を用いることで、クロージャの中に制約をまとめて記載できる
- `offset`や`inset`を上記の式に続ける形で記載できる
- メソッドチェーンで複数の属性に一度に制約を記載できる

今回の記事を見て、SnapKitの概要と基本的な実装に関して理解が進みましたら幸いです。

明日の[レコチョク Advent Calendar](https://qiita.com/advent-calendar/2022/recochoku)は19日目`【Swift】Widgetの作り方 〜iOS 16対応版〜`です。お楽しみに！

## 参考資料

- [SnapKit/SnapKit: A Swift Autolayout DSL for iOS & OS X - GitHub](https://github.com/SnapKit/SnapKit)
- [SnapKit Docs - GitHub](https://snapkit.github.io/SnapKit/docs/)
- [【Swift4】SnapKitがめちゃくちゃ便利だった件 - RIGHTCODE](https://rightcode.co.jp/blog/information-technology/swift4-snapkit-takagai)
- [SnapKitを使ってみた。 - Qiita](https://qiita.com/Miya49_p0/items/5c5b498e90eca9bdbe42)
- [逆引きSnapKit - AutoLayoutをコードからサクッと設定しよう - Qiita](https://qiita.com/turara/items/bea0c41f8b5f26b04b20)
- [SnapKitを試す - カルボナーラ街道](https://tokizuoh.dev/posts/ljp5din1gkudr9e7/)

---

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/8770