diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift index 7fa7c415..2747d1f6 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift @@ -9,6 +9,10 @@ struct AvatarPreview: View { var body: some View { VStack { + PreviewWrapper(title: "UIKit") { + UKAvatar(model: self.model) + .preview + } PreviewWrapper(title: "SwiftUI") { SUAvatar(model: self.model) } diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift new file mode 100644 index 00000000..21067b6c --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift @@ -0,0 +1,57 @@ +import SwiftUI +import UIKit + +final class AvatarImageManager: ObservableObject { + @Published var avatarImage: UIImage + + private var model: AvatarVM + private static var remoteImagesCache = NSCache() + + init(model: AvatarVM) { + self.model = model + + let size = model.preferredSize + switch model.imageSrc { + case .remote(let url): + self.avatarImage = model.placeholderImage(for: size) + self.downloadImage(url: url) + case let .local(name, bundle): + self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .none: + self.avatarImage = model.placeholderImage(for: size) + } + } + + func update(model: AvatarVM, size: CGSize) { + self.model = model + + switch model.imageSrc { + case .remote(let url): + if let image = Self.remoteImagesCache.object(forKey: url.absoluteString as NSString) { + self.avatarImage = image + } else { + self.avatarImage = model.placeholderImage(for: size) + self.downloadImage(url: url) + } + case let .local(name, bundle): + self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .none: + self.avatarImage = model.placeholderImage(for: size) + } + } + + private func downloadImage(url: URL) { + Task { @MainActor in + let request = URLRequest(url: url) + guard let (data, _) = try? await URLSession.shared.data(for: request), + let image = UIImage(data: data) + else { return } + + Self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString) + + if url == self.model.imageURL { + self.avatarImage = image + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift deleted file mode 100644 index 29001c92..00000000 --- a/Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift +++ /dev/null @@ -1,22 +0,0 @@ -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/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift index 9ec6d4c4..994ba7d1 100644 --- a/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift +++ b/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift @@ -7,7 +7,8 @@ public struct SUAvatar: View { /// A model that defines the appearance properties. public var model: AvatarVM - @State private var loadedImage: (url: URL, image: UIImage)? + @StateObject private var imageManager: AvatarImageManager + @Environment(\.colorScheme) private var colorScheme // MARK: - Initialization @@ -16,71 +17,29 @@ public struct SUAvatar: View { /// - model: A model that defines the appearance properties. public init(model: AvatarVM) { self.model = model + self._imageManager = StateObject( + wrappedValue: AvatarImageManager(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 - )) + Image(uiImage: self.imageManager.avatarImage) .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) + .aspectRatio(contentMode: .fill) + .frame( + width: self.model.preferredSize.width, + height: self.model.preferredSize.height + ) + .clipShape( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) + ) + .onChange(of: self.model) { newValue in + self.imageManager.update(model: newValue, size: newValue.preferredSize) + } + .onChange(of: self.colorScheme) { _ in + self.imageManager.update(model: self.model, size: self.model.preferredSize) } - } } } diff --git a/Sources/ComponentsKit/Components/Avatar/UKAvatar.swift b/Sources/ComponentsKit/Components/Avatar/UKAvatar.swift new file mode 100644 index 00000000..40a2b880 --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/UKAvatar.swift @@ -0,0 +1,118 @@ +import Combine +import UIKit + +/// A UIKit component that displays a profile picture, initials or fallback icon for a user. +open class UKAvatar: UIImageView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: AvatarVM { + didSet { + self.update(oldValue) + } + } + + private let imageManager: AvatarImageManager + private var cancellable: AnyCancellable? + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.model.preferredSize + } + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: AvatarVM = .init()) { + self.model = model + self.imageManager = AvatarImageManager(model: model) + + super.init(frame: .zero) + + self.setup() + self.style() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Deinitialization + + deinit { + self.cancellable?.cancel() + self.cancellable = nil + } + + // MARK: - Setup + + private func setup() { + self.cancellable = self.imageManager.$avatarImage + .receive(on: DispatchQueue.main) + .sink { self.image = $0 } + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } + } + + // MARK: - Style + + private func style() { + self.contentMode = .scaleToFill + self.clipsToBounds = true + } + + // MARK: - Update + + public func update(_ oldModel: AvatarVM) { + guard self.model != oldModel else { return } + + self.imageManager.update(model: self.model, size: self.bounds.size) + + if self.model.cornerRadius != oldModel.cornerRadius { + self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) + } + if self.model.size != oldModel.size { + self.setNeedsLayout() + self.invalidateIntrinsicContentSize() + } + } + + // MARK: - Layout + + open override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) + + self.imageManager.update(model: self.model, size: self.bounds.size) + } + + // MARK: - UIView Methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let minProvidedSide = min(size.width, size.height) + let minPreferredSide = min(self.model.preferredSize.width, self.model.preferredSize.height) + let side = min(minProvidedSide, minPreferredSide) + return CGSize(width: side, height: side) + } + + open override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + // MARK: Helpers + + @objc private func handleTraitChanges() { + self.imageManager.update(model: self.model, size: self.bounds.size) + } +} diff --git a/Sources/ComponentsKit/Components/Divider/UKDivider.swift b/Sources/ComponentsKit/Components/Divider/UKDivider.swift index 9f667846..c38b122a 100644 --- a/Sources/ComponentsKit/Components/Divider/UKDivider.swift +++ b/Sources/ComponentsKit/Components/Divider/UKDivider.swift @@ -32,7 +32,7 @@ open class UKDivider: UIView, UKComponent { fatalError("init(coder:) has not been implemented") } - // MARK: - Setup + // MARK: - Style private func style() { self.backgroundColor = self.model.lineColor.uiColor