Last active
January 4, 2023 21:16
-
-
Save mattyoung/3c86ec93882327d86b7354ca1bb44370 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
// | |
// 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