Created
August 31, 2023 17:49
-
-
Save PhilipTrauner/7de2134627911d8584fcc39947eac079 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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