Reputation: 226
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 😅.
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)
}
Here's the theory above the mixtures of the color saturation with the brightness.
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:
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.
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!
Here, as you can see, the full saturated color (that should theoretically be 2π of the circumference) is not correct.
Here, as you can see, there's an issue by going outside of the circle.
updateCursorPosition
resulting color value, if the cursor is dragged out of the circle?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
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