Last active
February 23, 2022 20:44
-
-
Save sam-w/1ec82fe3809b3da1392b77241baeabb8 to your computer and use it in GitHub Desktop.
This file contains 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
import SwiftUI | |
import UIKit | |
extension Color { | |
var uiColor: UIColor { | |
if #available(iOS 14, *) { | |
// iOS 14 introduces an API to convert SwiftUI.Color to UIKit.UIColor | |
// but it does not produce a color which reacts to changes in color scheme | |
// (light mode/dark mode). To make that work we need to extract the color | |
// name and go back to the asset catalog. | |
if let color = try! UIColor.namedColor(from: stringRepresentation) { | |
return color | |
} | |
if let opacityColor = try! OpacityColor(color: self), | |
let color = try! UIColor.namedColor(from: opacityColor.stringRepresentation) | |
{ | |
return color.multiplyingAlphaComponent(by: opacityColor.opacityModifier) | |
} | |
return UIColor(self) | |
} else { | |
return try! convertToUIColor() | |
} | |
} | |
} | |
private enum ColorConversionError: Error { | |
case couldNotParseInternalType(source: String) | |
case couldNotParseSystemColor(source: String) | |
case couldNotParseHex(source: String) | |
case couldNotParseUIColorComponents(source: String) | |
case couldNotParseCGColorRef(source: String) | |
case couldNotParseP3Components(source: String) | |
case couldNotParseNamedColor(source: String) | |
case invalidInternalType(String) | |
case invalidColorSpace(String) | |
} | |
private extension Color { | |
var stringRepresentation: String { | |
description.trimmingCharacters(in: .whitespacesAndNewlines) | |
} | |
var internalType: String { | |
String(describing: type(of: Mirror(reflecting: self).children.first!.value)) | |
.replacingOccurrences( | |
of: "ColorBox<(.+)>", | |
with: "$1", | |
options: .regularExpression | |
) | |
} | |
func convertToUIColor() throws -> UIColor { | |
if let color = try OpacityColor(color: self) { | |
return try color.convertToUIColor() | |
} | |
return try UIColor.from(swiftUIDescription: stringRepresentation, internalType: internalType) | |
} | |
} | |
private struct OpacityColor { | |
let stringRepresentation: String | |
let internalType: String | |
let opacityModifier: CGFloat | |
init(stringRepresentation: String, internalType: String, opacityModifier: CGFloat) { | |
self.stringRepresentation = stringRepresentation | |
self.internalType = internalType | |
self.opacityModifier = opacityModifier | |
} | |
init?(color: Color) throws { | |
guard color.internalType == "OpacityColor" else { | |
return nil | |
} | |
let string = color.stringRepresentation | |
let opacityRegex = try NSRegularExpression(pattern: #"(\d+% )"#) | |
let opacityLayerCount = opacityRegex.numberOfMatches( | |
in: string, | |
options: [], | |
range: NSRange(string.startIndex ..< string.endIndex, in: string) | |
) | |
var dumpString = "" | |
dump(color, to: &dumpString) | |
dumpString = dumpString.replacingOccurrences( | |
of: #"^(?:.*\n){\#(4 * opacityLayerCount)}.*?base: "#, | |
with: "", | |
options: .regularExpression | |
) | |
let opacityModifier = dumpString | |
.split(separator: "\n") | |
.suffix(1) | |
.lazy | |
.map { | |
$0.replacingOccurrences( | |
of: #"\s+-\s+opacity: "#, | |
with: "", | |
options: .regularExpression | |
) | |
} | |
.map { CGFloat(Double($0)!) } | |
.reduce(1, *) | |
let internalTypeRegex = try NSRegularExpression(pattern: #"^.*\n.*ColorBox<.*?([A-Za-z0-9]+)>"#) | |
let matches = internalTypeRegex.matches( | |
in: dumpString, | |
options: [], | |
range: NSRange(dumpString.startIndex ..< dumpString.endIndex, in: dumpString) | |
) | |
guard let match = matches.first, matches.count == 1, match.numberOfRanges == 2 else { | |
throw ColorConversionError.couldNotParseInternalType(source: dumpString) | |
} | |
self.init( | |
stringRepresentation: String(dumpString.prefix { !$0.isNewline }), | |
internalType: String(dumpString[Range(match.range(at: 1), in: dumpString)!]), | |
opacityModifier: opacityModifier | |
) | |
} | |
func convertToUIColor() throws -> UIColor { | |
return try UIColor.from( | |
swiftUIDescription: stringRepresentation, | |
internalType: internalType | |
) | |
.multiplyingAlphaComponent( | |
by: opacityModifier | |
) | |
} | |
} | |
private extension UIColor { | |
static func from(swiftUIDescription description: String, internalType: String) throws -> UIColor { | |
switch internalType { | |
case "SystemColorType": | |
guard let color = systemColor(from: description) else { | |
throw ColorConversionError.couldNotParseSystemColor(source: description) | |
} | |
return color | |
case "_Resolved": | |
guard let color = try resolvedColor(from: description) ?? systemColor(from: description) else { | |
throw ColorConversionError.couldNotParseHex(source: description) | |
} | |
return color | |
case "UIColor": | |
guard let color = try uiColor(from: description) else { | |
throw ColorConversionError.couldNotParseUIColorComponents(source: description) | |
} | |
return color | |
case "CGColorRef": | |
guard let color = try cgColorRef(from: description) else { | |
throw ColorConversionError.couldNotParseCGColorRef(source: description) | |
} | |
return color | |
case "DisplayP3": | |
guard let color = try p3Color(from: description) else { | |
throw ColorConversionError.couldNotParseP3Components(source: description) | |
} | |
return color | |
case "NamedColor": | |
guard let color = try namedColor(from: description) else { | |
throw ColorConversionError.couldNotParseNamedColor(source: description) | |
} | |
return color | |
default: | |
throw ColorConversionError.invalidInternalType(internalType) | |
} | |
} | |
static func systemColor(from description: String) -> UIColor? { | |
switch description { | |
case "clear": return .clear | |
case "black": return .black | |
case "white": return .white | |
case "gray": return .systemGray | |
case "red": return .systemRed | |
case "green": return .systemGreen | |
case "blue": return .systemBlue | |
case "orange": return .systemOrange | |
case "yellow": return .systemYellow | |
case "pink": return .systemPink | |
case "purple": return .systemPurple | |
case "primary": return .label | |
case "secondary": return .secondaryLabel | |
default: return nil | |
} | |
} | |
static func resolvedColor(from description: String) throws -> UIColor? { | |
guard description.range(of: "^#[0-9A-F]{8}$", options: .regularExpression) != nil else { | |
return nil | |
} | |
let components = description | |
.dropFirst() | |
.chunks(of: 2) | |
.compactMap { CGFloat.decimalFromHexPair(String($0)) } | |
guard | |
components.count == 4, | |
let cgColor = CGColor(colorSpace: CGColorSpace(name: CGColorSpace.linearSRGB)!, components: components) | |
else { | |
throw ColorConversionError.couldNotParseHex(source: description) | |
} | |
return UIColor(cgColor: cgColor) | |
} | |
static func uiColor(from description: String) throws -> UIColor? { | |
let sections = description.split(separator: " ") | |
let colorSpace = String(sections[0]) | |
let components = sections[1...] | |
.compactMap { Double($0) } | |
.map { CGFloat($0) } | |
guard components.count == 4 else { | |
return nil | |
} | |
let (r, g, b, a) = (components[0], components[1], components[2], components[3]) | |
return try UIColor(red: r, green: g, blue: b, alpha: a, colorSpace: colorSpace) | |
} | |
static func cgColorRef(from description: String) throws -> UIColor? { | |
// TODO: Parse colorRef string, e.g.: | |
// "<CGColor 0x600001d98000> [<CGColorSpace 0x600001d984e0> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1)] ( 1 0 0 1 )" | |
return nil | |
} | |
static func p3Color(from description: String) throws -> UIColor? { | |
let regex = try NSRegularExpression( | |
pattern: #"^DisplayP3\(red: (-?\d+(?:\.\d+)?), green: (-?\d+(?:\.\d+)?), blue: (-?\d+(?:\.\d+)?), opacity: (-?\d+(?:\.\d+)?)"# | |
) | |
let matches = regex.matches( | |
in: description, | |
options: [], | |
range: NSRange(description.startIndex ..< description.endIndex, in: description) | |
) | |
guard let match = matches.first, matches.count == 1, match.numberOfRanges == 5 else { | |
return nil | |
} | |
let components = (0 ..< match.numberOfRanges) | |
.dropFirst() | |
.map { Range(match.range(at: $0), in: description)! } | |
.compactMap { Double(String(description[$0])) } | |
.map { CGFloat($0) } | |
guard components.count == 4 else { | |
throw ColorConversionError.couldNotParseP3Components(source: description) | |
} | |
let (r, g, b, a) = (components[0], components[1], components[2], components[3]) | |
return UIColor(displayP3Red: r, green: g, blue: b, alpha: a) | |
} | |
static func namedColor(from description: String) throws -> UIColor? { | |
guard description.range(of: #"^NamedColor\(name: "(.*)", bundle: .*\)$"#, options: .regularExpression) != nil else { | |
return nil | |
} | |
let nameRegex = try NSRegularExpression(pattern: #"name: "(.*)""#) | |
let name = nameRegex.matches( | |
in: description, | |
options: [], | |
range: NSRange(description.startIndex ..< description.endIndex, in: description) | |
) | |
.first | |
.flatMap { Range($0.range(at: 1), in: description) } | |
.map { String(description[$0]) } | |
guard let colorName = name else { | |
throw ColorConversionError.couldNotParseNamedColor(source: description) | |
} | |
let bundleRegex = try NSRegularExpression(pattern: #"bundle: .*NSBundle <(.*)>"#) | |
let bundlePath = bundleRegex.matches( | |
in: description, | |
options: [], | |
range: NSRange(description.startIndex ..< description.endIndex, in: description) | |
) | |
.first | |
.flatMap { Range($0.range(at: 1), in: description) } | |
.map { String(description[$0]) } | |
let bundle = bundlePath.map { Bundle(path: $0)! } | |
return UIColor(named: colorName, in: bundle, compatibleWith: nil)! | |
} | |
} | |
private extension UIColor { | |
convenience init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat, colorSpace: String) throws { | |
if colorSpace == "UIDisplayP3ColorSpace" { | |
self.init(displayP3Red: red, green: green, blue: blue, alpha: alpha) | |
} else if colorSpace == "UIExtendedSRGBColorSpace" { | |
self.init(red: red, green: green, blue: blue, alpha: alpha) | |
} else if colorSpace == "kCGColorSpaceModelRGB" { | |
let colorSpace = CGColorSpace(name: CGColorSpace.linearSRGB)! | |
let components = [red, green, blue, alpha] | |
let cgColor = CGColor(colorSpace: colorSpace, components: components)! | |
self.init(cgColor: cgColor) | |
} else { | |
throw ColorConversionError.invalidColorSpace(colorSpace) | |
} | |
} | |
func multiplyingAlphaComponent(by multiplier: CGFloat?) -> UIColor { | |
var a: CGFloat = 0 | |
getWhite(nil, alpha: &a) | |
return withAlphaComponent(a * (multiplier ?? 1)) | |
} | |
} | |
// MARK: Helper extensions | |
extension Int { | |
init?(hexString: String) { | |
self.init(hexString, radix: 16) | |
} | |
} | |
extension FloatingPoint { | |
static func decimalFromHexPair(_ hexPair: String) -> Self? { | |
guard hexPair.count == 2, let value = Int(hexString: hexPair) else { | |
return nil | |
} | |
return Self(value) / Self(255) | |
} | |
} | |
extension Collection { | |
/** | |
Splits this collection into a collection of chunks, each with a maximum size of `size`. | |
If the collection does not divide exactly, the final chunk may contain fewer elements. | |
*/ | |
func chunks(of size: Int) -> [[Element]] { | |
stride(from: 0, to: count, by: size).map { | |
let start = index(startIndex, offsetBy: $0) | |
let end = index(start, offsetBy: size, limitedBy: endIndex) ?? endIndex | |
return Array(self[start ..< end]) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment