第2回 現在値を表示する | SwiftUIでカラーピッカーを作る

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ビューです。そのため、ColorPickerChannelValueColorPickerが生成、破棄するようにします。

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などを渡せば良いように思えてしまいますが、ColorPickerChannelValueDoubleなので、$ColorPickerChannelValue.redBinding<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と入力しようとすると、次のような動作になってしまいます。

  1. 0.まで入力したところで、red0が代入される。(これはOK)
  2. redDisplayStringgetが呼ばれ、0.0という文字列が返される。(これはNG)
  3. フィールドの文字列は0.0になってしまう。

小数点以下の桁も入力できるようにするには、0.という文字列を維持できる必要があります。

そのためには、次のような処理になるようにredDisplayString, greenDisplayString, blueDisplayStringの実装を変更します。

  1. 入力中の文字列を文字列のまま保持するプロパティを作る。
  2. プロパティの取得処理は、1のプロパティを返す。
  3. プロパティの設定処理は、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()
    }
}

この状態でプレビューを見ると、次のようになり、バランスが悪い感じです。

プレビュー1
プレビュー1

バランスを調整する

バランスを調整します。ラベルの左端とテキストフィールドの右端に余白がないので、余白を入れます。各ラベルに入れるよりも、これらの項目を含む、VStackpaddingを追加する方が良さそうです。そうなると、PickerGradationViewpaddingがあると余計なので、これらは削除します。

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()
    }
}
プレビュー2
プレビュー2

ラベルを作る前は、PickerGradationViewpaddingを削除すると、上下の間隔が詰まりすぎてしまう印象でしたが、ラベルとテキストフィールドのおかげでちょうど良い感じになりました。

連載記事一覧

この記事は「SwiftUIでカラーピッカーを作る」という連載記事の一つです。連載の他の記事は次のページを開いてください。

SwiftUIでカラーピッカーを作る 連載記事一覧

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

アプリ開発が好きなアプリ開発者。このブログは学習メモを記事にしたテックブログです。仕事ではアプリ開発をメインに、技術書の執筆やセミナーの講師などもしています。業務や著書のサイトはこちらです→ アールケー開発

目次