Massimo De Luisa
Massimo De Luisa

Reputation: 226

Brightness Saturation circular pad in Swift

Preamble

I'm trying to create a rounded Brightness Saturation pad using SwiftUI both for indication (from a given color, show the cursor position) and use (by moving the cursor, obtain the resulting color).

I apologize for my bad English 😅.

Presentation code

Firstly I draw the Circle using two Circle elements, filled with a LinearGradient element and the luminosity bland mode.

    public var body: some View {
        ZStack(alignment: .top) {
            //MARK: - Inner Color circle
            Group {
                Circle()
                    .fill(
                        LinearGradient(
                            colors: [self.minSaturatedColor, self.maxSaturatedColor],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                    )
                Circle()
                    .fill(
                        LinearGradient(
                            colors: [.white, .black],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
                    .blendMode(.luminosity)
            }
            .frame(width: self.diameter, height: self.diameter, alignment: .center)
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onChanged(self.updateCursorPosition)
                    .onEnded({ _ in
                        self.startIndication = nil
                        self.changed(self.finalColor)
                    })
            )

            //MARK: - Indicator

            Circle()
                .stroke(Color.black, lineWidth: 2)
                .foregroundColor(Color.clear)
                .frame(width: self.cursorDiameter, height: self.cursorDiameter, alignment: .center)
                .position(cursorPosition)
        }
        .frame(width: self.diameter, height: self.diameter, alignment: .center)
    }

Theory

Here's the theory above the mixtures of the color saturation with the brightness. enter image description here

I'm not sure that this is the correct approach, but it seems so because it produces the following circle having (probably) all the shades:
enter image description here

Problem 1

The simplified code that I used to determine the Cursor position (from a given color) is:

    public var cursorPosition: CGPoint {
        let hsb = self.finalColor.hsba
        let x: CGFloat = hsb.saturation * self.diameter
        let y: CGFloat = hsb.brightness * self.diameter 

        // Adjust the Apple reversed ordinate
        return CGPoint(x: x, y: self.diameter - y)
    }

Unfortunately, this it seems not correct because by giving a certain color (I.e. #bd4d22) it makes the pointer placed in a wrong position. enter image description here

Problem 2

There's also another problem: the component should allow to move the cursor and so update the finalColor State property with the correct amount of brightness and saturation.

    private func updateCursorPosition(_ value: DragGesture.Value) -> Void {
        let hsb = self.baseColor.hsba
        
        let saturation = value.location.x / self.diameter
        let brightness = (0 - value.location.y + self.diameter) / self.diameter
        
        self.finalColor = Color(
            hue: hsb.hue,
            saturation: saturation,
            brightness: brightness
        )
    }

yet the result is not what expected because, event tough the cursor follows the point (meaning that the computed value is correct), the resulting color is not what rendered behind the pointer!

Example pt. 1

enter image description here Here, as you can see, the full saturated color (that should theoretically be 2π of the circumference) is not correct.

Example pt. 2

enter image description here Here, as you can see, there's an issue by going outside of the circle.

Question

  1. Can anyone explain where am I failing?
  2. Can anyone help me blocking the updateCursorPosition resulting color value, if the cursor is dragged out of the circle?
  3. Am I doing something else in the wrong way?

Thank you for the patience 🖖🏻!

Ps. To accomplish this, I take clue form what done by Procreate and what asked here Circular saturation-brightness gradient for color wheel

Upvotes: 1

Views: 500

Answers (1)

Yrb
Yrb

Reputation: 9665

Okay, this took a while as it wasn't one thing. I ended up pulling your code apart and rebuilding it a little more simply, as well as refactoring the indicator from the color view. To fix the indicator, I took inspiration from this answer. Give it a bump.

As to the color wheel itself, I simplified it into a Circle() with an overlay of the second Circle(). Getting the colors straight took a lot more time as I am not a designer, so I had to work through dealing with the colors less intuitively. I had a two realizations while dealing with this.

To handle the maximum and minimum saturations, the only variable that could be used was the hue. The brightness and saturation were fixed, either 0 or 1 for saturation and 1 for brightness, otherwise the base color gradient was off.

That caused me to have to deal with the brightness gradient. The color was already at maximum brightness, so putting a gradient from white to black rendered incorrectly, yet again. Another realization I had was that the brightness gradient should go from clear to black. That resolved the color issues. The code is below and commented.

The Color Wheel:

struct ColorWheelView: View {
    
    @State private var position: CGPoint
    @Binding public var selectedColor: Color
    
    private var saturation: CGFloat {
        position.x / diameter
    }
    private var brightness: CGFloat {
        (diameter - position.y) / diameter
    }
    
    private let diameter: CGFloat
    private let cursorDiameter: CGFloat
    
    private let baseHSB: (hue: Double, saturation: Double, brightness: Double, alpha: Double)
    private let minSaturatedColor: Color
    private let maxSaturatedColor: Color
    
    public init(
        //there needs to be an intitial fixed color to set the hue from
        color: Color,
        //this returns the selected color. It can start as anything as it is set to
        //color in .onAppear()
        selectedColor: Binding<Color>,
        diameter: CGFloat = 200,
        cursorDiameter: CGFloat = 20
    ) {
        let hsb = color.hsba
        // Because the view uses the different parts of color, it made sense to just have
        // an hsba here. I would consider making this its own Type just for readability.
        // The only thing that this entire variable is used for is setting the initial selected
        // color. Otherwise I could have just kept the hue.
        baseHSB = Color(hue: color.hsba.hue, saturation: color.hsba.saturation, brightness: color.hsba.brightness).hsba
    
        // This sets the initial indicator position to be at the correct place on the wheel as
        //The initial color sent in, including the initial saturation and brightness.
        _position = State(initialValue: CGPoint(x: hsb.saturation * diameter, y: diameter - (hsb.brightness * diameter)))
        // This is the return color.
        _selectedColor = selectedColor
        // self is only needed to avoid confusion for the compiler.
        self.diameter = diameter
        self.cursorDiameter = cursorDiameter
        // Note these are set to maximum brightness.
        minSaturatedColor = Color(hue: baseHSB.hue, saturation: 0, brightness: 1)
        maxSaturatedColor = Color(hue: baseHSB.hue, saturation: 1, brightness: 1)
    }
    
    
    var body: some View {
        VStack {
        Circle()
            .fill(
                LinearGradient(
                    colors: [self.minSaturatedColor, self.maxSaturatedColor],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .overlay(
                Circle()
                    .fill(
                        LinearGradient(
                            // Instead of a gradient of white to black, this is clear to black
                            // as the saturation gradients are at maximum brightness already.
                            colors: [.clear, .black],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
                    .blendMode(.luminosity)
            )
            .overlay(
                IndicatorView(position: $position, diameter: diameter, cursorDiameter: cursorDiameter)
            )
            .frame(width: self.diameter, height: self.diameter)
            .onAppear {
                // Sets the bound color to the original color.
                selectedColor = Color(hue: baseHSB.hue, saturation: baseHSB.saturation, brightness: baseHSB.brightness, opacity: baseHSB.alpha)
            }
            .onChange(of: position) { _ in
                // When position changes, this changes the saturation and brightness.
                selectedColor = Color(
                    hue: baseHSB.hue,
                    saturation: saturation,
                    brightness: brightness
                )
            }
        }
    }
}

The Indicator:

struct IndicatorView: View {
    
    @Binding var position: CGPoint
    private let diameter: CGFloat
    private let cursorDiameter: CGFloat
    private let radius: CGFloat
    private let center: CGPoint
    
    init(position: Binding<CGPoint>, diameter: CGFloat, cursorDiameter: CGFloat) {
        _position = position
        self.diameter = diameter
        self.cursorDiameter = cursorDiameter
        self.radius = diameter / 2
        // the center of the circle is the center of the frame which is CGPoint(radius, radius)
        self.center = CGPoint(x: radius, y: radius)
    }
    var body: some View {
        VStack {
            Circle()
                // This circle is simply to keep the indicator aligned with the ColorWheelView
                .fill(Color.clear)
                .overlay(
                    Circle()
                        .stroke(Color.black, lineWidth: 2)
                        .foregroundColor(Color.clear)
                        .frame(width: cursorDiameter, height: self.cursorDiameter)
                        .position(position)
                        .gesture(DragGesture()
                            .onChanged { value in
                                updatePosition(value.location)
                            }
                        )
                )
                .frame(width: diameter, height: diameter)
        }
        .onAppear {
            updatePosition(position)
        }
    }
    
    private func updatePosition(_ point: CGPoint) {
        let currentLocation = point
        let center = CGPoint(x: radius, y: radius)
        let distance = center.distance(to:currentLocation)
        // This triggers if the drag goes outside of the circle.
        if distance > radius {
            let overDrag = radius / distance
            //These coordinates are guaranteed inside of the circle.
            let newLocationX = (currentLocation.x - center.x) * overDrag + center.x
            let newLocationY = (currentLocation.y - center.y) * overDrag + center.y
            self.position = CGPoint(x: newLocationX, y: newLocationY)
        }else{
            self.position = point
        }
    }
}
    

extension CGPoint {
    //This is simply the Pythagorean theorem. Distance is the hypotenuse.
    func distance(to point: CGPoint) -> CGFloat {
        return sqrt(pow((point.x - x), 2) + pow((point.y - y), 2))
    }
}

Also, as a side note, most of the variables you had were constants and should be lets and not vars. Anything internal to the struct should be private. @State variables are always private.

Upvotes: 1

Related Questions