Skip to content

Commit 73f27c1

Browse files
Merge pull request #60 from componentskit/UKAvatar
UKAvatar
2 parents e3bebbe + c0bfea1 commit 73f27c1

File tree

6 files changed

+199
-83
lines changed

6 files changed

+199
-83
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ struct AvatarPreview: View {
99

1010
var body: some View {
1111
VStack {
12+
PreviewWrapper(title: "UIKit") {
13+
UKAvatar(model: self.model)
14+
.preview
15+
}
1216
PreviewWrapper(title: "SwiftUI") {
1317
SUAvatar(model: self.model)
1418
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
final class AvatarImageManager: ObservableObject {
5+
@Published var avatarImage: UIImage
6+
7+
private var model: AvatarVM
8+
private static var remoteImagesCache = NSCache<NSString, UIImage>()
9+
10+
init(model: AvatarVM) {
11+
self.model = model
12+
13+
let size = model.preferredSize
14+
switch model.imageSrc {
15+
case .remote(let url):
16+
self.avatarImage = model.placeholderImage(for: size)
17+
self.downloadImage(url: url)
18+
case let .local(name, bundle):
19+
self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size)
20+
case .none:
21+
self.avatarImage = model.placeholderImage(for: size)
22+
}
23+
}
24+
25+
func update(model: AvatarVM, size: CGSize) {
26+
self.model = model
27+
28+
switch model.imageSrc {
29+
case .remote(let url):
30+
if let image = Self.remoteImagesCache.object(forKey: url.absoluteString as NSString) {
31+
self.avatarImage = image
32+
} else {
33+
self.avatarImage = model.placeholderImage(for: size)
34+
self.downloadImage(url: url)
35+
}
36+
case let .local(name, bundle):
37+
self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size)
38+
case .none:
39+
self.avatarImage = model.placeholderImage(for: size)
40+
}
41+
}
42+
43+
private func downloadImage(url: URL) {
44+
Task { @MainActor in
45+
let request = URLRequest(url: url)
46+
guard let (data, _) = try? await URLSession.shared.data(for: request),
47+
let image = UIImage(data: data)
48+
else { return }
49+
50+
Self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString)
51+
52+
if url == self.model.imageURL {
53+
self.avatarImage = image
54+
}
55+
}
56+
}
57+
}

Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift

Lines changed: 0 additions & 22 deletions
This file was deleted.

Sources/ComponentsKit/Components/Avatar/SUAvatar.swift

Lines changed: 19 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ public struct SUAvatar: View {
77
/// A model that defines the appearance properties.
88
public var model: AvatarVM
99

10-
@State private var loadedImage: (url: URL, image: UIImage)?
10+
@StateObject private var imageManager: AvatarImageManager
11+
@Environment(\.colorScheme) private var colorScheme
1112

1213
// MARK: - Initialization
1314

@@ -16,71 +17,29 @@ public struct SUAvatar: View {
1617
/// - model: A model that defines the appearance properties.
1718
public init(model: AvatarVM) {
1819
self.model = model
20+
self._imageManager = StateObject(
21+
wrappedValue: AvatarImageManager(model: model)
22+
)
1923
}
2024

2125
// MARK: - Body
2226

2327
public var body: some View {
24-
Group {
25-
switch self.model.imageSrc {
26-
case .remote:
27-
if let loadedImage {
28-
Image(uiImage: loadedImage.image)
29-
.resizable()
30-
.transition(.opacity)
31-
} else {
32-
self.placeholder
33-
}
34-
case let .local(name, bundle):
35-
Image(name, bundle: bundle)
36-
.resizable()
37-
case .none:
38-
self.placeholder
39-
}
40-
}
41-
.aspectRatio(contentMode: .fill)
42-
.frame(
43-
width: self.model.preferredSize.width,
44-
height: self.model.preferredSize.height
45-
)
46-
.clipShape(
47-
RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
48-
)
49-
.onAppear {
50-
if let imageURL = self.model.imageURL {
51-
self.downloadImage(url: imageURL)
52-
}
53-
}
54-
.onChange(of: self.model.imageSrc) { newValue in
55-
switch newValue {
56-
case .remote(let url):
57-
self.downloadImage(url: url)
58-
case .local, .none:
59-
break
60-
}
61-
}
62-
}
63-
64-
// MARK: - Subviews
65-
66-
private var placeholder: some View {
67-
Image(uiImage: self.model.placeholderImage(
68-
for: self.model.preferredSize
69-
))
28+
Image(uiImage: self.imageManager.avatarImage)
7029
.resizable()
71-
}
72-
73-
// MARK: - Helpers
74-
75-
private func downloadImage(url: URL) {
76-
guard self.loadedImage?.url != url else { return }
77-
78-
self.loadedImage = nil
79-
Task { @MainActor in
80-
guard let image = await ImageLoader.download(url: url) else { return }
81-
withAnimation {
82-
self.loadedImage = (url, image)
30+
.aspectRatio(contentMode: .fill)
31+
.frame(
32+
width: self.model.preferredSize.width,
33+
height: self.model.preferredSize.height
34+
)
35+
.clipShape(
36+
RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
37+
)
38+
.onChange(of: self.model) { newValue in
39+
self.imageManager.update(model: newValue, size: newValue.preferredSize)
40+
}
41+
.onChange(of: self.colorScheme) { _ in
42+
self.imageManager.update(model: self.model, size: self.model.preferredSize)
8343
}
84-
}
8544
}
8645
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import Combine
2+
import UIKit
3+
4+
/// A UIKit component that displays a profile picture, initials or fallback icon for a user.
5+
open class UKAvatar: UIImageView, UKComponent {
6+
// MARK: - Properties
7+
8+
/// A model that defines the appearance properties.
9+
public var model: AvatarVM {
10+
didSet {
11+
self.update(oldValue)
12+
}
13+
}
14+
15+
private let imageManager: AvatarImageManager
16+
private var cancellable: AnyCancellable?
17+
18+
// MARK: - UIView Properties
19+
20+
open override var intrinsicContentSize: CGSize {
21+
return self.model.preferredSize
22+
}
23+
24+
// MARK: - Initialization
25+
26+
/// Initializer.
27+
/// - Parameters:
28+
/// - model: A model that defines the appearance properties.
29+
public init(model: AvatarVM = .init()) {
30+
self.model = model
31+
self.imageManager = AvatarImageManager(model: model)
32+
33+
super.init(frame: .zero)
34+
35+
self.setup()
36+
self.style()
37+
}
38+
39+
public required init?(coder: NSCoder) {
40+
fatalError("init(coder:) has not been implemented")
41+
}
42+
43+
// MARK: - Deinitialization
44+
45+
deinit {
46+
self.cancellable?.cancel()
47+
self.cancellable = nil
48+
}
49+
50+
// MARK: - Setup
51+
52+
private func setup() {
53+
self.cancellable = self.imageManager.$avatarImage
54+
.receive(on: DispatchQueue.main)
55+
.sink { self.image = $0 }
56+
57+
if #available(iOS 17.0, *) {
58+
self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in
59+
view.handleTraitChanges()
60+
}
61+
}
62+
}
63+
64+
// MARK: - Style
65+
66+
private func style() {
67+
self.contentMode = .scaleToFill
68+
self.clipsToBounds = true
69+
}
70+
71+
// MARK: - Update
72+
73+
public func update(_ oldModel: AvatarVM) {
74+
guard self.model != oldModel else { return }
75+
76+
self.imageManager.update(model: self.model, size: self.bounds.size)
77+
78+
if self.model.cornerRadius != oldModel.cornerRadius {
79+
self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
80+
}
81+
if self.model.size != oldModel.size {
82+
self.setNeedsLayout()
83+
self.invalidateIntrinsicContentSize()
84+
}
85+
}
86+
87+
// MARK: - Layout
88+
89+
open override func layoutSubviews() {
90+
super.layoutSubviews()
91+
92+
self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
93+
94+
self.imageManager.update(model: self.model, size: self.bounds.size)
95+
}
96+
97+
// MARK: - UIView Methods
98+
99+
open override func sizeThatFits(_ size: CGSize) -> CGSize {
100+
let minProvidedSide = min(size.width, size.height)
101+
let minPreferredSide = min(self.model.preferredSize.width, self.model.preferredSize.height)
102+
let side = min(minProvidedSide, minPreferredSide)
103+
return CGSize(width: side, height: side)
104+
}
105+
106+
open override func traitCollectionDidChange(
107+
_ previousTraitCollection: UITraitCollection?
108+
) {
109+
super.traitCollectionDidChange(previousTraitCollection)
110+
self.handleTraitChanges()
111+
}
112+
113+
// MARK: Helpers
114+
115+
@objc private func handleTraitChanges() {
116+
self.imageManager.update(model: self.model, size: self.bounds.size)
117+
}
118+
}

Sources/ComponentsKit/Components/Divider/UKDivider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ open class UKDivider: UIView, UKComponent {
3232
fatalError("init(coder:) has not been implemented")
3333
}
3434

35-
// MARK: - Setup
35+
// MARK: - Style
3636

3737
private func style() {
3838
self.backgroundColor = self.model.lineColor.uiColor

0 commit comments

Comments
 (0)