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
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NSString, UIImage>()

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
}
}
}
}
22 changes: 0 additions & 22 deletions Sources/ComponentsKit/Components/Avatar/Helpers/ImageLoader.swift

This file was deleted.

79 changes: 19 additions & 60 deletions Sources/ComponentsKit/Components/Avatar/SUAvatar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
}
}
118 changes: 118 additions & 0 deletions Sources/ComponentsKit/Components/Avatar/UKAvatar.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion Sources/ComponentsKit/Components/Divider/UKDivider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading