From afe81fa4620bc771272d43935c08da204dbefb42 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 16 Jan 2025 16:28:31 +0100 Subject: [PATCH 1/4] add AvatarGroupVM and AvatarItemVM --- .../AvatarGroup/Models/AvatarGroupVM.swift | 28 +++++++++++++++++++ .../AvatarGroup/Models/AvatarItemVM.swift | 13 +++++++++ 2 files changed, 41 insertions(+) create mode 100644 Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift create mode 100644 Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift new file mode 100644 index 00000000..a58a3e54 --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift @@ -0,0 +1,28 @@ +import UIKit + +/// A model that defines the appearance properties for an avatar group component. +public struct AvatarGroupVM: ComponentVM { + /// The color of the placeholder. + public var color: ComponentColor? + + /// The corner radius of the avatar. + /// + /// Defaults to `.full`. + public var cornerRadius: ComponentRadius = .full + + /// The array of avatars in the group. + public var items: [AvatarItemVM] = [] + + /// The maximum number of visible avatars + /// + /// Defaults to `5`. + public var maxVisibleAvatars: Int = 5 + + /// The predefined size of the avatar. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// Initializes a new instance of `AvatarVM` with default values. + public init() {} +} diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift new file mode 100644 index 00000000..66cdd41f --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift @@ -0,0 +1,13 @@ +import UIKit + +/// A model that defines the appearance properties for an avatar in the group. +public struct AvatarItemVM: ComponentVM { + /// The source of the image to be displayed. + public var imageSrc: AvatarVM.ImageSource? + + /// The placeholder that is displayed if the image is not provided or fails to load. + public var placeholder: AvatarVM.Placeholder = .icon("avatar_placeholder", Bundle.module) + + /// Initializes a new instance of `AvatarItemVM` with default values. + public init() {} +} From 0149c7b5d040352f02726773c2ed72c4f9611d61 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 17 Jan 2025 12:39:27 +0100 Subject: [PATCH 2/4] implement avatar group, add avatar group preview --- .../PreviewPages/AvatarGroupPreview.swift | 58 +++++++++++++++ Examples/DemosApp/DemosApp/Core/App.swift | 3 + .../Avatar/Helpers/AvatarImageManager.swift | 6 +- .../Components/Avatar/Models/AvatarVM.swift | 2 +- .../Avatar/SwiftUI/AvatarContent.swift | 34 +++++++++ .../Avatar/{ => SwiftUI}/SUAvatar.swift | 15 +--- .../Avatar/{ => UIKit}/UKAvatar.swift | 0 .../AvatarGroup/Models/AvatarGroupVM.swift | 72 ++++++++++++++++++- .../AvatarGroup/Models/AvatarItemVM.swift | 3 + .../AvatarGroup/SUAvatarGroup.swift | 37 ++++++++++ 10 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift create mode 100644 Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift rename Sources/ComponentsKit/Components/Avatar/{ => SwiftUI}/SUAvatar.swift (55%) rename Sources/ComponentsKit/Components/Avatar/{ => UIKit}/UKAvatar.swift (100%) create mode 100644 Sources/ComponentsKit/Components/AvatarGroup/SUAvatarGroup.swift diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift new file mode 100644 index 00000000..787c563d --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift @@ -0,0 +1,58 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct AvatarGroupPreview: View { + @State private var model = AvatarGroupVM { + $0.items = [ + .init { + $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=12")!) + }, + .init { + $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=14")!) + }, + .init { + $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=15")!) + }, + .init(), + .init(), + .init { + $0.placeholder = .text("IM") + }, + .init { + $0.placeholder = .sfSymbol("person.circle") + }, + ] + } + + var body: some View { + VStack { + PreviewWrapper(title: "SwiftUI") { + SUAvatarGroup(model: self.model) + } + Form { + Picker("Border Color", selection: self.$model.borderColor) { + Text("Background").tag(UniversalColor.background) + Text("Accent Background").tag(ComponentColor.accent.background) + Text("Success Background").tag(ComponentColor.success.background) + Text("Warning Background").tag(ComponentColor.warning.background) + Text("Danger Background").tag(ComponentColor.danger.background) + } + ComponentOptionalColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 4px").tag(ComponentRadius.custom(4)) + } + Picker("Max Visible Avatars", selection: self.$model.maxVisibleAvatars) { + Text("3").tag(3) + Text("5").tag(5) + Text("7").tag(7) + } + SizePicker(selection: self.$model.size) + } + } + } +} + +#Preview { + AvatarGroupPreview() +} diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index d1d184b2..1f58ab39 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -11,6 +11,9 @@ struct App: View { NavigationLinkWithTitle("Avatar") { AvatarPreview() } + NavigationLinkWithTitle("Avatar Group") { + AvatarGroupPreview() + } NavigationLinkWithTitle("Button") { ButtonPreview() } diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift index 4916c33a..21067b6c 100644 --- a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift +++ b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift @@ -5,7 +5,7 @@ final class AvatarImageManager: ObservableObject { @Published var avatarImage: UIImage private var model: AvatarVM - private var remoteImagesCache = NSCache() + private static var remoteImagesCache = NSCache() init(model: AvatarVM) { self.model = model @@ -27,7 +27,7 @@ final class AvatarImageManager: ObservableObject { switch model.imageSrc { case .remote(let url): - if let image = self.remoteImagesCache.object(forKey: url.absoluteString as NSString) { + if let image = Self.remoteImagesCache.object(forKey: url.absoluteString as NSString) { self.avatarImage = image } else { self.avatarImage = model.placeholderImage(for: size) @@ -47,7 +47,7 @@ final class AvatarImageManager: ObservableObject { let image = UIImage(data: data) else { return } - self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString) + Self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString) if url == self.model.imageURL { self.avatarImage = image diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift index a4be0be7..2d7418b0 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift @@ -1,7 +1,7 @@ import UIKit /// A model that defines the appearance properties for an avatar component. -public struct AvatarVM: ComponentVM { +public struct AvatarVM: ComponentVM, Hashable { /// The color of the placeholder. public var color: ComponentColor? diff --git a/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift new file mode 100644 index 00000000..a0da0239 --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct AvatarContent: View { + // MARK: - Properties + + var model: AvatarVM + + @StateObject private var imageManager: AvatarImageManager + + // MARK: - Initialization + + init(model: AvatarVM) { + self.model = model + self._imageManager = StateObject( + wrappedValue: AvatarImageManager(model: model) + ) + } + + // MARK: - Body + + var body: some View { + GeometryReader { geometry in + Image(uiImage: self.imageManager.avatarImage) + .resizable() + .aspectRatio(contentMode: .fill) + .clipShape( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) + ) + .onChange(of: self.model) { newValue in + self.imageManager.update(model: newValue, size: geometry.size) + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift similarity index 55% rename from Sources/ComponentsKit/Components/Avatar/SUAvatar.swift rename to Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift index 03962048..979f5efd 100644 --- a/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift +++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift @@ -7,8 +7,6 @@ public struct SUAvatar: View { /// A model that defines the appearance properties. public var model: AvatarVM - @StateObject private var imageManager: AvatarImageManager - // MARK: - Initialization /// Initializer. @@ -16,26 +14,15 @@ 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 { - Image(uiImage: self.imageManager.avatarImage) - .resizable() - .aspectRatio(contentMode: .fill) + AvatarContent(model: self.model) .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) - } } } diff --git a/Sources/ComponentsKit/Components/Avatar/UKAvatar.swift b/Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift similarity index 100% rename from Sources/ComponentsKit/Components/Avatar/UKAvatar.swift rename to Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift index a58a3e54..8fce7acb 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift @@ -2,6 +2,9 @@ import UIKit /// A model that defines the appearance properties for an avatar group component. public struct AvatarGroupVM: ComponentVM { + /// The border color of avatars. + public var borderColor: UniversalColor = .background + /// The color of the placeholder. public var color: ComponentColor? @@ -11,7 +14,13 @@ public struct AvatarGroupVM: ComponentVM { public var cornerRadius: ComponentRadius = .full /// The array of avatars in the group. - public var items: [AvatarItemVM] = [] + public var items: [AvatarItemVM] = [] { + didSet { + self._identifiedItems = self.items.map({ + return .init(id: UUID(), item: $0) + }) + } + } /// The maximum number of visible avatars /// @@ -23,6 +32,67 @@ public struct AvatarGroupVM: ComponentVM { /// Defaults to `.medium`. public var size: ComponentSize = .medium + /// The array of avatar items with an associated id value to properly display content in SwiftUI. + private var _identifiedItems: [IdentifiedAvatarItem] = [] + /// Initializes a new instance of `AvatarVM` with default values. public init() {} } + +// MARK: - Helpers + +fileprivate struct IdentifiedAvatarItem: Equatable { + var id: UUID + var item: AvatarItemVM +} + +extension AvatarGroupVM { + var identifiedAvatarVMs: [(UUID, AvatarVM)] { + var avatars = self._identifiedItems.prefix(self.maxVisibleAvatars).map { data in + return (data.id, AvatarVM { + $0.color = self.color + $0.cornerRadius = self.cornerRadius + $0.imageSrc = data.item.imageSrc + $0.placeholder = data.item.placeholder + $0.size = self.size + }) + } + + if self.numberOfHiddenAvatars > 0 { + avatars.append((UUID(), AvatarVM { + $0.color = self.color + $0.cornerRadius = self.cornerRadius + $0.placeholder = .text("+\(self.numberOfHiddenAvatars)") + $0.size = self.size + })) + } + + return avatars + } + + var avatarSize: CGSize { + switch self.size { + case .small: + return .init(width: 36, height: 36) + case .medium: + return .init(width: 48, height: 48) + case .large: + return .init(width: 64, height: 64) + } + } + + var padding: CGFloat { + switch self.size { + case .small: + return 3 + case .medium: + return 4 + case .large: + return 5 + } + } + + var numberOfHiddenAvatars: Int { + return self.items.count - self.maxVisibleAvatars + } +} diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift index 66cdd41f..704d2c61 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift @@ -2,6 +2,9 @@ import UIKit /// A model that defines the appearance properties for an avatar in the group. public struct AvatarItemVM: ComponentVM { + /// The unique identifier for the item. + public var id = UUID() + /// The source of the image to be displayed. public var imageSrc: AvatarVM.ImageSource? diff --git a/Sources/ComponentsKit/Components/AvatarGroup/SUAvatarGroup.swift b/Sources/ComponentsKit/Components/AvatarGroup/SUAvatarGroup.swift new file mode 100644 index 00000000..db72d163 --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/SUAvatarGroup.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// A SwiftUI component that displays a group of avatars. +public struct SUAvatarGroup: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: AvatarGroupVM + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: AvatarGroupVM) { + self.model = model + } + + // MARK: - Body + + public var body: some View { + HStack(spacing: -self.model.avatarSize.width / 3) { + ForEach(self.model.identifiedAvatarVMs, id: \.0) { _, avatarVM in + AvatarContent(model: avatarVM) + .padding(self.model.padding) + .background(self.model.borderColor.color) + .clipShape( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) + ) + .frame( + width: self.model.avatarSize.width, + height: self.model.avatarSize.height + ) + } + } + } +} From f09f40e5452e067f91aaf0889c3bf68275a1a617 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 17 Jan 2025 12:47:12 +0100 Subject: [PATCH 3/4] improve docs --- .../Components/AvatarGroup/Models/AvatarGroupVM.swift | 10 +++++----- .../Components/AvatarGroup/Models/AvatarItemVM.swift | 3 --- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift index 8fce7acb..9d4dadbd 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift @@ -5,10 +5,10 @@ public struct AvatarGroupVM: ComponentVM { /// The border color of avatars. public var borderColor: UniversalColor = .background - /// The color of the placeholder. + /// The color of the placeholders. public var color: ComponentColor? - /// The corner radius of the avatar. + /// The corner radius of the avatars. /// /// Defaults to `.full`. public var cornerRadius: ComponentRadius = .full @@ -22,12 +22,12 @@ public struct AvatarGroupVM: ComponentVM { } } - /// The maximum number of visible avatars + /// The maximum number of visible avatars. /// /// Defaults to `5`. public var maxVisibleAvatars: Int = 5 - /// The predefined size of the avatar. + /// The predefined size of the component. /// /// Defaults to `.medium`. public var size: ComponentSize = .medium @@ -35,7 +35,7 @@ public struct AvatarGroupVM: ComponentVM { /// The array of avatar items with an associated id value to properly display content in SwiftUI. private var _identifiedItems: [IdentifiedAvatarItem] = [] - /// Initializes a new instance of `AvatarVM` with default values. + /// Initializes a new instance of `AvatarGroupVM` with default values. public init() {} } diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift index 704d2c61..66cdd41f 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift @@ -2,9 +2,6 @@ import UIKit /// A model that defines the appearance properties for an avatar in the group. public struct AvatarItemVM: ComponentVM { - /// The unique identifier for the item. - public var id = UUID() - /// The source of the image to be displayed. public var imageSrc: AvatarVM.ImageSource? From 0107f14c7b708c6519eb53a3bc38dc7e3bc4604f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 17 Jan 2025 16:29:49 +0100 Subject: [PATCH 4/4] update image manager when avatar content view appears --- .../Components/Avatar/SwiftUI/AvatarContent.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift index 0f462a0d..f4fed34f 100644 --- a/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift +++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift @@ -27,6 +27,9 @@ struct AvatarContent: View { .clipShape( RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) ) + .onAppear { + self.imageManager.update(model: self.model, size: geometry.size) + } .onChange(of: self.model) { newValue in self.imageManager.update(model: newValue, size: geometry.size) }