diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json new file mode 100644 index 00000000..09cea8c4 --- /dev/null +++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "avatar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png new file mode 100644 index 00000000..54babc50 Binary files /dev/null and b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png differ diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json new file mode 100644 index 00000000..68cb3fb8 --- /dev/null +++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "avatar_placeholder.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg new file mode 100644 index 00000000..6c089d7f --- /dev/null +++ b/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift new file mode 100644 index 00000000..948ecbe2 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift @@ -0,0 +1,62 @@ +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: "UIKit") { + UKAvatarGroup(model: self.model) + .preview + } + 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/ComponentsPreview/PreviewPages/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift new file mode 100644 index 00000000..2747d1f6 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift @@ -0,0 +1,42 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct AvatarPreview: View { + @State private var model = AvatarVM { + $0.placeholder = .icon("avatar_placeholder") + } + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKAvatar(model: self.model) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUAvatar(model: self.model) + } + Form { + ComponentOptionalColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 4px").tag(ComponentRadius.custom(4)) + } + Picker("Image Source", selection: self.$model.imageSrc) { + Text("Remote").tag(AvatarVM.ImageSource.remote(URL(string: "https://i.pravatar.cc/150?img=12")!)) + Text("Local").tag(AvatarVM.ImageSource.local("avatar_image")) + Text("None").tag(Optional.none) + } + Picker("Placeholder", selection: self.$model.placeholder) { + Text("Text").tag(AvatarVM.Placeholder.text("IM")) + Text("SF Symbol").tag(AvatarVM.Placeholder.sfSymbol("person")) + Text("Icon").tag(AvatarVM.Placeholder.icon("avatar_placeholder")) + } + SizePicker(selection: self.$model.size) + } + } + } +} + +#Preview { + AvatarPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift new file mode 100644 index 00000000..be023fbc --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift @@ -0,0 +1,50 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct BadgePreview: View { + @State private var model = BadgeVM { + $0.title = "Badge" + } + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKBadge(model: self.model) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUBadge(model: self.model) + } + Form { + Picker("Font", selection: self.$model.font) { + Text("Default").tag(Optional.none) + Text("Small").tag(UniversalFont.smButton) + Text("Medium").tag(UniversalFont.mdButton) + Text("Large").tag(UniversalFont.lgButton) + Text("Custom: system bold of size 16").tag(UniversalFont.system(size: 16, weight: .bold)) + } + ComponentOptionalColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 4px").tag(ComponentRadius.custom(4)) + } + Picker("Style", selection: self.$model.style) { + Text("Filled").tag(BadgeVM.Style.filled) + Text("Light").tag(BadgeVM.Style.light) + } + Picker("Paddings", selection: self.$model.paddings) { + Text("8px; 6px") + .tag(Paddings(top: 6, leading: 8, bottom: 6, trailing: 8)) + Text("10px; 8px") + .tag(Paddings(top: 8, leading: 10, bottom: 8, trailing: 10)) + Text("12px; 10px") + .tag(Paddings(top: 10, leading: 12, bottom: 10, trailing: 12)) + } + } + } + } +} + +#Preview { + BadgePreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift new file mode 100644 index 00000000..ae6b0356 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift @@ -0,0 +1,67 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct ProgressBarPreview: View { + @State private var model = Self.initialModel + @State private var currentValue: CGFloat = Self.initialValue + + private let progressBar = UKProgressBar(initialValue: Self.initialValue, model: Self.initialModel) + + private let timer = Timer + .publish(every: 0.1, on: .main, in: .common) + .autoconnect() + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + self.progressBar + .preview + .onAppear { + self.progressBar.currentValue = self.currentValue + self.progressBar.model = Self.initialModel + } + .onChange(of: self.model) { newValue in + self.progressBar.model = newValue + } + } + PreviewWrapper(title: "SwiftUI") { + SUProgressBar(currentValue: self.$currentValue, model: self.model) + } + Form { + ComponentColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 2px").tag(ComponentRadius.custom(2)) + } + SizePicker(selection: self.$model.size) + Picker("Style", selection: self.$model.style) { + Text("Light").tag(ProgressBarVM.Style.light) + Text("Filled").tag(ProgressBarVM.Style.filled) + Text("Striped").tag(ProgressBarVM.Style.striped) + } + } + } + .onReceive(self.timer) { _ in + if self.currentValue < self.model.maxValue { + self.currentValue += (self.model.maxValue - self.model.minValue) / 100 + } else { + self.currentValue = self.model.minValue + } + + self.progressBar.currentValue = self.currentValue + } + } + + // MARK: - Helpers + + private static var initialValue: Double { + return 0.0 + } + private static var initialModel: ProgressBarVM { + return .init() + } +} + +#Preview { + ProgressBarPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift new file mode 100644 index 00000000..1ca235c5 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift @@ -0,0 +1,46 @@ +import SwiftUI +import ComponentsKit + +struct SliderPreview: View { + @State private var model = SliderVM { + $0.style = .light + $0.minValue = 0 + $0.maxValue = 100 + $0.cornerRadius = .full + } + @State private var currentValue: CGFloat = 30 + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKSlider(initialValue: self.currentValue, model: self.model) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUSlider(currentValue: self.$currentValue, model: self.model) + } + Form { + ComponentColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 2px").tag(ComponentRadius.custom(2)) + } + SizePicker(selection: self.$model.size) + Picker("Step", selection: self.$model.step) { + Text("1").tag(CGFloat(1)) + Text("5").tag(CGFloat(5)) + Text("10").tag(CGFloat(10)) + Text("25").tag(CGFloat(25)) + Text("50").tag(CGFloat(50)) + } + Picker("Style", selection: self.$model.style) { + Text("Light").tag(SliderVM.Style.light) + Text("Striped").tag(SliderVM.Style.striped) + } + } + } + } +} + +#Preview { + SliderPreview() +} diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index 1ffaa23f..c3d5e00c 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -8,6 +8,15 @@ struct App: View { NavigationLinkWithTitle("Alert") { AlertPreview() } + NavigationLinkWithTitle("Avatar") { + AvatarPreview() + } + NavigationLinkWithTitle("Avatar Group") { + AvatarGroupPreview() + } + NavigationLinkWithTitle("Badge") { + BadgePreview() + } NavigationLinkWithTitle("Button") { ButtonPreview() } @@ -29,6 +38,9 @@ struct App: View { NavigationLinkWithTitle("Loading") { LoadingPreview() } + NavigationLinkWithTitle("Progress Bar") { + ProgressBarPreview() + } NavigationLinkWithTitle("Modal (Bottom)") { BottomModalPreview() } @@ -41,6 +53,9 @@ struct App: View { NavigationLinkWithTitle("Segmented Control") { SegmentedControlPreview() } + NavigationLinkWithTitle("Slider") { + SliderPreview() + } NavigationLinkWithTitle("Text Input") { TextInputPreviewPreview() } diff --git a/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift index 3312394f..e12bb3e4 100644 --- a/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift +++ b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift @@ -6,8 +6,8 @@ struct AlertButtonsOrientationCalculator { case horizontal } - private static let primaryButton = UKButton() - private static let secondaryButton = UKButton() + private static let primaryButton = UKButton(model: .init()) + private static let secondaryButton = UKButton(model: .init()) private init() {} diff --git a/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift index 3fdbd192..5999bfc2 100644 --- a/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift +++ b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift @@ -8,17 +8,17 @@ public struct AlertVM: ComponentVM { /// The message of the alert. public var message: String? - /// The modal that defines the appearance properties for a primary button in the alert. + /// The model that defines the appearance properties for a primary button in the alert. /// /// If it is `nil`, the primary button will not be displayed. public var primaryButton: AlertButtonVM? - /// The modal that defines the appearance properties for a secondary button in the alert. + /// The model that defines the appearance properties for a secondary button in the alert. /// /// If it is `nil`, the secondary button will not be displayed. public var secondaryButton: AlertButtonVM? - /// The background color of the modal. + /// The background color of the alert. public var backgroundColor: UniversalColor? /// The border thickness of the alert. @@ -26,27 +26,27 @@ public struct AlertVM: ComponentVM { /// Defaults to `.small`. public var borderWidth: BorderWidth = .small - /// A Boolean value indicating whether the modal should close when tapping on the overlay. + /// A Boolean value indicating whether the alert should close when tapping on the overlay. /// /// Defaults to `false`. public var closesOnOverlayTap: Bool = false - /// The padding applied to the modal's content area. + /// The padding applied to the alert's content area. /// /// Defaults to a padding value of `16` for all sides. public var contentPaddings: Paddings = .init(padding: 16) - /// The corner radius of the modal. + /// The corner radius of the alert. /// /// Defaults to `.medium`. public var cornerRadius: ContainerRadius = .medium - /// The style of the overlay displayed behind the modal. + /// The style of the overlay displayed behind the alert. /// /// Defaults to `.dimmed`. public var overlayStyle: ModalOverlayStyle = .dimmed - /// The transition duration of the modal's appearance and dismissal animations. + /// The transition duration of the alert's appearance and dismissal animations. /// /// Defaults to `.fast`. public var transition: ModalTransition = .fast diff --git a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift index beae4539..a1731720 100644 --- a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift +++ b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift @@ -48,9 +48,9 @@ public class UKAlertController: UKCenterModalController { /// The label used to display the subtitle or message of the alert. public let subtitleLabel = UILabel() /// The button representing the primary action in the alert. - public let primaryButton = UKButton() + public let primaryButton = UKButton(model: .init()) /// The button representing the secondary action in the alert. - public let secondaryButton = UKButton() + public let secondaryButton = UKButton(model: .init()) /// A stack view that arranges the primary and secondary buttons. public let buttonsStackView = UIStackView() 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/Models/AvatarImageSource.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift new file mode 100644 index 00000000..fb0b0a4f --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Defines the source options for an avatar image. +extension AvatarVM { + public enum ImageSource: Hashable { + /// An image loaded from a remote URL. + /// + /// - Parameter url: The URL pointing to the remote image resource. + /// - Note: Ensure the URL is valid and accessible to prevent errors during image fetching. + case remote(_ url: URL) + + /// An image loaded from a local asset. + /// + /// - Parameters: + /// - name: The name of the local image asset. + /// - bundle: The bundle containing the image resource. Defaults to `nil`, which uses the main bundle. + case local(_ name: String, _ bundle: Bundle? = nil) + } +} diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift new file mode 100644 index 00000000..18f017e2 --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Defines the placeholder options for an avatar. +/// +/// It is used to provide a fallback or alternative visual representation when an image is not provided or fails to load. +extension AvatarVM { + public enum Placeholder: Hashable { + /// A placeholder that displays a text string. + /// + /// This option is typically used to show initials, names, or other textual representations. + /// + /// - Parameter text: The text to display as the placeholder. + /// - Note: Only 3 first letters are displayed. + case text(String) + + /// A placeholder that displays an SF Symbol. + /// + /// This option allows you to use Apple's system-provided icons as placeholders. + /// + /// - Parameter name: The name of the SF Symbol to display. + /// - Note: Ensure that the SF Symbol name corresponds to an existing icon in the system's symbol library. + case sfSymbol(_ name: String) + + /// A placeholder that displays a custom icon from an asset catalog. + /// + /// This option allows you to use icons from your app's bundled resources or a specified bundle. + /// + /// - Parameters: + /// - name: The name of the icon asset to use as the placeholder. + /// - bundle: The bundle containing the icon resource. Defaults to `nil`, which uses the main bundle. + case icon(_ name: String, _ bundle: Bundle? = nil) + } +} diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift new file mode 100644 index 00000000..2d7418b0 --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift @@ -0,0 +1,131 @@ +import UIKit + +/// A model that defines the appearance properties for an avatar component. +public struct AvatarVM: ComponentVM, Hashable { + /// The color of the placeholder. + public var color: ComponentColor? + + /// The corner radius of the avatar. + /// + /// Defaults to `.full`. + public var cornerRadius: ComponentRadius = .full + + /// The source of the image to be displayed. + public var imageSrc: ImageSource? + + /// The placeholder that is displayed if the image is not provided or fails to load. + public var placeholder: Placeholder = .icon("avatar_placeholder", Bundle.module) + + /// 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() {} +} + +// MARK: - Helpers + +extension AvatarVM { + var preferredSize: 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 imageURL: URL? { + switch self.imageSrc { + case .remote(let url): + return url + case .local, .none: + return nil + } + } +} + +extension AvatarVM { + func placeholderImage(for size: CGSize) -> UIImage { + switch self.placeholder { + case .text(let value): + return self.drawName(value, size: size) + case .icon(let name, let bundle): + let icon = UIImage(named: name, in: bundle, with: nil) + return self.drawIcon(icon, size: size) + case .sfSymbol(let name): + let systemIcon = UIImage(systemName: name) + return self.drawIcon(systemIcon, size: size) + } + } + + private var placeholderFont: UIFont { + switch self.size { + case .small: + return UniversalFont.smButton.uiFont + case .medium: + return UniversalFont.mdButton.uiFont + case .large: + return UniversalFont.lgButton.uiFont + } + } + + private func iconSize(for avatarSize: CGSize) -> CGSize { + let minSide = min(avatarSize.width, avatarSize.height) + let iconSize = minSide / 3 * 2 + return .init(width: iconSize, height: iconSize) + } + + private var placeholderBackgroundColor: UIColor { + return (self.color?.background ?? .content1).uiColor + } + + private var placeholderForegroundColor: UIColor { + return (self.color?.main ?? .foreground).uiColor + } + + private func drawIcon(_ icon: UIImage?, size: CGSize) -> UIImage { + let iconSize = self.iconSize(for: size) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + self.placeholderBackgroundColor.setFill() + UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill() + + icon?.withTintColor(self.placeholderForegroundColor, renderingMode: .alwaysOriginal).draw(in: CGRect( + x: (size.width - iconSize.width) / 2, + y: (size.height - iconSize.height) / 2, + width: iconSize.width, + height: iconSize.height + )) + } + } + + private func drawName(_ name: String, size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + self.placeholderBackgroundColor.setFill() + UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill() + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes = [ + NSAttributedString.Key.font: self.placeholderFont, + NSAttributedString.Key.paragraphStyle: paragraphStyle, + NSAttributedString.Key.foregroundColor: self.placeholderForegroundColor + ] + + let yOffset = (size.height - self.placeholderFont.lineHeight) / 2 + String(name.prefix(3)).draw( + with: CGRect(x: 0, y: yOffset, width: size.width, height: size.height), + options: .usesLineFragmentOrigin, + attributes: attributes, + context: nil + ) + } + } +} 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/UIKit/UKAvatar.swift b/Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift new file mode 100644 index 00000000..5b614f91 --- /dev/null +++ b/Sources/ComponentsKit/Components/Avatar/UIKit/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) { + 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/AvatarGroup/Models/AvatarGroupVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift new file mode 100644 index 00000000..1d271844 --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift @@ -0,0 +1,113 @@ +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 itemSize: 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 spacing: CGFloat { + return -self.itemSize.width / 3 + } + + var numberOfHiddenAvatars: Int { + return max(0, self.items.count - self.maxVisibleAvatars) + } +} + +// MARK: - UIKit Helpers + +extension AvatarGroupVM { + var avatarHeight: CGFloat { + return self.itemSize.height - self.padding * 2 + } + var avatarWidth: CGFloat { + return self.itemSize.width - self.padding * 2 + } +} 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/SwiftUI/SUAvatarGroup.swift b/Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/SUAvatarGroup.swift new file mode 100644 index 00000000..a089b174 --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/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.spacing) { + 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.itemSize.width, + height: self.model.itemSize.height + ) + } + } + } +} diff --git a/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift new file mode 100644 index 00000000..c37c9d9c --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift @@ -0,0 +1,90 @@ +import AutoLayout +import UIKit + +final class AvatarContainer: UIView { + // MARK: - Properties + + let avatar: UKAvatar + var groupVM: AvatarGroupVM + var avatarConstraints = LayoutConstraints() + + // MARK: - Initialization + + init(avatarVM: AvatarVM, groupVM: AvatarGroupVM) { + self.avatar = UKAvatar(model: avatarVM) + self.groupVM = groupVM + + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + func setup() { + self.addSubview(self.avatar) + } + + // MARK: - Style + + func style() { + Self.Style.mainView(self, model: self.groupVM) + } + + // MARK: - Layout + + func layout() { + self.avatarConstraints = .merged { + self.avatar.allEdges(self.groupVM.padding) + self.avatar.height(self.groupVM.avatarHeight) + self.avatar.width(self.groupVM.avatarWidth) + } + + self.avatarConstraints.height?.priority = .defaultHigh + self.avatarConstraints.width?.priority = .defaultHigh + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.groupVM.cornerRadius.value(for: self.bounds.height) + } + + // MARK: - Update + + func update(avatarVM: AvatarVM, groupVM: AvatarGroupVM) { + let oldModel = self.groupVM + self.groupVM = groupVM + + if self.groupVM.size != oldModel.size { + self.avatarConstraints.top?.constant = groupVM.padding + self.avatarConstraints.leading?.constant = groupVM.padding + self.avatarConstraints.bottom?.constant = -groupVM.padding + self.avatarConstraints.trailing?.constant = -groupVM.padding + self.avatarConstraints.height?.constant = groupVM.avatarHeight + self.avatarConstraints.width?.constant = groupVM.avatarWidth + + self.setNeedsLayout() + } + + self.avatar.model = avatarVM + self.style() + } +} + +// MARK: - Style Helpers + +extension AvatarContainer { + fileprivate enum Style { + static func mainView(_ view: UIView, model: AvatarGroupVM) { + view.backgroundColor = model.borderColor.uiColor + view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height) + } + } +} diff --git a/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift new file mode 100644 index 00000000..dabc9411 --- /dev/null +++ b/Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift @@ -0,0 +1,121 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a group of avatars. +open class UKAvatarGroup: UIView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: AvatarGroupVM { + didSet { + self.update(oldValue) + } + } + + // MARK: - Subviews + + /// The stack view that contains avatars. + public var stackView = UIStackView() + + // MARK: - Initializers + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: AvatarGroupVM) { + self.model = model + + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.stackView) + self.model.identifiedAvatarVMs.forEach { _, avatarVM in + self.stackView.addArrangedSubview(AvatarContainer( + avatarVM: avatarVM, + groupVM: self.model + )) + } + } + + // MARK: - Style + + private func style() { + Self.Style.stackView(self.stackView, model: self.model) + } + + // MARK: - Layout + + private func layout() { + self.stackView.centerVertically() + self.stackView.centerHorizontally() + + self.stackView.topAnchor.constraint( + greaterThanOrEqualTo: self.topAnchor + ).isActive = true + self.stackView.bottomAnchor.constraint( + lessThanOrEqualTo: self.bottomAnchor + ).isActive = true + self.stackView.leadingAnchor.constraint( + greaterThanOrEqualTo: self.leadingAnchor + ).isActive = true + self.stackView.trailingAnchor.constraint( + lessThanOrEqualTo: self.trailingAnchor + ).isActive = true + } + + // MARK: - Update + + public func update(_ oldModel: AvatarGroupVM) { + guard self.model != oldModel else { return } + + let avatarVMs = self.model.identifiedAvatarVMs.map(\.1) + self.addOrRemoveArrangedSubviews(newNumber: avatarVMs.count) + + self.stackView.arrangedSubviews.enumerated().forEach { index, view in + (view as? AvatarContainer)?.update( + avatarVM: avatarVMs[index], + groupVM: self.model + ) + } + self.style() + } + + private func addOrRemoveArrangedSubviews(newNumber: Int) { + let diff = newNumber - self.stackView.arrangedSubviews.count + if diff > 0 { + for _ in 0 ..< diff { + self.stackView.addArrangedSubview(AvatarContainer(avatarVM: .init(), groupVM: self.model)) + } + } else if diff < 0 { + for _ in 0 ..< abs(diff) { + if let view = self.stackView.arrangedSubviews.first { + self.stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + } + } + } +} + +// MARK: - Style Helpers + +extension UKAvatarGroup { + fileprivate enum Style { + static func stackView(_ view: UIStackView, model: Model) { + view.axis = .horizontal + view.spacing = model.spacing + view.distribution = .equalCentering + } + } +} diff --git a/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift b/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift new file mode 100644 index 00000000..5cc3cdb8 --- /dev/null +++ b/Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +extension BadgeVM { + /// Defines the available visual styles for a badge. + public enum Style: Equatable { + case filled + case light + } +} diff --git a/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift new file mode 100644 index 00000000..4232dff6 --- /dev/null +++ b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// A model that defines the appearance properties for a badge component. +public struct BadgeVM: ComponentVM { + /// The text displayed on the badge. + public var title: String = "" + + /// The color of the badge. + public var color: ComponentColor? + + /// The visual style of the badge. + /// + /// Defaults to `.filled`. + public var style: Style = .filled + + /// The font used for the badge's text. + /// + /// Defaults to `.smButton`. + public var font: UniversalFont = .smButton + + /// The corner radius of the badge. + /// + /// Defaults to `.medium`. + public var cornerRadius: ComponentRadius = .medium + + /// Paddings for the badge. + public var paddings: Paddings = .init(horizontal: 10, vertical: 8) + + /// Initializes a new instance of `BadgeVM` with default values. + public init() {} +} + +// MARK: Helpers + +extension BadgeVM { + /// Returns the background color of the badge based on its style. + var backgroundColor: UniversalColor { + switch self.style { + case .filled: + return self.color?.main ?? .content2 + case .light: + return self.color?.background ?? .content1 + } + } + + /// Returns the foreground color of the badge based on its style. + var foregroundColor: UniversalColor { + switch self.style { + case .filled: + return self.color?.contrast ?? .foreground + case .light: + return self.color?.main ?? .foreground + } + } +} + +// MARK: UIKit Helpers + +extension BadgeVM { + func shouldUpdateLayout(_ oldModel: Self?) -> Bool { + return self.font != oldModel?.font + || self.paddings != oldModel?.paddings + } +} diff --git a/Sources/ComponentsKit/Components/Badge/SUBadge.swift b/Sources/ComponentsKit/Components/Badge/SUBadge.swift new file mode 100644 index 00000000..216af4b5 --- /dev/null +++ b/Sources/ComponentsKit/Components/Badge/SUBadge.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// A SwiftUI component that displays a badge. +public struct SUBadge: View { + // MARK: Properties + + /// A model that defines the appearance properties. + public var model: BadgeVM + + // MARK: Initialization + + /// Initializes a new instance of `SUBadge`. + /// - Parameter model: A model that defines the appearance properties. + public init(model: BadgeVM) { + self.model = model + } + + // MARK: Body + + public var body: some View { + Text(self.model.title) + .font(self.model.font.font) + .padding(self.model.paddings.edgeInsets) + .foregroundStyle(self.model.foregroundColor.color) + .background(self.model.backgroundColor.color) + .clipShape( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) + ) + } +} diff --git a/Sources/ComponentsKit/Components/Badge/UKBadge.swift b/Sources/ComponentsKit/Components/Badge/UKBadge.swift new file mode 100644 index 00000000..d6e100db --- /dev/null +++ b/Sources/ComponentsKit/Components/Badge/UKBadge.swift @@ -0,0 +1,127 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a badge. +open class UKBadge: UIView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: BadgeVM { + didSet { + self.update(oldValue) + } + } + + private var titleLabelConstraints: LayoutConstraints = .init() + + // MARK: - Subviews + + /// A label that displays the title from the model. + public var titleLabel = UILabel() + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.sizeThatFits(UIView.layoutFittingExpandedSize) + } + + // MARK: - Initialization + + /// Initializes a new instance of `UKBadge`. + /// - Parameter model: A model that defines the appearance properties for the badge. + public init(model: BadgeVM) { + self.model = model + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.titleLabel) + } + + // MARK: - Style + + private func style() { + Self.Style.mainView(self, model: self.model) + Self.Style.titleLabel(self.titleLabel, model: self.model) + } + + // MARK: - Layout + + private func layout() { + self.titleLabelConstraints = .merged { + self.titleLabel.top(self.model.paddings.top) + self.titleLabel.leading(self.model.paddings.leading) + self.titleLabel.bottom(self.model.paddings.bottom) + self.titleLabel.trailing(self.model.paddings.trailing) + } + + self.titleLabelConstraints.allConstraints.forEach { $0?.priority = .defaultHigh } + } + + open override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) + } + + // MARK: - Update + + public func update(_ oldModel: BadgeVM) { + guard self.model != oldModel else { return } + + self.style() + if self.model.shouldUpdateLayout(oldModel) { + self.titleLabelConstraints.leading?.constant = self.model.paddings.leading + self.titleLabelConstraints.top?.constant = self.model.paddings.top + self.titleLabelConstraints.bottom?.constant = -self.model.paddings.bottom + self.titleLabelConstraints.trailing?.constant = -self.model.paddings.trailing + + self.invalidateIntrinsicContentSize() + self.setNeedsLayout() + } + } + + // MARK: - UIView Methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let contentSize = self.titleLabel.sizeThatFits(size) + + let totalWidthPadding = self.model.paddings.leading + self.model.paddings.trailing + let totalHeightPadding = self.model.paddings.top + self.model.paddings.bottom + + let width = contentSize.width + totalWidthPadding + let height = contentSize.height + totalHeightPadding + + return CGSize( + width: min(width, size.width), + height: min(height, size.height) + ) + } +} + +// MARK: - Style Helpers + +extension UKBadge { + fileprivate enum Style { + static func mainView(_ view: UIView, model: BadgeVM) { + view.backgroundColor = model.backgroundColor.uiColor + view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height) + } + static func titleLabel(_ label: UILabel, model: BadgeVM) { + label.textAlignment = .center + label.text = model.title + label.font = model.font.uiFont + label.textColor = model.foregroundColor.uiColor + } + } +} diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index d2b35b4f..c7f573ce 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -47,7 +47,7 @@ open class UKButton: UIView, UKComponent { /// - model: A model that defines the appearance properties. /// - action: A closure that is triggered when the button is tapped. public init( - model: ButtonVM = .init(), + model: ButtonVM, action: @escaping () -> Void = {} ) { self.model = model diff --git a/Sources/ComponentsKit/Components/Card/SUCard.swift b/Sources/ComponentsKit/Components/Card/SUCard.swift index 3c0f57e6..ad6be3dd 100644 --- a/Sources/ComponentsKit/Components/Card/SUCard.swift +++ b/Sources/ComponentsKit/Components/Card/SUCard.swift @@ -27,7 +27,7 @@ public struct SUCard: View { /// - model: A model that defines the appearance properties. /// - content: The content that is displayed in the card. public init( - model: CardVM, + model: CardVM = .init(), content: @escaping () -> Content ) { self.model = model diff --git a/Sources/ComponentsKit/Components/Card/UKCard.swift b/Sources/ComponentsKit/Components/Card/UKCard.swift index 7e379647..42a54629 100644 --- a/Sources/ComponentsKit/Components/Card/UKCard.swift +++ b/Sources/ComponentsKit/Components/Card/UKCard.swift @@ -44,7 +44,10 @@ open class UKCard: UIView, UKComponent { /// - Parameters: /// - model: A model that defines the appearance properties. /// - content: The content that is displayed in the card. - public init(model: CardVM, content: @escaping Content) { + public init( + model: CardVM = .init(), + content: @escaping Content + ) { self.model = model self.content = content() diff --git a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift index 48399011..2830f296 100644 --- a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift +++ b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift @@ -17,7 +17,7 @@ public struct SUCountdown: View { /// Initializer. /// - Parameters: /// - model: A model that defines the appearance properties. - public init(model: CountdownVM = .init()) { + public init(model: CountdownVM) { self.model = model } 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 diff --git a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift new file mode 100644 index 00000000..08c0639c --- /dev/null +++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift @@ -0,0 +1,10 @@ +import Foundation + +extension ProgressBarVM { + /// Defines the visual styles for the progress bar component. + public enum Style { + case light + case filled + case striped + } +} diff --git a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift new file mode 100644 index 00000000..902293e9 --- /dev/null +++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift @@ -0,0 +1,178 @@ +import SwiftUI + +/// A model that defines the appearance properties for a a progress bar component. +public struct ProgressBarVM: ComponentVM { + /// The color of the progress bar. + /// + /// Defaults to `.accent`. + public var color: ComponentColor = .accent + + /// The visual style of the progress bar component. + /// + /// Defaults to `.striped`. + public var style: Style = .striped + + /// The size of the progress bar. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The minimum value of the progress bar. + public var minValue: CGFloat = 0 + + /// The maximum value of the progress bar. + public var maxValue: CGFloat = 100 + + /// The corner radius of the progress bar. + /// + /// Defaults to `.medium`. + public var cornerRadius: ComponentRadius = .medium + + /// Initializes a new instance of `ProgressBarVM` with default values. + public init() {} +} + +// MARK: - Shared Helpers + +extension ProgressBarVM { + var backgroundHeight: CGFloat { + switch self.style { + case .light: + switch size { + case .small: + return 4 + case .medium: + return 8 + case .large: + return 12 + } + case .filled, .striped: + switch self.size { + case .small: + return 20 + case .medium: + return 32 + case .large: + return 42 + } + } + } + + var progressHeight: CGFloat { + return self.backgroundHeight - self.progressPadding * 2 + } + + func cornerRadius(for height: CGFloat) -> CGFloat { + switch self.cornerRadius { + case .none: + return 0 + case .small: + return height / 3.5 + case .medium: + return height / 3.0 + case .large: + return height / 2.5 + case .full: + return height / 2.0 + case .custom(let value): + return min(value, height / 2) + } + } + + var animationDuration: TimeInterval { + return 0.2 + } + + var progressPadding: CGFloat { + switch self.style { + case .light: + return 0 + case .filled, .striped: + return 3 + } + } + + var lightBarSpacing: CGFloat { + return 4 + } + + var backgroundColor: UniversalColor { + switch style { + case .light: + return self.color.background + case .filled, .striped: + return self.color.main + } + } + + var barColor: UniversalColor { + switch style { + case .light: + return self.color.main + case .filled, .striped: + return self.color.contrast + } + } + + private func stripesCGPath(in rect: CGRect) -> CGMutablePath { + let stripeWidth: CGFloat = 2 + let stripeSpacing: CGFloat = 4 + let stripeAngle: Angle = .degrees(135) + + let path = CGMutablePath() + let step = stripeWidth + stripeSpacing + let radians = stripeAngle.radians + let dx = rect.height * tan(radians) + for x in stride(from: dx, through: rect.width + rect.height, by: step) { + let topLeft = CGPoint(x: x, y: 0) + let topRight = CGPoint(x: x + stripeWidth, y: 0) + let bottomLeft = CGPoint(x: x + dx, y: rect.height) + let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height) + path.move(to: topLeft) + path.addLine(to: topRight) + path.addLine(to: bottomRight) + path.addLine(to: bottomLeft) + path.closeSubpath() + } + return path + } +} + +extension ProgressBarVM { + func progress(for currentValue: CGFloat) -> CGFloat { + let range = self.maxValue - self.minValue + guard range > 0 else { return 0 } + let normalized = (currentValue - self.minValue) / range + return max(0, min(1, normalized)) + } +} + +// MARK: - UIKit Helpers + +extension ProgressBarVM { + func stripesBezierPath(in rect: CGRect) -> UIBezierPath { + return UIBezierPath(cgPath: self.stripesCGPath(in: rect)) + } + + func shouldUpdateLayout(_ oldModel: Self) -> Bool { + return self.style != oldModel.style || self.size != oldModel.size + } +} + +// MARK: - SwiftUI Helpers + +extension ProgressBarVM { + func stripesPath(in rect: CGRect) -> Path { + return Path(self.stripesCGPath(in: rect)) + } +} + +// MARK: - Validation + +extension ProgressBarVM { + func validateMinMaxValues() { + if self.minValue > self.maxValue { + assertionFailure("Min value must be less than max value") + } + } +} diff --git a/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift new file mode 100644 index 00000000..756d49a4 --- /dev/null +++ b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// A SwiftUI component that displays a progress bar. +public struct SUProgressBar: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: ProgressBarVM + /// A binding to control the current value. + @Binding public var currentValue: CGFloat + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - currentValue: A binding to the current value. + /// - model: A model that defines the appearance properties. + public init( + currentValue: Binding, + model: ProgressBarVM = .init() + ) { + self._currentValue = currentValue + self.model = model + } + + // MARK: - Body + + public var body: some View { + GeometryReader { geometry in + switch self.model.style { + case .light: + HStack(spacing: self.model.lightBarSpacing) { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) + .foregroundStyle(self.model.barColor.color) + .frame(width: geometry.size.width * self.progress) + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) + .foregroundStyle(self.model.backgroundColor.color) + .frame(width: geometry.size.width * (1 - self.progress)) + } + + case .filled: + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) + .foregroundStyle(self.model.color.main.color) + .frame(width: geometry.size.width) + + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) + .foregroundStyle(self.model.color.contrast.color) + .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) + .padding(.vertical, self.model.progressPadding) + .padding(.horizontal, self.model.progressPadding) + } + + case .striped: + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) + .foregroundStyle(self.model.color.main.color) + .frame(width: geometry.size.width) + + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) + .foregroundStyle(self.model.color.contrast.color) + .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) + .padding(.vertical, self.model.progressPadding) + .padding(.horizontal, self.model.progressPadding) + + StripesShape(model: self.model) + .foregroundStyle(self.model.color.main.color) + .cornerRadius(self.model.cornerRadius(for: self.model.progressHeight)) + .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) + .padding(.vertical, self.model.progressPadding) + .padding(.horizontal, self.model.progressPadding) + } + } + } + .animation( + Animation.linear(duration: self.model.animationDuration), + value: self.progress + ) + .frame(height: self.model.backgroundHeight) + .onAppear { + self.model.validateMinMaxValues() + } + } +} + +// MARK: - Helpers + +struct StripesShape: Shape, @unchecked Sendable { + var model: ProgressBarVM + + func path(in rect: CGRect) -> Path { + self.model.stripesPath(in: rect) + } +} diff --git a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift new file mode 100644 index 00000000..3a14cc31 --- /dev/null +++ b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift @@ -0,0 +1,218 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a progress bar. +open class UKProgressBar: UIView, UKComponent { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: ProgressBarVM { + didSet { + self.update(oldValue) + } + } + + /// The current progress value for the progress bar. + public var currentValue: CGFloat { + didSet { + self.updateProgressWidthAndAppearance() + } + } + + // MARK: - Subviews + + /// The background view of the progress bar. + public let backgroundView = UIView() + + /// The view that displays the current progress. + public let progressView = UIView() + + /// A shape layer used to render striped styling. + public let stripedLayer = CAShapeLayer() + + // MARK: - Layout Constraints + + private var backgroundViewLightLeadingConstraint: NSLayoutConstraint? + private var backgroundViewFilledLeadingConstraint: NSLayoutConstraint? + private var progressViewConstraints: LayoutConstraints = .init() + + // MARK: - Private Properties + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.sizeThatFits(UIView.layoutFittingExpandedSize) + } + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - initialValue: The initial progress value. Defaults to `0`. + /// - model: A model that defines the appearance properties. + public init( + initialValue: CGFloat = 0, + model: ProgressBarVM = .init() + ) { + self.currentValue = initialValue + self.model = model + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.backgroundView) + self.addSubview(self.progressView) + + self.progressView.layer.addSublayer(self.stripedLayer) + } + + // MARK: - Style + + private func style() { + Self.Style.backgroundView(self.backgroundView, model: self.model) + Self.Style.progressView(self.progressView, model: self.model) + Self.Style.stripedLayer(self.stripedLayer, model: self.model) + } + + // MARK: - Layout + + private func layout() { + self.backgroundView.vertically() + self.backgroundView.trailing() + self.backgroundViewLightLeadingConstraint = self.backgroundView.after( + self.progressView, + padding: self.model.lightBarSpacing + ).leading + self.backgroundViewFilledLeadingConstraint = self.backgroundView.leading().leading + + switch self.model.style { + case .light: + self.backgroundViewFilledLeadingConstraint?.isActive = false + case .filled, .striped: + self.backgroundViewLightLeadingConstraint?.isActive = false + } + + self.progressViewConstraints = .merged { + self.progressView.leading(self.model.progressPadding) + self.progressView.vertically(self.model.progressPadding) + self.progressView.width(0) + } + } + + // MARK: - Update + + public func update(_ oldModel: ProgressBarVM) { + guard self.model != oldModel else { return } + + self.style() + + if self.model.shouldUpdateLayout(oldModel) { + switch self.model.style { + case .light: + self.backgroundViewFilledLeadingConstraint?.isActive = false + self.backgroundViewLightLeadingConstraint?.isActive = true + case .filled, .striped: + self.backgroundViewLightLeadingConstraint?.isActive = false + self.backgroundViewFilledLeadingConstraint?.isActive = true + } + + self.progressViewConstraints.leading?.constant = self.model.progressPadding + self.progressViewConstraints.top?.constant = self.model.progressPadding + self.progressViewConstraints.bottom?.constant = -self.model.progressPadding + + self.invalidateIntrinsicContentSize() + self.setNeedsLayout() + } + + UIView.performWithoutAnimation { + self.updateProgressWidthAndAppearance() + } + } + + private func updateProgressWidthAndAppearance() { + if self.model.style == .striped { + self.stripedLayer.frame = self.bounds + self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath + } + + let totalHorizontalPadding: CGFloat = switch self.model.style { + case .light: self.model.lightBarSpacing + case .filled, .striped: self.model.progressPadding * 2 + } + let totalWidth = self.bounds.width - totalHorizontalPadding + let progressWidth = totalWidth * self.progress + + self.progressViewConstraints.width?.constant = max(0, progressWidth) + + UIView.animate( + withDuration: self.model.animationDuration, + animations: { + self.layoutIfNeeded() + } + ) + } + + // MARK: - Layout + + open override func layoutSubviews() { + super.layoutSubviews() + + self.backgroundView.layer.cornerRadius = self.model.cornerRadius(for: self.backgroundView.bounds.height) + self.progressView.layer.cornerRadius = self.model.cornerRadius(for: self.progressView.bounds.height) + + self.updateProgressWidthAndAppearance() + + self.model.validateMinMaxValues() + } + + // MARK: - UIView methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let width = self.superview?.bounds.width ?? size.width + return CGSize( + width: min(size.width, width), + height: min(size.height, self.model.backgroundHeight) + ) + } +} + +// MARK: - Style Helpers + +extension UKProgressBar { + fileprivate enum Style { + static func backgroundView(_ view: UIView, model: ProgressBarVM) { + view.backgroundColor = model.backgroundColor.uiColor + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + } + + static func progressView(_ view: UIView, model: ProgressBarVM) { + view.backgroundColor = model.barColor.uiColor + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + view.layer.masksToBounds = true + } + + static func stripedLayer(_ layer: CAShapeLayer, model: ProgressBarVM) { + layer.fillColor = model.color.main.uiColor.cgColor + switch model.style { + case .light, .filled: + layer.isHidden = true + case .striped: + layer.isHidden = false + } + } + } +} diff --git a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift index 768e6472..3ee1a78e 100644 --- a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift @@ -41,7 +41,7 @@ open class UKRadioGroup: UIView, UKComponent, UIGestureRecognizerD /// - onSelectionChange: A closure that is triggered when the selected radio button changes. public init( initialSelectedId: ID? = nil, - model: RadioGroupVM = .init(), + model: RadioGroupVM, onSelectionChange: ((ID?) -> Void)? = nil ) { self.selectedId = initialSelectedId diff --git a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift index 6aab2aad..e95308a7 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift @@ -102,7 +102,7 @@ extension SegmentedControlVM { case .none, .full, .custom: return componentRadius case .small, .medium, .large: - return max(0, componentRadius - self.outerPaddings / 2) + return max(0, componentRadius - self.outerPaddings) } } func preferredFont(for id: ID) -> UniversalFont { diff --git a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift index 15fd9588..eced335a 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift @@ -53,7 +53,7 @@ open class UKSegmentedControl: UIView, UKComponent { /// - onSelectionChange: A closure that is triggered when a selected segment changes. public init( selectedId: ID, - model: SegmentedControlVM = .init(), + model: SegmentedControlVM, onSelectionChange: @escaping (ID) -> Void = { _ in } ) { self.selectedId = selectedId diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift new file mode 100644 index 00000000..c4a70868 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift @@ -0,0 +1,9 @@ +import Foundation + +extension SliderVM { + /// Defines the visual styles for the slider component. + public enum Style { + case light + case striped + } +} diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift new file mode 100644 index 00000000..beb271b9 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// A model that defines the appearance properties for a slider component. +public struct SliderVM: ComponentVM { + /// The color of the slider. + /// + /// Defaults to `.accent`. + public var color: ComponentColor = .accent + + /// The visual style of the slider component. + /// + /// Defaults to `.light`. + public var style: Style = .light + + /// The size of the slider. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The minimum value of the slider. + public var minValue: CGFloat = 0 + + /// The maximum value of the slider. + public var maxValue: CGFloat = 100 + + /// The corner radius of the slider track and handle. + /// + /// Defaults to `.full`. + public var cornerRadius: ComponentRadius = .full + + /// The step value for the slider. + /// + /// Defaults to `1`. + public var step: CGFloat = 1 + + /// Initializes a new instance of `SliderVM` with default values. + public init() {} +} + +// MARK: - Shared Helpers + +extension SliderVM { + var trackHeight: CGFloat { + switch self.size { + case .small: + return 6 + case .medium: + return 12 + case .large: + return 32 + } + } + var handleSize: CGSize { + switch self.size { + case .small, .medium: + return CGSize(width: 20, height: 32) + case .large: + return CGSize(width: 40, height: 40) + } + } + func cornerRadius(for height: CGFloat) -> CGFloat { + switch self.cornerRadius { + case .none: + return 0 + case .small: + return height / 3.5 + case .medium: + return height / 3.0 + case .large: + return height / 2.5 + case .full: + return height / 2.0 + case .custom(let value): + return min(value, height / 2) + } + } + var trackSpacing: CGFloat { + return 4 + } + var handleOverlaySide: CGFloat { + return 12 + } + private func stripesCGPath(in rect: CGRect) -> CGMutablePath { + let stripeWidth: CGFloat = 2 + let stripeSpacing: CGFloat = 4 + let stripeAngle: Angle = .degrees(135) + + let path = CGMutablePath() + let step = stripeWidth + stripeSpacing + let radians = stripeAngle.radians + let dx = rect.height * tan(radians) + + for x in stride(from: rect.width + rect.height, through: dx, by: -step) { + let topLeft = CGPoint(x: x, y: 0) + let topRight = CGPoint(x: x + stripeWidth, y: 0) + let bottomLeft = CGPoint(x: x + dx, y: rect.height) + let bottomRight = CGPoint(x: x + stripeWidth + dx, y: rect.height) + path.move(to: topLeft) + path.addLine(to: topRight) + path.addLine(to: bottomRight) + path.addLine(to: bottomLeft) + path.closeSubpath() + } + + return path + } +} + +extension SliderVM { + func steppedValue(for offset: CGFloat, trackWidth: CGFloat) -> CGFloat { + guard trackWidth > 0 else { return self.minValue } + + let newProgress = offset / trackWidth + + let newValue = self.minValue + newProgress * (self.maxValue - self.minValue) + + if self.step > 0 { + let stepsCount = (newValue / self.step).rounded() + return stepsCount * self.step + } else { + return newValue + } + } +} + +extension SliderVM { + func progress(for currentValue: CGFloat) -> CGFloat { + let range = self.maxValue - self.minValue + guard range > 0 else { return 0 } + let normalized = (currentValue - self.minValue) / range + return max(0, min(1, normalized)) + } +} + +extension SliderVM { + var containerHeight: CGFloat { + max(self.handleSize.height, self.trackHeight) + } + + func sliderWidth(for totalWidth: CGFloat) -> CGFloat { + max(0, totalWidth - self.handleSize.width - 2 * self.trackSpacing) + } + + func barWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat { + let width = self.sliderWidth(for: totalWidth) + return width * progress + } + + func backgroundWidth(for totalWidth: CGFloat, progress: CGFloat) -> CGFloat { + let width = self.sliderWidth(for: totalWidth) + let filled = width * progress + return width - filled + } +} + +// MARK: - UIKit Helpers + +extension SliderVM { + var isHandleOverlayVisible: Bool { + switch self.size { + case .small, .medium: + return false + case .large: + return true + } + } + + func stripesBezierPath(in rect: CGRect) -> UIBezierPath { + return UIBezierPath(cgPath: self.stripesCGPath(in: rect)) + } + + func shouldUpdateLayout(_ oldModel: Self) -> Bool { + return self.size != oldModel.size + } +} + +// MARK: - SwiftUI Helpers + +extension SliderVM { + func stripesPath(in rect: CGRect) -> Path { + Path(self.stripesCGPath(in: rect)) + } +} + +// MARK: - Validation + +extension SliderVM { + func validateMinMaxValues() { + if self.minValue > self.maxValue { + assertionFailure("Min value must be less than max value") + } + } +} diff --git a/Sources/ComponentsKit/Components/Slider/SUSlider.swift b/Sources/ComponentsKit/Components/Slider/SUSlider.swift new file mode 100644 index 00000000..257a1c35 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/SUSlider.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// A SwiftUI component that displays a slider. +public struct SUSlider: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: SliderVM + + /// A binding to control the current value. + @Binding public var currentValue: CGFloat + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - currentValue: A binding to the current value. + /// - model: A model that defines the appearance properties. + public init( + currentValue: Binding, + model: SliderVM = .init() + ) { + self._currentValue = currentValue + self.model = model + } + + // MARK: - Body + + public var body: some View { + GeometryReader { geometry in + let barWidth = self.model.barWidth(for: geometry.size.width, progress: self.progress) + let backgroundWidth = self.model.backgroundWidth(for: geometry.size.width, progress: self.progress) + + HStack(spacing: self.model.trackSpacing) { + // Progress segment + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) + .foregroundStyle(self.model.color.main.color) + .frame(width: barWidth, height: self.model.trackHeight) + + // Handle + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.width)) + .foregroundStyle(self.model.color.main.color) + .frame(width: self.model.handleSize.width, height: self.model.handleSize.height) + .overlay( + Group { + if self.model.size == .large { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleOverlaySide)) + .foregroundStyle(self.model.color.contrast.color) + .frame(width: self.model.handleOverlaySide, height: self.model.handleOverlaySide) + } + } + ) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let totalWidth = geometry.size.width + let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing) + + let currentLeft = barWidth + let newOffset = currentLeft + value.translation.width + + let clampedOffset = min(max(newOffset, 0), sliderWidth) + self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth) + } + ) + + // Remaining segment + Group { + switch self.model.style { + case .light: + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) + .foregroundStyle(self.model.color.background.color) + .frame(width: backgroundWidth) + case .striped: + ZStack { + RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) + .foregroundStyle(.clear) + + StripesShapeSlider(model: self.model) + .foregroundStyle(self.model.color.main.color) + .cornerRadius(self.model.cornerRadius(for: self.model.trackHeight)) + } + .frame(width: backgroundWidth) + } + } + .frame(height: self.model.trackHeight) + } + } + .frame(height: self.model.containerHeight) + .onAppear { + self.model.validateMinMaxValues() + } + } +} +// MARK: - Helpers + +struct StripesShapeSlider: Shape, @unchecked Sendable { + var model: SliderVM + + func path(in rect: CGRect) -> Path { + self.model.stripesPath(in: rect) + } +} diff --git a/Sources/ComponentsKit/Components/Slider/UKSlider.swift b/Sources/ComponentsKit/Components/Slider/UKSlider.swift new file mode 100644 index 00000000..07ce9b96 --- /dev/null +++ b/Sources/ComponentsKit/Components/Slider/UKSlider.swift @@ -0,0 +1,287 @@ +import AutoLayout +import UIKit + +/// A UIKit component that displays a slider. +open class UKSlider: UIView, UKComponent { + // MARK: - Properties + + /// A closure that is triggered when the `currentValue` changes. + public var onValueChange: (CGFloat) -> Void + + /// A model that defines the appearance properties. + public var model: SliderVM { + didSet { + self.update(oldValue) + } + } + + /// The current value of the slider. + public var currentValue: CGFloat { + didSet { + guard self.currentValue != oldValue else { return } + self.updateSliderAppearance() + self.onValueChange(self.currentValue) + } + } + + // MARK: - Subviews + + /// The background view of the slider track. + public let backgroundView = UIView() + + /// The filled portion of the slider track. + public let barView = UIView() + + /// A shape layer used to render striped styling. + public let stripedLayer = CAShapeLayer() + + /// The draggable handle representing the current value. + public let handleView = UIView() + + /// An overlay view for handle for the `large` style. + private let handleOverlayView = UIView() + + // MARK: - Layout Constraints + + private var barViewConstraints = LayoutConstraints() + private var backgroundViewConstraints = LayoutConstraints() + private var handleViewConstraints = LayoutConstraints() + + // MARK: - Private Properties + + private var isDragging = false + + private var progress: CGFloat { + self.model.progress(for: self.currentValue) + } + + // MARK: - UIView Properties + + open override var intrinsicContentSize: CGSize { + return self.sizeThatFits(UIView.layoutFittingExpandedSize) + } + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - initialValue: The initial slider value. Defaults to `0`. + /// - model: A model that defines the appearance properties. + /// - onValueChange: A closure triggered whenever `currentValue` changes. + public init( + initialValue: CGFloat = 0, + model: SliderVM = .init(), + onValueChange: @escaping (CGFloat) -> Void = { _ in } + ) { + self.currentValue = initialValue + self.model = model + self.onValueChange = onValueChange + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.backgroundView) + self.addSubview(self.barView) + self.addSubview(self.handleView) + self.backgroundView.layer.addSublayer(self.stripedLayer) + self.handleView.addSubview(self.handleOverlayView) + } + + // MARK: - Style + + private func style() { + Self.Style.backgroundView(self.backgroundView, model: self.model) + Self.Style.barView(self.barView, model: self.model) + Self.Style.stripedLayer(self.stripedLayer, model: self.model) + Self.Style.handleView(self.handleView, model: self.model) + Self.Style.handleOverlayView(self.handleOverlayView, model: self.model) + } + + // MARK: - Update + + public func update(_ oldModel: SliderVM) { + guard self.model != oldModel else { return } + + self.style() + + if self.model.shouldUpdateLayout(oldModel) { + self.barViewConstraints.height?.constant = self.model.trackHeight + self.backgroundViewConstraints.height?.constant = self.model.trackHeight + self.handleViewConstraints.height?.constant = self.model.handleSize.height + self.handleViewConstraints.width?.constant = self.model.handleSize.width + + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + } + + self.updateSliderAppearance() + } + + private func updateSliderAppearance() { + if self.model.style == .striped { + self.stripedLayer.frame = self.backgroundView.bounds + self.stripedLayer.path = self.model.stripesBezierPath(in: self.stripedLayer.bounds).cgPath + } + + let barWidth = self.model.barWidth(for: self.bounds.width, progress: self.progress) + self.barViewConstraints.width?.constant = barWidth + } + + // MARK: - Layout + + private func layout() { + self.barViewConstraints = .merged { + self.barView.leading() + self.barView.centerVertically() + self.barView.height(self.model.trackHeight) + self.barView.width(0) + } + + self.backgroundViewConstraints = .merged { + self.backgroundView.trailing() + self.backgroundView.centerVertically() + self.backgroundView.height(self.model.trackHeight) + } + + self.handleViewConstraints = .merged { + self.handleView.after(self.barView, padding: self.model.trackSpacing) + self.handleView.before(self.backgroundView, padding: self.model.trackSpacing) + self.handleView.size( + width: self.model.handleSize.width, + height: self.model.handleSize.height + ) + self.handleView.centerVertically() + } + + self.handleOverlayView.center() + self.handleOverlayView.size( + width: self.model.handleOverlaySide, + height: self.model.handleOverlaySide + ) + } + + open override func layoutSubviews() { + super.layoutSubviews() + + self.backgroundView.layer.cornerRadius = + self.model.cornerRadius(for: self.backgroundView.bounds.height) + + self.barView.layer.cornerRadius = + self.model.cornerRadius(for: self.barView.bounds.height) + + self.handleView.layer.cornerRadius = + self.model.cornerRadius(for: self.handleView.bounds.width) + + self.handleOverlayView.layer.cornerRadius = + self.model.cornerRadius(for: self.handleOverlayView.bounds.width) + + self.updateSliderAppearance() + self.model.validateMinMaxValues() + } + + // MARK: - UIView Methods + + open override func sizeThatFits(_ size: CGSize) -> CGSize { + let width = self.superview?.bounds.width ?? size.width + return CGSize( + width: min(size.width, width), + height: min(size.height, self.model.handleSize.height) + ) + } + + open override func touchesBegan( + _ touches: Set, + with event: UIEvent? + ) { + guard let point = touches.first?.location(in: self), + self.hitTest(point, with: nil) == self.handleView + else { return } + + self.isDragging = true + } + + open override func touchesMoved( + _ touches: Set, + with event: UIEvent? + ) { + guard self.isDragging, + let translation = touches.first?.location(in: self) + else { return } + + let totalWidth = self.bounds.width + let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing) + + let newOffset = translation.x - self.model.trackSpacing - self.model.handleSize.width / 2 + let clampedOffset = min(max(newOffset, 0), sliderWidth) + + self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth) + } + + open override func touchesEnded( + _ touches: Set, + with event: UIEvent? + ) { + self.isDragging = false + } + + open override func touchesCancelled( + _ touches: Set, + with event: UIEvent? + ) { + self.isDragging = false + } +} + +// MARK: - Style Helpers + +extension UKSlider { + fileprivate enum Style { + static func backgroundView(_ view: UIView, model: SliderVM) { + view.backgroundColor = model.color.background.uiColor + if model.style == .striped { + view.backgroundColor = .clear + } + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + view.layer.masksToBounds = true + } + + static func barView(_ view: UIView, model: SliderVM) { + view.backgroundColor = model.color.main.uiColor + view.layer.cornerRadius = model.cornerRadius(for: view.bounds.height) + view.layer.masksToBounds = true + } + + static func stripedLayer(_ layer: CAShapeLayer, model: SliderVM) { + layer.fillColor = model.color.main.uiColor.cgColor + switch model.style { + case .light: + layer.isHidden = true + case .striped: + layer.isHidden = false + } + } + + static func handleView(_ view: UIView, model: SliderVM) { + view.backgroundColor = model.color.main.uiColor + view.layer.cornerRadius = model.cornerRadius(for: model.handleSize.width) + view.layer.masksToBounds = true + } + + static func handleOverlayView(_ view: UIView, model: SliderVM) { + view.isVisible = model.isHandleOverlayVisible + view.backgroundColor = model.color.contrast.uiColor + view.layer.cornerRadius = model.cornerRadius(for: model.handleOverlaySide) + } + } +} diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json b/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json new file mode 100644 index 00000000..68cb3fb8 --- /dev/null +++ b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "avatar_placeholder.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg new file mode 100644 index 00000000..6c089d7f --- /dev/null +++ b/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift index 7792ba04..24be0319 100644 --- a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift +++ b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift @@ -100,7 +100,7 @@ public struct UniversalColor: Hashable { self.uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return (red, green, blue, alpha) + return (red * 255, green * 255, blue * 255, alpha) } }