diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift index 948ecbe2..d4014db1 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift @@ -20,7 +20,7 @@ struct AvatarGroupPreview: View { $0.placeholder = .text("IM") }, .init { - $0.placeholder = .sfSymbol("person.circle") + $0.placeholder = .image(.init(systemName: "person.circle")) }, ] } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift index 2747d1f6..dc438b6b 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift @@ -4,9 +4,9 @@ import UIKit struct AvatarPreview: View { @State private var model = AvatarVM { - $0.placeholder = .icon("avatar_placeholder") + $0.placeholder = .image(.init("avatar_placeholder")) } - + var body: some View { VStack { PreviewWrapper(title: "UIKit") { @@ -23,13 +23,19 @@ struct AvatarPreview: View { } Picker("Image Source", selection: self.$model.imageSrc) { Text("Remote").tag(AvatarVM.ImageSource.remote(URL(string: "https://i.pravatar.cc/150?img=12")!)) - Text("Local").tag(AvatarVM.ImageSource.local("avatar_image")) + Text("Local").tag( + AvatarVM.ImageSource.local(UniversalImage("avatar_image")) + ) Text("None").tag(Optional.none) } Picker("Placeholder", selection: self.$model.placeholder) { Text("Text").tag(AvatarVM.Placeholder.text("IM")) - Text("SF Symbol").tag(AvatarVM.Placeholder.sfSymbol("person")) - Text("Icon").tag(AvatarVM.Placeholder.icon("avatar_placeholder")) + Text("SF Symbol").tag( + AvatarVM.Placeholder.image(UniversalImage(systemName: "person")) + ) + Text("Asset").tag( + AvatarVM.Placeholder.image(UniversalImage("avatar_placeholder")) + ) } SizePicker(selection: self.$model.size) } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index 5a9ce2bd..430476a8 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift @@ -35,15 +35,26 @@ struct ButtonPreview: View { Text("Leading").tag(ButtonVM.ImageLocation.leading) Text("Trailing").tag(ButtonVM.ImageLocation.trailing) } - Picker("Image Rendering Mode", selection: self.$model.imageRenderingMode) { - Text("Default").tag(Optional.none) - Text("Template").tag(ImageRenderingMode.template) - Text("Original").tag(ImageRenderingMode.original) - } - Picker("Image Source", selection: self.$model.imageSrc) { - Text("SF Symbol").tag(ButtonVM.ImageSource.sfSymbol("camera.fill")) - Text("Local").tag(ButtonVM.ImageSource.local("avatar_placeholder")) - Text("None").tag(Optional.none) + Picker("Image", selection: self.$model.image) { + Text("SF Symbol").tag(UniversalImage(systemName: "camera.fill")) + Text("SF Symbol (Template)").tag( + UniversalImage(systemName: "camera.fill") + .withRenderingMode(.template) + ) + Text("SF Symbol (Original)").tag( + UniversalImage(systemName: "camera.fill") + .withRenderingMode(.original) + ) + Text("Local").tag(UniversalImage("avatar_placeholder")) + Text("Local (Template)").tag( + UniversalImage("avatar_placeholder") + .withRenderingMode(.template) + ) + Text("Local (Original)").tag( + UniversalImage("avatar_placeholder") + .withRenderingMode(.original) + ) + Text("None").tag(Optional.none) } Toggle("Loading", isOn: self.$model.isLoading) Toggle("Show Title", isOn: Binding( diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift index 21067b6c..b71cdc9b 100644 --- a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift +++ b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift @@ -15,8 +15,8 @@ final class AvatarImageManager: ObservableObject { case .remote(let url): self.avatarImage = model.placeholderImage(for: size) self.downloadImage(url: url) - case let .local(name, bundle): - self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .local(let image): + self.avatarImage = image.uiImage ?? model.placeholderImage(for: size) case .none: self.avatarImage = model.placeholderImage(for: size) } @@ -33,8 +33,8 @@ final class AvatarImageManager: ObservableObject { self.avatarImage = model.placeholderImage(for: size) self.downloadImage(url: url) } - case let .local(name, bundle): - self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .local(let image): + self.avatarImage = image.uiImage ?? model.placeholderImage(for: size) case .none: self.avatarImage = model.placeholderImage(for: size) } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift index fb0b0a4f..81fcc20d 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift @@ -9,11 +9,15 @@ extension AvatarVM { /// - Note: Ensure the URL is valid and accessible to prevent errors during image fetching. case remote(_ url: URL) - /// An image loaded from a local asset. + /// An image referenced locally using a `UniversalImage`. /// - /// - Parameters: - /// - name: The name of the local image asset. - /// - bundle: The bundle containing the image resource. Defaults to `nil`, which uses the main bundle. - case local(_ name: String, _ bundle: Bundle? = nil) + /// - Parameter image: See ``UniversalImage``. + case local(_ image: UniversalImage) + + /// An image loaded from a local asset. + @available(*, deprecated, message: "Use `local(_:)` instead.") + public static func local(_ name: String, _ bundle: Bundle? = nil) -> Self { + return .local(.init(name, bundle: bundle)) + } } } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift index 18f017e2..b1b05975 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift @@ -13,21 +13,21 @@ extension AvatarVM { /// - Note: Only 3 first letters are displayed. case text(String) - /// A placeholder that displays an SF Symbol. - /// - /// This option allows you to use Apple's system-provided icons as placeholders. + /// A placeholder that displays an image. /// - /// - Parameter name: The name of the SF Symbol to display. - /// - Note: Ensure that the SF Symbol name corresponds to an existing icon in the system's symbol library. - case sfSymbol(_ name: String) + /// - Parameter image: See ``UniversalImage``. + case image(_ image: UniversalImage) + + /// A placeholder that displays an SF Symbol. + @available(*, deprecated, message: "Use `image(_:)` instead.") + public static func sfSymbol(_ name: String) -> Self { + return .image(.init(systemName: name)) + } /// A placeholder that displays a custom icon from an asset catalog. - /// - /// This option allows you to use icons from your app's bundled resources or a specified bundle. - /// - /// - Parameters: - /// - name: The name of the icon asset to use as the placeholder. - /// - bundle: The bundle containing the icon resource. Defaults to `nil`, which uses the main bundle. - case icon(_ name: String, _ bundle: Bundle? = nil) + @available(*, deprecated, message: "Use `image(_:)` instead.") + public static func icon(_ name: String, _ bundle: Bundle? = nil) -> Self { + return .image(.init(name, bundle: bundle)) + } } } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift index b946e1ed..ee6cedb3 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift @@ -14,7 +14,7 @@ public struct AvatarVM: ComponentVM, Hashable { public var imageSrc: ImageSource? /// The placeholder that is displayed if the image is not provided or fails to load. - public var placeholder: Placeholder = .icon("avatar_placeholder", Bundle.module) + public var placeholder: Placeholder = .image(.init("avatar_placeholder", bundle: .module)) /// The predefined size of the avatar. /// @@ -54,12 +54,8 @@ extension AvatarVM { switch self.placeholder { case .text(let value): return self.drawName(value, size: size) - case .icon(let name, let bundle): - let icon = UIImage(named: name, in: bundle, with: nil) - return self.drawIcon(icon, size: size) - case .sfSymbol(let name): - let systemIcon = UIImage(systemName: name) - return self.drawIcon(systemIcon, size: size) + case .image(let image): + return self.drawIcon(image.uiImage, size: size) } } diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift index 66cdd41f..06f9e197 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift @@ -6,7 +6,7 @@ public struct AvatarItemVM: ComponentVM { public var imageSrc: AvatarVM.ImageSource? /// The placeholder that is displayed if the image is not provided or fails to load. - public var placeholder: AvatarVM.Placeholder = .icon("avatar_placeholder", Bundle.module) + public var placeholder: AvatarVM.Placeholder = .image(.init("avatar_placeholder", bundle: .module)) /// Initializes a new instance of `AvatarItemVM` with default values. public init() {} diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index 9303de5e..c42a8865 100644 --- a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -25,15 +25,20 @@ public struct ButtonVM: ComponentVM { /// If not provided, the font is automatically calculated based on the button's size. public var font: UniversalFont? + /// The image to be displayed. + public var image: UniversalImage? + /// The position of the image relative to the button's title. /// /// Defaults to `.leading`. public var imageLocation: ImageLocation = .leading /// Defines how image is rendered. + @available(*, deprecated, message: "Use `image.withRenderingMode(_:)` instead.") public var imageRenderingMode: ImageRenderingMode? /// The source of the image to be displayed. + @available(*, deprecated, message: "Use `image` instead.") public var imageSrc: ImageSource? /// A Boolean value indicating whether the button is enabled or disabled. @@ -182,25 +187,31 @@ extension ButtonVM { } } } -} + var imageWithLegacyFallback: UniversalImage? { + if let image { return image } -extension ButtonVM { - var image: UIImage? { guard let imageSrc else { return nil } let image = switch imageSrc { case .sfSymbol(let name): - UIImage(systemName: name) + UniversalImage(systemName: name) case .local(let name, let bundle): - UIImage(named: name, in: bundle, compatibleWith: nil) + UniversalImage(name, bundle: bundle) + } + if let imageRenderingMode { + return image.withRenderingMode(imageRenderingMode) + } else { + return image } - return image?.withRenderingMode(self.imageRenderingMode) } } // MARK: UIKit Helpers extension ButtonVM { + var isImageHidden: Bool { + return self.isLoading || self.imageWithLegacyFallback.isNil + } func preferredSize( for contentSize: CGSize, parentWidth: CGFloat? @@ -232,7 +243,7 @@ extension ButtonVM { || self.font != oldModel.font || self.isFullWidth != oldModel.isFullWidth || self.isLoading != oldModel.isLoading - || self.imageSrc != oldModel.imageSrc + || self.imageWithLegacyFallback != oldModel.imageWithLegacyFallback || self.contentSpacing != oldModel.contentSpacing || self.title != oldModel.title } diff --git a/Sources/ComponentsKit/Components/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift index 550eb577..ef930ed5 100644 --- a/Sources/ComponentsKit/Components/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -50,38 +50,32 @@ public struct SUButton: View { @ViewBuilder private var content: some View { - switch (self.model.isLoading, self.model.image, self.model.imageLocation) { + switch (self.model.isLoading, self.model.imageWithLegacyFallback, self.model.imageLocation) { case (true, _, _) where self.model.title.isEmpty: SULoading(model: self.model.preferredLoadingVM) case (true, _, _): SULoading(model: self.model.preferredLoadingVM) Text(self.model.title) - case (false, let uiImage?, .leading) where self.model.title.isEmpty: - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + case (false, let image?, _) where self.model.title.isEmpty: + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) - case (false, let uiImage?, .leading): - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + case (false, let image?, .leading): + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) Text(self.model.title) - case (false, let uiImage?, .trailing) where self.model.title.isEmpty: - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor - ) - .frame(width: self.model.imageSide, height: self.model.imageSide) - case (false, let uiImage?, .trailing): + case (false, let image?, .trailing): Text(self.model.title) - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) case (false, _, _): Text(self.model.title) } @@ -90,28 +84,27 @@ public struct SUButton: View { // MARK: - Helpers -private struct ButtonImageView: UIViewRepresentable { - class InternalImageView: UIImageView { - override var intrinsicContentSize: CGSize { - return .zero - } - } +private struct ButtonImage: View { + let universalImage: UniversalImage + let tintColor: UniversalColor + let side: CGFloat - let image: UIImage - let tintColor: UIColor - - func makeUIView(context: Context) -> UIImageView { - let imageView = InternalImageView() - imageView.image = self.image - imageView.tintColor = self.tintColor - imageView.contentMode = .scaleAspectFit - imageView.isUserInteractionEnabled = true - return imageView + init( + image: UniversalImage, + tintColor: UniversalColor, + side: CGFloat + ) { + self.universalImage = image + self.tintColor = tintColor + self.side = side } - func updateUIView(_ imageView: UIImageView, context: Context) { - imageView.image = self.image - imageView.tintColor = self.tintColor + var body: some View { + self.universalImage.image + .resizable() + .scaledToFit() + .tint(self.tintColor.color) + .frame(width: self.side, height: self.side) } } diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index 747897b8..c95a8bd9 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -255,9 +255,9 @@ extension UKButton { view.isVisible = model.isLoading } static func imageView(_ imageView: UIImageView, model: Model) { - imageView.image = model.image + imageView.image = model.imageWithLegacyFallback?.uiImage imageView.contentMode = .scaleAspectFit - imageView.isHidden = model.isLoading || model.imageSrc.isNil + imageView.isHidden = model.isImageHidden imageView.tintColor = model.foregroundColor.uiColor imageView.isUserInteractionEnabled = true } diff --git a/Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift b/Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift similarity index 80% rename from Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift rename to Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift index b4b8f662..4cf973bb 100644 --- a/Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift +++ b/Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift @@ -26,16 +26,6 @@ extension ImageRenderingMode { } } -extension UIImage { - func withRenderingMode(_ mode: ImageRenderingMode?) -> UIImage { - if let mode { - return self.withRenderingMode(mode.uiImageRenderingMode) - } else { - return self - } - } -} - // MARK: - SwiftUI Helpers extension ImageRenderingMode { diff --git a/Sources/ComponentsKit/Shared/Image/UniversalImage.swift b/Sources/ComponentsKit/Shared/Image/UniversalImage.swift new file mode 100644 index 00000000..a30a4443 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Image/UniversalImage.swift @@ -0,0 +1,95 @@ +import SwiftUI +import UIKit + +/// A lightweight, platform-agnostic container for image sources. +/// +/// UniversalImage abstracts how an image is specified without committing +/// to a particular rendering type (e.g., `UIImage` on UIKit or `Image` on SwiftUI). +/// It supports two kinds of sources: +/// - SF Symbols by name +/// - Asset catalog images by name (optionally scoping lookup to a specific bundle) +/// +/// This type is intended to be carried in view models and resolved into +/// platform-specific images by higher-level helpers or components. +public struct UniversalImage: Hashable { + /// Internal representation of the image source. + private enum ImageRepresentable: Hashable { + /// An SF Symbol identified by its system name. + case sfSymbol(String) + /// An asset catalog image identified by name and an optional bundle. + case asset(String, Bundle?) + } + + /// The underlying image source. + private let internalImage: ImageRepresentable + /// Indicates how images are rendered. + private var renderingMode: ImageRenderingMode? + + /// Creates a new instance with provided image and mode. + private init(image: ImageRepresentable, mode: ImageRenderingMode) { + self.internalImage = image + self.renderingMode = mode + } + + /// Creates an image that references an SF Symbol. + /// + /// - Parameter sfSymbol: The system symbol name, such as `"star.fill"`. + /// + /// Notes: + /// - Availability and appearance vary by platform and OS version. + /// - Rendering mode and weight should be applied when converting to a platform image. + public init(systemName name: String) { + self.internalImage = .sfSymbol(name) + } + + /// Creates an image that references an asset catalog image. + /// + /// - Parameters: + /// - name: The asset name. + /// - bundle: The bundle to search. Pass `nil` to search the main bundle. + /// + /// Use this when your image is stored in an asset catalog. If you are distributing + /// a framework or Swift package, provide the bundle that contains the asset. + public init(_ name: String, bundle: Bundle? = nil) { + self.internalImage = .asset(name, bundle) + } +} + +// MARK: - Helpers + +extension UniversalImage { + /// Resolves the universal image into a UIKit `UIImage`. + public var uiImage: UIImage? { + let image = switch self.internalImage { + case .sfSymbol(let name): + UIImage(systemName: name) + case .asset(let name, let bundle): + UIImage(named: name, in: bundle, with: nil) + } + if let renderingMode { + return image?.withRenderingMode(renderingMode.uiImageRenderingMode) + } else { + return image + } + } + + /// Resolves the universal image into a SwiftUI `Image`. + public var image: Image { + let image = switch self.internalImage { + case .sfSymbol(let name): + Image(systemName: name) + case .asset(let name, let bundle): + Image(name, bundle: bundle) + } + if let renderingMode { + return image.renderingMode(renderingMode.imageRenderingModel) + } else { + return image + } + } + + /// Returns a new version of the image that uses the specified rendering mode. + public func withRenderingMode(_ mode: ImageRenderingMode) -> Self { + return Self(image: self.internalImage, mode: mode) + } +}