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 @@ + + + +