[Swift/SwiftUI] subscriptを使って気持ちよくバインドする

6 min read読了の目安(約5900字

状況

あなたは以下のようなenumを持っています。Binding<検索条件>を受け取って、この検索条件を設定するSearchViewを作ることになりました。

enum 検索条件 {
    case 完全一致(String)
    case 前方一致(String)
    case 部分一致(String)

    enum タイプ {
        case 完全一致, 前方一致, 部分一致

        var ジェネレータ: (String) -> 検索条件 {
            switch self{
            case .完全一致: return 検索条件.完全一致
            case .前方一致: return 検索条件.前方一致
            case .部分一致: return 検索条件.部分一致
            }
        }
    }
}

素朴に実装してみます。

struct SearchView1: View {
    @Binding var 条件: 検索条件
    @State var タイプ: 検索条件.タイプ = .部分一致
    @State var 中身 = ""
    var body: some View {
        VStack{
            Picker("タイプを設定", selection: $タイプ){
                Text("完全一致").tag(検索条件.タイプ.完全一致)
                Text("前方一致").tag(検索条件.タイプ.前方一致)
                Text("部分一致").tag(検索条件.タイプ.部分一致)
            }
            TextField("中身", text: $中身)
        }
        .onChange(of: タイプ){ value in
            self.update()
        }
        .onChange(of: 中身){ value in
            self.update()
        }
    }

    func update(){
        条件 = タイプ.ジェネレータ(中身)
    }
}

パッと見は良さそうですが、よく見ると気になる部分があります。

  • まず二つのonChangeが定義されていますが、微妙です。処理は同じなのに二つ書かなきゃいけないのは不愉快です。
  • このViewの外側で条件が変更された場合これを検知できません。したがって外側での変更に基づいてクエリタイプを変更できないので、場合によっては状態がズレてしまう可能性があります。明白に不具合です。

subscriptで解決

そこでsubscriptが活躍します。まずは以下のextensionで2つのsubscriptを定義しましょう。

extension 検索条件 {
    enum タイプキー {case タイプ}
    subscript(_ key: タイプキー) -> タイプ {
        get {
            switch self {
            case .完全一致: return .完全一致
            case .前方一致: return .前方一致
            case .部分一致: return .部分一致
            }
        }
        set {
            let 中身 = self[.中身]
            self = newValue.ジェネレータ(中身)
        }
    }

    enum 中身キー {case 中身}
    subscript(_ key: 中身キー) -> String {
        get {
            switch self {
            case let .完全一致(value), let .前方一致(value), let .部分一致(value):
                return value
            }
        }
        set {
            let タイプ = self[.タイプ]
            self = タイプ.ジェネレータ(newValue)
        }
    }
}

これを導入することでSearchViewを以下のように書けるようになります。

struct SearchView2: View {
    @Binding var 条件: 検索条件
    var body: some View {
        VStack{
            Picker("タイプを設定", selection: $条件[.タイプ]){
                Text("完全一致").tag(検索条件.タイプ.完全一致)
                Text("前方一致").tag(検索条件.タイプ.前方一致)
                Text("部分一致").tag(検索条件.タイプ.部分一致)
            }
            TextField("中身", text: $条件[.中身])
        }
    }
}

内部的に変数を余計に持つのではなく、条件から得られる値を直接バインドしているため、外部で条件が変更されても状態がズレてしまうことはありません。またonChangeもいなくなり、本質的な部分だけが残っています。

お試し用のViewも用意しておきました。

struct SearchViewTest: View {
    @State private var 条件1: 検索条件 = .部分一致("")
    @State private var 条件2: 検索条件 = .部分一致("")

    var body: some View {
        SearchView1(条件: $条件1)
        Text(verbatim: "\(条件1)").font(.caption)
        Button("リセット"){
	    //SearchViewの外部から条件を変更してみる
            条件1 = .部分一致("")
        }
        .font(.caption)
        SearchView2(条件: $条件2)
        Text(verbatim: "\(条件2)").font(.caption)
        Button("リセット"){
	    //SearchViewの外部から条件を変更してみる
            条件2 = .部分一致("")
        }
        .font(.caption)
    }
}

動作について

extension部分について補足的な説明です。

まず、表記についてはextensionで追加したsubscriptの定義では、内部的に定義したenumを引数としてとっています。これによって条件[.タイプ]とか条件[.中身]という表記が実現されます。

enum タイプキー {case タイプ}
subscript(_ key: タイプキー) -> タイプ

enum 中身キー {case 中身}
subscript(_ key: 中身キー) -> String

また、Binding型はKeyPathを用いたdynamicMemberLookupに対応しています。これによって$条件[dynamicMember: \.[.タイプ]]$条件[.タイプ]と略記できます。

さらに、subscriptをバインドするとそのgetsetを通して値が変更されます。これは$array[0]のようなバインドと全く同じ動作を利用したものです。

別の方法

別にsubscriptを用いなくても、次のような定義をすればいけます。subscript内の処理をBindinggetset引数にそれぞれ指定したもので、ほとんど変わりません。動作もSearchView2と全く同じです。

struct SearchView3: View {
    @Binding var 条件: 検索条件

    var タイプ: Binding<検索条件.タイプ> {
        .init(
            get: {
                switch 条件 {
                case .完全一致: return .完全一致
                case .前方一致: return .前方一致
                case .部分一致: return .部分一致
                }
            },
            set: { newValue in
                let 中身 = self.中身.wrappedValue
                条件 = newValue.ジェネレータ(中身)
            }
        )
    }

    var 中身: Binding<String> {
        .init(
            get: {
                switch 条件 {
                case let .完全一致(value), let .前方一致(value), let .部分一致(value):
                    return value
                }
            },
            set: { newValue in
                let タイプ = self.タイプ.wrappedValue
                条件 = タイプ.ジェネレータ(newValue)
            }
        )
    }

    var body: some View {
        VStack{
            Picker("タイプを設定", selection: タイプ){
                Text("完全一致").tag(検索条件.タイプ.完全一致)
                Text("前方一致").tag(検索条件.タイプ.前方一致)
                Text("部分一致").tag(検索条件.タイプ.部分一致)
            }
            TextField("中身", text: 中身)
        }
    }
}

この方法もとても良いと思うのですが、いくつかの点でsubscriptを用いる方法の方を推しています。

  • subscriptを使った場合、SwiftUIでバインドに象徴的に用いられる$の記号を維持できるため、可読性が高い。
  • subscriptを使った場合、値自体にアクセスする際に.wrappedValueを書かなくて良い。
  • 複数の構造体で手軽なアクセスが必要な場合や、(通常はあまり起こらないが)別のproperty wrapperでもデータを包む場合には、subscriptを用いる方が圧倒的にすっきりと書ける。

ただ、subscriptを用いた方法では、オーバーロードのためにわざわざ不要な引数を設定するなど若干無駄なことをやっている部分もあり、常にどちらかを選ぶべきとまで言えるほどの圧倒的なメリットがあるわけではありません。

ツッコミ

(架空引用)Viewを実装するためだけにextensionを余計に生やすのはあまりよろしくないのでは?

適切にアクセス修飾子を付与すれば大きな問題ではないと思います。コーディング規約などで気になる場合はSearchView3の方式が良さそうです。

(架空引用)そもそもこうなってれば楽だったのでは?

struct 検索条件 {
   var タイプ: タイプ
   var 中身: String

   enum タイプ {
       case 完全一致, 前方一致, 部分一致
   }
}

現実にはそううまくいかず、例えば中身がそれぞれ

case 完全一致(完全一致データ)
case 前方一致(前方一致データ)
case 部分一致(部分一致データ)

みたいに型が違い、その中でそれぞれがtextというプロパティを持っている、みたいな場面があります。データ形式の変更による対処が有効な場面は限定的ですが、今回説明した方法は比較的一般的に使えます。

終わりに

定義部分は少し面倒ですが、subscriptを用いることでめちゃくちゃスマートにバインドを書くことができます。楽しいですね。