Swift IBDesignable入門

プロジェクト変わる度に同じ議論をしていて、もうこれはまとめた方が良いなと。。

<img width="803" alt="ば.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/c7f75d6c-ea3e-95e8-05af-f78dd9b12a64.png">
# 概要
IBDesignable（およびIBInspectable）への対応方法まとめ。

# 基本
#### IBDesignable、IBInspectableとは
**IBファイル（StoryboardやXib）の実装がとてもしやすくなる機能。**
（なお、これ以外のメリットはない🌛）

- **登場以前：**カスタムビューをIBファイル（StoryboardやXib）上で使う際「*実機上ではきちんと表示されるものの、IBファイル上では真っ白なビュー*」という状態だったため、新規開発はおろか追加開発もデバッグもしづらかった。
- **登場後：**カスタムビューでもIBファイル上で実機のようにレンダリングしてくれるようになり、さらにIBInspectableによってカスタムインターフェースを追加できるようになった。

#### IBDesignableを実装する方法
```swift:IBDesignable例：レンダリングだけするUIViewカスタムクラス
@IBDesignable final class DesignableView: UIView {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
}
```
やることは2つ。
1. クラス宣言の頭に`@IBDesinnable`アノテーションを付加する。
2. イニシャライザを実装する。

#### IBInspectableを実装する方法
```swift:IBInspectable例：UITextViewのTopPaddingを調整可能に
extension UITextView {
    @IBInspectable var topPadding: Float {
        get { return Float(self.textContainerInset.top) }
        set { self.textContainerInset.top = CGFloat(newValue) }
    }
}
```
やることは1つ。プロパティ宣言の頭に`@IBInspectable`アノテーションを付加するだけ。
ただし、指定できる型は下記いずれかのみ。

- Bool
- Int
- Double
- String
- Float
- CGPoint
- CGSize
- CGRect
- UIColor
- UIImage

#### 実装で気をつけるべきこと
1. **標準インターフェースと被るIBInspectableは作らない**
  - 標準インターフェースと被るIBInspectableを実装すると、後々のデバッグの邪魔になる。
  - 仕方なく作る場合は、IBファイルでのみ使うよう警告アノテーションを付加する。

2. **基底クラスは作らない**
  - 基底クラスを作ると後々のデバッグで影響範囲の特定が困難になる。
  - なお、チームの方針によるが、個人的には、IBDesignable/IBInspectableの実装を簡単にするためだけに基底クラスを作るのは微妙かと。

3. **IBファイルでのみ使うクラスやプロパティには警告アノテーションを付加**
  - IBDesignable/IBInspectableの目的は「IBファイルの実装をアシストする」ことでしかないので、これらに関する実装でSwiftファイル側のコードが散らかるのはデバッグ観点で避けるべき。
  - よって、実装する際、IBファイルでIBファイル専用Viewクラス(例: CustomLabel)を使う場合は、SwiftファイルのIBOutletは標準クラス(例: UILabel)にしたりする方が望ましい。
  - ただし、チーム内で「実装気をつけてね」というルールを作ってもだいたい忘れたりするため、警告アノテーションを付加してSwiftファイル側でそれらを使った場合にビルド失敗するようにする。
  - `@available(*, unavailable, message: "やっちゃったね。これはIBファイル専用クラスだよ。Swiftファイルで参照する際は継承元クラスで参照してね👴", renamed: "UILabel")`

# 対応例
#### 対応例１：　影や楕円のついたビューを作りたい
```swift:レンダリングだけするUIViewカスタムクラス（アノテーション付き）
// MARK: UIView
@available(*, unavailable, message: "Only use it at Storybord or Xib. When referring it from their file to Swift file, replace reference class name to inherited class.", renamed: "UIView")
@IBDesignable final class DesignableView: UIView {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
}
```
<img width="605" alt="Clip.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/be0d4b78-0294-da02-ec47-f55c141ebe92.png">
<img width="615" alt="Att.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/693f221f-ac56-df05-5c9d-c44267c7eefd.png">

1. **利用Viewを継承したIBファイル（StoryboardやXib）専用クラスを作成**
  - 描画専門となるようアノテーション付加とイニシャライザ実装以外はしない。
  - ちなみに、拡張することはないクラスなので、見通しよくなるようUIViewやUILabelなどの各拡張クラスは1ファイルにまとめていいかと。

2. **Clip to Bounds（「Viewの中身を切り取るか」の指定）を指定**
  - 影をつける場合は`false`、角丸にする場合は`true`にする。なお、コードで指定する場合は`layer.masksToBounds`で指定。
  - 影付きかつ角丸のUILabelやUIImageViewを作りたい場合は、`Clip to Bounds = false`の角丸＆影付きのUIViewを作成し、その上に`Clip to Bounds = true`で角丸指定した対象ビューを置く。

3. **UserDefinedRuntimeAttributesを設定**
  `layer.~`のような形でCALayerプロパティを追加。

|No.|項目|Key Path|Type|Value例|備考|
|:--|:--|:-------|:---|:------|:---|
|1|影の半径|layer.shadowRadius|Number|20.0||
|2|影のかかる方向|layer.shadowOffset|Size|{10.0,10.0}|横と縦。下目に影を落としたい場合は、{0,5}等で指定。|
|3|影の透明度|layer.shadowOpacity|Number|0.7|0~1の間|
|4|角丸の半径|layer.cornerRadius|Number|10.0|これをビューの高さの半分の値にすると楕円形になる|
|5|枠線の幅|layer.borderWidth|Number|1.0||

#### 対応例２：　影や枠線に色をつけたい
```Swift:影や枠線に色をつけられるようなCALayerExtension（アノテーション付き）
extension CALayer {
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "borderColor")
    @IBInspectable var borderUIColor: UIColor? {
        get { return borderColor.map { UIColor(cgColor: $0) } }
        set { borderColor = newValue?.cgColor }
    }
    
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "shadowColor")
    @IBInspectable var shadowUIColor: UIColor? {
        get { return shadowColor.map { UIColor(cgColor: $0) } }
        set { shadowColor = newValue?.cgColor }
    }
}
```
<img width="559" alt="スクリーンショット 2018-12-29 14.50.03.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/75de1486-fe5b-2a0f-3922-f9fc5573a294.png">

1. **CALayerのエクステンションを作成し、IBファイル専用プロパティを追加。**
  CALayerの`shadowColor`や`borderColor`はUIColorではなくCGColorのプロパティであるため、ラップしてUIColorアクセスできるようにする。加えて、Swiftコードからアクセスできないようアノテーションも付加する。

2. **UserDefinedRuntimeAttributesで、上記プロパティを用いて色指定。**

#### 対応例３：　Paddingを調整できるUILabelを作りたい
```swift:Padding調整UILabel
@IBDesignable final class PaddingLabel: UILabel {
    // MARK: Property
    @IBInspectable var horizontalPadding: CGFloat = 0
    @IBInspectable var verticalPadding: CGFloat = 0
    
    // MARK: Lifecycle
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        invalidateIntrinsicContentSize()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        invalidateIntrinsicContentSize()
    }
    
    override var intrinsicContentSize: CGSize {
        var contentSize = super.intrinsicContentSize
        contentSize.width += horizontalPadding * 2
        contentSize.height += verticalPadding * 2
        return contentSize
    }
}
```

1. IBDesignable対応のUILabel継承クラスを作成。
2. Paddingを設定できるようなIBInspectableプロパティを追加。
3. `intrinsicContentSize`（推奨サイズ）について、上記プロパティ値を反映するようオーバーライド
4. IBファイルで`intrinsicContentSize`が反映されるよう、イニシャライザに`invalidateIntrinsicContentSize()`を付加

#### 対応例４：　UITextViewのPadding等を調整できるようにして、UILabel同等に使いたい
```swift:UITextViewのPadding等を調整可能に
extension UITextView {
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "textContainerInset.top")
    @IBInspectable var topPadding: Float {
        get { return Float(self.textContainerInset.top) }
        set { textContainerInset.top = CGFloat(newValue) }
    }
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "textContainerInset.left")
    @IBInspectable var leftPadding: Float {
        get { return Float(self.textContainerInset.left + 5) }
        set { textContainerInset.left = CGFloat(newValue - 5) }
    }
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "textContainerInset.right")
    @IBInspectable var rightPadding: Float {
        get { return Float(self.textContainerInset.right + 5) }
        set { textContainerInset.right = CGFloat(newValue - 5) }
    }
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "textContainerInset.bottom")
    @IBInspectable var bottomPadding: Float {
        get { return -1 * Float(self.textContainerInset.bottom) }
        set { textContainerInset.bottom = -1 * CGFloat(newValue) }
    }
    @available(*, unavailable, message: "Only use it at Storybord or Xib.", renamed: "textContainer.maximumNumberOfLines")
    @IBInspectable var lines: Int {
        get { return textContainer.maximumNumberOfLines }
        set { textContainer.maximumNumberOfLines = newValue }
    }
}
```
<img width="633" alt="スクリーンショット 2018-12-29 15.23.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/d538a353-8435-34c2-4404-711bb2eae740.png">

1. UITextViewのextensionを作成。
2. Paddingと行数最大値を設定できるようなIBInspectableプロパティを追加。
3. IBファイルで下記設定をする。
  - Paddingの各値を0に。
  - `Lines`を指定。
  - `Scrolling Enabled`をfalseに指定。

# アンチパターン🙅
#### 🙅１：　CALayerのプロパティをUIView系のファイルのIBInspectableに付加
```swift:アンチパターン①
@IBDesignable final class AntiPatternDesignableView1: UIView {
    @IBInspectable var cornerRadius: Float {
        get { return Float(layer.cornerRadius) }
        set { layer.cornerRadius = CGFloat(newValue) }
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
}
```

UserDefinedRuntimeAttributesで「layer.~」で指定すれば済む。以下、「付加すべき」とした場合のデメリット。

1. ~~**UIViewのextensionに付加すべき**~~
  付加した設定項目が全Viewのインスペクタに出てきてしまいカオス。実装の邪魔になる。

2. ~~**基底クラスを作ってそこに付加すべき**~~：
  1と同じデメリットに加え、基底クラスを作ることでデバッグ時の影響範囲特定を困難にする。

3. ~~**各UIView系クラスで付加すべき**~~
  - クラスごとに都度実装しなければならなくなる。
  - IBファイルでこのクラスを使った場合、SwiftファイルのIBOutletはそのクラスにしなければならなくなるため、デバッグ時や改修時にそのクラスがどれぐらい特殊かを確認・記憶する必要が出てくる。
  - なお、「実装者が付加したかったら…」というチーム方針にすると、UserDefinedRuntimeAttributesとIBInspectableの2パターンの実装が混在するのでデバッグの阻害要因になる。

**結論：**CALayerのプロパティは、UserDefinedRuntimeAttributesでアクセスすべきで、UIViewにIBInspectableで別インターフェースを作るべきではない。

#### 🙅２：　イニシャライザ実装の代わりにawakeFromNib実装
```swift:アンチパターン②
@IBDesignable final class AntiPatternDesignableView2: UIView {
    override func awakeFromNib() {
        super.awakeFromNib()
    }
}
```

IBファイル（StoryboardやXib）経由で生成した場合には期待通り生成されるが、コードで生成した場合には期待通り生成されない。

**結論：**イニシャライザ実装 is 正義


# Tips🌞
#### 🌞１：　レンダリングを手動実行にする方法
<img width="839" alt="dd.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/8988b0c5-8868-ad18-64da-f684c7b3f071.png">

レンダリング処理に引きづられてマシンが重い場合は、`Editor > AutomaticallyRefreshViews`のチェックを外して、レンダリングを手動実行にする。（レンダリング実行するときはその下の`RefreshAllViews`を選択）

#### 🌞２：　レンダリングされない場合のチェックリスト
<img width="258" alt="スクリーンショット 2018-12-29 15.52.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/2e061761-c644-3687-9cf3-933f7ae47adc.png">
<img width="1138" alt="ssss.png" src="https://qiita-image-store.s3.amazonaws.com/0/69002/27626dbf-09d1-9c8d-9134-7b990e4481a4.png">


|No.|項目|確認箇所|内容|備考|
|:--|:--|:------|:--|:--|
|1|レンダリング中ではないか確認|IBファイル（StoryboardやXib）のビュー利用箇所：Inspector（＝ウインドウの右ビューエリア） > IdentityInspector(上部アイコンの左から3つ目) > CustomViews > Designables|Updatingではないことを確認。||
|2|再レンダリングして再確認|-|メニューバー>Editor>RefreshAllViewsを選んで再レンダリング。||
|3|キャッシュクリア、Xcode再起動して再確認|-|キャッシュクリア（⌘+shift+k）やXcode再起動をして再確認。||
|4|Swiftファイル実装の確認|Swiftファイル|アノテーションの付加はされているか。継承元クラスのRequiredInitをすべて実装しているか。||
|5|レイアウト制約エラーの確認|Navigator（＝ウインドウの左ビューエリア）>ReportNavigator（＝上部アイコンの右端）>InterfaceBuilder|レイアウト制約(Constraint)が不適当だと、レンダリング失敗する。||
|6|その他実装のエラーの確認|Navigator（＝ウインドウの左ビューエリア）>ReportNavigator（＝上部アイコンの右端）>InterfaceBuilder|プロジェクト全体のビルドの後にレンダリングするので、無関係の箇所のエラーも失敗原因になる。|以前、失敗原因が"通常ビルドではスルーされる通信ライブラリのビルドエラー"だったときもあった。|
|7|他の開発環境でも起こるか確認|-|他PCで再確認。||

# 備考
汎用で使える分は[Gist: ShoichiKuraoka/IBDesignableExtension.swift](https://gist.github.com/ShoichiKuraoka/cfcef64f98519a581dde9987fd02d288)にまとめてあるので、よければ。