Part 9 Dynamically Changing Gradients | Create the Color Picker with SwiftUI

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.

TOC

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.

Live Preview
Live Preview

Download the code

Click here to download the code created for this article.

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