Skip to content

Commit 6b4181e

Browse files
refactor: extract logic for getting an avatar image into a separate manager that is shared between uikit and swiftui implementations
1 parent cf8039d commit 6b4181e

File tree

4 files changed

+88
-127
lines changed

4 files changed

+88
-127
lines changed
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 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: 15 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ 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
1111

1212
// MARK: - Initialization
1313

@@ -16,73 +16,26 @@ public struct SUAvatar: View {
1616
/// - model: A model that defines the appearance properties.
1717
public init(model: AvatarVM) {
1818
self.model = model
19+
self._imageManager = StateObject(
20+
wrappedValue: AvatarImageManager(model: model)
21+
)
1922
}
2023

2124
// MARK: - Body
2225

2326
public var body: some View {
24-
Group {
25-
if let source = self.model.imageSrc {
26-
switch source {
27-
case .remote:
28-
if let loadedImage {
29-
Image(uiImage: loadedImage.image)
30-
.resizable()
31-
.transition(.opacity)
32-
} else {
33-
self.placeholder
34-
}
35-
case let .local(name, bundle):
36-
Image(name, bundle: bundle)
37-
.resizable()
38-
}
39-
} else {
40-
self.placeholder
41-
}
42-
}
43-
.aspectRatio(contentMode: .fill)
44-
.frame(
45-
width: self.model.preferredSize.width,
46-
height: self.model.preferredSize.height
47-
)
48-
.clipShape(
49-
RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
50-
)
51-
.onAppear {
52-
if let imageURL = self.model.imageURL {
53-
self.downloadImage(url: imageURL)
54-
}
55-
}
56-
.onChange(of: self.model.imageSrc) { newValue in
57-
switch newValue {
58-
case .remote(let url):
59-
self.downloadImage(url: url)
60-
case .local, .none:
61-
break
62-
}
63-
}
64-
}
65-
66-
// MARK: - Subviews
67-
68-
private var placeholder: some View {
69-
Image(uiImage: self.model.placeholderImage(
70-
for: self.model.preferredSize
71-
))
27+
Image(uiImage: self.imageManager.avatarImage)
7228
.resizable()
73-
}
74-
75-
// MARK: - Helpers
76-
77-
private func downloadImage(url: URL) {
78-
guard self.loadedImage?.url != url else { return }
79-
80-
self.loadedImage = nil
81-
Task { @MainActor in
82-
guard let image = await ImageLoader.download(url: url) else { return }
83-
withAnimation {
84-
self.loadedImage = (url, image)
29+
.aspectRatio(contentMode: .fill)
30+
.frame(
31+
width: self.model.preferredSize.width,
32+
height: self.model.preferredSize.height
33+
)
34+
.clipShape(
35+
RoundedRectangle(cornerRadius: self.model.cornerRadius.value())
36+
)
37+
.onChange(of: self.model) { newValue in
38+
self.imageManager.update(model: newValue, size: newValue.preferredSize)
8539
}
86-
}
8740
}
8841
}
Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Combine
12
import UIKit
23

34
/// A UIKit component that displays a profile picture, initials or fallback icon for a user.
@@ -11,7 +12,8 @@ open class UKAvatar: UIImageView, UKComponent {
1112
}
1213
}
1314

14-
private var loadedImage: (url: URL, image: UIImage)?
15+
private let imageManager: AvatarImageManager
16+
private var cancellable: AnyCancellable?
1517

1618
// MARK: - UIView Properties
1719

@@ -26,16 +28,26 @@ open class UKAvatar: UIImageView, UKComponent {
2628
/// - model: A model that defines the appearance properties.
2729
public init(model: AvatarVM = .init()) {
2830
self.model = model
31+
self.imageManager = AvatarImageManager(model: model)
2932

3033
super.init(frame: .zero)
3134

35+
self.setup()
3236
self.style()
3337
}
3438

3539
public required init?(coder: NSCoder) {
3640
fatalError("init(coder:) has not been implemented")
3741
}
3842

43+
// MARK: - Setup
44+
45+
private func setup() {
46+
self.cancellable = self.imageManager.$avatarImage
47+
.receive(on: DispatchQueue.main)
48+
.sink { self.image = $0 }
49+
}
50+
3951
// MARK: - Style
4052

4153
private func style() {
@@ -48,9 +60,8 @@ open class UKAvatar: UIImageView, UKComponent {
4860
public func update(_ oldModel: AvatarVM) {
4961
guard self.model != oldModel else { return }
5062

51-
if self.model.shouldUpdateImage(oldModel) {
52-
self.updateImage()
53-
}
63+
self.imageManager.update(model: self.model, size: self.bounds.size)
64+
5465
if self.model.cornerRadius != oldModel.cornerRadius {
5566
self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
5667
}
@@ -67,7 +78,7 @@ open class UKAvatar: UIImageView, UKComponent {
6778

6879
self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height)
6980

70-
self.updateImage()
81+
self.imageManager.update(model: self.model, size: self.bounds.size)
7182
}
7283

7384
// MARK: - UIView Methods
@@ -78,42 +89,4 @@ open class UKAvatar: UIImageView, UKComponent {
7889
let side = min(minProvidedSide, minPreferredSide)
7990
return CGSize(width: side, height: side)
8091
}
81-
82-
// MARK: - Helpers
83-
84-
private func downloadImage(url: URL) {
85-
self.loadedImage = nil
86-
Task { @MainActor in
87-
guard let image = await ImageLoader.download(url: url),
88-
url == self.model.imageURL
89-
else { return }
90-
91-
self.loadedImage = (url, image)
92-
UIView.transition(
93-
with: self,
94-
duration: 0.2,
95-
options: .transitionCrossDissolve,
96-
animations: {
97-
self.image = image
98-
}
99-
)
100-
}
101-
}
102-
103-
private func updateImage() {
104-
let size = self.bounds.size
105-
switch self.model.imageSrc {
106-
case .remote(let url):
107-
if let loadedImage, loadedImage.url == url {
108-
self.image = loadedImage.image
109-
} else {
110-
self.image = self.model.placeholderImage(for: size)
111-
self.downloadImage(url: url)
112-
}
113-
case let .local(name, bundle):
114-
self.image = UIImage(named: name, in: bundle, compatibleWith: nil) ?? self.model.placeholderImage(for: size)
115-
case .none:
116-
self.image = self.model.placeholderImage(for: size)
117-
}
118-
}
11992
}

0 commit comments

Comments
 (0)