5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUIAdvent Calendar 2022

Day 14

SwiftUIのToggleデザインあれこれ

Posted at

履歴

2022/12/14 初版

開発環境

Xcode Version 14.2 (14C18)
Swift5.7

経緯

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

SwiftUIでチェックボックスを作ろう!(MacOSのみ可)

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

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

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

macOSでは良いのですが、iOSでは.checkbox(中身はCheckBoxToggleStyle())が使えません。
image.png
↓の書き方と同じです。
image.png

デフォルトにするとエラーは消えますがスイッチのデザインになります。

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

カスタマイズしたStyleを作る

とどのつまり、CheckboxToggleStyle()のようなものをiOS用にも作れば良い、ということになります。

で、とりあえず作った。

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
image.png

もちろん、iOSでも動きます。
image.png
image.png

他のStyle同様、enum-likeに呼び出したいのであれば、↓のコードを足しておきます。
これでtoggleStyle(.checkBoxForIOS)が使えます。

extension ToggleStyle where Self == CheckBoxForIOSToggleStyle {
    static var checkBoxForIOS: CheckBoxForIOSToggleStyle { .init() }
}

呼び出しは

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というだけです。

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

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

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

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

というわけで、次の画面のようなものを作ろうと思います。
image.png

image.png

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
ブラーをつけても良いかなとおもったのですが端がボケるのできちんと処理をするならばもうひと手間かけないとだめっぽいですね。


enum-like的に使う書き方の部分は先程と大きな変化はありません。構造体の名前が変わったくらいです

extension ToggleStyle where Self == ImageToggleStyle {
    static var imageToggleStyle: ImageToggleStyle { .init() }
}

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

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を使うとかなり楽に開発ができると思うので、もう少し色々調べて行きたい所存です。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?