SwiftUIを使わないで宣言的にレイアウトを実装する

SwiftUIが出て数年経ちましたが、既存のUIKitのプロジェクトに導入しようとするとリアクティブプログラミングの学習やアーキテクチャの切り替えなど導入障壁が高いと感じていました

そのため、既存のUIKitのプロジェクトのアーキテクチャやロジックを変更することなく、レイアウトを組む部分を宣言的に書けるようにするライブラリを作りました(と言いつつ宣言的UIとは何かが分かってないので根本的に違うかもしれませんw)

このライブラリを使用しても既存の通りの記述(つまり手続的に書く記述)と混ぜることもできるため、ひとつのViewController内のさらに一部だけ導入するといったことも可能です

まとめると以下のような要件で使ってもらえることを期待しています

```
・既存のアーキテクチャや実装をなるべく変えたくないよ
・SwiftUIはバージョンあがればどうせ書き方変わるだろうしまだ手は出せないよ
・RxSwift,Combineとか難しそうだし、ただ宣言的にUIを組みたいだけだよ
```
この記事では使い方を紹介していきます

ライブラリのリンクはこちらです

https://github.com/sakiyamaK/DeclarativeUIKit

#環境
|名前|バージョン|
|:---|:-----|
|DeclarativeUIKit|0.17.0|
|Installation|SwiftPackageManager/ CocoaPods|
|iOS|11以上|
|Swift|5.0|
|Simulator|iPhone13|

#基本

## 宣言的にレイアウトを書く準備

宣言的にレイアウトを組むには`UIViewController`で`declarative`メソッドを呼び出します

```swift
import UIKit
import DeclarativeUIKit

class DeclarativeViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .white
                
         //宣言的にレイアウトを組んでいくためのメソッド
        self.declarative {
            //セーフエリア内にViewを配置
            UIView()
                .backgroundColor(.red)
        }

       return
    }
}
```

実行結果はこのようになります

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/0f355890-b85f-451f-6b9c-5110d8a7d26b.png" width=300>

### Xcode Previewで確認

Xcode PreviewはUIKitでも使えるため、当然`DeclarativeUIKit`で組んだレイアウトもXcode Previewで確認することができます

```swift
import SwiftUI

private struct ViewController_Wrapper: UIViewControllerRepresentable {
    typealias ViewController = SimpleViewController
    func makeUIViewController(context: Context) -> ViewController {
        let vc = ViewController()
        return vc
    }
    
    func updateUIViewController(_ vc: ViewController, context: Context) {
    }
}

struct SimpleViewController_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ViewController_Wrapper()
        }
    }
}
```

# UIStackView

`DeclarativeUIKit`は`UIStackView`を使ってレイアウトを組むことを基本とします

## 縦に並べる

```swift
self.declarative {
    UIStackView.vertical {
        UIView()
            .backgroundColor(.red)
        UIView()
            .backgroundColor(.green)
        UIView()
            .backgroundColor(.blue)
    }
    .spacing(10)
    .distribution(.fillEqually)
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/65e51a20-9d41-389d-87fc-4b57b7a1026d.png" width=300>

## 横に並べる

```swift
self.declarative {
    UIStackView.horizontal {
        UIView()
            .backgroundColor(.red)
        UIView()
            .backgroundColor(.green)
        UIView()
            .backgroundColor(.blue)
    }
    .spacing(10)
    .distribution(.fillEqually)
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/39fcb297-de86-537f-a746-a8aa968496b6.png" width=300>
 

## 指定した大きさにする

UIViewのパラメータと組み合わせて配置します

### 指定した大きさにして左上寄せに配置する

```swift
self.declarative {
    UIStackView.vertical {
        UIView()
            .backgroundColor(.red)
            //大きさを指定する
            .size(width: 100, height: 100)
            //こう指定することもできる
//          .width(100)
//          .height(100)

        //下に余白をつける
        UIView.spacer()

    }
    //UIStackViewのパラメータで左寄せにする
    .alignment(.leading)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/bb51d3a4-5dc8-0fed-5352-a8f88e9f12b1.png" width=300>

### 指定した大きさにして上中央に配置する

```swift
self.declarative {
    UIStackView.vertical {

        UIView()
            .backgroundColor(.red)
            .size(width: 100, height: 100)

        UIView.spacer()

    }
    //UIStackViewのパラメータで中央寄せにする
    .alignment(.center)
}

```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/46f35373-ebbb-0a27-692f-f3c7497d95a6.png" width=300>

### 指定した大きさにして右中央に配置する

```swift
self.declarative {
    UIStackView.vertical {

        UIView()
            .backgroundColor(.red)
            .size(width: 100, height: 100)

        UIView.spacer()

    }
    //UIStackViewのパラメータで右寄せにする
    .alignment(.trailing)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/0914bb84-53ca-cb40-0ce8-f35f9e0fb755.png" width=300>


### 指定した大きさにして真ん中(左寄せ or 中央 or右寄せ)に配置する
```swift
self.declarative {
    UIStackView.vertical {

        //上側の余白
        UIView.spacer()
        
        //センターに配置したいView
        UIView()
            .backgroundColor(.red)
            .size(width: 100, height: 100)

        //下側の余白
        UIView.spacer()
    }
    .alignment(.center)
    //UIStackViewのパラメータで真ん中にする
    .distribution(.equalCentering)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/7a246e66-eb6a-eb42-30b7-fe031e297435.png" width=300>

## 配列からビューを生成して並べる

```swift
//カラーの配列を用意
let colors: [UIColor] = [
    .red,
    .green,
    .blue,
    .yellow,
    .purple
]

self.declarative {
    UIStackView.vertical {
        //色の配列からビューの配列に変換
        colors.compactMap {
            UIView()
                .backgroundColor($0)
        }
    }
    .distribution(.fillEqually)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/36beda0b-666a-2a97-340e-e05a351f4366.png" width=300>

# UIView

UIViewの主要なパラメータの設定を宣言的にメソッドを用いて記述することができます
また、円形にするといったよく使う設定もメソッドを用意しています

## 基本的なパラメータ

```swift
UIView()
    .tag(1)
    .backgroundColor(.red)
    .isUserInteractionEnabled(true)
    .clipsToBounds(true)
    .contentMode(.scaleAspectFit)
    .alpha(0.5)
    .isHidden(false)
    .transform(.init(rotationAngle: 45.0/Double.pi))
```

## 特殊なパラメータ

### ボーダーラインを付ける

```swift
UIStackView.vertical {
    UIView.spacer()
    
    UIView()
        .backgroundColor(.red)
        .size(width: 200, height: 200)
         // *
        .border(color: .blue, width: 10)
    
    UIView.spacer()
}
.alignment(.center)
.distribution(.equalCentering)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/0bf8a3d4-aeb8-61c8-70e6-62ad2eb652e8.png" width=300>

### 角丸を付ける

```swift
UIStackView.vertical {
    UIView.spacer()
    
    UIView()
        .backgroundColor(.red)
        .size(width: 200, height: 200)
         //maskedCornersで丸くする箇所を指定する
         //指定しなければ四隅が丸くなる
        .cornerRadius(100, maskedCorners: [.layerMaxXMaxYCorner, .layerMaxXMinYCorner])
    
    UIView.spacer()
}
.alignment(.center)
.distribution(.equalCentering)

```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/3fb89982-3c5c-6188-e80d-9782a624f9f1.png" width=300>

### 影を付ける

```swift
UIStackView.vertical {
    UIView.spacer()
    
    UIView()
        .backgroundColor(.red)
        .size(width: 200, height: 200)
         // *
        .shadow(color: .black, radius: 10, x: 10, y: 10)

    UIView.spacer()
}
.alignment(.center)
.distribution(.equalCentering)

```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/dbeb311b-fa2f-2c29-9220-fce7092ed993.png" width=300>

## 特殊なパラメータ2
### 周りに余白を付ける

borderとは違い、自身の外側に新たな領域が追加されます

```swift
self.declarative {
    UIStackView.vertical {
        
        UIView()
            .backgroundColor(.red)
            //四隅に10の余白を付ける
            .padding(insets: UIEdgeInsets.init(all: 10))
            //paddingの後に指定するメソッドは余白側の設定となる
            .backgroundColor(.blue)
        
    }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/200be4c6-6222-8d57-60a9-e99989f71abb.png" width=300>

### 親ビューの大きさから比率で自身の大きさを決める

<b>※親ビューはUIStackViewしか指定できません</b>

```swift
self.declarative {
    UIStackView.vertical { superview in
        
        UIView.spacer()
        
        UIView()
            .backgroundColor(.red)
            //横幅を superview(UIStackView)の横幅 * 0.8 + 10 にする
            .widthEqual(to: superview, constraint: superview.widthAnchor * 0.8 + 10)
            //縦幅を superview(UIStackView)の縦幅 * 0.5 にする
            .heightEqual(to: superview, constraint: superview.heightAnchor * 0.5)

        UIView.spacer()

    }
    .alignment(.center)
    .distribution(.equalCentering)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/5c135f7a-182e-6cf5-ac61-7eb813578922.png" width=300>

## ビューを重ねる

```swift
UIStackView.vertical {
    UIView.spacer()
    
    UIView()
        .backgroundColor(.red)
        .size(width: 300, height: 500)
        //addSubviewでも同じ
        .zStack {
            UIStackView.vertical {
                UIView().backgroundColor(.blue)
                UIView().backgroundColor(.green)
                UIView().backgroundColor(.yellow)
            }
            .spacing(10)
            .distribution(.fillEqually)
            .padding(10)
        }

    UIView.spacer()
}
.alignment(.center)
.distribution(.equalCentering)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/219b4b94-2c36-f504-0083-3073034debf1.png" width=300>


# UIScrollView

## 縦スクロール

```swift
self.declarative {
    UIScrollView.vertical {
        UIStackView.vertical {
            UIView()
                .backgroundColor(.red)
                .height(400)
            
            UIView()
                .backgroundColor(.green)
                .height(400)
            
            UIView()
                .backgroundColor(.blue)
                .height(400)
        }.spacing(10)
    }
}

```
<img src="https://i.gyazo.com/2e7eee3ac4fe12e66b9e0bcd2de28f58.gif" />


## 横スクロール

```swift
self.declarative {
    UIScrollView.horizontal {
        UIStackView.horizontal {
            UIView()
                .backgroundColor(.red)
                .width(200)
            
            UIView()
                .backgroundColor(.green)
                .width(200)

            UIView()
                .backgroundColor(.blue)
                .width(200)
        }.spacing(10)
    }
}
```
<img src="https://i.gyazo.com/70b117696c72a36170519426e86f6454.gif" />

## その他のパラメータの設定

```swift
self.declarative {
    UIScrollView.vertical {
        UIStackView.vertical {
            UIView()
                .backgroundColor(.red)
                .height(400)
            
            UIView()
                .backgroundColor(.green)

            UIView()
                .backgroundColor(.blue)
        }
        .spacing(10)
        .distribution(.fillEqually)
    }
    // *
    .isScrollEnabled(true)
    .showsScrollIndicator(true)
    //縦横のインジケータの表示を個別に設定もできる
    //.showsVerticalScrollIndicator(true)
    //.showsHorizontalScrollIndicator(true)
}
```

# 手続的に記述する

`addTarget`などレイアウトに関わらない部分の設定は宣言的な記述が非対応のため、それらを既存の通り手続的に設定するやり方を解説します
逆にいうとレイアウト以外の記述は既存のソースコードを流用させることができます

## 初期値を手続的に設定する

```swift
self.declarative {
    UIScrollView.vertical {
        UIStackView.vertical {
            //imperativeメソッドの中で手続的に記述する
            UILabel().imperative {
                let label = $0 as! UILabel
                label.text = "手続的だね"
                label.font = UIFont.systemFont(ofSize: 20)
            }
            //下線を引く
            UIView.divider()

            //imperativeメソッドは省略できる (ver0.15.0で記述が変更されている)
            UILabel {
                let label = $0 as! UILabel
                label.text = "手続的だね"
                label.font = UIFont.systemFont(ofSize: 20)
            }
            
            UIView.spacer()
        }
        .spacing(20)
        .padding(insets: .init(top: 10, left: 12, bottom: 10, right: 12))
    }
}
```

ver0.15.0で以下の記述が変更されました

```swift
//ver0.15.0より前
UILabel {
    let label = $0 as! UILabel
    label.text = "手続的だね"
    label.font = UIFont.systemFont(ofSize: 20)
}

//ver0.15.0以降
//`imperative`と明示する
UILabel.imperative {
    let label = $0 as! UILabel
    label.text = "手続的だね"
    label.font = UIFont.systemFont(ofSize: 20)
}
```

## 手続的にパラメータを更新する

宣言的にレイアウトを組むと`let label = UILabel()`のようにパラメータに代入されていないため、あとからアクセスすることができません
そのため`assign`でパラメータに代入するか、`imperative`内で代入させます


```swift
//クラスのパラメータを用意する
private var label1: UILabel!
private var label2: UILabel!
private var label3: UILabel!

self.declarative {
    UIStackView.vertical {

        UIView.spacer()

        UILabel.imperative {
            self.label1 = $0 as! UILabel
            label1.text = "パラメータに代入"
        }

        UILabel("パラメータに代入").assign(to: &label2)

        //assignはイニシャライザでも指定できる
        UILabel(assign: &label3).text("パラメータに代入")

        UIView.spacer()
    }
    .spacing(20)
    .distribution(.equalSpacing)
}
```

　これらを設定することで既存のUIKitの記述をそのまま利用してパラメータの更新ができます

```swift
final class SimpleViewController: UIViewController {
    
    private var count: Int = 0
    
    private var countLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .white
        
        self.declarative {
            UIStackView.vertical {
                
                UIView.spacer()
                
                UILabel().imperative {
                    let label = $0 as! UILabel
                    label.text = count.description
                    label.font = UIFont.systemFont(ofSize: 40)
                    label.textAlignment = .center
                    label.textColor = .blue
                    //あとから更新するのでcountLabelに代入
                    self.countLabel = label
                }
                
                UIButton().imperative {
                    let button = $0 as! UIButton
                    button.setTitle("カウントアップボタン", for: .normal)
                    button.setTitleColor(.black, for: .normal)
                    //ボタンをタップした時のアクションを指定
                    button.addTarget(self, action: #selector(tapButton), for: .touchUpInside)
                }
                
                UIView.spacer()
            }
            .spacing(20)
            .distribution(.equalSpacing)
        }
    }
}

@objc private extension SimpleViewController {
    func tapButton(_ sender: UIButton) {
        //ボタンがタップされた時の処理
        //普通のUIKitの記述
        count += 1
        countLabel.text = count.description
    }
}
```

<img src="https://i.gyazo.com/415e646d72b32aeca6229943661bd220.gif" />

# カスタムビュー
UIViewにも`declarative`メソッドがあるため、それを使って宣言的にレイアウトを組みます
UICollectionViewCellのカスタムセルの場合は以下のようになります


```swift
final class LandmarkRow: UICollectionViewCell {

    private enum ViewTag: Int {
        case icon = 1
        case text
        case star
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
                
        self.contentView.declarative {
            UIStackView.vertical {
                UIView.spacer()
                
                UIStackView.horizontal {
                    UIImageView(tag: ViewTag.icon.rawValue)
                    .size(width: 50, height: 50)
                    
                    UILabel(tag: ViewTag.text.rawValue)
                    
                    UIView.spacer()
                    
                    UIImageView(tag: ViewTag.star.rawValue) {
                        let imageView = $0 as! UIImageView
                        imageView.image = UIImage(systemName: "star.fill")?.withRenderingMode(.alwaysTemplate)
                        imageView.tintColor = .systemYellow
                    }
                    .isHidden(true)
                }
                .spacing(8)
                .alignment(.center)
                
                UIView.divider()
            }
            .spacing(10)
        }
    }
            
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/71d856e1-c2a4-a123-e1a0-46e58c382228.png" />


# セーフエリア

## セーフエリアを無視する
セーフエリア外までレイアウトを広げたい場合は`declarative`メソッドのパラメータで指定します

```swift
self.declarative(safeAreas: .init(all: false)) {
    UIView().backgroundColor(.red)
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/b2311703-b830-5a4d-eb02-b47e2e7f7670.png" width=300/>


## セーフエリア外のレイアウトを組む

同じく `declarative`メソッドのパラメータで指定します

```swift
self.declarative(outsideSafeAreaTop: {
    //セーフエリアの外の上側の実装
    UIView().backgroundColor(.blue)
}, outsideSafeAreaLeading: {
  //leadingの外側は何もせず
}, outsideSafeAreaBottom: {
    //セーフエリアの外の下側の実装
    UIView().backgroundColor(.green)
}, outsideSafeAreaTrailing: {
  //trailingの外側は何もせず
}) {
    //セーフエリア内の実装
    UIView().backgroundColor(.red)
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/e4a194d0-fadb-d6ef-eb8d-87b2a35f96a7.png" width = 300/>

# 終わり

まだベータ版のつもりでしたが意外と問題なくレイアウトが組めたのでまとめてみました

[Apple公式のSwiftUIチュートリアル](https://developer.apple.com/tutorials/swiftui)を真似たレイアウトも組めましたので[デモプログラム](https://github.com/sakiyamaK/DeclarativeUIKit/tree/main/DeclarativeUIKitDemo/DeclarativeUIKitDemo/CopyAppleSwiftUITutorial)も参考にしてください
以下のようなレイアウトが組めます

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105057/f0cd05e0-97b5-a0bb-a827-ed7ec156bb04.png" width = 300/>

ご意見やバグなどありましたらご連絡いただけると幸いです