Skip to content

Commit e3bebbe

Browse files
Merge pull request #59 from componentskit/SUAvatar
SUAvatar
2 parents 4d66c7f + 39166d6 commit e3bebbe

File tree

14 files changed

+408
-0
lines changed

14 files changed

+408
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "avatar.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
41.7 KB
Loading
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "avatar_placeholder.svg",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import ComponentsKit
2+
import SwiftUI
3+
import UIKit
4+
5+
struct AvatarPreview: View {
6+
@State private var model = AvatarVM {
7+
$0.placeholder = .icon("avatar_placeholder")
8+
}
9+
10+
var body: some View {
11+
VStack {
12+
PreviewWrapper(title: "SwiftUI") {
13+
SUAvatar(model: self.model)
14+
}
15+
Form {
16+
ComponentOptionalColorPicker(selection: self.$model.color)
17+
ComponentRadiusPicker(selection: self.$model.cornerRadius) {
18+
Text("Custom: 4px").tag(ComponentRadius.custom(4))
19+
}
20+
Picker("Image Source", selection: self.$model.imageSrc) {
21+
Text("Remote").tag(AvatarVM.ImageSource.remote(URL(string: "https://i.pravatar.cc/150?img=12")!))
22+
Text("Local").tag(AvatarVM.ImageSource.local("avatar_image"))
23+
Text("None").tag(Optional<AvatarVM.ImageSource>.none)
24+
}
25+
Picker("Placeholder", selection: self.$model.placeholder) {
26+
Text("Text").tag(AvatarVM.Placeholder.text("IM"))
27+
Text("SF Symbol").tag(AvatarVM.Placeholder.sfSymbol("person"))
28+
Text("Icon").tag(AvatarVM.Placeholder.icon("avatar_placeholder"))
29+
}
30+
SizePicker(selection: self.$model.size)
31+
}
32+
}
33+
}
34+
}
35+
36+
#Preview {
37+
AvatarPreview()
38+
}

Examples/DemosApp/DemosApp/Core/App.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ struct App: View {
88
NavigationLinkWithTitle("Alert") {
99
AlertPreview()
1010
}
11+
NavigationLinkWithTitle("Avatar") {
12+
AvatarPreview()
1113
NavigationLinkWithTitle("Badge") {
1214
BadgePreview()
1315
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import UIKit
2+
3+
struct ImageLoader {
4+
private static var cache = NSCache<NSString, UIImage>()
5+
6+
private init() {}
7+
8+
static func download(url: URL) async -> UIImage? {
9+
if let image = self.cache.object(forKey: url.absoluteString as NSString) {
10+
return image
11+
}
12+
13+
let request = URLRequest(url: url)
14+
guard let (data, _) = try? await URLSession.shared.data(for: request),
15+
let image = UIImage(data: data) else {
16+
return nil
17+
}
18+
19+
self.cache.setObject(image, forKey: url.absoluteString as NSString)
20+
return image
21+
}
22+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
/// Defines the source options for an avatar image.
4+
extension AvatarVM {
5+
public enum ImageSource: Hashable {
6+
/// An image loaded from a remote URL.
7+
///
8+
/// - Parameter url: The URL pointing to the remote image resource.
9+
/// - Note: Ensure the URL is valid and accessible to prevent errors during image fetching.
10+
case remote(_ url: URL)
11+
12+
/// An image loaded from a local asset.
13+
///
14+
/// - Parameters:
15+
/// - name: The name of the local image asset.
16+
/// - bundle: The bundle containing the image resource. Defaults to `nil`, which uses the main bundle.
17+
case local(_ name: String, _ bundle: Bundle? = nil)
18+
}
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
3+
/// Defines the placeholder options for an avatar.
4+
///
5+
/// It is used to provide a fallback or alternative visual representation when an image is not provided or fails to load.
6+
extension AvatarVM {
7+
public enum Placeholder: Hashable {
8+
/// A placeholder that displays a text string.
9+
///
10+
/// This option is typically used to show initials, names, or other textual representations.
11+
///
12+
/// - Parameter text: The text to display as the placeholder.
13+
/// - Note: Only 3 first letters are displayed.
14+
case text(String)
15+
16+
/// A placeholder that displays an SF Symbol.
17+
///
18+
/// This option allows you to use Apple's system-provided icons as placeholders.
19+
///
20+
/// - Parameter name: The name of the SF Symbol to display.
21+
/// - Note: Ensure that the SF Symbol name corresponds to an existing icon in the system's symbol library.
22+
case sfSymbol(_ name: String)
23+
24+
/// A placeholder that displays a custom icon from an asset catalog.
25+
///
26+
/// This option allows you to use icons from your app's bundled resources or a specified bundle.
27+
///
28+
/// - Parameters:
29+
/// - name: The name of the icon asset to use as the placeholder.
30+
/// - bundle: The bundle containing the icon resource. Defaults to `nil`, which uses the main bundle.
31+
case icon(_ name: String, _ bundle: Bundle? = nil)
32+
}
33+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import UIKit
2+
3+
/// A model that defines the appearance properties for an avatar component.
4+
public struct AvatarVM: ComponentVM {
5+
/// The color of the placeholder.
6+
public var color: ComponentColor?
7+
8+
/// The corner radius of the avatar.
9+
///
10+
/// Defaults to `.full`.
11+
public var cornerRadius: ComponentRadius = .full
12+
13+
/// The source of the image to be displayed.
14+
public var imageSrc: ImageSource?
15+
16+
/// The placeholder that is displayed if the image is not provided or fails to load.
17+
public var placeholder: Placeholder = .icon("avatar_placeholder", Bundle.module)
18+
19+
/// The predefined size of the avatar.
20+
///
21+
/// Defaults to `.medium`.
22+
public var size: ComponentSize = .medium
23+
24+
/// Initializes a new instance of `AvatarVM` with default values.
25+
public init() {}
26+
}
27+
28+
// MARK: - Helpers
29+
30+
extension AvatarVM {
31+
var preferredSize: CGSize {
32+
switch self.size {
33+
case .small:
34+
return .init(width: 36, height: 36)
35+
case .medium:
36+
return .init(width: 48, height: 48)
37+
case .large:
38+
return .init(width: 64, height: 64)
39+
}
40+
}
41+
42+
var imageURL: URL? {
43+
switch self.imageSrc {
44+
case .remote(let url):
45+
return url
46+
case .local, .none:
47+
return nil
48+
}
49+
}
50+
}
51+
52+
extension AvatarVM {
53+
func placeholderImage(for size: CGSize) -> UIImage {
54+
switch self.placeholder {
55+
case .text(let value):
56+
return self.drawName(value, size: size)
57+
case .icon(let name, let bundle):
58+
let icon = UIImage(named: name, in: bundle, with: nil)
59+
return self.drawIcon(icon, size: size)
60+
case .sfSymbol(let name):
61+
let systemIcon = UIImage(systemName: name)
62+
return self.drawIcon(systemIcon, size: size)
63+
}
64+
}
65+
66+
private var placeholderFont: UIFont {
67+
switch self.size {
68+
case .small:
69+
return UniversalFont.smButton.uiFont
70+
case .medium:
71+
return UniversalFont.mdButton.uiFont
72+
case .large:
73+
return UniversalFont.lgButton.uiFont
74+
}
75+
}
76+
77+
private func iconSize(for avatarSize: CGSize) -> CGSize {
78+
let minSide = min(avatarSize.width, avatarSize.height)
79+
let iconSize = minSide / 3 * 2
80+
return .init(width: iconSize, height: iconSize)
81+
}
82+
83+
private var placeholderBackgroundColor: UIColor {
84+
return (self.color?.background ?? .content1).uiColor
85+
}
86+
87+
private var placeholderForegroundColor: UIColor {
88+
return (self.color?.main ?? .foreground).uiColor
89+
}
90+
91+
private func drawIcon(_ icon: UIImage?, size: CGSize) -> UIImage {
92+
let iconSize = self.iconSize(for: size)
93+
let renderer = UIGraphicsImageRenderer(size: size)
94+
return renderer.image { _ in
95+
self.placeholderBackgroundColor.setFill()
96+
UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
97+
98+
icon?.withTintColor(self.placeholderForegroundColor, renderingMode: .alwaysOriginal).draw(in: CGRect(
99+
x: (size.width - iconSize.width) / 2,
100+
y: (size.height - iconSize.height) / 2,
101+
width: iconSize.width,
102+
height: iconSize.height
103+
))
104+
}
105+
}
106+
107+
private func drawName(_ name: String, size: CGSize) -> UIImage {
108+
let renderer = UIGraphicsImageRenderer(size: size)
109+
return renderer.image { _ in
110+
self.placeholderBackgroundColor.setFill()
111+
UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
112+
113+
let paragraphStyle = NSMutableParagraphStyle()
114+
paragraphStyle.alignment = .center
115+
116+
let attributes = [
117+
NSAttributedString.Key.font: self.placeholderFont,
118+
NSAttributedString.Key.paragraphStyle: paragraphStyle,
119+
NSAttributedString.Key.foregroundColor: self.placeholderForegroundColor
120+
]
121+
122+
let yOffset = (size.height - self.placeholderFont.lineHeight) / 2
123+
String(name.prefix(3)).draw(
124+
with: CGRect(x: 0, y: yOffset, width: size.width, height: size.height),
125+
options: .usesLineFragmentOrigin,
126+
attributes: attributes,
127+
context: nil
128+
)
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)