diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift index e97783d9..bf46eec7 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift @@ -254,6 +254,19 @@ struct KeyboardTypePicker: View { } } +// MARK: - LineCap + +struct LineCapPicker: View { + @Binding var selection: LineCap + + var body: some View { + Picker("Line Cap", selection: self.$selection) { + Text("Rounded").tag(LineCap.rounded) + Text("Square").tag(LineCap.square) + } + } +} + // MARK: - OverlayStylePicker struct OverlayStylePicker: View { @@ -282,6 +295,19 @@ struct SizePicker: View { } } +struct OptionalSizePicker: View { + @Binding var selection: ComponentSize? + + var body: some View { + Picker("Size", selection: self.$selection) { + Text("Nil").tag(Optional.none) + Text("Small").tag(ComponentSize.small) + Text("Medium").tag(ComponentSize.medium) + Text("Large").tag(ComponentSize.large) + } + } +} + // MARK: - SubmitTypePicker struct SubmitTypePicker: View { 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/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index ef98cd9d..0a23ac06 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -29,10 +29,7 @@ struct CircularProgressPreview: View { Form { ComponentColorPicker(selection: self.$model.color) CaptionFontPicker(selection: self.$model.font) - Picker("Line Cap", selection: self.$model.lineCap) { - Text("Rounded").tag(CircularProgressVM.LineCap.rounded) - Text("Square").tag(CircularProgressVM.LineCap.square) - } + LineCapPicker(selection: self.$model.lineCap) Picker("Line Width", selection: self.$model.lineWidth) { Text("Default").tag(Optional.none) Text("2").tag(Optional.some(2)) @@ -43,7 +40,7 @@ struct CircularProgressPreview: View { Text("Circle").tag(CircularProgressVM.Shape.circle) Text("Arc").tag(CircularProgressVM.Shape.arc) } - SizePicker(selection: self.$model.size) + OptionalSizePicker(selection: self.$model.size) } .onReceive(self.timer) { _ in if self.model.currentValue < self.model.maxValue { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift index 3ee062f6..c8003bb3 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift @@ -16,14 +16,12 @@ struct LoadingPreview: View { } Form { ComponentColorPicker(selection: self.$model.color) + LineCapPicker(selection: self.$model.lineCap) Picker("Line Width", selection: self.$model.lineWidth) { Text("Default").tag(Optional.none) Text("Custom: 6px").tag(CGFloat(6.0)) } - SizePicker(selection: self.$model.size) - Picker("Style", selection: self.$model.style) { - Text("Spinner").tag(LoadingVM.Style.spinner) - } + OptionalSizePicker(selection: self.$model.size) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift index 6566bdeb..86e66339 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift @@ -18,7 +18,7 @@ struct SegmentedControlPreview: View { $0.title = "iPad" }, .init(id: .mac) { - $0.title = "Mackbook" + $0.title = "Macbook" } ] } 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/Components/CircularProgress/Models/CircularProgressLineCap.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift deleted file mode 100644 index 72a86b8c..00000000 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI -import UIKit - -extension CircularProgressVM { - /// Defines the style of line endings. - public enum LineCap { - /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. - case rounded - /// The line ends exactly at the endpoint with a flat edge. - case square - } -} - -// MARK: - UIKit Helpers - -extension CircularProgressVM.LineCap { - var shapeLayerLineCap: CAShapeLayerLineCap { - switch self { - case .rounded: - return .round - case .square: - return .butt - } - } -} - -// MARK: - SwiftUI Helpers - -extension CircularProgressVM.LineCap { - var cgLineCap: CGLineCap { - switch self { - case .rounded: - return .round - case .square: - return .butt - } - } -} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift index 3422d75b..d444ae12 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -41,8 +41,13 @@ public struct CircularProgressVM: ComponentVM { /// The size of the circular progress. /// + /// If nil, the view is intended to expand to the available space provided by + /// the surrounding layout: + /// - In SwiftUI, constrain it with .frame(...). + /// - In UIKit, constrain it with Auto Layout. + /// /// Defaults to `.medium`. - public var size: ComponentSize = .medium + public var size: ComponentSize? = .medium /// Initializes a new instance of `CircularProgressVM` with default values. public init() {} @@ -55,10 +60,20 @@ extension CircularProgressVM { return 0.2 } var circularLineWidth: CGFloat { - return self.lineWidth ?? max(self.preferredSize.width / 8, 2) + if let lineWidth { + return lineWidth + } else if let width = self.preferredSize?.width { + return max(width / 8, 2) + } else { + return 3 + } } - var preferredSize: CGSize { - switch self.size { + var preferredSize: CGSize? { + guard let size else { + return nil + } + + switch size { case .small: return CGSize(width: 48, height: 48) case .medium: @@ -67,14 +82,11 @@ extension CircularProgressVM { return CGSize(width: 80, height: 80) } } - var radius: CGFloat { - return self.preferredSize.height / 2 - self.circularLineWidth / 2 + func radius(size: CGSize) -> CGFloat { + return min(size.width, size.height) / 2 - self.circularLineWidth / 2 } - var center: CGPoint { - return .init( - x: self.preferredSize.width / 2, - y: self.preferredSize.height / 2 - ) + func center(size: CGSize) -> CGPoint { + return .init(x: size.width / 2, y: size.height / 2) } var startAngle: CGFloat { switch self.shape { @@ -99,7 +111,7 @@ extension CircularProgressVM { switch self.size { case .small: return .smCaption - case .medium: + case .medium, .none: return .mdCaption case .large: return .lgCaption diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift index 392e4438..dee6ad47 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -40,49 +40,47 @@ public struct SUCircularProgress: View { public var body: some View { ZStack { - // Background part - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(self.model.startAngle), - endAngle: .radians(self.model.endAngle), - clockwise: false + GeometryReader { geometry in + // Background part + Path { path in + path.addArc( + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) + } + .stroke( + self.model.color.background.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) ) - } - .stroke( - self.model.color.background.color, - style: StrokeStyle( - lineWidth: self.model.circularLineWidth, - lineCap: self.model.lineCap.cgLineCap - ) - ) - .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height - ) - // Foreground part - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(self.model.startAngle), - endAngle: .radians(self.model.endAngle), - clockwise: false + // Foreground part + Path { path in + path.addArc( + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) + } + .trim(from: 0, to: self.progress) + .stroke( + self.model.color.main.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) ) } - .trim(from: 0, to: self.progress) - .stroke( - self.model.color.main.color, - style: StrokeStyle( - lineWidth: self.model.circularLineWidth, - lineCap: self.model.lineCap.cgLineCap - ) - ) .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height + width: self.model.preferredSize?.width, + height: self.model.preferredSize?.height ) // Optional label diff --git a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift index 193d2a11..c1777454 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift @@ -37,7 +37,7 @@ open class UKCircularProgress: UIView, UKComponent { // MARK: - UIView Properties open override var intrinsicContentSize: CGSize { - return self.model.preferredSize + return self.sizeThatFits(UIView.layoutFittingExpandedSize) } // MARK: - Initialization @@ -130,12 +130,9 @@ open class UKCircularProgress: UIView, UKComponent { } private func updateShapePaths() { - let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) - let minSide = min(self.bounds.width, self.bounds.height) - let radius = (minSide - self.model.circularLineWidth) / 2 let circlePath = UIBezierPath( - arcCenter: center, - radius: radius, + arcCenter: self.model.center(size: self.bounds.size), + radius: self.model.radius(size: self.bounds.size), startAngle: self.model.startAngle, endAngle: self.model.endAngle, clockwise: true @@ -171,10 +168,10 @@ open class UKCircularProgress: UIView, UKComponent { // MARK: - UIView Methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let preferred = self.model.preferredSize - return CGSize( - width: min(size.width, preferred.width), - height: min(size.height, preferred.height) + let preferredSize = self.model.preferredSize ?? size + return .init( + width: min(preferredSize.width, size.width), + height: min(preferredSize.height, size.height) ) } diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift deleted file mode 100644 index 71031529..00000000 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -extension LoadingVM { - /// The loading appearance style. - public enum Style { - case spinner - } -} diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index c9f7f30b..d92606de 100644 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -7,6 +7,9 @@ public struct LoadingVM: ComponentVM { /// Defaults to `.accent`. public var color: ComponentColor = .accent + /// The style of line endings. + public var lineCap: LineCap = .rounded + /// The width of the lines used in the loading indicator. /// /// If not provided, the line width is automatically adjusted based on the size. @@ -14,13 +17,13 @@ public struct LoadingVM: ComponentVM { /// The predefined size of the loading indicator. /// - /// Defaults to `.medium`. - public var size: ComponentSize = .medium - - /// The style of the loading indicator (e.g., spinner, bar). + /// If nil, the loader is intended to expand to the available space provided by + /// the surrounding layout: + /// - In SwiftUI, constrain it with .frame(...). + /// - In UIKit, constrain it with Auto Layout. /// - /// Defaults to `.spinner`. - public var style: Style = .spinner + /// Defaults to `.medium`. + public var size: ComponentSize? = .medium /// Initializes a new instance of `LoadingVM` with default values. public init() {} @@ -30,23 +33,33 @@ public struct LoadingVM: ComponentVM { extension LoadingVM { var loadingLineWidth: CGFloat { - return self.lineWidth ?? max(self.preferredSize.width / 8, 2) + if let lineWidth { + return lineWidth + } else if let width = self.preferredSize?.width { + return max(width / 8, 2) + } else { + return 3 + } } - var preferredSize: CGSize { - switch self.style { - case .spinner: - switch self.size { - case .small: - return .init(width: 24, height: 24) - case .medium: - return .init(width: 36, height: 36) - case .large: - return .init(width: 48, height: 48) - } + var preferredSize: CGSize? { + guard let size else { + return nil + } + + switch size { + case .small: + return .init(width: 24, height: 24) + case .medium: + return .init(width: 36, height: 36) + case .large: + return .init(width: 48, height: 48) } } - var radius: CGFloat { - return self.preferredSize.height / 2 - self.loadingLineWidth / 2 + func radius(size: CGSize) -> CGFloat { + return min(size.width, size.height) / 2 - self.loadingLineWidth / 2 + } + func center(size: CGSize) -> CGPoint { + return .init(x: size.width / 2, y: size.height / 2) } } @@ -57,14 +70,3 @@ extension LoadingVM { return self.size != oldModel.size || self.lineWidth != oldModel.lineWidth } } - -// MARK: SwiftUI Helpers - -extension LoadingVM { - var center: CGPoint { - return .init( - x: self.preferredSize.width / 2, - y: self.preferredSize.height / 2 - ) - } -} diff --git a/Sources/ComponentsKit/Components/Loading/SULoading.swift b/Sources/ComponentsKit/Components/Loading/SULoading.swift index 553e7644..2133d22e 100644 --- a/Sources/ComponentsKit/Components/Loading/SULoading.swift +++ b/Sources/ComponentsKit/Components/Loading/SULoading.swift @@ -22,21 +22,22 @@ public struct SULoading: View { // MARK: Body public var body: some View { - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), - clockwise: true - ) - } + GeometryReader { geometry in + Path { path in + path.addArc( + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: true + ) + } .trim(from: 0, to: 0.75) .stroke( self.model.color.main.color, style: StrokeStyle( lineWidth: self.model.loadingLineWidth, - lineCap: .round, + lineCap: self.model.lineCap.cgLineCap, lineJoin: .round, miterLimit: 0 ) @@ -47,15 +48,16 @@ public struct SULoading: View { .repeatForever(autoreverses: false), value: self.rotationAngle ) - .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height, - alignment: .center - ) .onAppear { DispatchQueue.main.async { self.rotationAngle = 2 * .pi } } + } + .frame( + width: self.model.preferredSize?.width, + height: self.model.preferredSize?.height, + alignment: .center + ) } } diff --git a/Sources/ComponentsKit/Components/Loading/UKLoading.swift b/Sources/ComponentsKit/Components/Loading/UKLoading.swift index bd4adecd..9a9f0e27 100644 --- a/Sources/ComponentsKit/Components/Loading/UKLoading.swift +++ b/Sources/ComponentsKit/Components/Loading/UKLoading.swift @@ -64,12 +64,6 @@ open class UKLoading: UIView, UKComponent { self.addSpinnerAnimation() - NotificationCenter.default.addObserver( - self, - selector: #selector(self.handleAppWillMoveToBackground), - name: UIApplication.willResignActiveNotification, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(self.handleAppMovedFromBackground), @@ -88,13 +82,10 @@ open class UKLoading: UIView, UKComponent { self.shapeLayer.lineWidth = self.model.loadingLineWidth self.shapeLayer.strokeColor = self.model.color.main.uiColor.cgColor self.shapeLayer.fillColor = UIColor.clear.cgColor - self.shapeLayer.lineCap = .round + self.shapeLayer.lineCap = self.model.lineCap.shapeLayerLineCap self.shapeLayer.strokeEnd = 0.75 } - @objc private func handleAppWillMoveToBackground() { - self.shapeLayer.removeAllAnimations() - } @objc private func handleAppMovedFromBackground() { self.addSpinnerAnimation() } @@ -106,6 +97,7 @@ open class UKLoading: UIView, UKComponent { self.shapeLayer.lineWidth = self.model.loadingLineWidth self.shapeLayer.strokeColor = self.model.color.main.uiColor.cgColor + self.shapeLayer.lineCap = self.model.lineCap.shapeLayerLineCap if self.model.shouldUpdateShapePath(oldModel) { self.updateShapePath() @@ -116,11 +108,9 @@ open class UKLoading: UIView, UKComponent { } private func updateShapePath() { - let radius = self.model.preferredSize.height / 2 - self.shapeLayer.lineWidth / 2 - let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) self.shapeLayer.path = UIBezierPath( - arcCenter: center, - radius: radius, + arcCenter: self.model.center(size: self.bounds.size), + radius: self.model.radius(size: self.bounds.size), startAngle: 0, endAngle: 2 * .pi, clockwise: true @@ -132,9 +122,11 @@ open class UKLoading: UIView, UKComponent { open override func layoutSubviews() { super.layoutSubviews() - // Adjust the layer's frame to fit within the view's bounds + CATransaction.begin() + CATransaction.setDisableActions(true) self.shapeLayer.frame = self.bounds self.updateShapePath() + CATransaction.commit() if self.isVisible { self.addSpinnerAnimation() @@ -144,7 +136,7 @@ open class UKLoading: UIView, UKComponent { // MARK: UIView methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let preferredSize = self.model.preferredSize + let preferredSize = self.model.preferredSize ?? size return .init( width: min(preferredSize.width, size.width), height: min(preferredSize.height, size.height) @@ -161,13 +153,19 @@ open class UKLoading: UIView, UKComponent { // MARK: Helpers private func addSpinnerAnimation() { + let animationKey = "rotationAnimation" + + guard self.shapeLayer.animation(forKey: animationKey).isNil else { + return + } + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z") rotationAnimation.fromValue = 0 rotationAnimation.toValue = CGFloat.pi * 2 rotationAnimation.duration = 1.0 rotationAnimation.repeatCount = .infinity rotationAnimation.timingFunction = CAMediaTimingFunction(name: .linear) - self.shapeLayer.add(rotationAnimation, forKey: "rotationAnimation") + self.shapeLayer.add(rotationAnimation, forKey: animationKey) } private func handleTraitChanges() { diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift index db5b9a10..49ef36d1 100644 --- a/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift @@ -151,6 +151,6 @@ extension UIViewController { animated: Bool, completion: (() -> Void)? = nil ) { - self.present(vc as UIViewController, animated: false) + self.present(vc as UIViewController, animated: false, completion: completion) } } diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift index c31e8704..fffdc564 100644 --- a/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift @@ -122,6 +122,6 @@ extension UIViewController { animated: Bool, completion: (() -> Void)? = nil ) { - self.present(vc as UIViewController, animated: false) + self.present(vc as UIViewController, animated: false, completion: completion) } } 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) + } +} diff --git a/Sources/ComponentsKit/Shared/Types/LineCap.swift b/Sources/ComponentsKit/Shared/Types/LineCap.swift new file mode 100644 index 00000000..833aa40c --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/LineCap.swift @@ -0,0 +1,36 @@ +import SwiftUI +import UIKit + +/// Defines the style of line endings. +public enum LineCap { + /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. + case rounded + /// The line ends exactly at the endpoint with a flat edge. + case square +} + +// MARK: - UIKit Helpers + +extension LineCap { + var shapeLayerLineCap: CAShapeLayerLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} + +// MARK: - SwiftUI Helpers + +extension LineCap { + var cgLineCap: CGLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +}