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/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift
new file mode 100644
index 00000000..7fa7c415
--- /dev/null
+++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift
@@ -0,0 +1,38 @@
+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: "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/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift
index ab7aaf9e..b3985ff2 100644
--- a/Examples/DemosApp/DemosApp/Core/App.swift
+++ b/Examples/DemosApp/DemosApp/Core/App.swift
@@ -8,6 +8,8 @@ struct App: View {
NavigationLinkWithTitle("Alert") {
AlertPreview()
}
+ NavigationLinkWithTitle("Avatar") {
+ AvatarPreview()
NavigationLinkWithTitle("Badge") {
BadgePreview()
}
diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift
new file mode 100644
index 00000000..29001c92
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift
@@ -0,0 +1,22 @@
+import UIKit
+
+struct ImageLoader {
+ private static var cache = NSCache()
+
+ private init() {}
+
+ static func download(url: URL) async -> UIImage? {
+ if let image = self.cache.object(forKey: url.absoluteString as NSString) {
+ return image
+ }
+
+ let request = URLRequest(url: url)
+ guard let (data, _) = try? await URLSession.shared.data(for: request),
+ let image = UIImage(data: data) else {
+ return nil
+ }
+
+ self.cache.setObject(image, forKey: url.absoluteString as NSString)
+ return 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..a4be0be7
--- /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 {
+ /// 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/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift
new file mode 100644
index 00000000..9ec6d4c4
--- /dev/null
+++ b/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift
@@ -0,0 +1,86 @@
+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
+
+ @State private var loadedImage: (url: URL, image: UIImage)?
+
+ // 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 {
+ Group {
+ switch self.model.imageSrc {
+ case .remote:
+ if let loadedImage {
+ Image(uiImage: loadedImage.image)
+ .resizable()
+ .transition(.opacity)
+ } else {
+ self.placeholder
+ }
+ case let .local(name, bundle):
+ Image(name, bundle: bundle)
+ .resizable()
+ case .none:
+ self.placeholder
+ }
+ }
+ .aspectRatio(contentMode: .fill)
+ .frame(
+ width: self.model.preferredSize.width,
+ height: self.model.preferredSize.height
+ )
+ .clipShape(
+ RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
+ )
+ .onAppear {
+ if let imageURL = self.model.imageURL {
+ self.downloadImage(url: imageURL)
+ }
+ }
+ .onChange(of: self.model.imageSrc) { newValue in
+ switch newValue {
+ case .remote(let url):
+ self.downloadImage(url: url)
+ case .local, .none:
+ break
+ }
+ }
+ }
+
+ // MARK: - Subviews
+
+ private var placeholder: some View {
+ Image(uiImage: self.model.placeholderImage(
+ for: self.model.preferredSize
+ ))
+ .resizable()
+ }
+
+ // MARK: - Helpers
+
+ private func downloadImage(url: URL) {
+ guard self.loadedImage?.url != url else { return }
+
+ self.loadedImage = nil
+ Task { @MainActor in
+ guard let image = await ImageLoader.download(url: url) else { return }
+ withAnimation {
+ self.loadedImage = (url, image)
+ }
+ }
+ }
+}
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 @@
+