Skip to content

Instantly share code, notes, and snippets.

@mattyoung
Last active January 4, 2023 21:16
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattyoung/3c86ec93882327d86b7354ca1bb44370 to your computer and use it in GitHub Desktop.
Save mattyoung/3c86ec93882327d86b7354ca1bb44370 to your computer and use it in GitHub Desktop.
//
// ContentView.swift
// ColorCodable
//
// Created by Mateo on 5/26/22.
//
import SwiftUI
// from @natpanferova https://nilcoalescing.com/blog/EncodeAndDecodeSwiftUIColor/
struct CGColorCodable: Codable {
let cgColor: CGColor
enum CodingKeys: String, CodingKey {
case colorSpace
case components
}
init(cgColor: CGColor) {
self.cgColor = cgColor
}
init(from decoder: Decoder) throws {
let container = try decoder
.container(keyedBy: CodingKeys.self)
let colorSpace = try container
.decode(String.self, forKey: .colorSpace)
let components = try container
.decode([CGFloat].self, forKey: .components)
guard
let cgColorSpace = CGColorSpace(name: colorSpace as CFString),
let cgColor = CGColor(
colorSpace: cgColorSpace, components: components
)
else {
throw CodingError.wrongData
}
self.cgColor = cgColor
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
guard
let colorSpace = cgColor.colorSpace?.name,
let components = cgColor.components
else {
throw CodingError.wrongData
}
try container.encode(colorSpace as String, forKey: .colorSpace)
try container.encode(components, forKey: .components)
}
}
enum CodingError: Error {
case wrongColor
case wrongData
case unknownDynamicColor(String)
}
// ============================================================================
extension Color {
var name: String {
cgColor.map { AXNameFromColor($0) } ?? String(describing: self)
}
}
extension Color {
var lightDarkCGColors: (light: CGColor, dark: CGColor) {
precondition(cgColor == nil) // only can call this for dynamic color!
#warning("There is a bug here: UIColor(self) only returns light mode color, not a dynamic color")
let uiColor = UIColor(self) // bug: this only returns light mode color, not a dynamic color
return (uiColor.resolvedColor(with: .init(userInterfaceStyle: .light)).cgColor, uiColor.resolvedColor(with: .init(userInterfaceStyle: .dark)).cgColor)
}
}
extension UITraitCollection {
var isLightMode: Bool {
userInterfaceStyle == .light
}
}
// black, white and clear are not dynamic colors!
enum BuiltInDynamicColor: String, Identifiable, Codable, CaseIterable {
// the rawValue match what the Color return with `String(describing: color)`
case blue = "blue"
case brown = "brown"
case cyan = "cyan"
case gray = "gray"
case green = "green"
case indigo = "indigo"
case mint = "mint"
case orange = "orange"
case pink = "pink"
case purple = "purple"
case red = "red"
case teal = "teal"
case yellow = "yellow"
case accentColor = "AccentColorProvider()"
case primary = "primary"
case secondary = "secondary"
var id: Self { self }
var color: Color {
switch self {
case .blue:
return .blue
case .brown:
return .brown
case .cyan:
return .cyan
case .gray:
return .gray
case .green:
return .green
case .indigo:
return .indigo
case .mint:
return .mint
case .orange:
return .orange
case .pink:
return .pink
case .purple:
return .purple
case .red:
return .red
case .teal:
return .teal
case .yellow:
return .yellow
case .accentColor:
return .accentColor
case .primary:
return .primary
case .secondary:
return .secondary
}
}
}
/// This property wrapper adapts Color to be Codable, encode/decode either dynamic colors or arbitrary color using cgColor
/// Use this inside your own type:
///
/// ```swift
/// struct Foo: Codable {
/// @ColorCodable let color: Color
/// // other codable fields:
/// let number: Int
/// let name: String
/// // etc
/// }
/// ```
@propertyWrapper
struct ColorCodable {
let wrappedValue: Color
}
extension ColorCodable: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let c = try? container.decode(BuiltInDynamicColor.self) {
wrappedValue = c.color
} else if let c = try? container.decode(CGColorCodable.self) {
wrappedValue = Color(c.cgColor)
} else if let lightDarkCGColors = try? container.decode([CGColorCodable].self) {
wrappedValue = Color(uiColor: UIColor { $0.isLightMode ? UIColor(cgColor: lightDarkCGColors[0].cgColor) : UIColor(cgColor: lightDarkCGColors[1].cgColor) })
} else {
throw CodingError.wrongData
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let cgColor = wrappedValue.cgColor {
try container.encode(CGColorCodable(cgColor: cgColor))
} else if let c = BuiltInDynamicColor(rawValue: wrappedValue.name) {
try container.encode(c)
} else {
// cgColor is nil and but it's not one of the BuiltInDynamicColor: must be user define adaptive/dynamic color
let (lightCGColor, darkCGColor) = wrappedValue.lightDarkCGColors
try container.encode([CGColorCodable(cgColor: lightCGColor), CGColorCodable(cgColor: darkCGColor)])
}
}
}
/// This property wrapper adapts Color to be `RawRepresentable where RawValue = String` so it can be
/// used with @AppStorage to persist Color to `UserDefaults`, support either dynamic colors or any arbitrary colors
///
/// How to use:
///
/// ```swift
/// @AppStorage("myColor") @ColorRawRepresentable var color = Color.blue
/// ```
/// To access the color var as `Binding<Color>`, must manually create a binding:
///
/// ```swift
/// let binding = Binding { color } set: { color = $0 }
/// ```
///
@propertyWrapper
struct ColorRawRepresentable {
var wrappedValue: Color
}
extension ColorRawRepresentable: RawRepresentable {
public var rawValue: String {
let encoder = JSONEncoder()
if let cgColor = wrappedValue.cgColor { // cgColor is not nil, it must be a plain color
do {
let jsonData = try encoder.encode(CGColorCodable(cgColor: cgColor))
return String(data: jsonData, encoding: .utf8)!
} catch {
fatalError("JSONEncode CGColor failed")
}
} else if let dynamicColor = BuiltInDynamicColor(rawValue: wrappedValue.name) { // cgColor is nil, this must be a dynamic color
do {
let jsonData = try encoder.encode(dynamicColor)
return String(data: jsonData, encoding: .utf8)!
} catch {
fatalError("JSONEncode BuiltInDynamicColor failed")
}
} else {
do {
// cgColor is nil and but it's not one of the BuiltInDynamicColor: must be user define adaptive/dynamic color
let (lightCGColor, darkCGColor) = wrappedValue.lightDarkCGColors
let jsonData = try encoder.encode([CGColorCodable(cgColor: lightCGColor), CGColorCodable(cgColor: darkCGColor)])
return String(data: jsonData, encoding: .utf8)!
} catch {
fatalError("JSONEncode user define dynamic color failed")
}
}
}
public init?(rawValue: String) {
guard let jsonData = rawValue.data(using: .utf8) else {
return nil
}
let decoder = JSONDecoder()
if let dynamicColor = try? decoder.decode(BuiltInDynamicColor.self, from: jsonData) {
wrappedValue = dynamicColor.color
} else if let cgColorCodable = try? decoder.decode(CGColorCodable.self, from: jsonData) {
wrappedValue = Color(cgColorCodable.cgColor)
} else if let lightDarkCGColors = try? decoder.decode([CGColorCodable].self, from: jsonData) {
wrappedValue = Color(uiColor: UIColor { $0.isLightMode ? UIColor(cgColor: lightDarkCGColors[0].cgColor) : UIColor(cgColor: lightDarkCGColors[1].cgColor) })
} else {
return nil
}
}
}
// ======================================= Demo =========================================
// The demo lets you pick a dynamic color or any color using color picker and the color
// is saved/loaded with @AppStorage
// Make all the ColorButton label equal width
struct PreferredWidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct PreferredWidthEnvironmentKey: EnvironmentKey {
static var defaultValue: CGFloat = 0
}
extension EnvironmentValues {
var preferredWidth: CGFloat {
get { self[PreferredWidthEnvironmentKey.self] }
set { self[PreferredWidthEnvironmentKey.self] = newValue }
}
}
struct PreferredWidthViewModifier: ViewModifier {
@Environment(\.preferredWidth) private var preferredWidth
func body(content: Content) -> some View {
content
.background(
GeometryReader {
Color.clear
.preference(key: PreferredWidthPreferenceKey.self, value: $0.size.width)
}
)
.frame(minWidth: preferredWidth)
}
}
extension View {
@warn_unqualified_access
func preferredWidth() -> some View {
self.modifier(PreferredWidthViewModifier())
}
}
// A view to pick a specific color used for predefined set of SwiftUI colors or user define color
struct ColorButton: View {
let name: String
let color: Color
@Binding var selection: Color
var body: some View {
Button {
selection = color
} label: {
Text(name.hasPrefix("AccentColor") ? "Accent" : name)
.lineLimit(1)
.preferredWidth() // make it equal width, must be here!
.padding(5)
.background(Capsule().fill(.bar))
.padding(.vertical, 5)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(color))
.overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(.primary))
.padding(.horizontal)
}
}
}
extension Color {
// a user define dynamic color, due to a bug (FB10031357), this will only be encoded/save to @AppStorage in light mode value
static let spiffy = Color(UIColor { $0.isLightMode ? .systemTeal : .yellow })
}
enum SpecialColor: Identifiable, CaseIterable {
case spiffy, black, white, clear
var id: Self { self }
var name: String {
String(describing: self)
}
var color: Color {
switch self {
case .spiffy:
return .spiffy
case .black:
return .black
case .white:
return .white
case .clear:
return .clear
}
}
}
struct ContentView: View {
// Property wrapper composition works! But $color is not `Binding<Color>`, but is `Binding<ColorRawPrepresentable>`,
// to get a binding to this Color, just manually create a `Binding<Color>`, see code below inside body
@AppStorage("myColor") @ColorRawRepresentable var color = Color.blue
@State private var labelWidth: CGFloat = 0 // for equal width ColorButton label
static let gridItems = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(color)
.overlay {
Text(color.name)
.padding(5)
.padding(.horizontal)
.background {
Capsule()
.fill(.bar)
}
}
.aspectRatio(3, contentMode: .fit)
.padding()
// Manually create this binding because PW composition of $color is not `Binding<Color>`, but is `Binding<ColorRawRepresentable>`
let binding = Binding { color } set: { color = $0 }
VStack {
ColorPicker("Pick a color", selection: binding)
.padding(.horizontal)
Divider()
ScrollView {
LazyVGrid(columns: Self.gridItems, spacing: 5) {
ForEach(SpecialColor.allCases) {
ColorButton(name: $0.name, color: $0.color, selection: binding)
}
ForEach(BuiltInDynamicColor.allCases) {
ColorButton(name: $0.rawValue, color: $0.color, selection: binding)
}
}
}
}
.padding(.vertical)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(.bar))
}
.environment(\.preferredWidth, labelWidth)
.onPreferenceChange(PreferredWidthPreferenceKey.self) { labelWidth = $0 }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment