第3回 現在値のノブを表示する | SwiftUIでカラーピッカーを作る

SwiftUIでカラーピッカーを作るという連載記事です。前回、現在値を表示するテキストフィールドを作りましたので、今回はその続きで、グラデーションビュー上に現在値を示すノブを表示する処理の見た目を実装します。

ノブの形状ですが、この記事では次のような形状にしたいと思います。

  • 白い円に黒の縁取りを行う。
  • X座標は左端を0.0、右端を1.0として、現在値の位置に円の中心が来るように配置する。
  • Y座標はグラデーションビューの高さに対して中央揃えとする。
目次

SwiftUIで円を描画する

SwiftUIで円を描画するには、GeometryReaderPathを組み合わせます。例えば次のようなコードです。

struct PickerIndicator: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let minLength = min(geometry.size.width, geometry.size.height)
                path.addEllipse(in: CGRect(x: 0, y: 0, width: minLength, height: minLength))
            }
            .stroke(lineWidth: 1)
            .foregroundColor(.black)
        }
    }
}

これを実行すると、次のように表示されます。

SwiftUIで円を描画する
SwiftUIで円を描画する

白い円に黒の縁取りを行うは、次のような処理で実装できます。

  1. 黒い円を太めに描く
  2. 白い円を上から細めに描く

SwiftUIでは次のようなコードで実現できます。

struct PickerIndicator: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let minLength = min(geometry.size.width, geometry.size.height)
                path.addEllipse(in: CGRect(x: 0, y: 0, width: minLength, height: minLength))
            }
            .stroke(lineWidth: 10)
            .foregroundColor(.black)
            
            Path { path in
                let minLength = min(geometry.size.width, geometry.size.height)
                path.addEllipse(in: CGRect(x: 0, y: 0, width: minLength, height: minLength))
            }
            .stroke(lineWidth: 3)
            .foregroundColor(.white)
        }
    }
}

このコードを実行すると、次のように表示されます。

SwiftUIで円を縁取る
SwiftUIで円を縁取る

グラデーションビュー上にインジケータを表示する

各チャネルのグラデーションビュー上にインジケータを表示しましょう。「SwiftUIで円を描画する」のコードを元に、インジケータの表示を実装します。

ColorPickerビューへのPickerIndicatorの追加

PickerIndicatorビューを追加し、コードは「SwiftUIで円を描画する」をのまま使用します。

その状態で、次のように、ColorPickerに各チャネルのグラデーションビューの中心にPickerIndicatorを表示するコードを追加します。次のようなコードになります。

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)
            }
            ZStack(alignment: .leading) {
                PickerGradationView(startColor: redStartColor, endColor: redEndColor)
                    .frame(height: 100)
                PickerIndicator()
                    .frame(width: 20, height: 20)
            }
            
            HStack {
                Text("Green: ")
                    .font(.title)
                TextField("", text: channelValue.greenDisplayString)
                    .textFieldStyle(.roundedBorder)
            }
            ZStack(alignment: .leading) {
                PickerGradationView(startColor: greenStartColor, endColor: greenEndColor)
                    .frame(height: 100)
                PickerIndicator()
                    .frame(width: 20, height: 20)
            }

            HStack {
                Text("Blue: ")
                    .font(.title)
                TextField("", text: channelValue.blueDisplayString)
                    .textFieldStyle(.roundedBorder)
            }
            ZStack(alignment: .leading) {
                PickerGradationView(startColor: blueStartColor, endColor: blueEndColor)
                    .frame(height: 100)
                PickerIndicator()
                    .frame(width: 20, height: 20)
            }
        }
        .padding()
    }
}

PickerGradationViewPickerIndicatorビューを重ねるので、ZStackの中にPickerGradationViewを移動しました。

PickerIndicatorビューはデフォルトの状態では大きすぎるので、frameモディファイアを使ってちょうど良いサイズに変更しています。

ZStackはデフォルトの状態だと中央揃えになります。引数alignment.leadingを指定して、左揃え(先頭揃え)に変更しました。

このコードを実行すると、次のように表示されます。

PickerGradationViewにPickerIndidatorを重ねる
PickerGradationViewにPickerIndidatorを重ねる

PickerIndicatorのゼロ位置の調整

プレビューをよく見てください。PickerIndicatorの位置が右に若干ずれているのが分かると思います。

PickerIndicatorに次のように// バウンディングボックスを描く以下のコードを追加してください。GeometryReaderのバウンディングボックスを描画します。

struct PickerIndicator: View {
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let minLength = min(geometry.size.width, geometry.size.height)
                path.addEllipse(in: CGRect(x: 0, y: 0, width: minLength, height: minLength))
            }
            .stroke(lineWidth: 10)
            .foregroundColor(.black)
            
            Path { path in
                let minLength = min(geometry.size.width, geometry.size.height)
                path.addEllipse(in: CGRect(x: 0, y: 0, width: minLength, height: minLength))
            }
            .stroke(lineWidth: 3)
            .foregroundColor(.white)
            
            // バウンディングボックスを描く
            Path { path in
                path.move(to: CGPoint(x: 0, y: 0))
                path.addLine(to: CGPoint(x: geometry.size.width - 1, y: 0))
                path.addLine(to: CGPoint(x: geometry.size.width - 1, y: geometry.size.height - 1))
                path.addLine(to: CGPoint(x: 0, y: geometry.size.height - 1))
                path.addLine(to: CGPoint(x: 0, y: 0))
            }
            .stroke(lineWidth: 1)
            .foregroundColor(.yellow)
        }
    }
}

バウンディングボックスの左端がZStackによる整列位置になっていることが分かると思います。

実行結果
実行結果

PickerIndicatorは値が0で左端にあるときは、PickerIndicatorの中心がPickerGradationViewの左端になる必要があります。そのためには、PickerIndicatorの幅の半分だけ左に位置をずらす必要があります。

位置の調整にはoffsetモディファイアを使用できます。次のようにPickerIndicator()offsetモディファイアを追加します。

ZStack(alignment: .leading) {
	PickerGradationView(startColor: redStartColor, endColor: redEndColor)
		.frame(height: 100)
	PickerIndicator()
		.frame(width: 20, height: 20)
		.offset(x: -10)
}

このコードを実行すると、次のように表示され正しい位置に表示されると思います。また、PickerIndicatorに追加したバウンディングボックスの描画コードも不要になるので削除してください。

offset調整後のプレビュー
offset調整後のプレビュー

連載目次

著書紹介

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

この記事を書いた人

Akira Hayashi (林 晃)のアバター Akira Hayashi (林 晃) Representative(代表), Software Engineer(ソフトウェアエンジニア)

アールケー開発代表。Appleプラットフォーム向けの開発を専門としているソフトウェアエンジニア。ソフトウェアの受託開発、技術書執筆、技術指導・セミナー講師。note, Medium, LinkedIn
-
Representative of RK Kaihatsu. Software Engineer Specializing in Development for the Apple Platform. Specializing in contract software development, technical writing, and serving as a tech workshop lecturer. note, Medium, LinkedIn

目次