[Swift/SwiftUI] subscriptを使って気持ちよくバインドする
状況
あなたは以下のような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
をバインドするとそのget
とset
を通して値が変更されます。これは$array[0]
のようなバインドと全く同じ動作を利用したものです。
別の方法
別にsubscript
を用いなくても、次のような定義をすればいけます。subscript
内の処理をBinding
のget
、set
引数にそれぞれ指定したもので、ほとんど変わりません。動作も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
を用いることでめちゃくちゃスマートにバインドを書くことができます。楽しいですね。
Discussion