Part 4 Changing the knob position according to its value | Create the Color Picker with SwiftUI

This is a series of articles on creating a color picker in SwiftUI. In the previous article, we implemented the placement of the knob, but it remains fixed at the location where the value is 0, regardless of the current value. This article will cover implementing a process to change the display position based on the value.

TOC

Calculate display position

In the color picker implemented in this series, the current value of 0.0 should be displayed on the left edge and 1.0 on the right edge; the Y coordinate should be center-aligned. The center coordinates of the knob are calculated using the following formula to implement this specification.

X = View Width * Current Value
Y = View Height / 2

Get the size of the PickerGradationView

Implement the process of getting the size of the PickerGradationView; see the following article on how to get the size of any view in SwiftUI.

Add a property to put the size obtained

To add a property to the ColorPicker that assigns the size of the PickerGradationView, add the following code to ColorPicker.swift.

import SwiftUI

struct ColorPicker: View {
    // omission ...

    @StateObject var channelValue = ColorPickerChannelValue()
    @State var redGradationViewSize = CGSize()
    @State var greenGradationViewSize = CGSize()
    @State var blueGradationViewSize = CGSize()

Get the sizes of the three PickerGradationViews

Implement a process to get the size of the PickerGradationView dynamically and assign it to the added property. Then, as the related article describes, place a GeometryReader in the background with the background modifier and check its size.

Assigning the retrieved size to a property causes the view to be rebuilt by SwiftUI. Since it is impossible to start another rebuild while a rebuild is in progress, we use DispatchQueue to delay the process. To minimize the number of rebuilds, we also check the value and assign it only if it has changed.

PickerGradationView(startColor: redStartColor, endColor: redEndColor)
	.frame(height: 100)
	.background {
		GeometryReader { geometry in
			Path { path in
				DispatchQueue.main.async {
					if geometry.size != redGradationViewSize {
						redGradationViewSize = geometry.size
					}
				}
			}
		}
	}

This code is for redGradationViewSize. A similar code is implemented for greenGradationViewSize and blueGradationViewSize. See “Entire Code After Implementation” at the bottom of this page for the entire code after implementation.

Move the knob with the current value

Implement the process of moving the knob according to the current value. The knob is moved by changing the coordinates passed to the offset modifier in the PickerIndicator view.

Implement the process of calculating X-coordinates

Add a computed property that calculates the X coordinate of the knob for each channel. Add code as follows.

import SwiftUI

struct ColorPicker: View {
    // omission

    var redIndicatorX: CGFloat {
        redGradationViewSize.width * channelValue.red - 10
    }
    
    var greenIndicatorX: CGFloat {
        greenGradationViewSize.width * channelValue.green - 10
    }
    
    var blueIndicatorX: CGFloat {
        blueGradationViewSize.width * channelValue.blue - 10
    }

As I wrote in Part 3, I need to shift it by half the width of the PickerIndicator view, so I return a value of -10.

Pass to PickerIndicator.offset

Change the value passed to the offset modifier in the PickerIndicator view to the computing property you added. The code will look something like this.

PickerIndicator()
	.frame(width: 20, height: 20)
	.offset(x: redIndicatorX)

This code is for the Red channel. Similar changes are made to the PickerIndicator view for the Green and Blue channels.

Entire Code After Implementation

The entire code after implementation looks like this.

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()
    @State var redGradationViewSize = CGSize()
    @State var greenGradationViewSize = CGSize()
    @State var blueGradationViewSize = CGSize()
    
    var redIndicatorX: CGFloat {
        redGradationViewSize.width * channelValue.red - 10
    }
    
    var greenIndicatorX: CGFloat {
        greenGradationViewSize.width * channelValue.green - 10
    }
    
    var blueIndicatorX: CGFloat {
        blueGradationViewSize.width * channelValue.blue - 10
    }
            
    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)
                    .background {
                        GeometryReader { geometry in
                            Path { path in
                                DispatchQueue.main.async {
                                    if geometry.size != redGradationViewSize {
                                        redGradationViewSize = geometry.size
                                    }
                                }
                            }
                        }
                    }
                PickerIndicator()
                    .frame(width: 20, height: 20)
                    .offset(x: redIndicatorX)
            }
            
            HStack {
                Text("Green: ")
                    .font(.title)
                TextField("", text: channelValue.greenDisplayString)
                    .textFieldStyle(.roundedBorder)
            }
            ZStack(alignment: .leading) {
                PickerGradationView(startColor: greenStartColor, endColor: greenEndColor)
                    .frame(height: 100)
                    .background {
                        GeometryReader { geometry in
                            Path { path in
                                DispatchQueue.main.async {
                                    if geometry.size != greenGradationViewSize {
                                        greenGradationViewSize = geometry.size
                                    }
                                }
                            }
                        }
                    }
                PickerIndicator()
                    .frame(width: 20, height: 20)
                    .offset(x: greenIndicatorX)
            }

            HStack {
                Text("Blue: ")
                    .font(.title)
                TextField("", text: channelValue.blueDisplayString)
                    .textFieldStyle(.roundedBorder)
            }
            ZStack(alignment: .leading) {
                PickerGradationView(startColor: blueStartColor, endColor: blueEndColor)
                    .frame(height: 100)
                    .background {
                        GeometryReader { geometry in
                            Path { path in
                                DispatchQueue.main.async {
                                    if geometry.size != blueGradationViewSize {
                                        blueGradationViewSize = geometry.size
                                    }
                                }
                            }
                        }
                    }
                PickerIndicator()
                    .frame(width: 20, height: 20)
                    .offset(x: blueIndicatorX)
            }
        }
        .padding()
    }
}

Check the preview

Check that the previews are displayed as intended: since there are three channels, change the code in ColorPicker_Previews in ColorPicker.swift so that the previews for the Red channel = 0.0, Green channel = 0.5, and Blue channel = 1.0 is displayed.

struct ColorPicker_Previews: PreviewProvider {
    static var channelValue: ColorPickerChannelValue {
        let value = ColorPickerChannelValue()
        value.red = 0.0
        value.green = 0.5
        value.blue = 1.0
        return value
    }
    
    static var previews: some View {
        ColorPicker(channelValue: channelValue)
            .previewInterfaceOrientation(.portrait)
    }
}

The value of each channel is retrieved from the ColorPicker.channelValue property, so when ColorPicker_Previews creates a ColorPicker view, it can be implemented by passing the ColorPickerChannelValue that stores the value you want to display.

When you run this code, you will see a preview as follows. The knob appears in the expected position.

Execution result of live preview
Execution result of live preview

Authored Books

Let's share this post !

Author of this article

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

TOC