Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:
- uses: actions/checkout@v4

- name: Build package
run: swift build
run: swift build --build-system swiftbuild

- name: Run tests
if: hashFiles('Tests/**') != ''
run: swift test
run: swift test --build-system swiftbuild
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let package = Package(
name: "HandySwiftUI",
dependencies: [.product(name: "HandySwift", package: "HandySwift")],
resources: [.process("Localizable.xcstrings")]
)
),
.testTarget(name: "HandySwiftUITests", dependencies: ["HandySwiftUI"]),
]
)
47 changes: 43 additions & 4 deletions Sources/HandySwiftUI/Extensions/ColorExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,14 +339,18 @@ extension Color {
}

// MARK: - Computed Properties
/// The HSB & alpha attributes of the `Color` instance.
/// The HSB & alpha attributes of the `Color` instance, based on its extended sRGB components (like ``rgbo``).
///
/// For wide-gamut colors (e.g. Display P3), saturation and brightness can be above 1. `Color(hue:saturation:brightness:opacity:)`
/// accepts such values and recreates the exact color.
public var hsbo: (hue: Double, saturation: Double, brightness: Double, opacity: Double) {
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var opacity: CGFloat = 0
#if os(macOS)
let color = NativeColor(self).usingColorSpace(.deviceRGB)!
// Extended sRGB (like UIColor and `rgbo`) instead of device RGB, which clips wide-gamut colors
let color = NativeColor(self).usingColorSpace(.extendedSRGB)!
#else
let color = NativeColor(self)
#endif
Expand All @@ -356,15 +360,24 @@ extension Color {
return (hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(opacity))
}

/// The RGB & alpha attributes of the `Color` instance.
/// The RGB & alpha attributes of the `Color` instance in the extended sRGB color space.
///
/// Dynamic colors (like `.primary` or `.systemBlue`) resolve through `UIColor`/`NSColor` against the traits current at the time of
/// the call: `UITraitCollection.current` on iOS-based platforms, which outside of trait-aware callbacks usually holds default
/// traits, and `NSAppearance.currentDrawing()` on macOS. For the values a view actually displays, use ``rgbo(in:)`` with the view's
/// environment, which also respects overrides like `.environment(\.colorScheme, .dark)`.
///
/// Wide-gamut colors (e.g. Display P3) can have components below 0 or above 1, as in `Color.Resolved`. Clamp them before
/// converting to a fixed range, e.g. `UInt8(min(max(red, 0), 1) * 255)`.
public var rgbo: (red: Double, green: Double, blue: Double, opacity: Double) {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var opacity: CGFloat = 0

#if os(macOS)
let color = NativeColor(self).usingColorSpace(.deviceRGB)!
// Extended sRGB (like UIColor and `Color.Resolved`) instead of device RGB, which clips wide-gamut colors
let color = NativeColor(self).usingColorSpace(.extendedSRGB)!
#else
let color = NativeColor(self)
#endif
Expand All @@ -373,6 +386,32 @@ extension Color {
return (red: Double(red), green: Double(green), blue: Double(blue), opacity: Double(opacity))
}

/// The RGB & alpha attributes of the `Color` instance in the extended sRGB color space, resolved in the given environment.
///
/// Use this inside a view to get the components the view actually displays, e.g. the dark variant of a dynamic color in dark mode:
///
/// ```swift
/// struct ColorInfoView: View {
/// @Environment(\.self) private var environment
///
/// var body: some View {
/// let components = Color.primary.rgbo(in: self.environment)
/// Text("Red: \(components.red)")
/// }
/// }
/// ```
///
/// - Note: Uses `Color.resolve(in:)` starting with iOS 17, macOS 14, tvOS 17, watchOS 10 and visionOS 1. On older versions, the
/// environment is ignored and the result equals ``rgbo``.
public func rgbo(in environment: EnvironmentValues) -> (red: Double, green: Double, blue: Double, opacity: Double) {
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *) {
let resolved = self.resolve(in: environment)
return (red: Double(resolved.red), green: Double(resolved.green), blue: Double(resolved.blue), opacity: Double(resolved.opacity))
} else {
return self.rgbo
}
}

/// The HLC & alpha attributes of the `Color` instance.
public var hlco: (hue: Double, luminance: Double, chroma: Double, opacity: Double) {
let lch = self.rgbColor().toLCH()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ struct ColorfulView: View {

When adjusting color brightness, use .luminance instead of .brightness from the HSB color system. Luminance better represents how humans perceive light and dark, which is why HandySwiftUI includes support for the HLC color space.

Inside a view, `rgbo(in:)` reads the components in the view's environment, so dynamic colors like `.primary` resolve exactly as the view displays them, including overrides like `.environment(\.colorScheme, .dark)`:

```swift
@Environment(\.self) private var environment

let components = Color.primary.rgbo(in: environment)
```


### Rich Text Formatting

Expand Down Expand Up @@ -268,6 +276,7 @@ The highlighting is case-insensitive and diacritic-insensitive by default, but y
- ``SwiftUICore/Color/hlco``
- ``SwiftUICore/Color/hsbo``
- ``SwiftUICore/Color/rgbo``
- ``SwiftUICore/Color/rgbo(in:)``
- ``SwiftUICore/Color/init(hex:)``
- ``SwiftUICore/Color/init(hue:luminance:chroma:opacity:)``
- ``SwiftUICore/Color/toHex(alpha:)``
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,6 @@ struct MainView: View {
- ``VPicker``
- ``SearchableGridPicker``
- ``SideTabView``
- ``WebView``

### Other

Expand All @@ -379,6 +378,12 @@ struct MainView: View {
- ``SFSymbol``
- ``Xcode``

### Deprecated

Starting with iOS 26, macOS 26 and visionOS 26, use the `WebView` that WebKit provides for SwiftUI instead.

- ``WebView``


[TranslateKit]: https://translatekit.app
[FreemiumKit]: https://freemiumkit.app
Expand Down
22 changes: 22 additions & 0 deletions Sources/HandySwiftUI/Types/Views/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@
/// }
/// }
/// ```
///
/// - Note: Deprecated starting with iOS 26, macOS 26 and visionOS 26, where WebKit ships its own SwiftUI `WebView`. Both types share
/// the same name, so in files that import both WebKit and HandySwiftUI, using `WebView` as a type (e.g. for a stored property) is
/// ambiguous and needs to be qualified as `HandySwiftUI.WebView` until the migration is done. To migrate, use
/// WebKit's `WebView(url:)` directly, or `WebView(_:)` with a `WebPage`: a custom `WebPage.NavigationDeciding` replaces
/// `onOpenExternalURL` (open the link via `openURL` and return `.cancel`), and `callJavaScript(_:)` or the
/// `webViewScrollPosition(_:)` modifier replaces `scrollOffsetOnPageLoad`.
@available(
iOS,
deprecated: 26.0,
message: "Since iOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally."
)
@available(
macOS,
deprecated: 26.0,
message: "Since macOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally."
)
@available(
visionOS,
deprecated: 26.0,
message: "Since visionOS 26, WebKit provides a native SwiftUI `WebView`. Use it with a `WebPage` whose `NavigationDeciding` opens link taps externally."
)
public struct WebView: View {
#if canImport(UIKit)
typealias NativeViewRepresentable = UIViewRepresentable
Expand Down
107 changes: 107 additions & 0 deletions Tests/HandySwiftUITests/ColorComponentsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import SwiftUI
import Testing

@testable import HandySwiftUI

@MainActor
struct ColorComponentsTests {
typealias Components = (red: Double, green: Double, blue: Double, opacity: Double)

nonisolated static let srgbColors: [(String, Color)] = [
("red", .red),
("blue", .blue),
("rgb", Color(red: 0.2, green: 0.4, blue: 0.6)),
("srgbWithOpacity", Color(.sRGB, red: 0.9, green: 0.1, blue: 0.3, opacity: 0.5)),
("gray", Color(white: 0.5)),
("hex", Color(hex: "#FF5733")),
("primary", .primary),
("systemBlue", .systemBlue),
("label", .label),
]

static func environment(_ colorScheme: ColorScheme) -> EnvironmentValues {
var environment = EnvironmentValues()
environment.colorScheme = colorScheme
return environment
}

func expectEqual(_ lhs: Components, _ rhs: Components, _ comment: Comment) {
let tolerance = 0.0005
#expect(abs(lhs.red - rhs.red) < tolerance, comment)
#expect(abs(lhs.green - rhs.green) < tolerance, comment)
#expect(abs(lhs.blue - rhs.blue) < tolerance, comment)
#expect(abs(lhs.opacity - rhs.opacity) < tolerance, comment)
}

#if os(macOS)
/// The implementation of `rgbo` before it switched from device RGB to extended sRGB on macOS.
func deviceRGBComponents(of color: Color) -> Components {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var opacity: CGFloat = 0
NSColor(color).usingColorSpace(.deviceRGB)!.getRed(&red, green: &green, blue: &blue, alpha: &opacity)
return (Double(red), Double(green), Double(blue), Double(opacity))
}

var currentColorScheme: ColorScheme {
NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .dark : .light
}

@Test(arguments: srgbColors)
func srgbColorsKeepTheirComponents(name: String, color: Color) {
self.expectEqual(color.rgbo, self.deviceRGBComponents(of: color), "\(name)")
}

/// With "Increase contrast" on, `rgbo` returns the high-contrast variants, which an `EnvironmentValues()` cannot request.
@Test(
.disabled("The system setting \"Increase contrast\" is on") {
await MainActor.run { NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast }
},
arguments: srgbColors
)
func rgboMatchesResolvedComponentsForTheCurrentAppearance(name: String, color: Color) {
self.expectEqual(color.rgbo, color.rgbo(in: Self.environment(self.currentColorScheme)), "\(name)")
}

@Test
func wideGamutColorIsNotClipped() {
let displayP3Red = Color(.displayP3, red: 1, green: 0, blue: 0)

#expect(displayP3Red.rgbo.red > 1.05)
#expect(displayP3Red.rgbo.green < -0.1)
self.expectEqual(displayP3Red.rgbo, displayP3Red.rgbo(in: EnvironmentValues()), "displayP3Red")
}
#endif

/// Older OS versions have no `resolve(in:)`, there `rgbo(in:)` falls back to `rgbo` and ignores the environment.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, *)
@Test
func dynamicColorsResolveAgainstTheGivenEnvironment() {
let light = Color.primary.rgbo(in: Self.environment(.light))
let dark = Color.primary.rgbo(in: Self.environment(.dark))

#expect(light.red < 0.1 && light.green < 0.1 && light.blue < 0.1)
#expect(dark.red > 0.9 && dark.green > 0.9 && dark.blue > 0.9)
}

@Test
func wideGamutColorKeepsItsHSBComponents() {
let displayP3Red = Color(.displayP3, red: 1, green: 0, blue: 0)
let hsbo = displayP3Red.hsbo

#expect(hsbo.saturation > 1.05)
self.expectEqual(
Color(hue: hsbo.hue, saturation: hsbo.saturation, brightness: hsbo.brightness, opacity: hsbo.opacity).rgbo,
displayP3Red.rgbo,
"HSB round trip"
)
self.expectEqual(displayP3Red.change(.brightness, by: 0).rgbo, displayP3Red.rgbo, "brightness change by 0")
}

@Test(arguments: srgbColors)
func staticColorsAreTheSameInEveryEnvironment(name: String, color: Color) {
guard !["red", "blue", "primary", "systemBlue", "label"].contains(name) else { return }
self.expectEqual(color.rgbo(in: Self.environment(.light)), color.rgbo(in: Self.environment(.dark)), "\(name)")
}
}
Loading