├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── lint.yml ├── .gitignore ├── .swiftlint.yml ├── CHANGELOG.md ├── ColorToolbox.podspec ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources └── ColorToolbox │ ├── PlatformColor+Brightness.swift │ ├── PlatformColor+Hex.swift │ ├── PlatformColor+Internal.swift │ ├── PlatformColor+RelativeLuminance.swift │ ├── PlatformColor.swift │ └── SwiftUI │ └── Color+ColorToolbox.swift └── Tests └── ColorToolboxTests ├── PlatformColorBrightnessTests.swift ├── PlatformColorHexTests.swift ├── PlatformRelativeLuminanceTests.swift └── SwiftUITests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.swift] 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [{*.yml,*.podspec,*.rb}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [Makfile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | paths: 7 | - ".github/workflows/build.yml" 8 | - "**.swift" 9 | pull_request: 10 | paths: 11 | - ".github/workflows/build.yml" 12 | - "**.swift" 13 | concurrency: 14 | group: build-${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | jobs: 17 | build-macos: 18 | name: Build macOS 19 | runs-on: macos-14 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: maxim-lobanov/setup-xcode@v1 23 | with: 24 | xcode-version: "15.0" 25 | - name: Run tests 26 | run: swift test -v 27 | build-other-platforms: 28 | runs-on: macos-14 29 | name: Build ${{ matrix.destination.name }} 30 | strategy: 31 | matrix: 32 | destination: 33 | - name: iOS 34 | value: "platform=iOS Simulator,name=iPhone 14,OS=latest" 35 | - name: tvOS 36 | value: "platform=tvOS Simulator,name=Apple TV,OS=latest" 37 | - name: watchOS 38 | value: "platform=watchOS Simulator,name=Apple Watch Series 8 (41mm),OS=latest" 39 | - name: Catalyst 40 | value: "platform=macOS,variant=Mac Catalyst,arch=arm64" 41 | # - name: visionOS 42 | # value: "generic/platform=visionOS" 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: maxim-lobanov/setup-xcode@v1 46 | with: 47 | xcode-version: "15.0" 48 | - name: Build 49 | run: |- 50 | set -o pipefail && NSUnbufferedIO=YES xcodebuild clean build \ 51 | -scheme ColorToolbox \ 52 | -destination '${{ matrix.destination.value }}' \ 53 | | xcpretty 54 | - name: Run tests 55 | run: |- 56 | set -o pipefail && NSUnbufferedIO=YES xcodebuild test \ 57 | -scheme ColorToolbox \ 58 | -destination '${{ matrix.destination.value }}' \ 59 | -sdk iphonesimulator \ 60 | | xcpretty 61 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | paths: 5 | - '.github/workflows/lint.yml' 6 | - '.swiftlint.yml' 7 | - '**/*.swift' 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: SwiftLint 14 | uses: norio-nomura/action-swiftlint@3.2.1 15 | with: 16 | args: --strict 17 | - name: SPM 18 | run: swift package dump-package 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | 5 | opt_in_rules: 6 | - attributes 7 | - closure_spacing 8 | - collection_alignment 9 | - contains_over_filter_count 10 | - contains_over_filter_is_empty 11 | - contains_over_first_not_nil 12 | - contains_over_range_nil_comparison 13 | - discouraged_object_literal 14 | - discouraged_optional_boolean 15 | - empty_count 16 | - empty_string 17 | - file_header 18 | - file_name 19 | - file_name_no_space 20 | - first_where 21 | - flatmap_over_map_reduce 22 | - force_unwrapping 23 | - identical_operands 24 | - implicit_return 25 | - implicitly_unwrapped_optional 26 | - missing_docs 27 | - number_separator 28 | - operator_usage_whitespace 29 | - overridden_super_call 30 | - prohibited_interface_builder 31 | - sorted_first_last 32 | - toggle_bool 33 | - weak_delegate 34 | - yoda_condition 35 | 36 | analyzer_rules: 37 | - capture_variable 38 | - typesafe_array_init 39 | - unused_declaration 40 | - unused_import 41 | 42 | identifier_name: 43 | excluded: 44 | - r 45 | - g 46 | - b 47 | - a 48 | - y 49 | - h 50 | - s 51 | 52 | excluded: 53 | - ".build" 54 | 55 | file_header: 56 | required_pattern: | 57 | \/\/ 58 | \/\/ SWIFTLINT_CURRENT_FILENAME 59 | \/\/ (ColorToolbox|ColorToolboxTests) 60 | \/\/ 61 | \/\/ Copyright \(c\) \d{4}(-\d{4})? Ramon Torres 62 | \/\/ 63 | \/\/ This file is part of ColorToolbox which is released under the MIT license\. 64 | \/\/ See the LICENSE file in the root directory of this source tree for full details\. 65 | \/\/ 66 | 67 | file_name: 68 | suffix_pattern: "[+-]{1}.*" 69 | 70 | line_length: 71 | warning: 130 72 | error: 150 73 | ignores_urls: true 74 | ignores_comments: true 75 | 76 | implicit_return: 77 | included: 78 | - closure 79 | - getter 80 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # v1.1.0 - 2024-04-21 4 | 5 | - Lowered the minimum deployment target to iOS/tvOS/Catalyst 13.0, macOS 10.15, and watchOS 6.0. ([#16](https://github.com/raymondjavaxx/ColorToolbox/pull/16)) 6 | 7 | # v1.0.1 - 2023-07-16 8 | 9 | - Fixed watchOS support. 10 | - `.toHex()` now properly rounds components to the nearest integer rather than always truncating/flooring. 11 | 12 | # v1.0.0 - 2023-07-10 13 | 14 | - Initial release. 15 | -------------------------------------------------------------------------------- /ColorToolbox.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "ColorToolbox" 3 | spec.version = "1.1.0" 4 | spec.summary = "Swift color utilities for UIKit, AppKit and SwiftUI." 5 | 6 | spec.homepage = "https://github.com/raymondjavaxx/ColorToolbox" 7 | spec.license = { :type => "MIT", :file => "LICENSE" } 8 | spec.author = { "Ramon Torres" => "raymondjavaxx@gmail.com" } 9 | 10 | spec.swift_version = "5.9" 11 | spec.ios.deployment_target = "13.0" 12 | spec.tvos.deployment_target = "13.0" 13 | spec.osx.deployment_target = "10.15" 14 | spec.watchos.deployment_target = "6.0" 15 | spec.visionos.deployment_target = "1.0" 16 | 17 | spec.source = { :git => "https://github.com/raymondjavaxx/ColorToolbox.git", :tag => "#{spec.version}" } 18 | spec.source_files = "Sources/ColorToolbox/**/*.swift" 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Ramon Torres 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test lint format 2 | 3 | build: 4 | swift build 5 | 6 | test: 7 | swift test 8 | 9 | lint: 10 | swiftlint 11 | 12 | format: 13 | swiftlint --fix 14 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ColorToolbox", 7 | platforms: [ 8 | .iOS(.v13), 9 | .tvOS(.v13), 10 | .macOS(.v10_15), 11 | .watchOS(.v6), 12 | .visionOS(.v1), 13 | .macCatalyst(.v13), 14 | ], 15 | products: [ 16 | .library( 17 | name: "ColorToolbox", 18 | targets: ["ColorToolbox"] 19 | ) 20 | ], 21 | targets: [ 22 | .target( 23 | name: "ColorToolbox" 24 | ), 25 | .testTarget( 26 | name: "ColorToolboxTests", 27 | dependencies: ["ColorToolbox"] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ColorToolbox 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fraymondjavaxx%2FColorToolbox%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/raymondjavaxx/ColorToolbox) 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fraymondjavaxx%2FColorToolbox%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/raymondjavaxx/ColorToolbox) 4 | 5 | Swift color utilities for UIKit, AppKit and SwiftUI. 6 | 7 | ## Installation 8 | ## Swift Package Manager 9 | 10 | Add the following dependency to your `Package.swift` file: 11 | 12 | ```swift 13 | .package(url: "https://github.com/raymondjavaxx/ColorToolbox.git", from: "1.0.1") 14 | ``` 15 | 16 | ## CocoaPods 17 | 18 | Add the following line to your `Podfile`: 19 | 20 | ```ruby 21 | pod 'ColorToolbox', '~> 1.0' 22 | ``` 23 | 24 | ## Usage 25 | 26 | ColorToolbox is implemented as a set of extensions on `UIColor`, `NSColor` and `Color` (SwiftUI). All utility methods and properties are available on all supported platforms. 27 | 28 | ### Converting from and to hex string 29 | 30 | To create a color from a hex string, use the `init(hex:)` initializer: 31 | 32 | ```swift 33 | import ColorToolbox 34 | 35 | // UIKit 36 | let color = UIColor(hex: "#ff0000") 37 | 38 | // AppKit 39 | let color = NSColor(hex: "#ff0000") 40 | 41 | // SwiftUI 42 | let color = Color(hex: "#ff0000") 43 | ``` 44 | 45 | To convert a color to hex, use the `toHex()` method: 46 | 47 | ```swift 48 | let hexString = color.toHex() 49 | ``` 50 | 51 | ### Calculating the relative luminance 52 | 53 | ```swift 54 | let color: UIColor = .red 55 | print(color.relativeLuminance) // 0.2126 56 | ``` 57 | 58 | ### Calculating WCAG contrast ratio 59 | 60 | ```swift 61 | let color1 = ... 62 | let color2 = ... 63 | 64 | let contrastRatio = color1.contrastRatio(to: color2) 65 | ``` 66 | 67 | ### Lightening and darkening colors 68 | 69 | ```swift 70 | let lighterColor = color.lightening(by: 0.2) 71 | let darkerColor = color.darkening(by: 0.2) 72 | ``` 73 | 74 | ## License 75 | 76 | ColorToolbox is available under the MIT license. See the [LICENSE](LICENSE) file for more info. 77 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/PlatformColor+Brightness.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColor+Brightness.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import Foundation 12 | 13 | extension PlatformColor { 14 | 15 | /// Returns a color that is lighter than the receiver by the given ratio. 16 | /// 17 | /// - Parameter ratio: The ratio to lighten the color by. 18 | /// - Returns: A lighter color. 19 | public func lightening(by ratio: CGFloat) -> PlatformColor { 20 | return .dynamicColor { 21 | let components = self.toHSBComponents() 22 | let newBrightness = components.b != 0 23 | ? components.b + (components.b * ratio) 24 | : ratio 25 | 26 | return PlatformColor( 27 | hue: components.h, 28 | saturation: components.s, 29 | brightness: min(newBrightness, 1), 30 | alpha: components.a 31 | ) 32 | } 33 | } 34 | 35 | /// Returns a color that is darker than the receiver by the given ratio. 36 | /// 37 | /// - Parameter ratio: The ratio to darken the color by. 38 | /// - Returns: A darker color. 39 | public func darkening(by ratio: CGFloat) -> PlatformColor { 40 | return .dynamicColor { 41 | let components = self.toHSBComponents() 42 | let newBrightness = components.b != 1 43 | ? components.b - (components.b * ratio) 44 | : 1 - ratio 45 | 46 | return PlatformColor( 47 | hue: components.h, 48 | saturation: components.s, 49 | brightness: max(newBrightness, 0), 50 | alpha: components.a 51 | ) 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/PlatformColor+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColor+Hex.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import Foundation 12 | 13 | extension PlatformColor { 14 | 15 | /// Creates a color from a hex string. 16 | /// 17 | /// - Parameter hex: Hex string. 18 | public convenience init?(hex: String) { 19 | let scanner = Scanner(string: hex) 20 | scanner.charactersToBeSkipped = nil 21 | 22 | // Consume optional `#` 23 | _ = scanner.scanString("#") 24 | 25 | switch scanner.charactersLeft() { 26 | case 6, 8: 27 | guard let red = scanner.scanHexByte(), 28 | let green = scanner.scanHexByte(), 29 | let blue = scanner.scanHexByte() else { 30 | return nil 31 | } 32 | 33 | var alpha: UInt8 = 255 34 | 35 | // Parse alpha if available 36 | if scanner.charactersLeft() == 2 { 37 | guard let parsedAlpha = scanner.scanHexByte() else { 38 | return nil 39 | } 40 | 41 | alpha = parsedAlpha 42 | } 43 | 44 | self.init( 45 | red: CGFloat(red) / 255, 46 | green: CGFloat(green) / 255, 47 | blue: CGFloat(blue) / 255, 48 | alpha: CGFloat(alpha) / 255 49 | ) 50 | case 3: 51 | guard let red = scanner.scanHexNibble(), 52 | let green = scanner.scanHexNibble(), 53 | let blue = scanner.scanHexNibble() else { 54 | return nil 55 | } 56 | 57 | self.init( 58 | red: CGFloat(red) / 15, 59 | green: CGFloat(green) / 15, 60 | blue: CGFloat(blue) / 15, 61 | alpha: 1 62 | ) 63 | default: 64 | return nil 65 | } 66 | } 67 | 68 | /// Returns the hex color representation of the color. 69 | /// 70 | /// - Returns: Hex string. 71 | public func toHex() -> String { 72 | var components = self.toRGBAComponents() 73 | 74 | // Clamp components to [0.0, 1.0] 75 | components.r = max(0, min(1, components.r)) 76 | components.g = max(0, min(1, components.g)) 77 | components.b = max(0, min(1, components.b)) 78 | components.a = max(0, min(1, components.a)) 79 | 80 | if components.a == 1 { 81 | // RGB 82 | return String( 83 | format: "#%02lX%02lX%02lX", 84 | Int(round(components.r * 255)), 85 | Int(round(components.g * 255)), 86 | Int(round(components.b * 255)) 87 | ) 88 | } else { 89 | // RGBA 90 | return String( 91 | format: "#%02lX%02lX%02lX%02lX", 92 | Int(round(components.r * 255)), 93 | Int(round(components.g * 255)), 94 | Int(round(components.b * 255)), 95 | Int(round(components.a * 255)) 96 | ) 97 | } 98 | } 99 | 100 | } 101 | 102 | private extension Scanner { 103 | 104 | func scanHexNibble() -> UInt8? { 105 | guard let character = scanCharacter(), character.isHexDigit else { 106 | return nil 107 | } 108 | 109 | return UInt8(String(character), radix: 16) 110 | } 111 | 112 | func scanHexByte() -> UInt8? { 113 | guard let highNibble = scanHexNibble(), let lowNibble = scanHexNibble() else { 114 | return nil 115 | } 116 | 117 | return (highNibble << 4) | lowNibble 118 | } 119 | 120 | func charactersLeft() -> Int { 121 | return string.count - currentIndex.utf16Offset(in: string) 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/PlatformColor+Internal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColor+Internal.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import Foundation 12 | 13 | extension PlatformColor { 14 | struct RGBAComponents { 15 | var r: CGFloat = 0 16 | var g: CGFloat = 0 17 | var b: CGFloat = 0 18 | var a: CGFloat = 0 19 | } 20 | 21 | struct HSBComponents { 22 | var h: CGFloat = 0 23 | var s: CGFloat = 0 24 | var b: CGFloat = 0 25 | var a: CGFloat = 0 26 | } 27 | 28 | func toRGBAComponents() -> RGBAComponents { 29 | var components = RGBAComponents() 30 | 31 | #if canImport(UIKit) 32 | let result = self.getRed( 33 | &components.r, 34 | green: &components.g, 35 | blue: &components.b, 36 | alpha: &components.a 37 | ) 38 | assert(result, "Failed to get RGBA components from UIColor") 39 | #else 40 | if let rgbColor = self.usingColorSpace(.sRGB) { 41 | rgbColor.getRed( 42 | &components.r, 43 | green: &components.g, 44 | blue: &components.b, 45 | alpha: &components.a 46 | ) 47 | } else { 48 | assertionFailure("Failed to convert color space") 49 | } 50 | #endif 51 | 52 | return components 53 | } 54 | 55 | func toHSBComponents() -> HSBComponents { 56 | var components = HSBComponents() 57 | 58 | #if canImport(UIKit) 59 | let result = self.getHue( 60 | &components.h, 61 | saturation: &components.s, 62 | brightness: &components.b, 63 | alpha: &components.a 64 | ) 65 | assert(result, "Failed to get HSB components from UIColor") 66 | #else 67 | if let rgbColor = self.usingColorSpace(.sRGB) { 68 | rgbColor.getHue( 69 | &components.h, 70 | saturation: &components.s, 71 | brightness: &components.b, 72 | alpha: &components.a 73 | ) 74 | } else { 75 | assertionFailure("Failed to convert color space") 76 | } 77 | #endif 78 | 79 | return components 80 | } 81 | 82 | static func dynamicColor(_ block: @escaping () -> PlatformColor) -> PlatformColor { 83 | #if canImport(UIKit) 84 | #if os(watchOS) 85 | // watchOS doesn't support dynamic color providers. We simply invoke 86 | // the block and return the transformed color. 87 | return block() 88 | #else 89 | // iOS, iPadOS, Mac Catalyst, and tvOS 90 | return PlatformColor { _ in block() } 91 | #endif 92 | #else 93 | // macOS 94 | return PlatformColor(name: nil) { _ in block() } 95 | #endif 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/PlatformColor+RelativeLuminance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColor+RelativeLuminance.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import Foundation 12 | 13 | extension PlatformColor { 14 | 15 | /// Relative luminance of the color. 16 | /// 17 | /// # Reference 18 | /// 19 | /// * [WCAG 2.2 specification](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) 20 | /// * [Wikipedia: Relative luminance](https://en.wikipedia.org/wiki/Relative_luminance) 21 | public var relativeLuminance: CGFloat { 22 | let components = self.toRGBAComponents() 23 | 24 | // Convert from sRGB to linear RGB 25 | let r = components.r < 0.04045 ? components.r / 12.92 : pow((components.r + 0.055) / 1.055, 2.4) 26 | let g = components.g < 0.04045 ? components.g / 12.92 : pow((components.g + 0.055) / 1.055, 2.4) 27 | let b = components.b < 0.04045 ? components.b / 12.92 : pow((components.b + 0.055) / 1.055, 2.4) 28 | 29 | // Calculate relative luminance (Y) 30 | let y = r * 0.2126 + g * 0.7152 + b * 0.0722 31 | 32 | return min(max(y, 0), 1) 33 | } 34 | 35 | /// Calculates the contrast ratio between two colors according to 36 | /// the Web Content Accessibility Guidelines (WCAG) 2.2. 37 | /// 38 | /// # Reference 39 | /// 40 | /// * [WCAG 2.2](https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio) 41 | /// 42 | /// - Parameter otherColor: The other color to compare with. 43 | /// - Returns: The contrast ratio. 44 | public func contrastRatio(to otherColor: PlatformColor) -> CGFloat { 45 | let luminance1 = self.relativeLuminance 46 | let luminance2 = otherColor.relativeLuminance 47 | return (max(luminance1, luminance2) + 0.05) / (min(luminance1, luminance2) + 0.05) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/PlatformColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColor.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | #else 14 | import AppKit 15 | #endif 16 | 17 | #if canImport(UIKit) 18 | /// Alias of UIColor. 19 | public typealias PlatformColor = UIColor 20 | #else 21 | /// Alias of NSColor. 22 | public typealias PlatformColor = NSColor 23 | #endif 24 | -------------------------------------------------------------------------------- /Sources/ColorToolbox/SwiftUI/Color+ColorToolbox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+ColorToolbox.swift 3 | // ColorToolbox 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | #if canImport(SwiftUI) 12 | import SwiftUI 13 | 14 | @available(macOS 11.0, iOS 14.0, tvOS 14.0, macCatalyst 14.0, watchOS 7.0, *) 15 | extension Color { 16 | 17 | /// Relative luminance of the color. 18 | /// 19 | /// # Reference 20 | /// 21 | /// * [WCAG 2.2 specification](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) 22 | /// * [Wikipedia: Relative luminance](https://en.wikipedia.org/wiki/Relative_luminance) 23 | public var relativeLuminance: CGFloat { 24 | PlatformColor(self).relativeLuminance 25 | } 26 | 27 | /// Creates a color from a hex string. 28 | /// 29 | /// - Parameter hex: Hex string. 30 | public init?(hex: String) { 31 | guard let color = PlatformColor(hex: hex) else { 32 | return nil 33 | } 34 | 35 | self.init(color) 36 | } 37 | 38 | /// Returns the hex color representation of the color. 39 | /// 40 | /// - Returns: Hex string. 41 | public func toHex() -> String { 42 | return PlatformColor(self).toHex() 43 | } 44 | 45 | /// Calculates the contrast ratio between two colors according to 46 | /// the Web Content Accessibility Guidelines (WCAG) 2.2. 47 | /// 48 | /// # Reference 49 | /// 50 | /// * [WCAG 2.2](https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio) 51 | /// 52 | /// - Parameter otherColor: The other color to compare with. 53 | /// - Returns: The contrast ratio. 54 | public func contrastRatio(to otherColor: Color) -> CGFloat { 55 | return PlatformColor(self).contrastRatio(to: PlatformColor(otherColor)) 56 | } 57 | 58 | /// Returns a color that is lighter than the receiver by the given ratio. 59 | /// 60 | /// - Parameter ratio: The ratio to lighten the color by. 61 | /// - Returns: A lighter color. 62 | public func lightening(by ratio: CGFloat) -> Color { 63 | return Color( 64 | PlatformColor(self).lightening(by: ratio) 65 | ) 66 | } 67 | 68 | /// Returns a color that is darker than the receiver by the given ratio. 69 | /// 70 | /// - Parameter ratio: The ratio to darken the color by. 71 | /// - Returns: A darker color. 72 | public func darkening(by ratio: CGFloat) -> Color { 73 | return Color( 74 | PlatformColor(self).darkening(by: ratio) 75 | ) 76 | } 77 | 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /Tests/ColorToolboxTests/PlatformColorBrightnessTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColorBrightnessTests.swift 3 | // ColorToolboxTests 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import ColorToolbox 13 | 14 | final class PlatformColorBrightnessTests: XCTestCase { 15 | 16 | func test_lightening() { 17 | let sut = PlatformColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1) 18 | let lightenedColor = sut.lightening(by: 0.5) 19 | XCTAssertEqual(lightenedColor.cgColor.components, [0.75, 0.75, 0.75, 1]) 20 | } 21 | 22 | func test_lightening_shouldNotExceedMaxBrightness() { 23 | let sut = PlatformColor(red: 1, green: 1, blue: 1, alpha: 1) 24 | let lightenedColor = sut.lightening(by: 0.5) 25 | XCTAssertEqual(lightenedColor.cgColor.components, [1, 1, 1, 1]) 26 | } 27 | 28 | func test_darkening() { 29 | let sut = PlatformColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1) 30 | let darkenedColor = sut.darkening(by: 0.5) 31 | XCTAssertEqual(darkenedColor.cgColor.components, [0.25, 0.25, 0.25, 1]) 32 | } 33 | 34 | func test_darkening_shouldNotExceedMinBrightness() { 35 | let sut = PlatformColor(red: 0, green: 0, blue: 0, alpha: 1) 36 | let darkenedColor = sut.darkening(by: 0.5) 37 | XCTAssertEqual(darkenedColor.cgColor.components, [0, 0, 0, 1]) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Tests/ColorToolboxTests/PlatformColorHexTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformColorHexTests.swift 3 | // ColorToolboxTests 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import ColorToolbox 13 | 14 | final class PlatformColorHexTests: XCTestCase { 15 | 16 | func test_fromHex() throws { 17 | XCTAssertEqual(PlatformColor(hex: "#000000"), .init(red: 0, green: 0, blue: 0, alpha: 1)) 18 | XCTAssertEqual(PlatformColor(hex: "#FFFFFF"), .init(red: 1, green: 1, blue: 1, alpha: 1)) 19 | XCTAssertEqual(PlatformColor(hex: "#FF0000"), .init(red: 1, green: 0, blue: 0, alpha: 1)) 20 | } 21 | 22 | func test_fromHex_shouldHandleColorsWithoutPoundSign() { 23 | XCTAssertEqual(PlatformColor(hex: "FF0000"), .init(red: 1, green: 0, blue: 0, alpha: 1)) 24 | } 25 | 26 | func test_toHex() throws { 27 | XCTAssertEqual(PlatformColor.black.toHex(), "#000000") 28 | XCTAssertEqual(PlatformColor.white.toHex(), "#FFFFFF") 29 | XCTAssertEqual(PlatformColor.darkGray.toHex(), "#555555") 30 | XCTAssertEqual(PlatformColor(white: 0.752, alpha: 1).toHex(), "#C0C0C0") 31 | XCTAssertEqual(PlatformColor.red.toHex(), "#FF0000") 32 | XCTAssertEqual(PlatformColor.green.toHex(), "#00FF00") 33 | XCTAssertEqual(PlatformColor.blue.toHex(), "#0000FF") 34 | } 35 | 36 | func test_toHex_shouldIncludeAlphaWhenNeeded() throws { 37 | XCTAssertEqual(PlatformColor(white: 1, alpha: 0.5).toHex(), "#FFFFFF80") 38 | XCTAssertEqual(PlatformColor(white: 1, alpha: 0).toHex(), "#FFFFFF00") 39 | } 40 | 41 | func test_hex_convertingBackAndForth() throws { 42 | let webColors: [String] = [ 43 | "#000000", "#C0C0C0", "#808080", "#FFFFFF", "#800000", "#FF0000", "#800080", 44 | "#FF00FF", "#008000", "#00FF00", "#808000", "#FFFF00", "#000080", "#0000FF", 45 | "#008080", "#00FFFF", "#F0F8FF", "#FAEBD7", "#00FFFF", "#7FFFD4", "#F0FFFF", 46 | "#F5F5DC", "#FFE4C4", "#000000", "#FFEBCD", "#0000FF", "#8A2BE2", "#A52A2A", 47 | "#DEB887", "#5F9EA0", "#7FFF00", "#D2691E", "#FF7F50", "#6495ED", "#FFF8DC", 48 | "#DC143C", "#00FFFF", "#00008B", "#008B8B", "#B8860B", "#A9A9A9", "#006400", 49 | "#A9A9A9", "#BDB76B", "#8B008B", "#556B2F", "#FF8C00", "#9932CC", "#8B0000", 50 | "#E9967A", "#8FBC8F", "#483D8B", "#2F4F4F", "#2F4F4F", "#00CED1", "#9400D3", 51 | "#FF1493", "#00BFFF", "#696969", "#696969", "#1E90FF", "#B22222", "#FFFAF0", 52 | "#228B22", "#FF00FF", "#DCDCDC", "#F8F8FF", "#FFD700", "#DAA520", "#808080", 53 | "#008000", "#ADFF2F", "#808080", "#F0FFF0", "#FF69B4", "#CD5C5C", "#4B0082", 54 | "#FFFFF0", "#F0E68C", "#E6E6FA", "#FFF0F5", "#7CFC00", "#FFFACD", "#ADD8E6", 55 | "#F08080", "#E0FFFF", "#FAFAD2", "#D3D3D3", "#90EE90", "#D3D3D3", "#FFB6C1", 56 | "#FFA07A", "#20B2AA", "#87CEFA", "#778899", "#778899", "#B0C4DE", "#FFFFE0", 57 | "#00FF00", "#32CD32", "#FAF0E6", "#FF00FF", "#800000", "#66CDAA", "#0000CD", 58 | "#BA55D3", "#9370DB", "#3CB371", "#7B68EE", "#00FA9A", "#48D1CC", "#C71585", 59 | "#191970", "#F5FFFA", "#FFE4E1", "#FFE4B5", "#FFDEAD", "#000080", "#FDF5E6", 60 | "#808000", "#6B8E23", "#FFA500", "#FF4500", "#DA70D6", "#EEE8AA", "#98FB98", 61 | "#AFEEEE", "#DB7093", "#FFEFD5", "#FFDAB9", "#CD853F", "#FFC0CB", "#DDA0DD", 62 | "#B0E0E6", "#800080", "#FF0000", "#BC8F8F", "#4169E1", "#8B4513", "#FA8072", 63 | "#F4A460", "#2E8B57", "#FFF5EE", "#A0522D", "#C0C0C0", "#87CEEB", "#6A5ACD", 64 | "#708090", "#708090", "#FFFAFA", "#00FF7F", "#4682B4", "#D2B48C", "#008080", 65 | "#D8BFD8", "#FF6347", "#40E0D0", "#EE82EE", "#F5DEB3", "#FFFFFF", "#F5F5F5", 66 | "#FFFF00", "#9ACD32" 67 | ] 68 | 69 | for hex in webColors { 70 | let color = try XCTUnwrap(PlatformColor(hex: hex)) 71 | XCTAssertEqual(color.toHex(), hex) 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /Tests/ColorToolboxTests/PlatformRelativeLuminanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlatformRelativeLuminanceTests.swift 3 | // ColorToolboxTests 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import ColorToolbox 13 | 14 | final class PlatformRelativeLuminanceTests: XCTestCase { 15 | 16 | func test_relativeLuminance() throws { 17 | let testCases: [(PlatformColor, CGFloat)] = [ 18 | // Grays 19 | (PlatformColor(white: 0, alpha: 1), 0.0), 20 | (PlatformColor(white: 0.25, alpha: 1), 0.05), 21 | (PlatformColor(white: 0.5, alpha: 1), 0.21), 22 | (PlatformColor(white: 0.75, alpha: 1), 0.52), 23 | (PlatformColor(white: 1, alpha: 1), 1.0), 24 | // Colors (Extract Rec. 709 coefficients) 25 | (PlatformColor(red: 1, green: 0, blue: 0, alpha: 1), 0.2126), 26 | (PlatformColor(red: 0, green: 1, blue: 0, alpha: 1), 0.7152), 27 | (PlatformColor(red: 0, green: 0, blue: 1, alpha: 1), 0.0722) 28 | ] 29 | 30 | for (color, expectedRelativeLuminance) in testCases { 31 | XCTAssertEqual(color.relativeLuminance, expectedRelativeLuminance, accuracy: 0.01) 32 | } 33 | } 34 | 35 | func test_contrastRatio() throws { 36 | // High contrast 37 | XCTAssertEqual(PlatformColor.white.contrastRatio(to: .black), 21) 38 | XCTAssertEqual(PlatformColor.black.contrastRatio(to: .white), 21) 39 | 40 | // Identical colors 41 | XCTAssertEqual(PlatformColor.white.contrastRatio(to: .white), 1) 42 | XCTAssertEqual(PlatformColor.black.contrastRatio(to: .black), 1) 43 | 44 | // Black to 50% gray 45 | XCTAssertEqual( 46 | PlatformColor.black.contrastRatio(to: PlatformColor(white: 0.5, alpha: 1)), 47 | 5.28, 48 | accuracy: 0.01 49 | ) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Tests/ColorToolboxTests/SwiftUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUITests.swift 3 | // ColorToolboxTests 4 | // 5 | // Copyright (c) 2023 Ramon Torres 6 | // 7 | // This file is part of ColorToolbox which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import SwiftUI 13 | import ColorToolbox 14 | 15 | @available(macOS 11.0, iOS 14.0, tvOS 14.0, macCatalyst 14.0, watchOS 6.0, *) 16 | final class SwiftUITests: XCTestCase { 17 | 18 | func test_fromHex() { 19 | let result = Color(hex: "#FF0000") 20 | XCTAssertEqual( 21 | result, 22 | Color(PlatformColor(red: 1, green: 0, blue: 0, alpha: 1)) 23 | ) 24 | } 25 | 26 | func test_toHex() { 27 | XCTAssertEqual(Color(.white).toHex(), "#FFFFFF") 28 | XCTAssertEqual(Color(.black).toHex(), "#000000") 29 | XCTAssertEqual(Color(.red).toHex(), "#FF0000") 30 | } 31 | 32 | func test_relativeLuminance() { 33 | XCTAssertEqual(Color(.red).relativeLuminance, 0.2126, accuracy: 0.01) 34 | } 35 | 36 | func test_contrastRatio() throws { 37 | XCTAssertEqual(Color.white.contrastRatio(to: .black), 21, accuracy: 0.01) 38 | } 39 | 40 | } 41 | --------------------------------------------------------------------------------