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 ad359527..e69537dc 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("Badge") { BadgePreview() } 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/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift deleted file mode 100644 index 994ba7d1..00000000 --- a/Sources/ComponentsKit/Components/Avatar/SUAvatar.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -/// A SwiftUI component that displays a profile picture, initials or fallback icon for a user. -public struct SUAvatar: View { - // MARK: - Properties - - /// A model that defines the appearance properties. - public var model: AvatarVM - - @StateObject private var imageManager: AvatarImageManager - @Environment(\.colorScheme) private var colorScheme - - // MARK: - Initialization - - /// Initializer. - /// - Parameters: - /// - 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) - .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/SwiftUI/AvatarContent.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift new file mode 100644 index 00000000..f4fed34f --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct AvatarContent: View { + // MARK: - Properties + + var model: AvatarVM + + @StateObject private var imageManager: AvatarImageManager + @Environment(\.colorScheme) private var colorScheme + + // 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()) + ) + .onAppear { + self.imageManager.update(model: self.model, size: geometry.size) + } + .onChange(of: self.model) { newValue in + self.imageManager.update(model: newValue, size: geometry.size) + } + .onChange(of: self.colorScheme) { _ in + self.imageManager.update(model: self.model, size: geometry.size) + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift new file mode 100644 index 00000000..979f5efd --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// A SwiftUI component that displays a profile picture, initials or fallback icon for a user. +public struct SUAvatar: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: AvatarVM + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: AvatarVM) { + self.model = model + } + + // MARK: - Body + + public var body: some View { + AvatarContent(model: self.model) + .frame( + width: self.model.preferredSize.width, + height: self.model.preferredSize.height + ) + } +} 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 new file mode 100644 index 00000000..9d4dadbd --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift @@ -0,0 +1,98 @@ +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 placeholders. + public var color: ComponentColor? + + /// The corner radius of the avatars. + /// + /// Defaults to `.full`. + public var cornerRadius: ComponentRadius = .full + + /// The array of avatars in the group. + public var items: [AvatarItemVM] = [] { + didSet { + self._identifiedItems = self.items.map({ + return .init(id: UUID(), item: $0) + }) + } + } + + /// The maximum number of visible avatars. + /// + /// Defaults to `5`. + public var maxVisibleAvatars: Int = 5 + + /// The predefined size of the component. + /// + /// 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 `AvatarGroupVM` 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 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() {} +} 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 + ) + } + } + } +}