diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96926b9..774aa32 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,6 +5,19 @@ on: branches: [main] jobs: + build-platforms: + runs-on: macos-26 + strategy: + fail-fast: false + matrix: + platform: [iOS, tvOS, visionOS, watchOS] + + steps: + - uses: actions/checkout@v4 + + - name: Build for ${{ matrix.platform }} + run: xcodebuild -scheme HandySwiftUI -destination 'generic/platform=${{ matrix.platform }}' CODE_SIGNING_ALLOWED=NO build + test-macos: runs-on: macos-26 @@ -12,8 +25,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 diff --git a/Sources/HandySwiftUI/Extensions/ColorExt.swift b/Sources/HandySwiftUI/Extensions/ColorExt.swift index 15b989d..edb0185 100644 --- a/Sources/HandySwiftUI/Extensions/ColorExt.swift +++ b/Sources/HandySwiftUI/Extensions/ColorExt.swift @@ -7,6 +7,8 @@ import SwiftUI #endif // MARK: - Apple-provided colors +// UIKit on watchOS has no semantic colors. There, the label and standard colors map to SwiftUI's own colors, which use the watchOS +// system palette, and the colors without a watchOS counterpart (backgrounds, fills, separators, …) are not available. extension Color { // MARK: - UI Element Colors (https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors) // MARK: Label Colors @@ -14,6 +16,8 @@ extension Color { public static var label: Color { #if os(macOS) return Color(NSColor.labelColor) + #elseif os(watchOS) + return .primary #else return Color(UIColor.label) #endif @@ -23,31 +27,35 @@ extension Color { public static var secondaryLabel: Color { #if os(macOS) return Color(NSColor.secondaryLabelColor) + #elseif os(watchOS) + return .secondary #else return Color(UIColor.secondaryLabel) #endif } - /// The color for text labels that contain tertiary content. - public static var tertiaryLabel: Color { - #if os(macOS) - return Color(NSColor.tertiaryLabelColor) - #else - return Color(UIColor.tertiaryLabel) - #endif - } + #if !os(watchOS) + /// The color for text labels that contain tertiary content. + public static var tertiaryLabel: Color { + #if os(macOS) + return Color(NSColor.tertiaryLabelColor) + #else + return Color(UIColor.tertiaryLabel) + #endif + } - /// The color for text labels that contain quaternary content. - public static var quaternaryLabel: Color { - #if os(macOS) - return Color(NSColor.quaternaryLabelColor) - #else - return Color(UIColor.quaternaryLabel) - #endif - } + /// The color for text labels that contain quaternary content. + public static var quaternaryLabel: Color { + #if os(macOS) + return Color(NSColor.quaternaryLabelColor) + #else + return Color(UIColor.quaternaryLabel) + #endif + } + #endif // MARK: Fill Colors - #if !os(tvOS) + #if !os(tvOS) && !os(watchOS) /// An overlay fill color for thin and small shapes. public static var systemFill: Color { #if os(macOS) @@ -152,39 +160,45 @@ extension Color { #endif // MARK: Separator Colors - /// The color for thin borders or divider lines that allows some underlying content to be visible. - public static var separator: Color { - #if os(macOS) - return Color(NSColor.separatorColor) - #else - return Color(UIColor.separator) - #endif - } + #if !os(watchOS) + /// The color for thin borders or divider lines that allows some underlying content to be visible. + public static var separator: Color { + #if os(macOS) + return Color(NSColor.separatorColor) + #else + return Color(UIColor.separator) + #endif + } - /// The color for borders or divider lines that hides any underlying content. - public static var opaqueSeparator: Color { - #if os(macOS) - return Color(NSColor.separatorColor).opacity(1) - #else - return Color(UIColor.opaqueSeparator) - #endif - } + /// The color for borders or divider lines that hides any underlying content. + public static var opaqueSeparator: Color { + #if os(macOS) + return Color(NSColor.separatorColor).opacity(1) + #else + return Color(UIColor.opaqueSeparator) + #endif + } + #endif // MARK: Link Color - /// The color for links. - public static var link: Color { - #if os(macOS) - return Color(NSColor.linkColor) - #else - return Color(UIColor.link) - #endif - } + #if !os(watchOS) + /// The color for links. + public static var link: Color { + #if os(macOS) + return Color(NSColor.linkColor) + #else + return Color(UIColor.link) + #endif + } + #endif // MARK: - Standard Colors (https://developer.apple.com/documentation/uikit/uicolor/standard_colors) // MARK: Adaptable Colors public static var systemBlue: Color { #if os(macOS) return Color(NSColor.systemBlue) + #elseif os(watchOS) + return .blue #else return Color(UIColor.systemBlue) #endif @@ -193,6 +207,8 @@ extension Color { public static var systemGreen: Color { #if os(macOS) return Color(NSColor.systemGreen) + #elseif os(watchOS) + return .green #else return Color(UIColor.systemGreen) #endif @@ -201,6 +217,8 @@ extension Color { public static var systemIndigo: Color { #if os(macOS) return Color(NSColor.systemIndigo) + #elseif os(watchOS) + return .indigo #else return Color(UIColor.systemIndigo) #endif @@ -209,6 +227,8 @@ extension Color { public static var systemOrange: Color { #if os(macOS) return Color(NSColor.systemOrange) + #elseif os(watchOS) + return .orange #else return Color(UIColor.systemOrange) #endif @@ -217,6 +237,8 @@ extension Color { public static var systemPink: Color { #if os(macOS) return Color(NSColor.systemPink) + #elseif os(watchOS) + return .pink #else return Color(UIColor.systemPink) #endif @@ -225,6 +247,8 @@ extension Color { public static var systemPurple: Color { #if os(macOS) return Color(NSColor.systemPurple) + #elseif os(watchOS) + return .purple #else return Color(UIColor.systemPurple) #endif @@ -233,6 +257,8 @@ extension Color { public static var systemRed: Color { #if os(macOS) return Color(NSColor.systemRed) + #elseif os(watchOS) + return .red #else return Color(UIColor.systemRed) #endif @@ -241,6 +267,8 @@ extension Color { public static var systemTeal: Color { #if os(macOS) return Color(NSColor.systemTeal) + #elseif os(watchOS) + return .teal #else return Color(UIColor.systemTeal) #endif @@ -249,6 +277,8 @@ extension Color { public static var systemYellow: Color { #if os(macOS) return Color(NSColor.systemYellow) + #elseif os(watchOS) + return .yellow #else return Color(UIColor.systemYellow) #endif @@ -258,12 +288,14 @@ extension Color { public static var systemGray: Color { #if os(macOS) return Color(NSColor.systemGray) + #elseif os(watchOS) + return .gray #else return Color(UIColor.systemGray) #endif } - #if !os(tvOS) + #if !os(tvOS) && !os(watchOS) public static var systemGray2: Color { #if os(macOS) return Color(NSColor.systemGray) diff --git a/Sources/HandySwiftUI/Extensions/ImageExt.swift b/Sources/HandySwiftUI/Extensions/ImageExt.swift index 832cb55..3d10af4 100644 --- a/Sources/HandySwiftUI/Extensions/ImageExt.swift +++ b/Sources/HandySwiftUI/Extensions/ImageExt.swift @@ -1,3 +1,4 @@ +import ImageIO import SwiftUI import UniformTypeIdentifiers @@ -106,35 +107,38 @@ extension Image { import UIKit extension UIImage { - /// Resizes the image to fit within the specified maximum width and height. - /// - /// - Parameters: - /// - maxWidth: The maximum width for the resized image. - /// - maxHeight: The maximum height for the resized image. - /// - Returns: A new `UIImage` instance with the resized dimensions, or `nil` if resizing fails. - @MainActor - public func resized(maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage? { - let size = self.size - if size.width <= maxWidth && size.height <= maxHeight { return self } - - #if os(visionOS) - let screenScale = 1.0 - #else - let screenScale = UIScreen.main.scale - #endif - let widthRatio = (maxWidth / screenScale) / size.width - let heightRatio = (maxHeight / screenScale) / size.height - let scaleFactor = min(widthRatio, heightRatio) - - let newSize = CGSize(width: size.width * scaleFactor, height: size.height * scaleFactor) + // watchOS has neither UIGraphicsImageRenderer nor UIScreen + #if !os(watchOS) + /// Resizes the image to fit within the specified maximum width and height. + /// + /// - Parameters: + /// - maxWidth: The maximum width for the resized image. + /// - maxHeight: The maximum height for the resized image. + /// - Returns: A new `UIImage` instance with the resized dimensions, or `nil` if resizing fails. + @MainActor + public func resized(maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage? { + let size = self.size + if size.width <= maxWidth && size.height <= maxHeight { return self } + + #if os(visionOS) + let screenScale = 1.0 + #else + let screenScale = UIScreen.main.scale + #endif + let widthRatio = (maxWidth / screenScale) / size.width + let heightRatio = (maxHeight / screenScale) / size.height + let scaleFactor = min(widthRatio, heightRatio) + + let newSize = CGSize(width: size.width * scaleFactor, height: size.height * scaleFactor) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let image = renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: newSize)) + } - let renderer = UIGraphicsImageRenderer(size: newSize) - let image = renderer.image { _ in - self.draw(in: CGRect(origin: .zero, size: newSize)) + return image.withRenderingMode(self.renderingMode) } - - return image.withRenderingMode(self.renderingMode) - } + #endif /// Converts the image to HEIC data with specified compression quality. /// diff --git a/Sources/HandySwiftUI/Modifiers/ViewExt.swift b/Sources/HandySwiftUI/Modifiers/ViewExt.swift index 37397fe..d777f21 100644 --- a/Sources/HandySwiftUI/Modifiers/ViewExt.swift +++ b/Sources/HandySwiftUI/Modifiers/ViewExt.swift @@ -300,67 +300,70 @@ extension View { } } - /// Executes the provided closure when the app is about to resign its active state (e.g., when switching to another app or minimizing the window). - /// - /// Use this modifier to perform cleanup tasks or save state when the app moves to the background. - /// - /// Example: - /// ```swift - /// struct VideoPlayerView: View { - /// @StateObject private var player = VideoPlayer() - /// - /// var body: some View { - /// VideoContent(player: player) - /// .onAppResignActive { - /// player.pause() - /// } - /// } - /// } - /// ``` - public func onAppResignActive(_ closure: @escaping () -> Void) -> some View { - #if os(macOS) - self.onReceive( - NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification), - perform: { _ in closure() } - ) - #else - self.onReceive( - NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification), - perform: { _ in closure() } - ) - #endif - } + // watchOS apps have no UIApplication, their lifecycle notifications come from WKApplication + #if !os(watchOS) + /// Executes the provided closure when the app is about to resign its active state (e.g., when switching to another app or minimizing the window). + /// + /// Use this modifier to perform cleanup tasks or save state when the app moves to the background. + /// + /// Example: + /// ```swift + /// struct VideoPlayerView: View { + /// @StateObject private var player = VideoPlayer() + /// + /// var body: some View { + /// VideoContent(player: player) + /// .onAppResignActive { + /// player.pause() + /// } + /// } + /// } + /// ``` + public func onAppResignActive(_ closure: @escaping () -> Void) -> some View { + #if os(macOS) + self.onReceive( + NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification), + perform: { _ in closure() } + ) + #else + self.onReceive( + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification), + perform: { _ in closure() } + ) + #endif + } - /// Executes the provided closure when the app becomes active (e.g., when returning from another app or un-minimizing the window). - /// - /// Use this modifier to resume tasks or refresh content when the app returns to the foreground. - /// - /// Example: - /// ```swift - /// struct ChatView: View { - /// @StateObject private var viewModel = ChatViewModel() - /// - /// var body: some View { - /// MessagesList(messages: viewModel.messages) - /// .onAppBecomeActive { - /// viewModel.refreshMessages() - /// } - /// } - /// } - /// ``` - public func onAppBecomeActive(_ closure: @escaping () -> Void) -> some View { - #if os(macOS) - self.onReceive( - NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), - perform: { _ in closure() } - ) - #else - self.onReceive( - NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification), - perform: { _ in closure() } - ) - #endif - } + /// Executes the provided closure when the app becomes active (e.g., when returning from another app or un-minimizing the window). + /// + /// Use this modifier to resume tasks or refresh content when the app returns to the foreground. + /// + /// Example: + /// ```swift + /// struct ChatView: View { + /// @StateObject private var viewModel = ChatViewModel() + /// + /// var body: some View { + /// MessagesList(messages: viewModel.messages) + /// .onAppBecomeActive { + /// viewModel.refreshMessages() + /// } + /// } + /// } + /// ``` + public func onAppBecomeActive(_ closure: @escaping () -> Void) -> some View { + #if os(macOS) + self.onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), + perform: { _ in closure() } + ) + #else + self.onReceive( + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification), + perform: { _ in closure() } + ) + #endif + } + #endif /// Presents a confirmation dialog for delete actions with a default title and warning message. /// diff --git a/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift b/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift index 601b7f1..9a4eba9 100644 --- a/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift +++ b/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift @@ -27,7 +27,7 @@ extension LabeledContentStyle where Self == HorizontalLabeledContentStyle { } } -#if DEBUG && !os(tvOS) +#if DEBUG && !os(tvOS) && !os(watchOS) #Preview { VStack { Form { diff --git a/Sources/HandySwiftUI/Types/Views/AsyncButton.swift b/Sources/HandySwiftUI/Types/Views/AsyncButton.swift index e389e51..be85748 100644 --- a/Sources/HandySwiftUI/Types/Views/AsyncButton.swift +++ b/Sources/HandySwiftUI/Types/Views/AsyncButton.swift @@ -162,7 +162,7 @@ public struct AsyncButton: View { } } -#if DEBUG && swift(>=6.0) +#if DEBUG && swift(>=6.0) && !os(watchOS) @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *) #Preview { @Previewable @State var errorMessage: String? diff --git a/Sources/HandySwiftUI/Types/Views/DisclosureSection.swift b/Sources/HandySwiftUI/Types/Views/DisclosureSection.swift index cc8bd58..da17000 100644 --- a/Sources/HandySwiftUI/Types/Views/DisclosureSection.swift +++ b/Sources/HandySwiftUI/Types/Views/DisclosureSection.swift @@ -142,7 +142,7 @@ public struct DisclosureSection: View } } -#if DEBUG && !os(tvOS) +#if DEBUG && !os(tvOS) && !os(watchOS) #Preview { Form { DisclosureGroup("Original Disclosure Group") { diff --git a/Sources/HandySwiftUI/Types/Views/HPicker.swift b/Sources/HandySwiftUI/Types/Views/HPicker.swift index 77e0580..6fe7553 100644 --- a/Sources/HandySwiftUI/Types/Views/HPicker.swift +++ b/Sources/HandySwiftUI/Types/Views/HPicker.swift @@ -41,6 +41,7 @@ import SwiftUI /// } /// } /// ``` +@available(watchOS 10.0, *) public struct HPicker: View { /// The array of selectable options. let options: [T] @@ -150,6 +151,7 @@ public struct HPicker: View { /// The array of selectable options. let options: [T] @@ -148,6 +149,7 @@ public struct VPicker