SwiftUIのToggleデザインあれこれ

## 履歴
2022/12/14　初版

## 開発環境
Xcode　Version 14.2 (14C18)
Swift5.7

## 経緯
何を書いたらいいかわからないし悩むしどうしたらいいんだ病にかかっていました。
なので、「iOSアプリでチェックボックスなんて作る意味はない、スイッチのデザインでいいよね」派である私が、「Androidにはチェックボックスあるらしいしチェックボックスの作り方でも書こう。多分というか絶対に他にも同じような記事はあると思うけど」という他と被りそうな話をすることにしました。

## SwiftUIでチェックボックスを作ろう！(MacOSのみ可）

チェックボックス押しづらいんだよねぇ…:qiitan-cry:

```Swift
struct ContentView: View {
    @State private var isChecked = true
    
    var body: some View {
        VStack(spacing: 10) {
            Text("チェックボタンがきちんと作られるよ：　\(isChecked ? "ON" : "OFF")")
            
            Toggle(isOn: $isChecked) {
                Text("ON")
            }.toggleStyle(.checkbox)
        }
        .padding(20)
    }
    
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/0b27243b-4372-820c-9caf-012a09471a30.png)

 ~~できましたね~~
冗談です:qiitan:

macOSでは良いのですが、iOSでは.checkbox（中身はCheckBoxToggleStyle())が使えません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/afecff80-2901-23ca-1ec4-30553806e333.png)
↓の書き方と同じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/0c69f198-68b2-3708-89ae-d963add36418.png)


デフォルトにするとエラーは消えますがスイッチのデザインになります。
```Swift
struct ContentView: View {
    @State private var isChecked = true
    
    var body: some View {
        VStack(spacing: 10) {
            Text("チェックボタンがきちんと作られるよ：　\(isChecked ? "ON" : "OFF")")
            
            Toggle(isOn: $isChecked) {
                Text("ON")
+            }.toggleStyle(.checkbox)
-            }.toggleStyle(.automatic)

        }
        .padding(20)
    }
    
}
```

automatic(中身はDefaultStyle())にするとMacはチェックボックス、iOS系はトグルになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/9576d59d-c7ae-e899-fe85-cd2aa1d4752f.png)

## カスタマイズしたStyleを作る
とどのつまり、CheckboxToggleStyle()のようなものをiOS用にも作れば良い、ということになります。

で、とりあえず作った。

```Swift
struct CheckBoxForIOSToggleStyle: ToggleStyle {
    
    func makeBody(configuration: Configuration) -> some View {
        CheckBoxView(configuration: configuration)
    }
    
    struct CheckBoxView: View {
        let configuration: CheckBoxForIOSToggleStyle.Configuration
        
        var body: some View {
            HStack {
                configuration.label
                    .font(.body)
                    .padding(20)
                
                ZStack {
                    RoundedRectangle(cornerRadius: 8)
                        .frame(width: 40, height: 40)
                        .foregroundColor(configuration.isOn ? .green : .white)
                        .opacity(configuration.isOn ? 1 : 0.3)
                        .overlay(
                            configuration.isOn ? AnyView(EmptyView()) :
                                AnyView(RoundedRectangle(cornerRadius: 8)
                                    .stroke(.gray, lineWidth: 1))
                        )
                    
                    if configuration.isOn {
                        Image(systemName: "checkmark")
                            .font(.title.weight(.black))
                    }
                    
                }.onTapGesture {
                    self.configuration.$isOn.wrappedValue.toggle()
                }
            }
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/fa0a7a3c-b329-ce06-21fe-da6d445e9176.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/14d25b21-d635-7362-18a4-dbdbadc39be9.png)


もちろん、iOSでも動きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/1b0ec27a-58e2-a154-2bb9-2e77b13a308b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/35823276-6483-5cc0-a15e-01d8d7b2294b.png)

他のStyle同様、enum-likeに呼び出したいのであれば、↓のコードを足しておきます。
これで`toggleStyle(.checkBoxForIOS)`が使えます。
```Swift
extension ToggleStyle where Self == CheckBoxForIOSToggleStyle {
    static var checkBoxForIOS: CheckBoxForIOSToggleStyle { .init() }
}
```

呼び出しは
```Swift
struct ContentView: View {
    @State private var isChecked = true
    
    var body: some View {
        VStack(spacing: 10) {
            Text("チェックボタンがきちんと作られるよ：　\(isChecked ? "ON" : "OFF")")
            
            Toggle(isOn: $isChecked) {
                Text("おす")
            }.toggleStyle(.checkBoxForIOS)
        }
        .padding(20)
    }
    
}
```
です。

新しいスタイルはToggleStyleプロトコルに準拠して作成します。
実装に必要なメソッドはmakeBodyだけです。
makeBodyの引数にあるConfigurationはonとoffの情報とToggleViewの内部に書いたViewがlabelという名前で入っています。
このlabelに該当する部分はTextである必要はありません。単純に親Viewから呼び出されたときに渡されて表示に利用できるViewというだけです。


```Swift
struct ContentView: View {
    @State private var isChecked = true
    
    var body: some View {
        VStack(spacing: 10) {
            Text("チェックボタンがきちんと作られるよ：　\(isChecked ? "ON" : "OFF")")
            
            Toggle(isOn: $isChecked) {
                ZStack {
                    AsyncImage(url:
                                URL(string: "https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/517d8b14e1ebec60d8915918e5419606fe53f55b/large.jpg?1639359895"),
                               scale: 3)
                }
            }.toggleStyle(.checkBoxForIOS)
        }
        .padding(20)
    } 
}

```
上記の記述ではQiitanの画像がToggleViewのlabelとして表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/dc23554b-f1aa-06ef-2806-87985b8e0127.png)

もちろん、Styleを使わないで自分でToggleViewをカスタマイズしたViewを作るというのもアリなのですが、使い方がViewによって異なる、あるいは本来持つべきToggleViewの性質を担保するには？という問題が生じかねないので、Styleのほうが良いと思っています。

## 本題：ON/OFFを切り替えるボタン的な扱いをする

Toggleの良いところはタップをするとBoolによってtrue/falseの２値での分岐しかないことを保証していることです。
つまり、Toggle(isOn:)に入るBoolはToggleの操作でtrue/falseが切り替わり、それ以外の変化は起きないという保証でもあります。
Buttonでもいいのですがわざわざ作るには面倒ですし、ON状態とOFF状態を司るのはToggleを使うほうが良いと思います。

というわけで、次の画面のようなものを作ろうと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/776a3255-6673-e86c-888d-e2d3584ac15c.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/9567f25a-6cfb-5ea4-caf4-655f5493cf3a.png)

```Swift
struct ImageToggleStyle: ToggleStyle {
    
    func makeBody(configuration: Configuration) -> some View {
        ImageToggleView(configuration: configuration)
    }
    
    struct ImageToggleView: View {
        let configuration: ImageToggleStyle.Configuration
        

        var body: some View {
            Group {
                configuration.label
                    .clipShape(Circle())
                    .overlay(
                        Circle()
                            .fill(.white)
                            .opacity(configuration.isOn ? 0 : 0.3)
                    )
                    .background(
                        (
                            configuration.isOn ? AnyView(Circle().stroke(
                                LinearGradient(colors: [
                                    Color(red: 0xf8 / 0xff, green: 0x36 / 0xff, blue: 0x00 / 0xff),
                                    Color(red: 0xf9 / 0xff, green: 0xd4 / 0xff, blue: 0x23 / 0xff)
                                ],
                                               startPoint: .leading,
                                               endPoint: .trailing),
                                lineWidth: 10)) :
                                AnyView(Circle().stroke(.gray, lineWidth: 10))
                         )
                        
                    )
                    .padding(20)
            }
            .onTapGesture {
                self.configuration.$isOn.wrappedValue.toggle()
            }
        }
    }
}
```
AnyViewを多用するのもどうかとは思いますが、今回はCircleにstrokeをつけているためAnyViewで階層を消さないとうまく動作しません。
ONの時はグラデーションの枠が付き、OFFの時は細めのグレーの線と画像を少し白っぽくしています。

---

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/4d231381-8356-0431-628e-c9ee7a15318c.png)
ブラーをつけても良いかなとおもったのですが端がボケるのできちんと処理をするならばもうひと手間かけないとだめっぽいですね。

---

enum-like的に使う書き方の部分は先程と大きな変化はありません。構造体の名前が変わったくらいです
```Swift
extension ToggleStyle where Self == ImageToggleStyle {
    static var imageToggleStyle: ImageToggleStyle { .init() }
}
```

親View側は、3つの画像を読み込むようにしています。三郎だけ写真がないので（我が家にはまだ存在していないので）固定のnoimage（Assetsに入っている画像）を表示するようにしています。

```Swift
struct ContentView: View {
    @State private var checkList = [true, false, false]
    
    var body: some View {
        let urls = [
            URL(string: "https://maskerdog.github.io/Advent2022SwiftUI/tarou.png"),
            URL(string: "https://maskerdog.github.io/Advent2022SwiftUI/jirou.png"),
            // 画像がないのでnotfoundでエラーになる
            URL(string: "https://maskerdog.github.io/Advent2022SwiftUI/saburou.png")
        ]
        let names = ["太郎", "次郎", "三郎"]
        
        VStack {
            HStack(spacing: 10) {
                
                    ForEach(0..<3) { number in
                        VStack (spacing: 10) {
                            Toggle(isOn: $checkList[number]) {
                                AsyncImage(url: urls[number]) { phase in
                                    
                                    switch phase {
                                    case .empty:
                                        Rectangle()
                                            .fill(.gray)
                                    case .success(let image):
                                        
                                        image
                                            .resizable()
                                            .aspectRatio(contentMode: .fit)
                                        
                                    case .failure:
                                        ZStack {
                                            Image("noimage")
                                                .resizable()
                                                .aspectRatio(contentMode: .fit)
                                        }
                                    @unknown default:
                                        EmptyView()
                                    }
                                    
                                }
                                .frame(width: 100, height: 100)
                            }
                            .toggleStyle(.imageToggleStyle)
                            Text(names[number])
                                .font(.system(.title, design: .rounded))
                            checkList[number] ? Text("ON") : Text("OFF")
                        }
                }
                
            }
            .padding(20)
        }
    }
    
}
```

## 最後に
Styleを使うとかなり楽に開発ができると思うので、もう少し色々調べて行きたい所存です。