Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<AvatarVM.ImageSource>.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()
}
2 changes: 2 additions & 0 deletions Examples/DemosApp/DemosApp/Core/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ struct App: View {
NavigationLinkWithTitle("Alert") {
AlertPreview()
}
NavigationLinkWithTitle("Avatar") {
AvatarPreview()
NavigationLinkWithTitle("Badge") {
BadgePreview()
}
Expand Down
22 changes: 22 additions & 0 deletions Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import UIKit

struct ImageLoader {
private static var cache = NSCache<NSString, UIImage>()

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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
131 changes: 131 additions & 0 deletions Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
86 changes: 86 additions & 0 deletions Sources/ComponentsKit/Components/Avatar/SUAvatar.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
6 changes: 6 additions & 0 deletions Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Loading