SwiftUIでカラーピッカーを作るという連載記事です。この連載で作成するカラーピッカーの現在値は次のような表示にしたいと思います。
- グラデーションビューの上にラベルと値を表示する
- 値はキーボードから編集出来るようにする
- グラデーションビュー上に現在値を示すカーソルを表示する
各チャネルの現在値の表示を追加します。現在値を文字列で表示し、キーボードから値を入力することもできるようにしたいので、テキストフィールドを使用します。今回はこのテキストフィールドとチャネル名のラベルを作ります。
モデルクラスの実装
現在値は0.0
から1.0
までの浮動小数点数で表示します。各チャネルの値を入れるプロパティを持ったモデルクラスを実装します。SwiftUIと組み合わせ使用するので、ObservableObject
プロトコルの適合クラスにします。
import SwiftUI
class ColorPickerChannelValue : ObservableObject {
@Published var red: Double = 0.0
@Published var green: Double = 0.0
@Published var blue: Double = 0.0
}
ColorPickerビューに値を追加する
各チャネルの値というデータの大元はColorPicker
ビューです。そのため、ColorPickerChannelValue
はColorPicker
が生成、破棄するようにします。
struct ColorPicker: View {
var redStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var redEndColor: Color = Color(red: 1.0, green: 0.0, blue: 0.0)
var greenStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var greenEndColor: Color = Color(red: 0.0, green: 1.0, blue: 0.0)
var blueStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var blueEndColor: Color = Color(red: 0.0, green: 0.0, blue: 1.0)
@StateObject var channelValue = ColorPickerChannelValue()
// 省略
TextField用のバインディングを作る
TextField
は値の表示だけではなく、編集が可能なので、Binding<String>
を渡すようになっています。一見、$ColorPickerChannelValue.red
などを渡せば良いように思えてしまいますが、ColorPickerChannelValue
はDouble
なので、$ColorPickerChannelValue.red
はBinding<Double>
になってしまいます。
そこで、次のようにBinding<String>
になるようにプロパティを作ります。
import SwiftUI
class ColorPickerChannelValue : ObservableObject {
@Published var red: Double = 0.0
@Published var green: Double = 0.0
@Published var blue: Double = 0.0
var redDisplayString: Binding<String> { Binding(
get: { String(format: "%g", self.red) },
set: { if let value = Double($0) {
self.red = value
}}
)}
var greenDisplayString: Binding<String> { Binding(
get: { String(format: "%g", self.green) },
set: { if let value = Double($0) {
self.green = value
}}
)}
var blueDisplayString: Binding<String> { Binding(
get: { String(format: "%g", self.blue) },
set: { if let value = Double($0) {
self.blue = value
}}
)}
}
Computed Propertyでバインディングを作る
今回のように管理する値のタイプとバインディングのタイプが合わないときは、バインディング用のプロパティをComputed Propertyを作るのが手軽かなと思います。Computed Propertyでバインディングを作るときは、次のように、値の設定と値の取得を行う処理をクロージャーで書けるイニシャライザが使用できます。
init(get: @escaping () -> Value, set: @escaping (Value) -> Void)
小数点以下の桁を入力できるようにする(2022年9月19日追加)
実装したComputed Propertyですが、ラベルに表示する文字列としては問題ないのですが、テキストフィールドで編集しようとすると、小数点以下の桁を入力できないという不具合が発生します。
原因は、Double
に変換できないときに編集前の状態の文字列に戻ってしまう(表示は戻らないが、内部的に戻る)ために、例えば、redチャネル用のフィールドに0.1
と入力しようとすると、次のような動作になってしまいます。
0.
まで入力したところで、red
に0
が代入される。(これはOK)redDisplayString
のget
が呼ばれ、0.0
という文字列が返される。(これはNG)- フィールドの文字列は
0.0
になってしまう。
小数点以下の桁も入力できるようにするには、0.
という文字列を維持できる必要があります。
そのためには、次のような処理になるようにredDisplayString
, greenDisplayString
, blueDisplayString
の実装を変更します。
- 入力中の文字列を文字列のまま保持するプロパティを作る。
- プロパティの取得処理は、1のプロパティを返す。
- プロパティの設定処理は、
Double
に変換できたときにのみ値を更新する。同時に1のプロパティに変換可否に関わらずず、文字列を代入する。
次のようなコードになります。
import SwiftUI
class ColorPickerChannelValue : ObservableObject {
@Published var red: Double = 0.0
@Published var green: Double = 0.0
@Published var blue: Double = 0.0
private var redEnteredString: String?
private var greenEnteredString: String?
private var blueEnteredString: String?
var redDisplayString: Binding<String> { Binding(
get: {
self.redEnteredString ?? String(format: "%g", self.red)
},
set: {
self.redEnteredString = $0
if let value = Double($0) {
self.red = value
}
}
)}
var greenDisplayString: Binding<String> { Binding(
get: {
self.greenEnteredString ?? String(format: "%g", self.green)
},
set: {
self.greenEnteredString = $0
if let value = Double($0) {
self.green = value
}
}
)}
var blueDisplayString: Binding<String> { Binding(
get: {
self.blueEnteredString ?? String(format: "%g", self.blue)
},
set: {
self.blueEnteredString = $0
if let value = Double($0) {
self.blue = value
}
}
)}
}
ラベルとテキストフィールドを作る
ラベルとテキストフィールドを作ります。チャネルごとに以下の2つを水平に並べます。
- ラベル
- テキストフィールド
コードでは次のようになります。
HStack {
Text("Red: ")
.font(.title)
TextField("", text: channelValue.redDisplayString)
.textFieldStyle(.roundedBorder)
}
他のチャネルも作るとコードは次のようになります。
var body: some View {
VStack {
HStack {
Text("Red: ")
.font(.title)
TextField("", text: channelValue.redDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: redStartColor, endColor: redEndColor)
.frame(height: 100)
.padding()
HStack {
Text("Green: ")
.font(.title)
TextField("", text: channelValue.greenDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: greenStartColor, endColor: greenEndColor)
.frame(height: 100)
.padding()
HStack {
Text("Blue: ")
.font(.title)
TextField("", text: channelValue.blueDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: blueStartColor, endColor: blueEndColor)
.frame(height: 100)
.padding()
}
}
この状態でプレビューを見ると、次のようになり、バランスが悪い感じです。
バランスを調整する
バランスを調整します。ラベルの左端とテキストフィールドの右端に余白がないので、余白を入れます。各ラベルに入れるよりも、これらの項目を含む、VStack
にpadding
を追加する方が良さそうです。そうなると、PickerGradationView
のpadding
があると余計なので、これらは削除します。
import SwiftUI
struct ColorPicker: View {
var redStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var redEndColor: Color = Color(red: 1.0, green: 0.0, blue: 0.0)
var greenStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var greenEndColor: Color = Color(red: 0.0, green: 1.0, blue: 0.0)
var blueStartColor: Color = Color(red: 0.0, green: 0.0, blue: 0.0)
var blueEndColor: Color = Color(red: 0.0, green: 0.0, blue: 1.0)
@StateObject var channelValue = ColorPickerChannelValue()
var body: some View {
VStack {
HStack {
Text("Red: ")
.font(.title)
TextField("", text: channelValue.redDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: redStartColor, endColor: redEndColor)
.frame(height: 100)
HStack {
Text("Green: ")
.font(.title)
TextField("", text: channelValue.greenDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: greenStartColor, endColor: greenEndColor)
.frame(height: 100)
HStack {
Text("Blue: ")
.font(.title)
TextField("", text: channelValue.blueDisplayString)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(startColor: blueStartColor, endColor: blueEndColor)
.frame(height: 100)
}
.padding()
}
}
ラベルを作る前は、PickerGradationView
のpadding
を削除すると、上下の間隔が詰まりすぎてしまう印象でしたが、ラベルとテキストフィールドのおかげでちょうど良い感じになりました。