diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json
new file mode 100644
index 00000000..09cea8c4
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "avatar.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png
new file mode 100644
index 00000000..54babc50
Binary files /dev/null and b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png differ
diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json
new file mode 100644
index 00000000..68cb3fb8
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "avatar_placeholder.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg
new file mode 100644
index 00000000..6c089d7f
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg
@@ -0,0 +1,4 @@
+
diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift
new file mode 100644
index 00000000..948ecbe2
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift
@@ -0,0 +1,62 @@
+import ComponentsKit
+import SwiftUI
+import UIKit
+
+struct AvatarGroupPreview: View {
+ @State private var model = AvatarGroupVM {
+ $0.items = [
+ .init {
+ $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=12")!)
+ },
+ .init {
+ $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=14")!)
+ },
+ .init {
+ $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=15")!)
+ },
+ .init(),
+ .init(),
+ .init {
+ $0.placeholder = .text("IM")
+ },
+ .init {
+ $0.placeholder = .sfSymbol("person.circle")
+ },
+ ]
+ }
+
+ var body: some View {
+ VStack {
+ PreviewWrapper(title: "UIKit") {
+ UKAvatarGroup(model: self.model)
+ .preview
+ }
+ PreviewWrapper(title: "SwiftUI") {
+ SUAvatarGroup(model: self.model)
+ }
+ Form {
+ Picker("Border Color", selection: self.$model.borderColor) {
+ Text("Background").tag(UniversalColor.background)
+ Text("Accent Background").tag(ComponentColor.accent.background)
+ Text("Success Background").tag(ComponentColor.success.background)
+ Text("Warning Background").tag(ComponentColor.warning.background)
+ Text("Danger Background").tag(ComponentColor.danger.background)
+ }
+ ComponentOptionalColorPicker(selection: self.$model.color)
+ ComponentRadiusPicker(selection: self.$model.cornerRadius) {
+ Text("Custom: 4px").tag(ComponentRadius.custom(4))
+ }
+ Picker("Max Visible Avatars", selection: self.$model.maxVisibleAvatars) {
+ Text("3").tag(3)
+ Text("5").tag(5)
+ Text("7").tag(7)
+ }
+ SizePicker(selection: self.$model.size)
+ }
+ }
+ }
+}
+
+#Preview {
+ AvatarGroupPreview()
+}
diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift
new file mode 100644
index 00000000..2747d1f6
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift
@@ -0,0 +1,42 @@
+import ComponentsKit
+import SwiftUI
+import UIKit
+
+struct AvatarPreview: View {
+ @State private var model = AvatarVM {
+ $0.placeholder = .icon("avatar_placeholder")
+ }
+
+ var body: some View {
+ VStack {
+ PreviewWrapper(title: "UIKit") {
+ UKAvatar(model: self.model)
+ .preview
+ }
+ PreviewWrapper(title: "SwiftUI") {
+ SUAvatar(model: self.model)
+ }
+ Form {
+ ComponentOptionalColorPicker(selection: self.$model.color)
+ ComponentRadiusPicker(selection: self.$model.cornerRadius) {
+ Text("Custom: 4px").tag(ComponentRadius.custom(4))
+ }
+ 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("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"))
+ }
+ SizePicker(selection: self.$model.size)
+ }
+ }
+ }
+}
+
+#Preview {
+ AvatarPreview()
+}
diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift
new file mode 100644
index 00000000..be023fbc
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift
@@ -0,0 +1,50 @@
+import ComponentsKit
+import SwiftUI
+import UIKit
+
+struct BadgePreview: View {
+ @State private var model = BadgeVM {
+ $0.title = "Badge"
+ }
+
+ var body: some View {
+ VStack {
+ PreviewWrapper(title: "UIKit") {
+ UKBadge(model: self.model)
+ .preview
+ }
+ PreviewWrapper(title: "SwiftUI") {
+ SUBadge(model: self.model)
+ }
+ Form {
+ Picker("Font", selection: self.$model.font) {
+ Text("Default").tag(Optional.none)
+ Text("Small").tag(UniversalFont.smButton)
+ Text("Medium").tag(UniversalFont.mdButton)
+ Text("Large").tag(UniversalFont.lgButton)
+ Text("Custom: system bold of size 16").tag(UniversalFont.system(size: 16, weight: .bold))
+ }
+ ComponentOptionalColorPicker(selection: self.$model.color)
+ ComponentRadiusPicker(selection: self.$model.cornerRadius) {
+ Text("Custom: 4px").tag(ComponentRadius.custom(4))
+ }
+ Picker("Style", selection: self.$model.style) {
+ Text("Filled").tag(BadgeVM.Style.filled)
+ Text("Light").tag(BadgeVM.Style.light)
+ }
+ Picker("Paddings", selection: self.$model.paddings) {
+ Text("8px; 6px")
+ .tag(Paddings(top: 6, leading: 8, bottom: 6, trailing: 8))
+ Text("10px; 8px")
+ .tag(Paddings(top: 8, leading: 10, bottom: 8, trailing: 10))
+ Text("12px; 10px")
+ .tag(Paddings(top: 10, leading: 12, bottom: 10, trailing: 12))
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ BadgePreview()
+}
diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift
new file mode 100644
index 00000000..ae6b0356
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift
@@ -0,0 +1,67 @@
+import ComponentsKit
+import SwiftUI
+import UIKit
+
+struct ProgressBarPreview: View {
+ @State private var model = Self.initialModel
+ @State private var currentValue: CGFloat = Self.initialValue
+
+ private let progressBar = UKProgressBar(initialValue: Self.initialValue, model: Self.initialModel)
+
+ private let timer = Timer
+ .publish(every: 0.1, on: .main, in: .common)
+ .autoconnect()
+
+ var body: some View {
+ VStack {
+ PreviewWrapper(title: "UIKit") {
+ self.progressBar
+ .preview
+ .onAppear {
+ self.progressBar.currentValue = self.currentValue
+ self.progressBar.model = Self.initialModel
+ }
+ .onChange(of: self.model) { newValue in
+ self.progressBar.model = newValue
+ }
+ }
+ PreviewWrapper(title: "SwiftUI") {
+ SUProgressBar(currentValue: self.$currentValue, model: self.model)
+ }
+ Form {
+ ComponentColorPicker(selection: self.$model.color)
+ ComponentRadiusPicker(selection: self.$model.cornerRadius) {
+ Text("Custom: 2px").tag(ComponentRadius.custom(2))
+ }
+ SizePicker(selection: self.$model.size)
+ Picker("Style", selection: self.$model.style) {
+ Text("Light").tag(ProgressBarVM.Style.light)
+ Text("Filled").tag(ProgressBarVM.Style.filled)
+ Text("Striped").tag(ProgressBarVM.Style.striped)
+ }
+ }
+ }
+ .onReceive(self.timer) { _ in
+ if self.currentValue < self.model.maxValue {
+ self.currentValue += (self.model.maxValue - self.model.minValue) / 100
+ } else {
+ self.currentValue = self.model.minValue
+ }
+
+ self.progressBar.currentValue = self.currentValue
+ }
+ }
+
+ // MARK: - Helpers
+
+ private static var initialValue: Double {
+ return 0.0
+ }
+ private static var initialModel: ProgressBarVM {
+ return .init()
+ }
+}
+
+#Preview {
+ ProgressBarPreview()
+}
diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift
new file mode 100644
index 00000000..1ca235c5
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+import ComponentsKit
+
+struct SliderPreview: View {
+ @State private var model = SliderVM {
+ $0.style = .light
+ $0.minValue = 0
+ $0.maxValue = 100
+ $0.cornerRadius = .full
+ }
+ @State private var currentValue: CGFloat = 30
+
+ var body: some View {
+ VStack {
+ PreviewWrapper(title: "UIKit") {
+ UKSlider(initialValue: self.currentValue, model: self.model)
+ .preview
+ }
+ PreviewWrapper(title: "SwiftUI") {
+ SUSlider(currentValue: self.$currentValue, model: self.model)
+ }
+ Form {
+ ComponentColorPicker(selection: self.$model.color)
+ ComponentRadiusPicker(selection: self.$model.cornerRadius) {
+ Text("Custom: 2px").tag(ComponentRadius.custom(2))
+ }
+ SizePicker(selection: self.$model.size)
+ Picker("Step", selection: self.$model.step) {
+ Text("1").tag(CGFloat(1))
+ Text("5").tag(CGFloat(5))
+ Text("10").tag(CGFloat(10))
+ Text("25").tag(CGFloat(25))
+ Text("50").tag(CGFloat(50))
+ }
+ Picker("Style", selection: self.$model.style) {
+ Text("Light").tag(SliderVM.Style.light)
+ Text("Striped").tag(SliderVM.Style.striped)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ SliderPreview()
+}
diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift
index 1ffaa23f..c3d5e00c 100644
--- a/Examples/DemosApp/DemosApp/Core/App.swift
+++ b/Examples/DemosApp/DemosApp/Core/App.swift
@@ -8,6 +8,15 @@ struct App: View {
NavigationLinkWithTitle("Alert") {
AlertPreview()
}
+ NavigationLinkWithTitle("Avatar") {
+ AvatarPreview()
+ }
+ NavigationLinkWithTitle("Avatar Group") {
+ AvatarGroupPreview()
+ }
+ NavigationLinkWithTitle("Badge") {
+ BadgePreview()
+ }
NavigationLinkWithTitle("Button") {
ButtonPreview()
}
@@ -29,6 +38,9 @@ struct App: View {
NavigationLinkWithTitle("Loading") {
LoadingPreview()
}
+ NavigationLinkWithTitle("Progress Bar") {
+ ProgressBarPreview()
+ }
NavigationLinkWithTitle("Modal (Bottom)") {
BottomModalPreview()
}
@@ -41,6 +53,9 @@ struct App: View {
NavigationLinkWithTitle("Segmented Control") {
SegmentedControlPreview()
}
+ NavigationLinkWithTitle("Slider") {
+ SliderPreview()
+ }
NavigationLinkWithTitle("Text Input") {
TextInputPreviewPreview()
}
diff --git a/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift
index 3312394f..e12bb3e4 100644
--- a/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift
+++ b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift
@@ -6,8 +6,8 @@ struct AlertButtonsOrientationCalculator {
case horizontal
}
- private static let primaryButton = UKButton()
- private static let secondaryButton = UKButton()
+ private static let primaryButton = UKButton(model: .init())
+ private static let secondaryButton = UKButton(model: .init())
private init() {}
diff --git a/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift
index 3fdbd192..5999bfc2 100644
--- a/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift
+++ b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift
@@ -8,17 +8,17 @@ public struct AlertVM: ComponentVM {
/// The message of the alert.
public var message: String?
- /// The modal that defines the appearance properties for a primary button in the alert.
+ /// The model that defines the appearance properties for a primary button in the alert.
///
/// If it is `nil`, the primary button will not be displayed.
public var primaryButton: AlertButtonVM?
- /// The modal that defines the appearance properties for a secondary button in the alert.
+ /// The model that defines the appearance properties for a secondary button in the alert.
///
/// If it is `nil`, the secondary button will not be displayed.
public var secondaryButton: AlertButtonVM?
- /// The background color of the modal.
+ /// The background color of the alert.
public var backgroundColor: UniversalColor?
/// The border thickness of the alert.
@@ -26,27 +26,27 @@ public struct AlertVM: ComponentVM {
/// Defaults to `.small`.
public var borderWidth: BorderWidth = .small
- /// A Boolean value indicating whether the modal should close when tapping on the overlay.
+ /// A Boolean value indicating whether the alert should close when tapping on the overlay.
///
/// Defaults to `false`.
public var closesOnOverlayTap: Bool = false
- /// The padding applied to the modal's content area.
+ /// The padding applied to the alert's content area.
///
/// Defaults to a padding value of `16` for all sides.
public var contentPaddings: Paddings = .init(padding: 16)
- /// The corner radius of the modal.
+ /// The corner radius of the alert.
///
/// Defaults to `.medium`.
public var cornerRadius: ContainerRadius = .medium
- /// The style of the overlay displayed behind the modal.
+ /// The style of the overlay displayed behind the alert.
///
/// Defaults to `.dimmed`.
public var overlayStyle: ModalOverlayStyle = .dimmed
- /// The transition duration of the modal's appearance and dismissal animations.
+ /// The transition duration of the alert's appearance and dismissal animations.
///
/// Defaults to `.fast`.
public var transition: ModalTransition = .fast
diff --git a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift
index beae4539..a1731720 100644
--- a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift
+++ b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift
@@ -48,9 +48,9 @@ public class UKAlertController: UKCenterModalController {
/// The label used to display the subtitle or message of the alert.
public let subtitleLabel = UILabel()
/// The button representing the primary action in the alert.
- public let primaryButton = UKButton()
+ public let primaryButton = UKButton(model: .init())
/// The button representing the secondary action in the alert.
- public let secondaryButton = UKButton()
+ public let secondaryButton = UKButton(model: .init())
/// A stack view that arranges the primary and secondary buttons.
public let buttonsStackView = UIStackView()
diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift
new file mode 100644
index 00000000..21067b6c
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import UIKit
+
+final class AvatarImageManager: ObservableObject {
+ @Published var avatarImage: UIImage
+
+ private var model: AvatarVM
+ private static var remoteImagesCache = NSCache()
+
+ init(model: AvatarVM) {
+ self.model = model
+
+ let size = model.preferredSize
+ switch model.imageSrc {
+ 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 .none:
+ self.avatarImage = model.placeholderImage(for: size)
+ }
+ }
+
+ func update(model: AvatarVM, size: CGSize) {
+ self.model = model
+
+ switch model.imageSrc {
+ case .remote(let url):
+ if let image = Self.remoteImagesCache.object(forKey: url.absoluteString as NSString) {
+ self.avatarImage = image
+ } else {
+ 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 .none:
+ self.avatarImage = model.placeholderImage(for: size)
+ }
+ }
+
+ private func downloadImage(url: URL) {
+ Task { @MainActor in
+ let request = URLRequest(url: url)
+ guard let (data, _) = try? await URLSession.shared.data(for: request),
+ let image = UIImage(data: data)
+ else { return }
+
+ Self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString)
+
+ if url == self.model.imageURL {
+ self.avatarImage = image
+ }
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift
new file mode 100644
index 00000000..fb0b0a4f
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// Defines the source options for an avatar image.
+extension AvatarVM {
+ public enum ImageSource: Hashable {
+ /// An image loaded from a remote URL.
+ ///
+ /// - Parameter url: The URL pointing to the remote image resource.
+ /// - 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.
+ ///
+ /// - 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)
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift
new file mode 100644
index 00000000..18f017e2
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+/// Defines the placeholder options for an avatar.
+///
+/// It is used to provide a fallback or alternative visual representation when an image is not provided or fails to load.
+extension AvatarVM {
+ public enum Placeholder: Hashable {
+ /// A placeholder that displays a text string.
+ ///
+ /// This option is typically used to show initials, names, or other textual representations.
+ ///
+ /// - Parameter text: The text to display as the placeholder.
+ /// - 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.
+ ///
+ /// - 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)
+
+ /// 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)
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift
new file mode 100644
index 00000000..2d7418b0
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift
@@ -0,0 +1,131 @@
+import UIKit
+
+/// A model that defines the appearance properties for an avatar component.
+public struct AvatarVM: ComponentVM, Hashable {
+ /// The color of the placeholder.
+ public var color: ComponentColor?
+
+ /// The corner radius of the avatar.
+ ///
+ /// Defaults to `.full`.
+ public var cornerRadius: ComponentRadius = .full
+
+ /// The source of the image to be displayed.
+ 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)
+
+ /// The predefined size of the avatar.
+ ///
+ /// Defaults to `.medium`.
+ public var size: ComponentSize = .medium
+
+ /// Initializes a new instance of `AvatarVM` with default values.
+ public init() {}
+}
+
+// MARK: - Helpers
+
+extension AvatarVM {
+ var preferredSize: CGSize {
+ switch self.size {
+ case .small:
+ return .init(width: 36, height: 36)
+ case .medium:
+ return .init(width: 48, height: 48)
+ case .large:
+ return .init(width: 64, height: 64)
+ }
+ }
+
+ var imageURL: URL? {
+ switch self.imageSrc {
+ case .remote(let url):
+ return url
+ case .local, .none:
+ return nil
+ }
+ }
+}
+
+extension AvatarVM {
+ func placeholderImage(for size: CGSize) -> UIImage {
+ 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)
+ }
+ }
+
+ private var placeholderFont: UIFont {
+ switch self.size {
+ case .small:
+ return UniversalFont.smButton.uiFont
+ case .medium:
+ return UniversalFont.mdButton.uiFont
+ case .large:
+ return UniversalFont.lgButton.uiFont
+ }
+ }
+
+ private func iconSize(for avatarSize: CGSize) -> CGSize {
+ let minSide = min(avatarSize.width, avatarSize.height)
+ let iconSize = minSide / 3 * 2
+ return .init(width: iconSize, height: iconSize)
+ }
+
+ private var placeholderBackgroundColor: UIColor {
+ return (self.color?.background ?? .content1).uiColor
+ }
+
+ private var placeholderForegroundColor: UIColor {
+ return (self.color?.main ?? .foreground).uiColor
+ }
+
+ private func drawIcon(_ icon: UIImage?, size: CGSize) -> UIImage {
+ let iconSize = self.iconSize(for: size)
+ let renderer = UIGraphicsImageRenderer(size: size)
+ return renderer.image { _ in
+ self.placeholderBackgroundColor.setFill()
+ UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
+
+ icon?.withTintColor(self.placeholderForegroundColor, renderingMode: .alwaysOriginal).draw(in: CGRect(
+ x: (size.width - iconSize.width) / 2,
+ y: (size.height - iconSize.height) / 2,
+ width: iconSize.width,
+ height: iconSize.height
+ ))
+ }
+ }
+
+ private func drawName(_ name: String, size: CGSize) -> UIImage {
+ let renderer = UIGraphicsImageRenderer(size: size)
+ return renderer.image { _ in
+ self.placeholderBackgroundColor.setFill()
+ UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
+
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .center
+
+ let attributes = [
+ NSAttributedString.Key.font: self.placeholderFont,
+ NSAttributedString.Key.paragraphStyle: paragraphStyle,
+ NSAttributedString.Key.foregroundColor: self.placeholderForegroundColor
+ ]
+
+ let yOffset = (size.height - self.placeholderFont.lineHeight) / 2
+ String(name.prefix(3)).draw(
+ with: CGRect(x: 0, y: yOffset, width: size.width, height: size.height),
+ options: .usesLineFragmentOrigin,
+ attributes: attributes,
+ context: nil
+ )
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift
new file mode 100644
index 00000000..f4fed34f
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+struct AvatarContent: View {
+ // MARK: - Properties
+
+ var model: AvatarVM
+
+ @StateObject private var imageManager: AvatarImageManager
+ @Environment(\.colorScheme) private var colorScheme
+
+ // MARK: - Initialization
+
+ init(model: AvatarVM) {
+ self.model = model
+ self._imageManager = StateObject(
+ wrappedValue: AvatarImageManager(model: model)
+ )
+ }
+
+ // MARK: - Body
+
+ var body: some View {
+ GeometryReader { geometry in
+ Image(uiImage: self.imageManager.avatarImage)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .clipShape(
+ RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
+ )
+ .onAppear {
+ self.imageManager.update(model: self.model, size: geometry.size)
+ }
+ .onChange(of: self.model) { newValue in
+ self.imageManager.update(model: newValue, size: geometry.size)
+ }
+ .onChange(of: self.colorScheme) { _ in
+ self.imageManager.update(model: self.model, size: geometry.size)
+ }
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift
new file mode 100644
index 00000000..979f5efd
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+/// A SwiftUI component that displays a profile picture, initials or fallback icon for a user.
+public struct SUAvatar: View {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: AvatarVM
+
+ // MARK: - Initialization
+
+ /// Initializer.
+ /// - Parameters:
+ /// - model: A model that defines the appearance properties.
+ public init(model: AvatarVM) {
+ self.model = model
+ }
+
+ // MARK: - Body
+
+ public var body: some View {
+ AvatarContent(model: self.model)
+ .frame(
+ width: self.model.preferredSize.width,
+ height: self.model.preferredSize.height
+ )
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift b/Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift
new file mode 100644
index 00000000..5b614f91
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift
@@ -0,0 +1,118 @@
+import Combine
+import UIKit
+
+/// A UIKit component that displays a profile picture, initials or fallback icon for a user.
+open class UKAvatar: UIImageView, UKComponent {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: AvatarVM {
+ didSet {
+ self.update(oldValue)
+ }
+ }
+
+ private let imageManager: AvatarImageManager
+ private var cancellable: AnyCancellable?
+
+ // MARK: - UIView Properties
+
+ open override var intrinsicContentSize: CGSize {
+ return self.model.preferredSize
+ }
+
+ // MARK: - Initialization
+
+ /// Initializer.
+ /// - Parameters:
+ /// - model: A model that defines the appearance properties.
+ public init(model: AvatarVM) {
+ self.model = model
+ self.imageManager = AvatarImageManager(model: model)
+
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Deinitialization
+
+ deinit {
+ self.cancellable?.cancel()
+ self.cancellable = nil
+ }
+
+ // MARK: - Setup
+
+ private func setup() {
+ self.cancellable = self.imageManager.$avatarImage
+ .receive(on: DispatchQueue.main)
+ .sink { self.image = $0 }
+
+ if #available(iOS 17.0, *) {
+ self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in
+ view.handleTraitChanges()
+ }
+ }
+ }
+
+ // MARK: - Style
+
+ private func style() {
+ self.contentMode = .scaleToFill
+ self.clipsToBounds = true
+ }
+
+ // MARK: - Update
+
+ public func update(_ oldModel: AvatarVM) {
+ guard self.model != oldModel else { return }
+
+ self.imageManager.update(model: self.model, size: self.bounds.size)
+
+ if self.model.cornerRadius != oldModel.cornerRadius {
+ self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
+ }
+ if self.model.size != oldModel.size {
+ self.setNeedsLayout()
+ self.invalidateIntrinsicContentSize()
+ }
+ }
+
+ // MARK: - Layout
+
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+
+ self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
+
+ self.imageManager.update(model: self.model, size: self.bounds.size)
+ }
+
+ // MARK: - UIView Methods
+
+ open override func sizeThatFits(_ size: CGSize) -> CGSize {
+ let minProvidedSide = min(size.width, size.height)
+ let minPreferredSide = min(self.model.preferredSize.width, self.model.preferredSize.height)
+ let side = min(minProvidedSide, minPreferredSide)
+ return CGSize(width: side, height: side)
+ }
+
+ open override func traitCollectionDidChange(
+ _ previousTraitCollection: UITraitCollection?
+ ) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ self.handleTraitChanges()
+ }
+
+ // MARK: Helpers
+
+ @objc private func handleTraitChanges() {
+ self.imageManager.update(model: self.model, size: self.bounds.size)
+ }
+}
diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift
new file mode 100644
index 00000000..1d271844
--- /dev/null
+++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift
@@ -0,0 +1,113 @@
+import UIKit
+
+/// A model that defines the appearance properties for an avatar group component.
+public struct AvatarGroupVM: ComponentVM {
+ /// The border color of avatars.
+ public var borderColor: UniversalColor = .background
+
+ /// The color of the placeholders.
+ public var color: ComponentColor?
+
+ /// The corner radius of the avatars.
+ ///
+ /// Defaults to `.full`.
+ public var cornerRadius: ComponentRadius = .full
+
+ /// The array of avatars in the group.
+ public var items: [AvatarItemVM] = [] {
+ didSet {
+ self._identifiedItems = self.items.map({
+ return .init(id: UUID(), item: $0)
+ })
+ }
+ }
+
+ /// The maximum number of visible avatars.
+ ///
+ /// Defaults to `5`.
+ public var maxVisibleAvatars: Int = 5
+
+ /// The predefined size of the component.
+ ///
+ /// Defaults to `.medium`.
+ public var size: ComponentSize = .medium
+
+ /// The array of avatar items with an associated id value to properly display content in SwiftUI.
+ private var _identifiedItems: [IdentifiedAvatarItem] = []
+
+ /// Initializes a new instance of `AvatarGroupVM` with default values.
+ public init() {}
+}
+
+// MARK: - Helpers
+
+fileprivate struct IdentifiedAvatarItem: Equatable {
+ var id: UUID
+ var item: AvatarItemVM
+}
+
+extension AvatarGroupVM {
+ var identifiedAvatarVMs: [(UUID, AvatarVM)] {
+ var avatars = self._identifiedItems.prefix(self.maxVisibleAvatars).map { data in
+ return (data.id, AvatarVM {
+ $0.color = self.color
+ $0.cornerRadius = self.cornerRadius
+ $0.imageSrc = data.item.imageSrc
+ $0.placeholder = data.item.placeholder
+ $0.size = self.size
+ })
+ }
+
+ if self.numberOfHiddenAvatars > 0 {
+ avatars.append((UUID(), AvatarVM {
+ $0.color = self.color
+ $0.cornerRadius = self.cornerRadius
+ $0.placeholder = .text("+\(self.numberOfHiddenAvatars)")
+ $0.size = self.size
+ }))
+ }
+
+ return avatars
+ }
+
+ var itemSize: CGSize {
+ switch self.size {
+ case .small:
+ return .init(width: 36, height: 36)
+ case .medium:
+ return .init(width: 48, height: 48)
+ case .large:
+ return .init(width: 64, height: 64)
+ }
+ }
+
+ var padding: CGFloat {
+ switch self.size {
+ case .small:
+ return 3
+ case .medium:
+ return 4
+ case .large:
+ return 5
+ }
+ }
+
+ var spacing: CGFloat {
+ return -self.itemSize.width / 3
+ }
+
+ var numberOfHiddenAvatars: Int {
+ return max(0, self.items.count - self.maxVisibleAvatars)
+ }
+}
+
+// MARK: - UIKit Helpers
+
+extension AvatarGroupVM {
+ var avatarHeight: CGFloat {
+ return self.itemSize.height - self.padding * 2
+ }
+ var avatarWidth: CGFloat {
+ return self.itemSize.width - self.padding * 2
+ }
+}
diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift
new file mode 100644
index 00000000..66cdd41f
--- /dev/null
+++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift
@@ -0,0 +1,13 @@
+import UIKit
+
+/// A model that defines the appearance properties for an avatar in the group.
+public struct AvatarItemVM: ComponentVM {
+ /// The source of the image to be displayed.
+ 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)
+
+ /// Initializes a new instance of `AvatarItemVM` with default values.
+ public init() {}
+}
diff --git a/Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/SUAvatarGroup.swift b/Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/SUAvatarGroup.swift
new file mode 100644
index 00000000..a089b174
--- /dev/null
+++ b/Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/SUAvatarGroup.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+/// A SwiftUI component that displays a group of avatars.
+public struct SUAvatarGroup: View {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: AvatarGroupVM
+
+ // MARK: - Initialization
+
+ /// Initializer.
+ /// - Parameters:
+ /// - model: A model that defines the appearance properties.
+ public init(model: AvatarGroupVM) {
+ self.model = model
+ }
+
+ // MARK: - Body
+
+ public var body: some View {
+ HStack(spacing: self.model.spacing) {
+ ForEach(self.model.identifiedAvatarVMs, id: \.0) { _, avatarVM in
+ AvatarContent(model: avatarVM)
+ .padding(self.model.padding)
+ .background(self.model.borderColor.color)
+ .clipShape(
+ RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
+ )
+ .frame(
+ width: self.model.itemSize.width,
+ height: self.model.itemSize.height
+ )
+ }
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift
new file mode 100644
index 00000000..c37c9d9c
--- /dev/null
+++ b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift
@@ -0,0 +1,90 @@
+import AutoLayout
+import UIKit
+
+final class AvatarContainer: UIView {
+ // MARK: - Properties
+
+ let avatar: UKAvatar
+ var groupVM: AvatarGroupVM
+ var avatarConstraints = LayoutConstraints()
+
+ // MARK: - Initialization
+
+ init(avatarVM: AvatarVM, groupVM: AvatarGroupVM) {
+ self.avatar = UKAvatar(model: avatarVM)
+ self.groupVM = groupVM
+
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ self.layout()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Setup
+
+ func setup() {
+ self.addSubview(self.avatar)
+ }
+
+ // MARK: - Style
+
+ func style() {
+ Self.Style.mainView(self, model: self.groupVM)
+ }
+
+ // MARK: - Layout
+
+ func layout() {
+ self.avatarConstraints = .merged {
+ self.avatar.allEdges(self.groupVM.padding)
+ self.avatar.height(self.groupVM.avatarHeight)
+ self.avatar.width(self.groupVM.avatarWidth)
+ }
+
+ self.avatarConstraints.height?.priority = .defaultHigh
+ self.avatarConstraints.width?.priority = .defaultHigh
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+
+ self.layer.cornerRadius = self.groupVM.cornerRadius.value(for: self.bounds.height)
+ }
+
+ // MARK: - Update
+
+ func update(avatarVM: AvatarVM, groupVM: AvatarGroupVM) {
+ let oldModel = self.groupVM
+ self.groupVM = groupVM
+
+ if self.groupVM.size != oldModel.size {
+ self.avatarConstraints.top?.constant = groupVM.padding
+ self.avatarConstraints.leading?.constant = groupVM.padding
+ self.avatarConstraints.bottom?.constant = -groupVM.padding
+ self.avatarConstraints.trailing?.constant = -groupVM.padding
+ self.avatarConstraints.height?.constant = groupVM.avatarHeight
+ self.avatarConstraints.width?.constant = groupVM.avatarWidth
+
+ self.setNeedsLayout()
+ }
+
+ self.avatar.model = avatarVM
+ self.style()
+ }
+}
+
+// MARK: - Style Helpers
+
+extension AvatarContainer {
+ fileprivate enum Style {
+ static func mainView(_ view: UIView, model: AvatarGroupVM) {
+ view.backgroundColor = model.borderColor.uiColor
+ view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height)
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift
new file mode 100644
index 00000000..dabc9411
--- /dev/null
+++ b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift
@@ -0,0 +1,121 @@
+import AutoLayout
+import UIKit
+
+/// A UIKit component that displays a group of avatars.
+open class UKAvatarGroup: UIView, UKComponent {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: AvatarGroupVM {
+ didSet {
+ self.update(oldValue)
+ }
+ }
+
+ // MARK: - Subviews
+
+ /// The stack view that contains avatars.
+ public var stackView = UIStackView()
+
+ // MARK: - Initializers
+
+ /// Initializer.
+ /// - Parameters:
+ /// - model: A model that defines the appearance properties.
+ public init(model: AvatarGroupVM) {
+ self.model = model
+
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ self.layout()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Setup
+
+ private func setup() {
+ self.addSubview(self.stackView)
+ self.model.identifiedAvatarVMs.forEach { _, avatarVM in
+ self.stackView.addArrangedSubview(AvatarContainer(
+ avatarVM: avatarVM,
+ groupVM: self.model
+ ))
+ }
+ }
+
+ // MARK: - Style
+
+ private func style() {
+ Self.Style.stackView(self.stackView, model: self.model)
+ }
+
+ // MARK: - Layout
+
+ private func layout() {
+ self.stackView.centerVertically()
+ self.stackView.centerHorizontally()
+
+ self.stackView.topAnchor.constraint(
+ greaterThanOrEqualTo: self.topAnchor
+ ).isActive = true
+ self.stackView.bottomAnchor.constraint(
+ lessThanOrEqualTo: self.bottomAnchor
+ ).isActive = true
+ self.stackView.leadingAnchor.constraint(
+ greaterThanOrEqualTo: self.leadingAnchor
+ ).isActive = true
+ self.stackView.trailingAnchor.constraint(
+ lessThanOrEqualTo: self.trailingAnchor
+ ).isActive = true
+ }
+
+ // MARK: - Update
+
+ public func update(_ oldModel: AvatarGroupVM) {
+ guard self.model != oldModel else { return }
+
+ let avatarVMs = self.model.identifiedAvatarVMs.map(\.1)
+ self.addOrRemoveArrangedSubviews(newNumber: avatarVMs.count)
+
+ self.stackView.arrangedSubviews.enumerated().forEach { index, view in
+ (view as? AvatarContainer)?.update(
+ avatarVM: avatarVMs[index],
+ groupVM: self.model
+ )
+ }
+ self.style()
+ }
+
+ private func addOrRemoveArrangedSubviews(newNumber: Int) {
+ let diff = newNumber - self.stackView.arrangedSubviews.count
+ if diff > 0 {
+ for _ in 0 ..< diff {
+ self.stackView.addArrangedSubview(AvatarContainer(avatarVM: .init(), groupVM: self.model))
+ }
+ } else if diff < 0 {
+ for _ in 0 ..< abs(diff) {
+ if let view = self.stackView.arrangedSubviews.first {
+ self.stackView.removeArrangedSubview(view)
+ view.removeFromSuperview()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Style Helpers
+
+extension UKAvatarGroup {
+ fileprivate enum Style {
+ static func stackView(_ view: UIStackView, model: Model) {
+ view.axis = .horizontal
+ view.spacing = model.spacing
+ view.distribution = .equalCentering
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift b/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift
new file mode 100644
index 00000000..5cc3cdb8
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+extension BadgeVM {
+ /// Defines the available visual styles for a badge.
+ public enum Style: Equatable {
+ case filled
+ case light
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift
new file mode 100644
index 00000000..4232dff6
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+/// A model that defines the appearance properties for a badge component.
+public struct BadgeVM: ComponentVM {
+ /// The text displayed on the badge.
+ public var title: String = ""
+
+ /// The color of the badge.
+ public var color: ComponentColor?
+
+ /// The visual style of the badge.
+ ///
+ /// Defaults to `.filled`.
+ public var style: Style = .filled
+
+ /// The font used for the badge's text.
+ ///
+ /// Defaults to `.smButton`.
+ public var font: UniversalFont = .smButton
+
+ /// The corner radius of the badge.
+ ///
+ /// Defaults to `.medium`.
+ public var cornerRadius: ComponentRadius = .medium
+
+ /// Paddings for the badge.
+ public var paddings: Paddings = .init(horizontal: 10, vertical: 8)
+
+ /// Initializes a new instance of `BadgeVM` with default values.
+ public init() {}
+}
+
+// MARK: Helpers
+
+extension BadgeVM {
+ /// Returns the background color of the badge based on its style.
+ var backgroundColor: UniversalColor {
+ switch self.style {
+ case .filled:
+ return self.color?.main ?? .content2
+ case .light:
+ return self.color?.background ?? .content1
+ }
+ }
+
+ /// Returns the foreground color of the badge based on its style.
+ var foregroundColor: UniversalColor {
+ switch self.style {
+ case .filled:
+ return self.color?.contrast ?? .foreground
+ case .light:
+ return self.color?.main ?? .foreground
+ }
+ }
+}
+
+// MARK: UIKit Helpers
+
+extension BadgeVM {
+ func shouldUpdateLayout(_ oldModel: Self?) -> Bool {
+ return self.font != oldModel?.font
+ || self.paddings != oldModel?.paddings
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Badge/SUBadge.swift b/Sources/ComponentsKit/Components/Badge/SUBadge.swift
new file mode 100644
index 00000000..216af4b5
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Badge/SUBadge.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+/// A SwiftUI component that displays a badge.
+public struct SUBadge: View {
+ // MARK: Properties
+
+ /// A model that defines the appearance properties.
+ public var model: BadgeVM
+
+ // MARK: Initialization
+
+ /// Initializes a new instance of `SUBadge`.
+ /// - Parameter model: A model that defines the appearance properties.
+ public init(model: BadgeVM) {
+ self.model = model
+ }
+
+ // MARK: Body
+
+ public var body: some View {
+ Text(self.model.title)
+ .font(self.model.font.font)
+ .padding(self.model.paddings.edgeInsets)
+ .foregroundStyle(self.model.foregroundColor.color)
+ .background(self.model.backgroundColor.color)
+ .clipShape(
+ RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
+ )
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Badge/UKBadge.swift b/Sources/ComponentsKit/Components/Badge/UKBadge.swift
new file mode 100644
index 00000000..d6e100db
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Badge/UKBadge.swift
@@ -0,0 +1,127 @@
+import AutoLayout
+import UIKit
+
+/// A UIKit component that displays a badge.
+open class UKBadge: UIView, UKComponent {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: BadgeVM {
+ didSet {
+ self.update(oldValue)
+ }
+ }
+
+ private var titleLabelConstraints: LayoutConstraints = .init()
+
+ // MARK: - Subviews
+
+ /// A label that displays the title from the model.
+ public var titleLabel = UILabel()
+
+ // MARK: - UIView Properties
+
+ open override var intrinsicContentSize: CGSize {
+ return self.sizeThatFits(UIView.layoutFittingExpandedSize)
+ }
+
+ // MARK: - Initialization
+
+ /// Initializes a new instance of `UKBadge`.
+ /// - Parameter model: A model that defines the appearance properties for the badge.
+ public init(model: BadgeVM) {
+ self.model = model
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ self.layout()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Setup
+
+ private func setup() {
+ self.addSubview(self.titleLabel)
+ }
+
+ // MARK: - Style
+
+ private func style() {
+ Self.Style.mainView(self, model: self.model)
+ Self.Style.titleLabel(self.titleLabel, model: self.model)
+ }
+
+ // MARK: - Layout
+
+ private func layout() {
+ self.titleLabelConstraints = .merged {
+ self.titleLabel.top(self.model.paddings.top)
+ self.titleLabel.leading(self.model.paddings.leading)
+ self.titleLabel.bottom(self.model.paddings.bottom)
+ self.titleLabel.trailing(self.model.paddings.trailing)
+ }
+
+ self.titleLabelConstraints.allConstraints.forEach { $0?.priority = .defaultHigh }
+ }
+
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+
+ self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
+ }
+
+ // MARK: - Update
+
+ public func update(_ oldModel: BadgeVM) {
+ guard self.model != oldModel else { return }
+
+ self.style()
+ if self.model.shouldUpdateLayout(oldModel) {
+ self.titleLabelConstraints.leading?.constant = self.model.paddings.leading
+ self.titleLabelConstraints.top?.constant = self.model.paddings.top
+ self.titleLabelConstraints.bottom?.constant = -self.model.paddings.bottom
+ self.titleLabelConstraints.trailing?.constant = -self.model.paddings.trailing
+
+ self.invalidateIntrinsicContentSize()
+ self.setNeedsLayout()
+ }
+ }
+
+ // MARK: - UIView Methods
+
+ open override func sizeThatFits(_ size: CGSize) -> CGSize {
+ let contentSize = self.titleLabel.sizeThatFits(size)
+
+ let totalWidthPadding = self.model.paddings.leading + self.model.paddings.trailing
+ let totalHeightPadding = self.model.paddings.top + self.model.paddings.bottom
+
+ let width = contentSize.width + totalWidthPadding
+ let height = contentSize.height + totalHeightPadding
+
+ return CGSize(
+ width: min(width, size.width),
+ height: min(height, size.height)
+ )
+ }
+}
+
+// MARK: - Style Helpers
+
+extension UKBadge {
+ fileprivate enum Style {
+ static func mainView(_ view: UIView, model: BadgeVM) {
+ view.backgroundColor = model.backgroundColor.uiColor
+ view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height)
+ }
+ static func titleLabel(_ label: UILabel, model: BadgeVM) {
+ label.textAlignment = .center
+ label.text = model.title
+ label.font = model.font.uiFont
+ label.textColor = model.foregroundColor.uiColor
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift
index d2b35b4f..c7f573ce 100644
--- a/Sources/ComponentsKit/Components/Button/UKButton.swift
+++ b/Sources/ComponentsKit/Components/Button/UKButton.swift
@@ -47,7 +47,7 @@ open class UKButton: UIView, UKComponent {
/// - model: A model that defines the appearance properties.
/// - action: A closure that is triggered when the button is tapped.
public init(
- model: ButtonVM = .init(),
+ model: ButtonVM,
action: @escaping () -> Void = {}
) {
self.model = model
diff --git a/Sources/ComponentsKit/Components/Card/SUCard.swift b/Sources/ComponentsKit/Components/Card/SUCard.swift
index 3c0f57e6..ad6be3dd 100644
--- a/Sources/ComponentsKit/Components/Card/SUCard.swift
+++ b/Sources/ComponentsKit/Components/Card/SUCard.swift
@@ -27,7 +27,7 @@ public struct SUCard: View {
/// - model: A model that defines the appearance properties.
/// - content: The content that is displayed in the card.
public init(
- model: CardVM,
+ model: CardVM = .init(),
content: @escaping () -> Content
) {
self.model = model
diff --git a/Sources/ComponentsKit/Components/Card/UKCard.swift b/Sources/ComponentsKit/Components/Card/UKCard.swift
index 7e379647..42a54629 100644
--- a/Sources/ComponentsKit/Components/Card/UKCard.swift
+++ b/Sources/ComponentsKit/Components/Card/UKCard.swift
@@ -44,7 +44,10 @@ open class UKCard: UIView, UKComponent {
/// - Parameters:
/// - model: A model that defines the appearance properties.
/// - content: The content that is displayed in the card.
- public init(model: CardVM, content: @escaping Content) {
+ public init(
+ model: CardVM = .init(),
+ content: @escaping Content
+ ) {
self.model = model
self.content = content()
diff --git a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift
index 48399011..2830f296 100644
--- a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift
+++ b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift
@@ -17,7 +17,7 @@ public struct SUCountdown: View {
/// Initializer.
/// - Parameters:
/// - model: A model that defines the appearance properties.
- public init(model: CountdownVM = .init()) {
+ public init(model: CountdownVM) {
self.model = model
}
diff --git a/Sources/ComponentsKit/Components/Divider/UKDivider.swift b/Sources/ComponentsKit/Components/Divider/UKDivider.swift
index 9f667846..c38b122a 100644
--- a/Sources/ComponentsKit/Components/Divider/UKDivider.swift
+++ b/Sources/ComponentsKit/Components/Divider/UKDivider.swift
@@ -32,7 +32,7 @@ open class UKDivider: UIView, UKComponent {
fatalError("init(coder:) has not been implemented")
}
- // MARK: - Setup
+ // MARK: - Style
private func style() {
self.backgroundColor = self.model.lineColor.uiColor
diff --git a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift
new file mode 100644
index 00000000..08c0639c
--- /dev/null
+++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+extension ProgressBarVM {
+ /// Defines the visual styles for the progress bar component.
+ public enum Style {
+ case light
+ case filled
+ case striped
+ }
+}
diff --git a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift
new file mode 100644
index 00000000..902293e9
--- /dev/null
+++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift
@@ -0,0 +1,178 @@
+import SwiftUI
+
+/// A model that defines the appearance properties for a a progress bar component.
+public struct ProgressBarVM: ComponentVM {
+ /// The color of the progress bar.
+ ///
+ /// Defaults to `.accent`.
+ public var color: ComponentColor = .accent
+
+ /// The visual style of the progress bar component.
+ ///
+ /// Defaults to `.striped`.
+ public var style: Style = .striped
+
+ /// The size of the progress bar.
+ ///
+ /// Defaults to `.medium`.
+ public var size: ComponentSize = .medium
+
+ /// The minimum value of the progress bar.
+ public var minValue: CGFloat = 0
+
+ /// The maximum value of the progress bar.
+ public var maxValue: CGFloat = 100
+
+ /// The corner radius of the progress bar.
+ ///
+ /// Defaults to `.medium`.
+ public var cornerRadius: ComponentRadius = .medium
+
+ /// Initializes a new instance of `ProgressBarVM` with default values.
+ public init() {}
+}
+
+// MARK: - Shared Helpers
+
+extension ProgressBarVM {
+ var backgroundHeight: CGFloat {
+ switch self.style {
+ case .light:
+ switch size {
+ case .small:
+ return 4
+ case .medium:
+ return 8
+ case .large:
+ return 12
+ }
+ case .filled, .striped:
+ switch self.size {
+ case .small:
+ return 20
+ case .medium:
+ return 32
+ case .large:
+ return 42
+ }
+ }
+ }
+
+ var progressHeight: CGFloat {
+ return self.backgroundHeight - self.progressPadding * 2
+ }
+
+ func cornerRadius(for height: CGFloat) -> CGFloat {
+ switch self.cornerRadius {
+ case .none:
+ return 0
+ case .small:
+ return height / 3.5
+ case .medium:
+ return height / 3.0
+ case .large:
+ return height / 2.5
+ case .full:
+ return height / 2.0
+ case .custom(let value):
+ return min(value, height / 2)
+ }
+ }
+
+ var animationDuration: TimeInterval {
+ return 0.2
+ }
+
+ var progressPadding: CGFloat {
+ switch self.style {
+ case .light:
+ return 0
+ case .filled, .striped:
+ return 3
+ }
+ }
+
+ var lightBarSpacing: CGFloat {
+ return 4
+ }
+
+ var backgroundColor: UniversalColor {
+ switch style {
+ case .light:
+ return self.color.background
+ case .filled, .striped:
+ return self.color.main
+ }
+ }
+
+ var barColor: UniversalColor {
+ switch style {
+ case .light:
+ return self.color.main
+ case .filled, .striped:
+ return self.color.contrast
+ }
+ }
+
+ private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
+ let stripeWidth: CGFloat = 2
+ let stripeSpacing: CGFloat = 4
+ let stripeAngle: Angle = .degrees(135)
+
+ let path = CGMutablePath()
+ let step = stripeWidth + stripeSpacing
+ let radians = stripeAngle.radians
+ let dx = rect.height * tan(radians)
+ for x in stride(from: dx, through: rect.width + rect.height, by: step) {
+ let topLeft = CGPoint(x: x, y: 0)
+ let topRight = CGPoint(x: x + stripeWidth, y: 0)
+ let bottomLeft = CGPoint(x: x + dx, y: rect.height)
+ let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height)
+ path.move(to: topLeft)
+ path.addLine(to: topRight)
+ path.addLine(to: bottomRight)
+ path.addLine(to: bottomLeft)
+ path.closeSubpath()
+ }
+ return path
+ }
+}
+
+extension ProgressBarVM {
+ func progress(for currentValue: CGFloat) -> CGFloat {
+ let range = self.maxValue - self.minValue
+ guard range > 0 else { return 0 }
+ let normalized = (currentValue - self.minValue) / range
+ return max(0, min(1, normalized))
+ }
+}
+
+// MARK: - UIKit Helpers
+
+extension ProgressBarVM {
+ func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
+ return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
+ }
+
+ func shouldUpdateLayout(_ oldModel: Self) -> Bool {
+ return self.style != oldModel.style || self.size != oldModel.size
+ }
+}
+
+// MARK: - SwiftUI Helpers
+
+extension ProgressBarVM {
+ func stripesPath(in rect: CGRect) -> Path {
+ return Path(self.stripesCGPath(in: rect))
+ }
+}
+
+// MARK: - Validation
+
+extension ProgressBarVM {
+ func validateMinMaxValues() {
+ if self.minValue > self.maxValue {
+ assertionFailure("Min value must be less than max value")
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift
new file mode 100644
index 00000000..756d49a4
--- /dev/null
+++ b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+/// A SwiftUI component that displays a progress bar.
+public struct SUProgressBar: View {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: ProgressBarVM
+ /// A binding to control the current value.
+ @Binding public var currentValue: CGFloat
+
+ private var progress: CGFloat {
+ self.model.progress(for: self.currentValue)
+ }
+
+ // MARK: - Initializer
+
+ /// Initializer.
+ /// - Parameters:
+ /// - currentValue: A binding to the current value.
+ /// - model: A model that defines the appearance properties.
+ public init(
+ currentValue: Binding,
+ model: ProgressBarVM = .init()
+ ) {
+ self._currentValue = currentValue
+ self.model = model
+ }
+
+ // MARK: - Body
+
+ public var body: some View {
+ GeometryReader { geometry in
+ switch self.model.style {
+ case .light:
+ HStack(spacing: self.model.lightBarSpacing) {
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight))
+ .foregroundStyle(self.model.barColor.color)
+ .frame(width: geometry.size.width * self.progress)
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight))
+ .foregroundStyle(self.model.backgroundColor.color)
+ .frame(width: geometry.size.width * (1 - self.progress))
+ }
+
+ case .filled:
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight))
+ .foregroundStyle(self.model.color.main.color)
+ .frame(width: geometry.size.width)
+
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight))
+ .foregroundStyle(self.model.color.contrast.color)
+ .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress)
+ .padding(.vertical, self.model.progressPadding)
+ .padding(.horizontal, self.model.progressPadding)
+ }
+
+ case .striped:
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight))
+ .foregroundStyle(self.model.color.main.color)
+ .frame(width: geometry.size.width)
+
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight))
+ .foregroundStyle(self.model.color.contrast.color)
+ .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress)
+ .padding(.vertical, self.model.progressPadding)
+ .padding(.horizontal, self.model.progressPadding)
+
+ StripesShape(model: self.model)
+ .foregroundStyle(self.model.color.main.color)
+ .cornerRadius(self.model.cornerRadius(for: self.model.progressHeight))
+ .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress)
+ .padding(.vertical, self.model.progressPadding)
+ .padding(.horizontal, self.model.progressPadding)
+ }
+ }
+ }
+ .animation(
+ Animation.linear(duration: self.model.animationDuration),
+ value: self.progress
+ )
+ .frame(height: self.model.backgroundHeight)
+ .onAppear {
+ self.model.validateMinMaxValues()
+ }
+ }
+}
+
+// MARK: - Helpers
+
+struct StripesShape: Shape, @unchecked Sendable {
+ var model: ProgressBarVM
+
+ func path(in rect: CGRect) -> Path {
+ self.model.stripesPath(in: rect)
+ }
+}
diff --git a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift
new file mode 100644
index 00000000..3a14cc31
--- /dev/null
+++ b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift
@@ -0,0 +1,218 @@
+import AutoLayout
+import UIKit
+
+/// A UIKit component that displays a progress bar.
+open class UKProgressBar: UIView, UKComponent {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: ProgressBarVM {
+ didSet {
+ self.update(oldValue)
+ }
+ }
+
+ /// The current progress value for the progress bar.
+ public var currentValue: CGFloat {
+ didSet {
+ self.updateProgressWidthAndAppearance()
+ }
+ }
+
+ // MARK: - Subviews
+
+ /// The background view of the progress bar.
+ public let backgroundView = UIView()
+
+ /// The view that displays the current progress.
+ public let progressView = UIView()
+
+ /// A shape layer used to render striped styling.
+ public let stripedLayer = CAShapeLayer()
+
+ // MARK: - Layout Constraints
+
+ private var backgroundViewLightLeadingConstraint: NSLayoutConstraint?
+ private var backgroundViewFilledLeadingConstraint: NSLayoutConstraint?
+ private var progressViewConstraints: LayoutConstraints = .init()
+
+ // MARK: - Private Properties
+
+ private var progress: CGFloat {
+ self.model.progress(for: self.currentValue)
+ }
+
+ // MARK: - UIView Properties
+
+ open override var intrinsicContentSize: CGSize {
+ return self.sizeThatFits(UIView.layoutFittingExpandedSize)
+ }
+
+ // MARK: - Initialization
+
+ /// Initializer.
+ /// - Parameters:
+ /// - initialValue: The initial progress value. Defaults to `0`.
+ /// - model: A model that defines the appearance properties.
+ public init(
+ initialValue: CGFloat = 0,
+ model: ProgressBarVM = .init()
+ ) {
+ self.currentValue = initialValue
+ self.model = model
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ self.layout()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Setup
+
+ private func setup() {
+ self.addSubview(self.backgroundView)
+ self.addSubview(self.progressView)
+
+ self.progressView.layer.addSublayer(self.stripedLayer)
+ }
+
+ // MARK: - Style
+
+ private func style() {
+ Self.Style.backgroundView(self.backgroundView, model: self.model)
+ Self.Style.progressView(self.progressView, model: self.model)
+ Self.Style.stripedLayer(self.stripedLayer, model: self.model)
+ }
+
+ // MARK: - Layout
+
+ private func layout() {
+ self.backgroundView.vertically()
+ self.backgroundView.trailing()
+ self.backgroundViewLightLeadingConstraint = self.backgroundView.after(
+ self.progressView,
+ padding: self.model.lightBarSpacing
+ ).leading
+ self.backgroundViewFilledLeadingConstraint = self.backgroundView.leading().leading
+
+ switch self.model.style {
+ case .light:
+ self.backgroundViewFilledLeadingConstraint?.isActive = false
+ case .filled, .striped:
+ self.backgroundViewLightLeadingConstraint?.isActive = false
+ }
+
+ self.progressViewConstraints = .merged {
+ self.progressView.leading(self.model.progressPadding)
+ self.progressView.vertically(self.model.progressPadding)
+ self.progressView.width(0)
+ }
+ }
+
+ // MARK: - Update
+
+ public func update(_ oldModel: ProgressBarVM) {
+ guard self.model != oldModel else { return }
+
+ self.style()
+
+ if self.model.shouldUpdateLayout(oldModel) {
+ switch self.model.style {
+ case .light:
+ self.backgroundViewFilledLeadingConstraint?.isActive = false
+ self.backgroundViewLightLeadingConstraint?.isActive = true
+ case .filled, .striped:
+ self.backgroundViewLightLeadingConstraint?.isActive = false
+ self.backgroundViewFilledLeadingConstraint?.isActive = true
+ }
+
+ self.progressViewConstraints.leading?.constant = self.model.progressPadding
+ self.progressViewConstraints.top?.constant = self.model.progressPadding
+ self.progressViewConstraints.bottom?.constant = -self.model.progressPadding
+
+ self.invalidateIntrinsicContentSize()
+ self.setNeedsLayout()
+ }
+
+ UIView.performWithoutAnimation {
+ self.updateProgressWidthAndAppearance()
+ }
+ }
+
+ private func updateProgressWidthAndAppearance() {
+ if self.model.style == .striped {
+ self.stripedLayer.frame = self.bounds
+ self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath
+ }
+
+ let totalHorizontalPadding: CGFloat = switch self.model.style {
+ case .light: self.model.lightBarSpacing
+ case .filled, .striped: self.model.progressPadding * 2
+ }
+ let totalWidth = self.bounds.width - totalHorizontalPadding
+ let progressWidth = totalWidth * self.progress
+
+ self.progressViewConstraints.width?.constant = max(0, progressWidth)
+
+ UIView.animate(
+ withDuration: self.model.animationDuration,
+ animations: {
+ self.layoutIfNeeded()
+ }
+ )
+ }
+
+ // MARK: - Layout
+
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+
+ self.backgroundView.layer.cornerRadius = self.model.cornerRadius(for: self.backgroundView.bounds.height)
+ self.progressView.layer.cornerRadius = self.model.cornerRadius(for: self.progressView.bounds.height)
+
+ self.updateProgressWidthAndAppearance()
+
+ self.model.validateMinMaxValues()
+ }
+
+ // MARK: - UIView methods
+
+ open override func sizeThatFits(_ size: CGSize) -> CGSize {
+ let width = self.superview?.bounds.width ?? size.width
+ return CGSize(
+ width: min(size.width, width),
+ height: min(size.height, self.model.backgroundHeight)
+ )
+ }
+}
+
+// MARK: - Style Helpers
+
+extension UKProgressBar {
+ fileprivate enum Style {
+ static func backgroundView(_ view: UIView, model: ProgressBarVM) {
+ view.backgroundColor = model.backgroundColor.uiColor
+ view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
+ }
+
+ static func progressView(_ view: UIView, model: ProgressBarVM) {
+ view.backgroundColor = model.barColor.uiColor
+ view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
+ view.layer.masksToBounds = true
+ }
+
+ static func stripedLayer(_ layer: CAShapeLayer, model: ProgressBarVM) {
+ layer.fillColor = model.color.main.uiColor.cgColor
+ switch model.style {
+ case .light, .filled:
+ layer.isHidden = true
+ case .striped:
+ layer.isHidden = false
+ }
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift
index 768e6472..3ee1a78e 100644
--- a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift
+++ b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift
@@ -41,7 +41,7 @@ open class UKRadioGroup: UIView, UKComponent, UIGestureRecognizerD
/// - onSelectionChange: A closure that is triggered when the selected radio button changes.
public init(
initialSelectedId: ID? = nil,
- model: RadioGroupVM = .init(),
+ model: RadioGroupVM,
onSelectionChange: ((ID?) -> Void)? = nil
) {
self.selectedId = initialSelectedId
diff --git a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift
index 6aab2aad..e95308a7 100644
--- a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift
+++ b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift
@@ -102,7 +102,7 @@ extension SegmentedControlVM {
case .none, .full, .custom:
return componentRadius
case .small, .medium, .large:
- return max(0, componentRadius - self.outerPaddings / 2)
+ return max(0, componentRadius - self.outerPaddings)
}
}
func preferredFont(for id: ID) -> UniversalFont {
diff --git a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift
index 15fd9588..eced335a 100644
--- a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift
+++ b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift
@@ -53,7 +53,7 @@ open class UKSegmentedControl: UIView, UKComponent {
/// - onSelectionChange: A closure that is triggered when a selected segment changes.
public init(
selectedId: ID,
- model: SegmentedControlVM = .init(),
+ model: SegmentedControlVM,
onSelectionChange: @escaping (ID) -> Void = { _ in }
) {
self.selectedId = selectedId
diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift
new file mode 100644
index 00000000..c4a70868
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+extension SliderVM {
+ /// Defines the visual styles for the slider component.
+ public enum Style {
+ case light
+ case striped
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift
new file mode 100644
index 00000000..beb271b9
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+
+/// A model that defines the appearance properties for a slider component.
+public struct SliderVM: ComponentVM {
+ /// The color of the slider.
+ ///
+ /// Defaults to `.accent`.
+ public var color: ComponentColor = .accent
+
+ /// The visual style of the slider component.
+ ///
+ /// Defaults to `.light`.
+ public var style: Style = .light
+
+ /// The size of the slider.
+ ///
+ /// Defaults to `.medium`.
+ public var size: ComponentSize = .medium
+
+ /// The minimum value of the slider.
+ public var minValue: CGFloat = 0
+
+ /// The maximum value of the slider.
+ public var maxValue: CGFloat = 100
+
+ /// The corner radius of the slider track and handle.
+ ///
+ /// Defaults to `.full`.
+ public var cornerRadius: ComponentRadius = .full
+
+ /// The step value for the slider.
+ ///
+ /// Defaults to `1`.
+ public var step: CGFloat = 1
+
+ /// Initializes a new instance of `SliderVM` with default values.
+ public init() {}
+}
+
+// MARK: - Shared Helpers
+
+extension SliderVM {
+ var trackHeight: CGFloat {
+ switch self.size {
+ case .small:
+ return 6
+ case .medium:
+ return 12
+ case .large:
+ return 32
+ }
+ }
+ var handleSize: CGSize {
+ switch self.size {
+ case .small, .medium:
+ return CGSize(width: 20, height: 32)
+ case .large:
+ return CGSize(width: 40, height: 40)
+ }
+ }
+ func cornerRadius(for height: CGFloat) -> CGFloat {
+ switch self.cornerRadius {
+ case .none:
+ return 0
+ case .small:
+ return height / 3.5
+ case .medium:
+ return height / 3.0
+ case .large:
+ return height / 2.5
+ case .full:
+ return height / 2.0
+ case .custom(let value):
+ return min(value, height / 2)
+ }
+ }
+ var trackSpacing: CGFloat {
+ return 4
+ }
+ var handleOverlaySide: CGFloat {
+ return 12
+ }
+ private func stripesCGPath(in rect: CGRect) -> CGMutablePath {
+ let stripeWidth: CGFloat = 2
+ let stripeSpacing: CGFloat = 4
+ let stripeAngle: Angle = .degrees(135)
+
+ let path = CGMutablePath()
+ let step = stripeWidth + stripeSpacing
+ let radians = stripeAngle.radians
+ let dx = rect.height * tan(radians)
+
+ for x in stride(from: rect.width + rect.height, through: dx, by: -step) {
+ let topLeft = CGPoint(x: x, y: 0)
+ let topRight = CGPoint(x: x + stripeWidth, y: 0)
+ let bottomLeft = CGPoint(x: x + dx, y: rect.height)
+ let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height)
+ path.move(to: topLeft)
+ path.addLine(to: topRight)
+ path.addLine(to: bottomRight)
+ path.addLine(to: bottomLeft)
+ path.closeSubpath()
+ }
+
+ return path
+ }
+}
+
+extension SliderVM {
+ func steppedValue(for offset: CGFloat, trackWidth: CGFloat) -> CGFloat {
+ guard trackWidth > 0 else { return self.minValue }
+
+ let newProgress = offset / trackWidth
+
+ let newValue = self.minValue + newProgress * (self.maxValue - self.minValue)
+
+ if self.step > 0 {
+ let stepsCount = (newValue / self.step).rounded()
+ return stepsCount * self.step
+ } else {
+ return newValue
+ }
+ }
+}
+
+extension SliderVM {
+ func progress(for currentValue: CGFloat) -> CGFloat {
+ let range = self.maxValue - self.minValue
+ guard range > 0 else { return 0 }
+ let normalized = (currentValue - self.minValue) / range
+ return max(0, min(1, normalized))
+ }
+}
+
+extension SliderVM {
+ var containerHeight: CGFloat {
+ max(self.handleSize.height, self.trackHeight)
+ }
+
+ func sliderWidth(for totalWidth: CGFloat) -> CGFloat {
+ max(0, totalWidth - self.handleSize.width - 2 * self.trackSpacing)
+ }
+
+ func barWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
+ let width = self.sliderWidth(for: totalWidth)
+ return width * progress
+ }
+
+ func backgroundWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat {
+ let width = self.sliderWidth(for: totalWidth)
+ let filled = width * progress
+ return width - filled
+ }
+}
+
+// MARK: - UIKit Helpers
+
+extension SliderVM {
+ var isHandleOverlayVisible: Bool {
+ switch self.size {
+ case .small, .medium:
+ return false
+ case .large:
+ return true
+ }
+ }
+
+ func stripesBezierPath(in rect: CGRect) -> UIBezierPath {
+ return UIBezierPath(cgPath: self.stripesCGPath(in: rect))
+ }
+
+ func shouldUpdateLayout(_ oldModel: Self) -> Bool {
+ return self.size != oldModel.size
+ }
+}
+
+// MARK: - SwiftUI Helpers
+
+extension SliderVM {
+ func stripesPath(in rect: CGRect) -> Path {
+ Path(self.stripesCGPath(in: rect))
+ }
+}
+
+// MARK: - Validation
+
+extension SliderVM {
+ func validateMinMaxValues() {
+ if self.minValue > self.maxValue {
+ assertionFailure("Min value must be less than max value")
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Slider/SUSlider.swift b/Sources/ComponentsKit/Components/Slider/SUSlider.swift
new file mode 100644
index 00000000..257a1c35
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Slider/SUSlider.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+/// A SwiftUI component that displays a slider.
+public struct SUSlider: View {
+ // MARK: - Properties
+
+ /// A model that defines the appearance properties.
+ public var model: SliderVM
+
+ /// A binding to control the current value.
+ @Binding public var currentValue: CGFloat
+
+ private var progress: CGFloat {
+ self.model.progress(for: self.currentValue)
+ }
+
+ // MARK: - Initializer
+
+ /// Initializer.
+ /// - Parameters:
+ /// - currentValue: A binding to the current value.
+ /// - model: A model that defines the appearance properties.
+ public init(
+ currentValue: Binding,
+ model: SliderVM = .init()
+ ) {
+ self._currentValue = currentValue
+ self.model = model
+ }
+
+ // MARK: - Body
+
+ public var body: some View {
+ GeometryReader { geometry in
+ let barWidth = self.model.barWidth(for: geometry.size.width, progress: self.progress)
+ let backgroundWidth = self.model.backgroundWidth(for: geometry.size.width, progress: self.progress)
+
+ HStack(spacing: self.model.trackSpacing) {
+ // Progress segment
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
+ .foregroundStyle(self.model.color.main.color)
+ .frame(width: barWidth, height: self.model.trackHeight)
+
+ // Handle
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.width))
+ .foregroundStyle(self.model.color.main.color)
+ .frame(width: self.model.handleSize.width, height: self.model.handleSize.height)
+ .overlay(
+ Group {
+ if self.model.size == .large {
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleOverlaySide))
+ .foregroundStyle(self.model.color.contrast.color)
+ .frame(width: self.model.handleOverlaySide, height: self.model.handleOverlaySide)
+ }
+ }
+ )
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ let totalWidth = geometry.size.width
+ let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)
+
+ let currentLeft = barWidth
+ let newOffset = currentLeft + value.translation.width
+
+ let clampedOffset = min(max(newOffset, 0), sliderWidth)
+ self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
+ }
+ )
+
+ // Remaining segment
+ Group {
+ switch self.model.style {
+ case .light:
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
+ .foregroundStyle(self.model.color.background.color)
+ .frame(width: backgroundWidth)
+ case .striped:
+ ZStack {
+ RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight))
+ .foregroundStyle(.clear)
+
+ StripesShapeSlider(model: self.model)
+ .foregroundStyle(self.model.color.main.color)
+ .cornerRadius(self.model.cornerRadius(for: self.model.trackHeight))
+ }
+ .frame(width: backgroundWidth)
+ }
+ }
+ .frame(height: self.model.trackHeight)
+ }
+ }
+ .frame(height: self.model.containerHeight)
+ .onAppear {
+ self.model.validateMinMaxValues()
+ }
+ }
+}
+// MARK: - Helpers
+
+struct StripesShapeSlider: Shape, @unchecked Sendable {
+ var model: SliderVM
+
+ func path(in rect: CGRect) -> Path {
+ self.model.stripesPath(in: rect)
+ }
+}
diff --git a/Sources/ComponentsKit/Components/Slider/UKSlider.swift b/Sources/ComponentsKit/Components/Slider/UKSlider.swift
new file mode 100644
index 00000000..07ce9b96
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Slider/UKSlider.swift
@@ -0,0 +1,287 @@
+import AutoLayout
+import UIKit
+
+/// A UIKit component that displays a slider.
+open class UKSlider: UIView, UKComponent {
+ // MARK: - Properties
+
+ /// A closure that is triggered when the `currentValue` changes.
+ public var onValueChange: (CGFloat) -> Void
+
+ /// A model that defines the appearance properties.
+ public var model: SliderVM {
+ didSet {
+ self.update(oldValue)
+ }
+ }
+
+ /// The current value of the slider.
+ public var currentValue: CGFloat {
+ didSet {
+ guard self.currentValue != oldValue else { return }
+ self.updateSliderAppearance()
+ self.onValueChange(self.currentValue)
+ }
+ }
+
+ // MARK: - Subviews
+
+ /// The background view of the slider track.
+ public let backgroundView = UIView()
+
+ /// The filled portion of the slider track.
+ public let barView = UIView()
+
+ /// A shape layer used to render striped styling.
+ public let stripedLayer = CAShapeLayer()
+
+ /// The draggable handle representing the current value.
+ public let handleView = UIView()
+
+ /// An overlay view for handle for the `large` style.
+ private let handleOverlayView = UIView()
+
+ // MARK: - Layout Constraints
+
+ private var barViewConstraints = LayoutConstraints()
+ private var backgroundViewConstraints = LayoutConstraints()
+ private var handleViewConstraints = LayoutConstraints()
+
+ // MARK: - Private Properties
+
+ private var isDragging = false
+
+ private var progress: CGFloat {
+ self.model.progress(for: self.currentValue)
+ }
+
+ // MARK: - UIView Properties
+
+ open override var intrinsicContentSize: CGSize {
+ return self.sizeThatFits(UIView.layoutFittingExpandedSize)
+ }
+
+ // MARK: - Initialization
+
+ /// Initializer.
+ /// - Parameters:
+ /// - initialValue: The initial slider value. Defaults to `0`.
+ /// - model: A model that defines the appearance properties.
+ /// - onValueChange: A closure triggered whenever `currentValue` changes.
+ public init(
+ initialValue: CGFloat = 0,
+ model: SliderVM = .init(),
+ onValueChange: @escaping (CGFloat) -> Void = { _ in }
+ ) {
+ self.currentValue = initialValue
+ self.model = model
+ self.onValueChange = onValueChange
+ super.init(frame: .zero)
+
+ self.setup()
+ self.style()
+ self.layout()
+ }
+
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Setup
+
+ private func setup() {
+ self.addSubview(self.backgroundView)
+ self.addSubview(self.barView)
+ self.addSubview(self.handleView)
+ self.backgroundView.layer.addSublayer(self.stripedLayer)
+ self.handleView.addSubview(self.handleOverlayView)
+ }
+
+ // MARK: - Style
+
+ private func style() {
+ Self.Style.backgroundView(self.backgroundView, model: self.model)
+ Self.Style.barView(self.barView, model: self.model)
+ Self.Style.stripedLayer(self.stripedLayer, model: self.model)
+ Self.Style.handleView(self.handleView, model: self.model)
+ Self.Style.handleOverlayView(self.handleOverlayView, model: self.model)
+ }
+
+ // MARK: - Update
+
+ public func update(_ oldModel: SliderVM) {
+ guard self.model != oldModel else { return }
+
+ self.style()
+
+ if self.model.shouldUpdateLayout(oldModel) {
+ self.barViewConstraints.height?.constant = self.model.trackHeight
+ self.backgroundViewConstraints.height?.constant = self.model.trackHeight
+ self.handleViewConstraints.height?.constant = self.model.handleSize.height
+ self.handleViewConstraints.width?.constant = self.model.handleSize.width
+
+ UIView.performWithoutAnimation {
+ self.layoutIfNeeded()
+ }
+ }
+
+ self.updateSliderAppearance()
+ }
+
+ private func updateSliderAppearance() {
+ if self.model.style == .striped {
+ self.stripedLayer.frame = self.backgroundView.bounds
+ self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath
+ }
+
+ let barWidth = self.model.barWidth(for: self.bounds.width, progress: self.progress)
+ self.barViewConstraints.width?.constant = barWidth
+ }
+
+ // MARK: - Layout
+
+ private func layout() {
+ self.barViewConstraints = .merged {
+ self.barView.leading()
+ self.barView.centerVertically()
+ self.barView.height(self.model.trackHeight)
+ self.barView.width(0)
+ }
+
+ self.backgroundViewConstraints = .merged {
+ self.backgroundView.trailing()
+ self.backgroundView.centerVertically()
+ self.backgroundView.height(self.model.trackHeight)
+ }
+
+ self.handleViewConstraints = .merged {
+ self.handleView.after(self.barView, padding: self.model.trackSpacing)
+ self.handleView.before(self.backgroundView, padding: self.model.trackSpacing)
+ self.handleView.size(
+ width: self.model.handleSize.width,
+ height: self.model.handleSize.height
+ )
+ self.handleView.centerVertically()
+ }
+
+ self.handleOverlayView.center()
+ self.handleOverlayView.size(
+ width: self.model.handleOverlaySide,
+ height: self.model.handleOverlaySide
+ )
+ }
+
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+
+ self.backgroundView.layer.cornerRadius =
+ self.model.cornerRadius(for: self.backgroundView.bounds.height)
+
+ self.barView.layer.cornerRadius =
+ self.model.cornerRadius(for: self.barView.bounds.height)
+
+ self.handleView.layer.cornerRadius =
+ self.model.cornerRadius(for: self.handleView.bounds.width)
+
+ self.handleOverlayView.layer.cornerRadius =
+ self.model.cornerRadius(for: self.handleOverlayView.bounds.width)
+
+ self.updateSliderAppearance()
+ self.model.validateMinMaxValues()
+ }
+
+ // MARK: - UIView Methods
+
+ open override func sizeThatFits(_ size: CGSize) -> CGSize {
+ let width = self.superview?.bounds.width ?? size.width
+ return CGSize(
+ width: min(size.width, width),
+ height: min(size.height, self.model.handleSize.height)
+ )
+ }
+
+ open override func touchesBegan(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ guard let point = touches.first?.location(in: self),
+ self.hitTest(point, with: nil) == self.handleView
+ else { return }
+
+ self.isDragging = true
+ }
+
+ open override func touchesMoved(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ guard self.isDragging,
+ let translation = touches.first?.location(in: self)
+ else { return }
+
+ let totalWidth = self.bounds.width
+ let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing)
+
+ let newOffset = translation.x - self.model.trackSpacing - self.model.handleSize.width / 2
+ let clampedOffset = min(max(newOffset, 0), sliderWidth)
+
+ self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth)
+ }
+
+ open override func touchesEnded(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ self.isDragging = false
+ }
+
+ open override func touchesCancelled(
+ _ touches: Set,
+ with event: UIEvent?
+ ) {
+ self.isDragging = false
+ }
+}
+
+// MARK: - Style Helpers
+
+extension UKSlider {
+ fileprivate enum Style {
+ static func backgroundView(_ view: UIView, model: SliderVM) {
+ view.backgroundColor = model.color.background.uiColor
+ if model.style == .striped {
+ view.backgroundColor = .clear
+ }
+ view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
+ view.layer.masksToBounds = true
+ }
+
+ static func barView(_ view: UIView, model: SliderVM) {
+ view.backgroundColor = model.color.main.uiColor
+ view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height)
+ view.layer.masksToBounds = true
+ }
+
+ static func stripedLayer(_ layer: CAShapeLayer, model: SliderVM) {
+ layer.fillColor = model.color.main.uiColor.cgColor
+ switch model.style {
+ case .light:
+ layer.isHidden = true
+ case .striped:
+ layer.isHidden = false
+ }
+ }
+
+ static func handleView(_ view: UIView, model: SliderVM) {
+ view.backgroundColor = model.color.main.uiColor
+ view.layer.cornerRadius = model.cornerRadius(for: model.handleSize.width)
+ view.layer.masksToBounds = true
+ }
+
+ static func handleOverlayView(_ view: UIView, model: SliderVM) {
+ view.isVisible = model.isHandleOverlayVisible
+ view.backgroundColor = model.color.contrast.uiColor
+ view.layer.cornerRadius = model.cornerRadius(for: model.handleOverlaySide)
+ }
+ }
+}
diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json b/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json
new file mode 100644
index 00000000..68cb3fb8
--- /dev/null
+++ b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "avatar_placeholder.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg
new file mode 100644
index 00000000..6c089d7f
--- /dev/null
+++ b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift
index 7792ba04..24be0319 100644
--- a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift
+++ b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift
@@ -100,7 +100,7 @@ public struct UniversalColor: Hashable {
self.uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
- return (red, green, blue, alpha)
+ return (red * 255, green * 255, blue * 255, alpha)
}
}