Skip to content

Instantly share code, notes, and snippets.

@PhilipTrauner
Created August 31, 2023 17:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save PhilipTrauner/7de2134627911d8584fcc39947eac079 to your computer and use it in GitHub Desktop.
Save PhilipTrauner/7de2134627911d8584fcc39947eac079 to your computer and use it in GitHub Desktop.
// Created by Philip Trauner on 29.08.23.
import SwiftUI
public typealias RGB = (r: CGFloat, g: CGFloat, b: CGFloat)
extension Angle: Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(degrees)
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let degrees = try container.decode(Double.self)
self.init(degrees: degrees)
}
}
extension Collection {
/// https://stackoverflow.com/a/30593673
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : .none
}
}
extension CGColor {
static func from(hsbHue hue: Angle) -> Self {
Self.from(
hsb: .init(
hue: hue,
saturation: 1,
brightness: 1
)
)
}
public static func from(hsb: HSB) -> Self {
let rgb = hsb.rgb
return .init(
srgbRed: rgb.r,
green: rgb.g,
blue: rgb.b,
alpha: 1
)
}
}
extension Gradient {
static let colorWheelSpectrum: Gradient = Gradient(colors: [
Color(cgColor: .from(hsbHue: .radians(.pi / 2))),
Color(cgColor: .from(hsbHue: .radians(.pi / 4))),
Color(cgColor: .from(hsbHue: .radians(2 * .pi))),
Color(cgColor: .from(hsbHue: .radians(7/4 * .pi))),
Color(cgColor: .from(hsbHue: .radians(3/2 * .pi))),
Color(cgColor: .from(hsbHue: .radians(5/4 * .pi))),
Color(cgColor: .from(hsbHue: .radians(.pi))),
Color(cgColor: .from(hsbHue: .radians( 3/4 * .pi))),
Color(cgColor: .from(hsbHue: .radians(.pi / 2)))
])
}
public struct HSB: Equatable, Codable {
public var hue: Angle
public var saturation: CGFloat
public var brightness: CGFloat
public init(hue: Angle, saturation: CGFloat, brightness: CGFloat) {
self.hue = hue
self.saturation = saturation
self.brightness = brightness
}
/// https://gist.github.com/FredrikSjoberg/cdea97af68c6bdb0a89e3aba57a966ce
public var rgb: RGB {
let h = self.hue.degrees
let s = self.saturation
let v = self.brightness
if s == 0 {
return (r: v, g: v, b: v)
}
let angle = (h >= 360 ? 0 : h)
let sector = angle / 60
let i = floor(sector)
let f = sector - i
let p = v * (1 - s)
let q = v * (1 - (s * f))
let t = v * (1 - (s * (1 - f)))
switch(i) {
case 0:
return (r: v, g: t, b: p)
case 1:
return (r: q, g: v, b: p)
case 2:
return (r: p, g: v, b: t)
case 3:
return (r: p, g: q, b: v)
case 4:
return (r: t, g: p, b: v)
default:
return (r: v, g: p, b: q)
}
}
}
struct ColorWheel: View {
@Environment(\.colorWheelPreviewing) private var colorWheelPreviewing
@Binding var picked: HSB?
let fallback: HSB
public var body: some View {
GeometryReader { reader in
let frame = reader.frame(in: .local)
/// aspect ratio is constrained to `1`, therefor width and height are guaranteed to be equal
let radius = frame.width / 2
let strokeWidth: CGFloat = frame.width / 8
let previewDiameter = frame.width - (strokeWidth * 3)
let dragIndicatorDiameter = strokeWidth + frame.width / 20
let conic = AngularGradient(
gradient: Gradient.colorWheelSpectrum,
center: .center,
angle: .degrees(-90)
)
ZStack(alignment: .center) {
Circle()
.strokeBorder(conic, lineWidth: strokeWidth)
.shadow(
color: Color.black.opacity(0.1),
radius: radius / 10, x: 0, y: 0
)
.gesture(
DragGesture(
minimumDistance: 0,
coordinateSpace: .local
)
.onChanged { value in
picked = .init(
hue: Self.hue(
point: value.location,
radius: radius
),
saturation: picked?.saturation ?? fallback.saturation,
brightness: picked?.brightness ?? fallback.brightness
)
}
)
ZStack {
if let picked, colorWheelPreviewing {
Circle()
.fill(Color(cgColor: .from(hsb: picked)))
.shadow(
color: Color.black.opacity(0.1),
radius: radius / 10, x: 0, y: 0
)
.frame(width: previewDiameter, height: previewDiameter)
.transition(.opacity)
}
}
.animation(.easeInOut, value: picked == nil)
let either = picked ?? fallback
let indicatorOffset = CGSize(
width: cos(either.hue.radians) * Double(frame.midX - strokeWidth / 2),
height: -sin(either.hue.radians) * Double(frame.midY - strokeWidth / 2)
)
Circle()
.fill(Color.white)
.frame(width: dragIndicatorDiameter, height: dragIndicatorDiameter)
.offset(indicatorOffset)
.allowsHitTesting(false)
.shadow(
color: Color.black.opacity(0.1),
radius: radius / 6, x: 0, y: 0
)
}
}
.animation(.interactiveSpring, value: picked)
.aspectRatio(1, contentMode: .fit)
}
private static func hue(point: CGPoint, radius: CGFloat) -> Angle {
let adjustedAngle = atan2f(Float(radius - point.x), Float(radius - point.y)) + .pi / 2
return Angle(
radians: Double(adjustedAngle < 0 ? adjustedAngle + .pi * 2 : adjustedAngle)
)
}
}
public struct ColorWheelPreviewing: EnvironmentKey {
public static let defaultValue: Bool = true
}
extension EnvironmentValues {
public var colorWheelPreviewing: Bool {
get { self[ColorWheelPreviewing.self] }
set { self[ColorWheelPreviewing.self] = newValue }
}
}
struct ColorSlider: View {
@Binding var picked: HSB
enum Mode {
case saturation
case brightness
}
let mode: Mode
private var trailingColor: CGColor {
switch mode {
case .saturation:
return .from(hsb: .init(hue: picked.hue, saturation: 0, brightness: picked.brightness))
case .brightness:
return .from(hsb: .init(hue: picked.hue, saturation: picked.saturation, brightness: 0))
}
}
private var leadingColor: CGColor {
switch mode {
case .saturation:
let modified: HSB = .init(hue: picked.hue, saturation: 1, brightness: picked.brightness)
return .from(hsb: modified)
case .brightness:
let modified: HSB = .init(hue: picked.hue, saturation: picked.saturation, brightness: 1)
return .from(hsb: modified)
}
}
private var gradient: LinearGradient {
LinearGradient(
gradient: .init(colors: [Color(cgColor: leadingColor), Color(cgColor: trailingColor)]),
startPoint: .top, endPoint: .bottom)
}
private func sliderPosition(sliderHeight: CGFloat, thumbHeight: CGFloat) -> CGFloat {
var value: CGFloat
switch mode {
case .brightness:
value = picked.brightness
case .saturation:
value = picked.saturation
}
let inverted = abs(value - 1)
return (sliderHeight * inverted) - (thumbHeight * inverted)
}
public var body: some View {
GeometryReader { reader in
let cornerDimension = reader.size.width / 6
let clipShape = RoundedRectangle(
cornerSize: .init(
width: cornerDimension,
height: cornerDimension
)
)
let spacing = reader.size.height / 24
let imageHeight = reader.size.width / (3 / 2)
let sliderHeight = reader.size.height - imageHeight - spacing
let thumbHeight = reader.size.width / 5
VStack(spacing: spacing) {
Group {
switch mode {
case .brightness:
Image(systemName: "sun.max")
.resizable()
.accessibilityLabel("Brightness")
case .saturation:
Image(systemName: "drop")
.resizable()
.accessibilityLabel("Saturation")
}
}
.aspectRatio(contentMode: .fit)
.frame(height: imageHeight)
ZStack(alignment: .top) {
gradient
.frame(width: reader.size.width)
.clipShape(clipShape)
clipShape
.fill(Color.white)
.frame(width: reader.size.width, height: thumbHeight)
.padding(reader.size.width / 16)
.background {
clipShape
.fill(Color.black)
}
.offset(
y: sliderPosition(sliderHeight: sliderHeight, thumbHeight: thumbHeight)
)
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
let clamped = min(max(value.location.y, 0), sliderHeight)
let percentage = abs((clamped / sliderHeight) - 1)
switch mode {
case .saturation:
picked.saturation = percentage
case .brightness:
picked.brightness = percentage
}
}
)
.animation(.interactiveSpring, value: picked)
}
}
.aspectRatio(1 / 9, contentMode: .fit)
}
}
struct ColorPickerViewLayout: Layout {
private static let fallbackWidth: CGFloat = 400
private static func spacing(proposedWidth: CGFloat) -> CGFloat {
proposedWidth / 10
}
private static func leadingSize(subviews: Subviews, proposal: ProposedViewSize) -> CGSize {
let proposedWidth = proposal.width ?? Self.fallbackWidth
guard let first = subviews.first, let second = subviews[safe: 1], subviews.count == 2 else {
fatalError("expected two subview")
}
let baseline = first.sizeThatFits(proposal)
let derived = second.sizeThatFits(.init(width: baseline.width, height: baseline.height))
let spacing = Self.spacing(proposedWidth: proposedWidth)
let dimension = baseline.width - (baseline.width + derived.width > proposedWidth ? derived.width : 0) - spacing / 2
return .init(width: dimension, height: dimension)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) -> CGSize {
let proposedWidth = proposal.width ?? Self.fallbackWidth
let leadingSize = Self.leadingSize(subviews: subviews, proposal: proposal)
return CGSize(
width: proposedWidth,
height: leadingSize.height
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) {
guard let first = subviews.first, let second = subviews[safe: 1], subviews.count == 2 else {
fatalError("expected two subview")
}
let leadingSize = Self.leadingSize(subviews: subviews, proposal: proposal)
let trailingSize = second.sizeThatFits(
.init(width: bounds.width - leadingSize.width, height: leadingSize.height)
)
let spacing = Self.spacing(proposedWidth: bounds.width)
let padding = max(
0,
(bounds.size.width - (leadingSize.width + spacing + trailingSize.width)) / 2
)
first.place(
at: .init(x: bounds.minX + padding, y: bounds.midY),
anchor: .leading,
proposal: .init(width: leadingSize.width, height: leadingSize.height)
)
second.place(
at: .init(x: bounds.maxX - padding, y: bounds.midY),
anchor: .trailing,
proposal: .init(width: .none, height: leadingSize.height)
)
}
}
public struct ColorPickerView: View {
@Binding var picked: HSB?
private let fallback: HSB
public init(
picked: Binding<HSB?>,
fallback: HSB = .init(hue: .zero, saturation: 1, brightness: 1)
) {
self._picked = picked
self.fallback = fallback
}
public var body: some View {
let pickedBinding: Binding<HSB> = .init(
get: {
picked ?? fallback
},
set: {
picked = $0
}
)
ColorPickerViewLayout {
ColorWheel(picked: $picked, fallback: fallback)
HStack(spacing: 20) {
ColorSlider(picked: pickedBinding, mode: .saturation)
ColorSlider(picked: pickedBinding, mode: .brightness)
}
}
}
}
struct ColorPickerView_Previews: PreviewProvider {
struct Wrap: View {
@State private var picked: HSB?
var body: some View {
ColorPickerView(picked: $picked)
.padding(.horizontal)
.environment(\.colorWheelPreviewing, false)
}
}
static var previews: some View {
Wrap()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment