This is a series of articles on creating a color picker in SwiftUI. In the previous articles, the model was partitioned by channel, with gradient and current channel value information being distinct entities, thereby complicating the dynamic gradient change implementation. This time, we will integrate this information into a model class that has information on the entire color picker, and at the same time, we will implement the process of dynamically changing the gradient.
Dynamic gradient changes
In the previous implementation, the gradient shows the value of each channel varying from 0.0
to 1.0
. The color created in this case is fixed to a value of 0.0
for channels other than the target.
If this is changed so that the current value of each channel is used instead of fixing it at 0.0
, the color of the gradient can be changed dynamically.
When the slider knob is moved, the actual color produced matches the gradient, thus making the slider more intuitive.
Add ColorPickerModel
Create a ColorPickerModel
class based on the ColorPickerChannelModel
and ColorPickerGradationModel
classes.
Define the properties for the current value
import Foundation
import SwiftUI
class ColorPickerModel : ObservableObject {
/// Current value: Red
@Published var red: Double = 0.0
/// Current value: Green
@Published var green: Double = 0.0
/// Current value: Blue
@Published var blue: Double = 0.0
}
Get color based on the current value
Add a property to get the color based on the current value of each channel.
/// Selected color
var color: Color {
Color(red: red, green: green, blue: blue)
}
Method that changes with rounding of the current value
Implement a method to perform rounding when setting the current value. Although it is acceptable to prepare a method for each channel, other methods may need to be processed for each channel, so we will implement a method that takes a channel specification as an argument.
/// Channel
enum Channel {
case red
case green
case blue
}
/// Change the current value
/// - Parameters:
/// - value: New value. If out of range, it will be rounded.
/// - channel: Channel to be changed
func changeCurrentValue(_ value: Double, channel: Channel) {
var newValue = value
if newValue < 0.0 {
newValue = 0.0
} else if newValue > 1.0 {
newValue = 1.0
}
switch channel {
case .red:
red = newValue
case .green:
green = newValue
case .blue:
blue = newValue
}
}
Editing text and bindings
Implement a property for each channel that assigns the text being edited. Also, since bindings are created dynamically, implement a method that takes a channel as an argument.
/// Text being entered: Red
private var fieldTextStoreRed: String?
/// Text being entered: Green
private var fieldTextStoreGreen: String?
/// Text being entered: Blue
private var fieldTextStoreBlue: String?
/// Binding of the text being entered: Red
var fieldTextRed: Binding<String> {
Binding {
self.fieldTextStoreRed ?? String(format: "%g", self.red)
} set: {
self.fieldTextStoreRed = $0
if let value = Double($0) {
self.changeCurrentValue(value, channel: .red)
}
}
}
/// Binding of the text being entered: Green
var fieldTextGreen: Binding<String> {
Binding {
self.fieldTextStoreGreen ?? String(format: "%g", self.green)
} set: {
self.fieldTextStoreGreen = $0
if let value = Double($0) {
self.changeCurrentValue(value, channel: .green)
}
}
}
/// Binding of the text being entered: Blue
var fieldTextBlue: Binding<String> {
Binding {
self.fieldTextStoreBlue ?? String(format: "%g", self.blue)
} set: {
self.fieldTextStoreBlue = $0
if let value = Double($0) {
self.changeCurrentValue(value, channel: .blue)
}
}
}
/// 入力中のテキストをクリアする
/// - Parameters:
/// - channel: チャネル
func clearFieldText(channel: Channel) {
switch channel {
case .red:
fieldTextStoreRed = nil
case .green:
fieldTextStoreGreen = nil
case .blue:
fieldTextStoreBlue = nil
}
}
Gradient View Size
The property containing the gradient view’s size is also defined for each channel. It is the same size, so that it could be just one.
/// Gradient view size: Red
@Published var gradationViewSizeRed: CGSize = CGSize()
/// Gradient view size: Green
@Published var gradationViewSizeGreen: CGSize = CGSize()
/// Gradient view size: Blue
@Published var gradationViewSizeBlue: CGSize = CGSize()
Gradient start and end colors
Define properties to get the start and end colors of the gradient. This is also per channel.
/// Gradient start color: Red
var startColorRed: Color {
Color(red: 0.0, green: self.green, blue: self.blue)
}
/// Gradient end color: Red
var endColorRed: Color {
Color(red: 1.0, green: self.green, blue: self.blue)
}
/// Gradient start color: Green
var startColorGreen: Color {
Color(red: self.red, green: 0.0, blue: self.blue)
}
/// Gradient end color: Green
var endColorGreen: Color {
Color(red: self.red, green: 1.0, blue: self.blue)
}
/// Gradient start color: Blue
var startColorBlue: Color {
Color(red: self.red, green: self.green, blue: 0.0)
}
/// Gradient end color: Blue
var endColorBlue: Color {
Color(red: self.red, green: self.green, blue: 1.0)
}
Change the implementation of the PickerGradientView
Replace ColorPickerChannelModel
and ColorPickerGradationModel
with ColorPickerModel
.
Change the model classes
Corresponding from PickerGradationView
, remove the properties that assign instances of the ColorPickerChannelModel
and ColorPickerGradationModel
classes. Instead, add properties that assign instances of the ColorPicker.Channel
and ColorPickerModel
classes.
struct PickerGradationView: View {
var channel: ColorPickerModel.Channel
@ObservedObject var model: ColorPickerModel
Change implementation of the “indicatorX” property
The indicatorX
property is changed to a value calculated using the channel value specified in the channel property.
/// X coordinate of knob
var indicatorX: CGFloat {
switch channel {
case .red:
return indicatorXRed
case .green:
return indicatorXGreen
case .blue:
return indicatorXBlue
}
}
var indicatorXRed: CGFloat {
model.gradationViewSizeRed.width * model.red - indicatorSize / 2
}
var indicatorXGreen: CGFloat {
model.gradationViewSizeGreen.width * model.green - indicatorSize / 2
}
var indicatorXBlue: CGFloat {
model.gradationViewSizeBlue.width * model.blue - indicatorSize / 2
}
Change implementation of drag property
Change the implementation of the drag
property. When calculating, the width of the gradient view should be used for the channel specified by the channel
property. Also, the changeCurrentValue
and clearFieldText
methods now specify the channel, so add the channel specification.
var drag: some Gesture {
DragGesture()
.onChanged { dragValue in
let width: Double
switch channel {
case .red:
width = self.model.gradationViewSizeRed.width
case .green:
width = self.model.gradationViewSizeGreen.width
case .blue:
width = self.model.gradationViewSizeBlue.width
}
let value = dragValue.location.x / width
model.changeCurrentValue(value, channel: channel)
model.clearFieldText(channel: channel)
}
}
Obtaining the start and end colors of the gradient
Add a property to get the start and end colors of the gradient, changing the referenced property depending on the value of the channel
property.
var startColor: Color {
switch channel {
case .red:
return model.startColorRed
case .green:
return model.startColorGreen
case .blue:
return model.startColorBlue
}
}
var endColor: Color {
switch channel {
case .red:
return model.endColorRed
case .green:
return model.endColorGreen
case .blue:
return model.endColorBlue
}
}
Change the Gradient
argument to draw a gradient using the added startColor
and endColor
properties.
var body: some View {
ZStack(alignment: .leading) {
GeometryReader() { geometry in
LinearGradient(gradient: Gradient(colors: [startColor, endColor]),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 1, y: 0))
}
Change processing when tapped
When tapped, the current value is updated, and the entered string is cleared. The code should be changed so that the channel
property is also used to change the target to be changed at this time.
var body: some View {
ZStack(alignment: .leading) {
GeometryReader() { geometry in
LinearGradient(gradient: Gradient(colors: [startColor, endColor]),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 1, y: 0))
}
.frame(height: gradationHeigh)
.background {
GeometryReader { geometry in
Path { path in
Task.detached {
await updateViewSize(geometry.size)
}
}
}
}
.onTapGesture { point in
switch self.channel {
case .red:
model.red = point.x / model.gradationViewSizeRed.width
case .green:
model.green = point.x / model.gradationViewSizeGreen.width
case .blue:
model.blue = point.x / model.gradationViewSizeBlue.width
}
model.clearFieldText(channel: channel)
}
.gesture(drag)
PickerIndicator()
.frame(width: indicatorSize, height: indicatorSize)
.offset(x: indicatorX)
}
}
Change the implementation of updateViewSize
Change the code so that the target to be updated by the updateViewSize
method is changed by the channel
property.
@MainActor
func updateViewSize(_ size: CGSize) async {
switch channel {
case .red:
if size != model.gradationViewSizeRed {
model.gradationViewSizeRed = size
}
case .green:
if size != model.gradationViewSizeGreen {
model.gradationViewSizeGreen = size
}
case .blue:
if size != model.gradationViewSizeBlue {
model.gradationViewSizeBlue = size
}
}
}
Change the implementation of the preview
Since the stored properties have changed, the code for Xcode’s live preview will also change.
struct PickerGradationView_Previews: PreviewProvider {
static var previews: some View {
PickerGradationView(channel: .red,
model: ColorPickerModel())
.padding()
}
}
Change the implementation of the ColorPickerSubView
Since the stored property of the PickerGradationView
has changed, the ColorPickerSubView
that uses the PickerGradationView
must also change its code.
Change properties for the model
Remove properties using the ColorPickerChannelModel
and ColorPickerGradationModel
classes and instead substitute instances of the ColorPickerModel.Channel
and ColorPickerModel
classes. Add the properties.
struct ColorPickerSubView: View {
var channel: ColorPickerModel.Channel
@ObservedObject var model: ColorPickerModel
Channel name
The code is designed to pass the channel name from a higher level. Still, since the channel is known, the implementation should be changed so that the channel
property changes the string.
var channelName: String {
switch channel {
case .red:
return "Red"
case .green:
return "Green"
case .blue:
return "Blue"
}
}
var body: some View {
VStack {
HStack {
Text("\(channelName): ")
.font(.title)
Text field binding
Change the code so that the channel
property changes the binding passed to TextField
.
var fieldText: Binding<String> {
switch self.channel {
case .red:
return self.model.fieldTextRed
case .green:
return self.model.fieldTextGreen
case .blue:
return self.model.fieldTextBlue
}
}
var body: some View {
VStack {
HStack {
Text("\(channelName): ")
.font(.title)
TextField("", text: fieldText)
.textFieldStyle(.roundedBorder)
}
Change the initializer arguments
The stored property of PickerGradationView
has changed, and the arguments passed to the initializer have changed.
var body: some View {
VStack {
HStack {
Text("\(channelName): ")
.font(.title)
TextField("", text: fieldText)
.textFieldStyle(.roundedBorder)
}
PickerGradationView(channel: channel, model: model)
}
}
Change the implementation of the ColorPickerSubView_Previews
Since the stored property of ColorPickerSubView
has changed, change the initializer argument for the ColorPickerSubView
to be generated for live preview.
struct ColorPickerSubView_Previews: PreviewProvider {
static var previews: some View {
ColorPickerSubView(channel: .red, model: ColorPickerModel())
.padding()
}
}
Change the implementation of the ColorPicker
The stored property of ColorPickerSubView
has changed, so the initializer argument needs to be changed. Also, the code must be changed to change the model to ColorPickerModel
.
Change models
The following properties are no longer needed and will be removed.
redChannel
redGradation
greenChannel
greenGradation
blueChannel
blueGradation
Instead, add a model
property that assigns an instance of the ColorPickerModel
class.
struct ColorPicker: View {
@StateObject var model: ColorPickerModel = ColorPickerModel()
Remove the color property
Remove the ColorPicker.color
property since it is no longer needed, and change the color passed to the initializer of ColorPickerPreview
to pass the ColorPickerModel.color
property.
ColorPickerPreview(color: model.color)
.frame(width: 100, height: 100)
ColorPickerPreview
was passing bindings, but since we will not change them on the preview side, we will change it to passing values instead of bindings.
import SwiftUI
struct ColorPickerPreview: View {
var color: Color
var body: some View {
GeometryReader { geometry in
Path { path in
path.addRect(CGRect(x: 0, y: 0, width: geometry.size.width, height: geometry.size.height))
}
.fill(color)
Path { path in
path.addRect(CGRect(x: 0, y: 0, width: geometry.size.width, height: geometry.size.height))
}
.stroke(lineWidth: 4)
}
}
}
struct ColorPickerPreview_Previews: PreviewProvider {
static var previews: some View {
ColorPickerPreview(color: .blue)
}
}
Change ColorPickerSubView initializer
The ColorPickerSubView
stored property has changed, so the initializer needs to be changed.
struct ColorPicker: View {
@StateObject var model: ColorPickerModel = ColorPickerModel()
var body: some View {
VStack {
ColorPickerSubView(channel: .red, model: model)
ColorPickerSubView(channel: .green, model: model)
ColorPickerSubView(channel: .blue, model: model)
VStack {
Text("Preview")
ColorPickerPreview(color: model.color)
.frame(width: 100, height: 100)
}
.padding()
}
.padding()
}
}
Check Running
Let’s test the execution. Xcode’s live preview acted up right after the code modification.
If something is wrong, clean the project or run it on an actual device or iOS simulator and restart Xcode to fix it.
Download the code
Click here to download the code created for this article.