diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift index bc195431..eb8fccea 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift @@ -60,10 +60,10 @@ struct ModalPreviewHelpers { Section("Properties") { Picker("Background Color", selection: self.$model.backgroundColor) { Text("Default").tag(Optional.none) - 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) + Text("Accent Background").tag(UniversalColor.accentBackground) + Text("Success Background").tag(UniversalColor.successBackground) + Text("Warning Background").tag(UniversalColor.warningBackground) + Text("Danger Background").tag(UniversalColor.dangerBackground) } BorderWidthPicker(selection: self.$model.borderWidth) Toggle("Closes On Overlay Tap", isOn: self.$model.closesOnOverlayTap) @@ -203,16 +203,20 @@ Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros ri } static func suBody(body: ContentBody) -> some View { - Group { - switch body { - case .shortText: - Text(self.bodyShortText) - case .longText: - Text(self.bodyLongText) + HStack { + Group { + switch body { + case .shortText: + Text(self.bodyShortText) + case .longText: + Text(self.bodyLongText) + } } + .font(self.bodyFont.font) + .multilineTextAlignment(.leading) + + Spacer() } - .font(self.bodyFont.font) - .multilineTextAlignment(.leading) } static func suFooter( diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift index e791b250..93e3b098 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift @@ -84,10 +84,10 @@ struct AlertPreview: View { Section("Main Properties") { Picker("Background Color", selection: self.$model.backgroundColor) { Text("Default").tag(Optional.none) - 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) + Text("Accent Background").tag(UniversalColor.accentBackground) + Text("Success Background").tag(UniversalColor.successBackground) + Text("Warning Background").tag(UniversalColor.warningBackground) + Text("Danger Background").tag(UniversalColor.dangerBackground) } BorderWidthPicker(selection: self.$model.borderWidth) Toggle("Closes On Overlay Tap", isOn: self.$model.closesOnOverlayTap) @@ -154,7 +154,7 @@ Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros ri } static let initialSecondaryButton = AlertButtonVM { $0.title = SecondaryButtonText.short.rawValue - $0.style = .light + $0.style = .plain } var primaryButtonVMOrDefault: Binding { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift index be023fbc..55b06b51 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift @@ -17,21 +17,17 @@ struct BadgePreview: View { SUBadge(model: self.model) } Form { + ComponentOptionalColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom: 4px").tag(ComponentRadius.custom(4)) + } + Toggle("Enabled", isOn: self.$model.isEnabled) 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)) @@ -40,6 +36,10 @@ struct BadgePreview: View { Text("12px; 10px") .tag(Paddings(top: 10, leading: 12, bottom: 10, trailing: 12)) } + Picker("Style", selection: self.$model.style) { + Text("Filled").tag(BadgeVM.Style.filled) + Text("Light").tag(BadgeVM.Style.light) + } } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift index 88931092..fe441eca 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift @@ -62,7 +62,7 @@ struct BottomModalPreview: View { footer: self.$contentFooter, additionalPickers: { Toggle("Draggable", isOn: self.$model.isDraggable) - Toggle("Hides On Swap", isOn: self.$model.hidesOnSwap) + Toggle("Hides On Swipe", isOn: self.$model.hidesOnSwipe) } ) } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift index c64ae340..1543e844 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift @@ -18,10 +18,10 @@ struct CardPreview: View { Picker("Background Color", selection: self.$model.backgroundColor) { Text("Default").tag(Optional.none) Text("Secondary Background").tag(UniversalColor.secondaryBackground) - 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) + Text("Accent Background").tag(UniversalColor.accentBackground) + Text("Success Background").tag(UniversalColor.successBackground) + Text("Warning Background").tag(UniversalColor.warningBackground) + Text("Danger Background").tag(UniversalColor.dangerBackground) } BorderWidthPicker(selection: self.$model.borderWidth) Picker("Content Paddings", selection: self.$model.contentPaddings) { @@ -37,7 +37,7 @@ struct CardPreview: View { Text("Small").tag(Shadow.small) Text("Medium").tag(Shadow.medium) Text("Large").tag(Shadow.large) - Text("Custom").tag(Shadow.custom(20.0, .zero, ComponentColor.accent.background)) + Text("Custom").tag(Shadow.custom(20.0, .zero, UniversalColor.accentBackground)) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index 40c9edee..ef98cd9d 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -4,11 +4,8 @@ import UIKit struct CircularProgressPreview: View { @State private var model = Self.initialModel - @State private var currentValue: CGFloat = Self.initialValue - private let circularProgress = UKCircularProgress( - model: Self.initialModel - ) + private let circularProgress = UKCircularProgress(model: Self.initialModel) private let timer = Timer .publish(every: 0.5, on: .main, in: .common) @@ -20,58 +17,54 @@ struct CircularProgressPreview: View { self.circularProgress .preview .onAppear { - self.circularProgress.currentValue = Self.initialValue self.circularProgress.model = Self.initialModel } .onChange(of: model) { newModel in self.circularProgress.model = newModel } - .onChange(of: self.currentValue) { newValue in - self.circularProgress.currentValue = newValue - } } PreviewWrapper(title: "SwiftUI") { - SUCircularProgress(currentValue: self.currentValue, model: self.model) + SUCircularProgress(model: self.model) } Form { ComponentColorPicker(selection: self.$model.color) CaptionFontPicker(selection: self.$model.font) + Picker("Line Cap", selection: self.$model.lineCap) { + Text("Rounded").tag(CircularProgressVM.LineCap.rounded) + Text("Square").tag(CircularProgressVM.LineCap.square) + } Picker("Line Width", selection: self.$model.lineWidth) { Text("Default").tag(Optional.none) Text("2").tag(Optional.some(2)) Text("4").tag(Optional.some(4)) Text("8").tag(Optional.some(8)) } - SizePicker(selection: self.$model.size) - Picker("Style", selection: self.$model.style) { - Text("Light").tag(CircularProgressVM.Style.light) - Text("Striped").tag(CircularProgressVM.Style.striped) + Picker("Shape", selection: self.$model.shape) { + Text("Circle").tag(CircularProgressVM.Shape.circle) + Text("Arc").tag(CircularProgressVM.Shape.arc) } + SizePicker(selection: self.$model.size) } .onReceive(self.timer) { _ in - if self.currentValue < self.model.maxValue { + if self.model.currentValue < self.model.maxValue { let step = (self.model.maxValue - self.model.minValue) / 100 - self.currentValue = min( + self.model.currentValue = min( self.model.maxValue, - self.currentValue + CGFloat(Int.random(in: 1...20)) * step + self.model.currentValue + CGFloat(Int.random(in: 1...20)) * step ) } else { - self.currentValue = self.model.minValue + self.model.currentValue = self.model.minValue } - self.model.label = "\(Int(self.currentValue))%" + self.model.label = "\(Int(self.model.currentValue))%" } } } // MARK: - Helpers - private static var initialValue: Double { - return 0.0 - } - private static var initialModel = CircularProgressVM { $0.label = "0%" - $0.style = .light + $0.currentValue = 0.0 } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift index 19589326..b01b6726 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift @@ -4,10 +4,9 @@ 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 progressBar = UKProgressBar(model: Self.initialModel) + private let timer = Timer .publish(every: 0.5, on: .main, in: .common) .autoconnect() @@ -18,7 +17,6 @@ struct ProgressBarPreview: View { self.progressBar .preview .onAppear { - self.progressBar.currentValue = self.currentValue self.progressBar.model = Self.initialModel } .onChange(of: self.model) { newValue in @@ -26,7 +24,7 @@ struct ProgressBarPreview: View { } } PreviewWrapper(title: "SwiftUI") { - SUProgressBar(currentValue: self.$currentValue, model: self.model) + SUProgressBar(model: self.model) } Form { ComponentColorPicker(selection: self.$model.color) @@ -42,25 +40,20 @@ struct ProgressBarPreview: View { } } .onReceive(self.timer) { _ in - if self.currentValue < self.model.maxValue { + if self.model.currentValue < self.model.maxValue { let step = (self.model.maxValue - self.model.minValue) / 100 - self.currentValue = min( + self.model.currentValue = min( self.model.maxValue, - self.currentValue + CGFloat(Int.random(in: 1...20)) * step + self.model.currentValue + CGFloat(Int.random(in: 1...20)) * step ) } else { - self.currentValue = self.model.minValue + self.model.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() } diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index 42d78710..52966511 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -41,15 +41,15 @@ struct App: View { NavigationLinkWithTitle("Loading") { LoadingPreview() } - NavigationLinkWithTitle("Progress Bar") { - ProgressBarPreview() - } NavigationLinkWithTitle("Modal (Bottom)") { BottomModalPreview() } NavigationLinkWithTitle("Modal (Center)") { CenterModalPreview() } + NavigationLinkWithTitle("Progress Bar") { + ProgressBarPreview() + } NavigationLinkWithTitle("Radio Group") { RadioGroupPreview() } diff --git a/README.md b/README.md index 6fa036ec..27c1b1b9 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,12 @@ inputField.resignFirstResponder() ### Styling -**Config** +**Theme** -The library comes with predefined fonts, sizes and colors, but you can change these values to customize the appearance of your app. To do this, alter the config: +The library comes with predefined fonts, sizes and colors, but you can change these values to customize the appearance of your app. To do this, alter the current theme: ```swift -ComponentsKitConfig.shared.update { +Theme.current.update { // Update colors $0.colors.primary = ... @@ -103,12 +103,12 @@ ComponentsKitConfig.shared.update { ``` > [!Note] -> The best place to set up the initial config is in the `func application(_:, didFinishLaunchingWithOptions:) -> Bool` method in your `AppDelegate` or a similar method in `SceneDelegate`. +> The best place to set up the initial theme is in the `func application(_:, didFinishLaunchingWithOptions:) -> Bool` method in your `AppDelegate` or a similar method in `SceneDelegate`. -By altering the config, you can also create *custom themes* for your app. To do this, first create a new instance of a config: +By altering the theme, you can also create *custom themes* for your app. To do this, first create a new instance of a `Theme`: ```swift -let halloweenTheme = ComponentsKitConfig { +let halloweenTheme = Theme { $0.colors.background = .themed( light: .hex("#e38f36"), dark: .hex("#ba5421") @@ -117,25 +117,98 @@ let halloweenTheme = ComponentsKitConfig { } ``` -When the user switches the theme, apply it by assigning it to the `shared` instance: +When the user switches the theme, apply it by assigning it to the `current` instance: ```swift -ComponentsKitConfig.shared = halloweenTheme +Theme.current = halloweenTheme +``` + +**Handling Theme Changes** + +When changing themes dynamically, you may need to **update the UI** to reflect the new theme. Below are approaches for handling this in different environments. + +**SwiftUI** + +For SwiftUI apps, you can use `ThemeChangeObserver` to automatically refresh views when the theme updates. + +```swift +@main +struct Root: App { + var body: some Scene { + WindowGroup { + ThemeChangeObserver { + Content() + } + } + } +} +``` + +We recommend using this helper in the root of your app to redraw everything at once. + +**UIKit** + +For UIKit apps, use the `observeThemeChange(_:)` method to update elements that depend on the properties from the library. + +```swift +override func viewDidLoad() { + super.viewDidLoad() + + style() + + observeThemeChange { [weak self] in + guard let self else { return } + self.style() + } +} + +func style() { + view.backgroundColor = UniversalColor.background.uiColor + button.model = ButtonVM { + $0.title = "Tap me" + $0.color = .accent + } +} +``` + +**Manually Handling Theme Changes** + +If you are not using the built-in helpers, you can listen for theme change notifications and manually update the UI: + +```swift +NotificationCenter.default.addObserver( + self, + selector: #selector(handleThemeChange), + name: Theme.didChangeThemeNotification, + object: nil +) + +@objc private func handleThemeChange() { + view.backgroundColor = UniversalColor.background.uiColor +} +``` + +Don't forget to remove the observer when the view is deallocated: +```swift +deinit { + NotificationCenter.default.removeObserver(self, name: Theme.didChangeThemeNotification, object: nil) +} ``` **Extend Colors** -All colors from the config can be used within the app. For example: +All colors from the theme can be used within the app. For example: ```swift // in UIKit view.backgroundColor = UniversalColor.background.uiColor // in SwiftUI -UniversalColor.background.color +SomeView() + .background(UniversalColor.background.color) ``` -If you want to use additional colors that are not included in the config, you can extend `UniversalColor`: +If you want to use additional colors that are not included in the theme, you can extend `UniversalColor`: ```swift extension UniversalColor { @@ -155,7 +228,7 @@ view.backgroundColor = UniversalColor.special.uiColor **Extend Fonts** -If you want to use additional fonts that are not included in the config, you can extend `UniversalFont`: +If you want to use additional fonts that are not included in the theme, you can extend `UniversalFont`: ```swift extension UniversalFont { diff --git a/Sources/ComponentsKit/Components/Alert/SUAlert.swift b/Sources/ComponentsKit/Components/Alert/SUAlert.swift index 517612c9..741a8ba6 100644 --- a/Sources/ComponentsKit/Components/Alert/SUAlert.swift +++ b/Sources/ComponentsKit/Components/Alert/SUAlert.swift @@ -1,5 +1,92 @@ import SwiftUI +struct AlertContent: View { + @Binding var isPresented: Bool + let model: AlertVM + let primaryAction: (() -> Void)? + let secondaryAction: (() -> Void)? + + var body: some View { + SUCenterModal( + isVisible: self.$isPresented, + model: self.model.modalVM, + header: { + if self.model.message.isNotNil, + let text = self.model.title { + self.title(text) + } + }, + body: { + if let text = self.model.message { + self.message(text) + } else if let text = self.model.title { + self.title(text) + } + }, + footer: { + switch AlertButtonsOrientationCalculator.preferredOrientation(model: model) { + case .horizontal: + HStack(spacing: AlertVM.buttonsSpacing) { + self.button( + model: self.model.secondaryButtonVM, + action: self.secondaryAction + ) + self.button( + model: self.model.primaryButtonVM, + action: self.primaryAction + ) + } + case .vertical: + VStack(spacing: AlertVM.buttonsSpacing) { + self.button( + model: self.model.primaryButtonVM, + action: self.primaryAction + ) + self.button( + model: self.model.secondaryButtonVM, + action: self.secondaryAction + ) + } + } + } + ) + } + + // MARK: - Helpers + + func title(_ text: String) -> some View { + Text(text) + .font(UniversalFont.mdHeadline.font) + .foregroundStyle(UniversalColor.foreground.color) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + + func message(_ text: String) -> some View { + Text(text) + .font(UniversalFont.mdBody.font) + .foregroundStyle(UniversalColor.secondaryForeground.color) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + + func button( + model: ButtonVM?, + action: (() -> Void)? + ) -> some View { + Group { + if let model { + SUButton(model: model) { + action?() + self.isPresented = false + } + } + } + } +} + +// MARK: - Presentation Helpers + extension View { /// A SwiftUI view modifier that presents an alert with a title, message, and up to two action buttons. /// @@ -53,95 +140,95 @@ extension View { transitionDuration: model.transition.value, onDismiss: onDismiss, content: { - SUCenterModal( - isVisible: isPresented, - model: model.modalVM, - header: { - if model.message.isNotNil, - let title = model.title { - AlertTitle(text: title) - } - }, - body: { - if let message = model.message { - AlertMessage(text: message) - } else if let title = model.title { - AlertTitle(text: title) - } - }, - footer: { - switch AlertButtonsOrientationCalculator.preferredOrientation(model: model) { - case .horizontal: - HStack(spacing: AlertVM.buttonsSpacing) { - AlertButton( - isAlertPresented: isPresented, - model: model.secondaryButtonVM, - action: secondaryAction - ) - AlertButton( - isAlertPresented: isPresented, - model: model.primaryButtonVM, - action: primaryAction - ) - } - case .vertical: - VStack(spacing: AlertVM.buttonsSpacing) { - AlertButton( - isAlertPresented: isPresented, - model: model.primaryButtonVM, - action: primaryAction - ) - AlertButton( - isAlertPresented: isPresented, - model: model.secondaryButtonVM, - action: secondaryAction - ) - } - } - } + AlertContent( + isPresented: isPresented, + model: model, + primaryAction: primaryAction, + secondaryAction: secondaryAction ) } ) } -} - -// MARK: - Helpers - -private struct AlertTitle: View { - let text: String - - var body: some View { - Text(self.text) - .font(UniversalFont.mdHeadline.font) - .foregroundStyle(UniversalColor.foreground.color) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } -} -private struct AlertMessage: View { - let text: String - - var body: some View { - Text(self.text) - .font(UniversalFont.mdBody.font) - .foregroundStyle(UniversalColor.secondaryForeground.color) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } -} - -private struct AlertButton: View { - @Binding var isAlertPresented: Bool - let model: ButtonVM? - let action: (() -> Void)? - - var body: some View { - if let model { - SUButton(model: model) { - self.action?() - self.isAlertPresented = false + /// A SwiftUI view modifier that presents an alert with a title, message, and up to two action buttons. + /// + /// All actions in an alert dismiss the alert after the action runs. If no actions are present, a standard “OK” action is included. + /// + /// - Parameters: + /// - isPresented: A binding that determines whether the alert is presented. + /// - item: A binding to an optional `Item` that determines whether the alert is presented. + /// When `item` is `nil`, the alert is hidden. + /// - primaryAction: An optional closure executed when the primary button is tapped. + /// - secondaryAction: An optional closure executed when the secondary button is tapped. + /// - onDismiss: An optional closure executed when the alert is dismissed. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct AlertData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: AlertData? + /// private let items: [AlertData] = [ + /// AlertData(text: "data 1"), + /// AlertData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Alert") { + /// selectedItem = item + /// } + /// } + /// .suAlert( + /// item: $selectedItem, + /// model: { data in + /// return AlertVM { + /// $0.title = "Data Preview" + /// $0.message = data.text + /// } + /// }, + /// onDismiss: { + /// print("Alert dismissed") + /// } + /// ) + /// } + /// } + /// ``` + public func suAlert( + item: Binding, + model: @escaping (Item) -> AlertVM, + primaryAction: ((Item) -> Void)? = nil, + secondaryAction: ((Item) -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) -> some View { + return self.modal( + item: item, + transitionDuration: { model($0).transition.value }, + onDismiss: onDismiss, + content: { unwrappedItem in + AlertContent( + isPresented: .init( + get: { + return item.wrappedValue.isNotNil + }, + set: { isPresented in + if isPresented { + item.wrappedValue = unwrappedItem + } else { + item.wrappedValue = nil + } + } + ), + model: model(unwrappedItem), + primaryAction: { primaryAction?(unwrappedItem) }, + secondaryAction: { secondaryAction?(unwrappedItem) } + ) } - } + ) } } diff --git a/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift index 4232dff6..8e61d7d9 100644 --- a/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift +++ b/Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift @@ -8,24 +8,29 @@ public struct BadgeVM: ComponentVM { /// The color of the badge. public var color: ComponentColor? - /// The visual style of the badge. + /// The corner radius of the badge. /// - /// Defaults to `.filled`. - public var style: Style = .filled + /// Defaults to `.medium`. + public var cornerRadius: ComponentRadius = .medium /// The font used for the badge's text. /// /// Defaults to `.smButton`. public var font: UniversalFont = .smButton - /// The corner radius of the badge. + /// A Boolean value indicating whether the button is enabled or disabled. /// - /// Defaults to `.medium`. - public var cornerRadius: ComponentRadius = .medium + /// Defaults to `true`. + public var isEnabled: Bool = true /// Paddings for the badge. public var paddings: Paddings = .init(horizontal: 10, vertical: 8) + /// The visual style of the badge. + /// + /// Defaults to `.filled`. + public var style: Style = .filled + /// Initializes a new instance of `BadgeVM` with default values. public init() {} } @@ -35,22 +40,24 @@ public struct BadgeVM: ComponentVM { extension BadgeVM { /// Returns the background color of the badge based on its style. var backgroundColor: UniversalColor { - switch self.style { + let color = switch self.style { case .filled: - return self.color?.main ?? .content2 + self.color?.main ?? .content2 case .light: - return self.color?.background ?? .content1 + self.color?.background ?? .content1 } + return color.enabled(self.isEnabled) } /// Returns the foreground color of the badge based on its style. var foregroundColor: UniversalColor { - switch self.style { + let color = switch self.style { case .filled: - return self.color?.contrast ?? .foreground + self.color?.contrast ?? .foreground case .light: - return self.color?.main ?? .foreground + self.color?.main ?? .foreground } + return color.enabled(self.isEnabled) } } diff --git a/Sources/ComponentsKit/Components/Badge/SUBadge.swift b/Sources/ComponentsKit/Components/Badge/SUBadge.swift index 216af4b5..b87c95f3 100644 --- a/Sources/ComponentsKit/Components/Badge/SUBadge.swift +++ b/Sources/ComponentsKit/Components/Badge/SUBadge.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component that displays a badge. +/// A SwiftUI component that is used to display status, notification counts, or labels. public struct SUBadge: View { // MARK: Properties diff --git a/Sources/ComponentsKit/Components/Badge/UKBadge.swift b/Sources/ComponentsKit/Components/Badge/UKBadge.swift index d6e100db..2dc63e3a 100644 --- a/Sources/ComponentsKit/Components/Badge/UKBadge.swift +++ b/Sources/ComponentsKit/Components/Badge/UKBadge.swift @@ -1,7 +1,7 @@ import AutoLayout import UIKit -/// A UIKit component that displays a badge. +/// A UIKit component that is used to display status, notification counts, or labels. open class UKBadge: UIView, UKComponent { // MARK: - Properties diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index 1b7ea7a6..8212febf 100644 --- a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -102,7 +102,7 @@ extension ButtonVM { case .medium: return .mdButton case .large: - return .mdButton + return .lgButton } } var height: CGFloat { diff --git a/Sources/ComponentsKit/Components/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift index f9e4ed73..d415a8ae 100644 --- a/Sources/ComponentsKit/Components/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -56,8 +56,7 @@ private struct CustomButtonStyle: SwiftUI.ButtonStyle { configuration.label .font(self.model.preferredFont.font) .lineLimit(1) - .padding(.leading, self.model.horizontalPadding) - .padding(.trailing, self.model.horizontalPadding) + .padding(.horizontal, self.model.horizontalPadding) .frame(maxWidth: self.model.width) .frame(height: self.model.height) .foregroundStyle(self.model.foregroundColor.color) diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index c7f573ce..058c1609 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -27,8 +27,6 @@ open class UKButton: UIView, UKComponent { } } - private var titleLabelConstraints: LayoutConstraints = .init() - // MARK: Subviews /// A label that displays the title from the model. @@ -85,11 +83,7 @@ open class UKButton: UIView, UKComponent { // MARK: Layout private func layout() { - self.titleLabelConstraints = self.titleLabel.horizontally(self.model.horizontalPadding) self.titleLabel.center() - - self.titleLabelConstraints.leading?.priority = .defaultHigh - self.titleLabelConstraints.trailing?.priority = .defaultHigh } open override func layoutSubviews() { @@ -106,10 +100,7 @@ open class UKButton: UIView, UKComponent { self.style() if self.model.shouldUpdateSize(oldModel) { - self.titleLabelConstraints.leading?.constant = self.model.horizontalPadding - self.titleLabelConstraints.trailing?.constant = -self.model.horizontalPadding self.invalidateIntrinsicContentSize() - self.setNeedsLayout() } } diff --git a/Sources/ComponentsKit/Components/Card/UKCard.swift b/Sources/ComponentsKit/Components/Card/UKCard.swift index 42a54629..bae38bde 100644 --- a/Sources/ComponentsKit/Components/Card/UKCard.swift +++ b/Sources/ComponentsKit/Components/Card/UKCard.swift @@ -15,16 +15,11 @@ import UIKit /// } /// ) /// ``` -open class UKCard: UIView, UKComponent { - // MARK: - Typealiases - - /// A closure that returns the content view to be displayed inside the card. - public typealias Content = () -> UIView - +open class UKCard: UIView, UKComponent { // MARK: - Subviews /// The primary content of the card, provided as a custom view. - public let content: UIView + public let content: Content // MARK: - Properties @@ -46,7 +41,7 @@ open class UKCard: UIView, UKComponent { /// - content: The content that is displayed in the card. public init( model: CardVM = .init(), - content: @escaping Content + content: @escaping () -> Content ) { self.model = model self.content = content() diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift new file mode 100644 index 00000000..72a86b8c --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift @@ -0,0 +1,38 @@ +import SwiftUI +import UIKit + +extension CircularProgressVM { + /// Defines the style of line endings. + public enum LineCap { + /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. + case rounded + /// The line ends exactly at the endpoint with a flat edge. + case square + } +} + +// MARK: - UIKit Helpers + +extension CircularProgressVM.LineCap { + var shapeLayerLineCap: CAShapeLayerLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} + +// MARK: - SwiftUI Helpers + +extension CircularProgressVM.LineCap { + var cgLineCap: CGLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift new file mode 100644 index 00000000..7c4e4e1c --- /dev/null +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift @@ -0,0 +1,11 @@ +import Foundation + +extension CircularProgressVM { + /// Defines the shapes for the circular progress component. + public enum Shape { + /// Renders a complete circle to represent the progress. + case circle + /// Renders only a portion of the circle (an arc) to represent progress. + case arc + } +} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift deleted file mode 100644 index 3c0588d7..00000000 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressStyle.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -extension CircularProgressVM { - public enum Style { - /// Defines the visual styles for the circular progress component. - case light - case striped - } -} diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift index a032d056..3422d75b 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -7,34 +7,42 @@ public struct CircularProgressVM: ComponentVM { /// Defaults to `.accent`. public var color: ComponentColor = .accent - /// The style of the circular progress indicator. + /// The current value of the circular progress. /// - /// Defaults to `.light`. - public var style: Style = .light + /// Defaults to `0`. + public var currentValue: CGFloat = 0 - /// The size of the circular progress. - /// - /// Defaults to `.medium`. - public var size: ComponentSize = .medium + /// The font used for the circular progress label text. + public var font: UniversalFont? - /// The minimum value of the circular progress. - /// - /// Defaults to `0`. - public var minValue: CGFloat = 0 + /// An optional label to display inside the circular progress. + public var label: String? + + /// The style of line endings. + public var lineCap: LineCap = .rounded + + /// The width of the circular progress stroke. + public var lineWidth: CGFloat? /// The maximum value of the circular progress. /// /// Defaults to `100`. public var maxValue: CGFloat = 100 - /// The width of the circular progress stroke. - public var lineWidth: CGFloat? + /// The minimum value of the circular progress. + /// + /// Defaults to `0`. + public var minValue: CGFloat = 0 - /// An optional label to display inside the circular progress. - public var label: String? + /// The shape of the circular progress indicator. + /// + /// Defaults to `.circle`. + public var shape: Shape = .circle - /// The font used for the circular progress label text. - public var font: UniversalFont? + /// The size of the circular progress. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium /// Initializes a new instance of `CircularProgressVM` with default values. public init() {} @@ -68,6 +76,22 @@ extension CircularProgressVM { y: self.preferredSize.height / 2 ) } + var startAngle: CGFloat { + switch self.shape { + case .circle: + return -0.5 * .pi + case .arc: + return 0.75 * .pi + } + } + var endAngle: CGFloat { + switch self.shape { + case .circle: + return 1.5 * .pi + case .arc: + return 2.25 * .pi + } + } var titleFont: UniversalFont { if let font { return font @@ -81,47 +105,16 @@ extension CircularProgressVM { return .lgCaption } } - var stripeWidth: CGFloat { - return 0.5 - } - private func stripesCGPath(in rect: CGRect) -> CGMutablePath { - let stripeSpacing: CGFloat = 3 - let stripeAngle: Angle = .degrees(135) - - let path = CGMutablePath() - let step = stripeWidth + stripeSpacing - let radians = stripeAngle.radians - - let dx: CGFloat = rect.height * tan(radians) - for x in stride(from: 0, through: rect.width + rect.height, by: step) { - let topLeft = CGPoint(x: x, y: 0) - let bottomRight = CGPoint(x: x + dx, y: rect.height) - - path.move(to: topLeft) - path.addLine(to: bottomRight) - path.closeSubpath() - } - return path - } } extension CircularProgressVM { - func gap(for normalized: CGFloat) -> CGFloat { - return normalized > 0 ? 0.05 : 0 - } - - func stripedArcStart(for normalized: CGFloat) -> CGFloat { - let gapValue = self.gap(for: normalized) - return max(0, min(1, normalized + gapValue)) - } - - func stripedArcEnd(for normalized: CGFloat) -> CGFloat { - let gapValue = self.gap(for: normalized) - return 1 - gapValue + var progress: CGFloat { + let range = self.maxValue - self.minValue + guard range > 0 else { return 0 } + let normalized = (self.currentValue - self.minValue) / range + return max(0, min(1, normalized)) } -} -extension CircularProgressVM { func progress(for currentValue: CGFloat) -> CGFloat { let range = self.maxValue - self.minValue guard range > 0 else { return 0 } @@ -133,33 +126,6 @@ extension CircularProgressVM { // MARK: - UIKit Helpers extension CircularProgressVM { - var isStripesLayerHidden: Bool { - switch self.style { - case .light: - return true - case .striped: - return false - } - } - var isBackgroundLayerHidden: Bool { - switch self.style { - case .light: - return false - case .striped: - return true - } - } - func stripesBezierPath(in rect: CGRect) -> UIBezierPath { - let center = CGPoint(x: rect.midX, y: rect.midY) - let path = UIBezierPath(cgPath: self.stripesCGPath(in: rect)) - var transform = CGAffineTransform.identity - transform = transform - .translatedBy(x: center.x, y: center.y) - .rotated(by: -CGFloat.pi / 2) - .translatedBy(x: -center.x, y: -center.y) - path.apply(transform) - return path - } func shouldInvalidateIntrinsicContentSize(_ oldModel: Self) -> Bool { return self.preferredSize != oldModel.preferredSize } @@ -169,13 +135,9 @@ extension CircularProgressVM { func shouldRecalculateProgress(_ oldModel: Self) -> Bool { return self.minValue != oldModel.minValue || self.maxValue != oldModel.maxValue + || self.currentValue != oldModel.currentValue } -} - -// MARK: - SwiftUI Helpers - -extension CircularProgressVM { - func stripesPath(in rect: CGRect) -> Path { - Path(self.stripesCGPath(in: rect)) + func shouldUpdateShape(_ oldModel: Self) -> Bool { + return self.shape != oldModel.shape } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift index 0ba970ac..392e4438 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component that displays a circular progress. +/// A SwiftUI component that displays the progress of a task or operation in a circular form. public struct SUCircularProgress: View { // MARK: - Properties @@ -8,10 +8,10 @@ public struct SUCircularProgress: View { public var model: CircularProgressVM /// The current progress value. - public var currentValue: CGFloat + public var currentValue: CGFloat? private var progress: CGFloat { - self.model.progress(for: self.currentValue) + self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress } // MARK: - Initializer @@ -20,6 +20,7 @@ public struct SUCircularProgress: View { /// - Parameters: /// - currentValue: Current progress. /// - model: A model that defines the appearance properties. + @available(*, deprecated, message: "Set `currentValue` in the model instead.") public init( currentValue: CGFloat = 0, model: CircularProgressVM = .init() @@ -28,19 +29,34 @@ public struct SUCircularProgress: View { self.model = model } + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: CircularProgressVM) { + self.model = model + } + // MARK: - Body public var body: some View { ZStack { // Background part - Group { - switch self.model.style { - case .light: - self.lightBackground - case .striped: - self.stripedBackground - } + Path { path in + path.addArc( + center: self.model.center, + radius: self.model.radius, + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) } + .stroke( + self.model.color.background.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) + ) .frame( width: self.model.preferredSize.width, height: self.model.preferredSize.height @@ -51,8 +67,8 @@ public struct SUCircularProgress: View { path.addArc( center: self.model.center, radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), clockwise: false ) } @@ -61,10 +77,9 @@ public struct SUCircularProgress: View { self.model.color.main.color, style: StrokeStyle( lineWidth: self.model.circularLineWidth, - lineCap: .round + lineCap: self.model.lineCap.cgLineCap ) ) - .rotationEffect(.degrees(-90)) .frame( width: self.model.preferredSize.width, height: self.model.preferredSize.height @@ -82,62 +97,4 @@ public struct SUCircularProgress: View { value: self.progress ) } - - // MARK: - Subviews - - var lightBackground: some View { - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), - clockwise: false - ) - } - .stroke( - self.model.color.background.color, - lineWidth: self.model.circularLineWidth - ) - } - - var stripedBackground: some View { - StripesShapeCircularProgress(model: self.model) - .stroke( - self.model.color.main.color, - style: StrokeStyle(lineWidth: self.model.stripeWidth) - ) - .mask { - Path { maskPath in - maskPath.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), - clockwise: false - ) - } - .trim( - from: self.model.stripedArcStart(for: self.progress), - to: self.model.stripedArcEnd(for: self.progress) - ) - .stroke( - style: StrokeStyle( - lineWidth: self.model.circularLineWidth, - lineCap: .round - ) - ) - } - .rotationEffect(.degrees(-90)) - } -} - -// MARK: - Helpers - -struct StripesShapeCircularProgress: Shape, @unchecked Sendable { - var model: CircularProgressVM - - func path(in rect: CGRect) -> Path { - self.model.stripesPath(in: rect) - } } diff --git a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift index e910e623..193d2a11 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift @@ -1,7 +1,7 @@ import AutoLayout import UIKit -/// A UIKit component that displays a circular progress indicator. +/// A UIKit component that displays the progress of a task or operation in a circular form. open class UKCircularProgress: UIView, UKComponent { // MARK: - Properties @@ -13,27 +13,25 @@ open class UKCircularProgress: UIView, UKComponent { } /// The current progress value. - public var currentValue: CGFloat { + public var currentValue: CGFloat? { didSet { self.updateProgress() } } + private var progress: CGFloat { + self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress + } + // MARK: - Subviews - /// The shape layer responsible for rendering the background of the circular progress indicator in a light style. + /// The shape layer responsible for rendering the background. public let backgroundLayer = CAShapeLayer() - /// The shape layer responsible for rendering the progress arc of the circular progress indicator. + /// The shape layer responsible for rendering the progress arc. public let progressLayer = CAShapeLayer() - /// The shape layer responsible for rendering the striped effect in the circular progress indicator. - public let stripesLayer = CAShapeLayer() - - /// The shape layer that acts as a mask for `stripesLayer`, ensuring it has the intended shape. - public let stripesMaskLayer = CAShapeLayer() - - /// The label used to display text inside the circular progress indicator. + /// The label used to display text. public let label = UILabel() // MARK: - UIView Properties @@ -48,6 +46,7 @@ open class UKCircularProgress: UIView, UKComponent { /// - Parameters: /// - initialValue: The initial progress value. Defaults to `0`. /// - model: The model that defines the appearance properties. + @available(*, deprecated, message: "Set `currentValue` in the model instead.") public init( initialValue: CGFloat = 0, model: CircularProgressVM = .init() @@ -61,6 +60,18 @@ open class UKCircularProgress: UIView, UKComponent { self.layout() } + /// Initializer. + /// - Parameters: + /// - model: The model that defines the appearance properties. + public init(model: CircularProgressVM) { + 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") } @@ -69,24 +80,16 @@ open class UKCircularProgress: UIView, UKComponent { private func setup() { self.layer.addSublayer(self.backgroundLayer) - self.layer.addSublayer(self.stripesLayer) self.layer.addSublayer(self.progressLayer) self.addSubview(self.label) - self.stripesLayer.mask = self.stripesMaskLayer - if #available(iOS 17.0, *) { self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in view.handleTraitChanges() } } - let progress = self.model.progress(for: self.currentValue) - self.progressLayer.strokeEnd = progress - if !self.model.isStripesLayerHidden { - self.stripesMaskLayer.strokeStart = self.model.stripedArcStart(for: progress) - self.stripesMaskLayer.strokeEnd = self.model.stripedArcEnd(for: progress) - } + self.progressLayer.strokeEnd = self.progress self.label.text = self.model.label } @@ -96,8 +99,6 @@ open class UKCircularProgress: UIView, UKComponent { Self.Style.backgroundLayer(self.backgroundLayer, model: self.model) Self.Style.progressLayer(self.progressLayer, model: self.model) Self.Style.label(self.label, model: self.model) - Self.Style.stripesLayer(self.stripesLayer, model: self.model) - Self.Style.stripesMaskLayer(self.stripesMaskLayer, model: self.model) } // MARK: - Update @@ -105,7 +106,6 @@ open class UKCircularProgress: UIView, UKComponent { public func update(_ oldModel: CircularProgressVM) { guard self.model != oldModel else { return } self.style() - self.updateShapePaths() if self.model.shouldUpdateText(oldModel) { UIView.transition( @@ -121,6 +121,9 @@ open class UKCircularProgress: UIView, UKComponent { if self.model.shouldRecalculateProgress(oldModel) { self.updateProgress() } + if self.model.shouldUpdateShape(oldModel) { + self.updateShapePaths() + } if self.model.shouldInvalidateIntrinsicContentSize(oldModel) { self.invalidateIntrinsicContentSize() } @@ -128,31 +131,25 @@ open class UKCircularProgress: UIView, UKComponent { private func updateShapePaths() { let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) + let minSide = min(self.bounds.width, self.bounds.height) + let radius = (minSide - self.model.circularLineWidth) / 2 let circlePath = UIBezierPath( arcCenter: center, - radius: self.model.radius, - startAngle: -CGFloat.pi / 2, - endAngle: -CGFloat.pi / 2 + 2 * .pi, + radius: radius, + startAngle: self.model.startAngle, + endAngle: self.model.endAngle, clockwise: true ) self.backgroundLayer.path = circlePath.cgPath self.progressLayer.path = circlePath.cgPath - self.stripesMaskLayer.path = circlePath.cgPath - self.stripesLayer.path = self.model.stripesBezierPath(in: self.bounds).cgPath } private func updateProgress() { - let progress = self.model.progress(for: self.currentValue) - CATransaction.begin() CATransaction.setAnimationDuration(self.model.animationDuration) CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .linear)) - self.progressLayer.strokeEnd = progress - if !self.model.isStripesLayerHidden { - self.stripesMaskLayer.strokeStart = self.model.stripedArcStart(for: progress) - self.stripesMaskLayer.strokeEnd = self.model.stripedArcEnd(for: progress) - } + self.progressLayer.strokeEnd = self.progress CATransaction.commit() } @@ -167,8 +164,6 @@ open class UKCircularProgress: UIView, UKComponent { self.backgroundLayer.frame = self.bounds self.progressLayer.frame = self.bounds - self.stripesLayer.frame = self.bounds - self.stripesMaskLayer.frame = self.bounds self.updateShapePaths() } @@ -191,8 +186,6 @@ open class UKCircularProgress: UIView, UKComponent { private func handleTraitChanges() { Self.Style.backgroundLayer(self.backgroundLayer, model: self.model) Self.Style.progressLayer(self.progressLayer, model: self.model) - Self.Style.stripesLayer(self.stripesLayer, model: self.model) - Self.Style.stripesMaskLayer(self.stripesMaskLayer, model: self.model) } } @@ -205,10 +198,9 @@ extension UKCircularProgress { model: CircularProgressVM ) { layer.fillColor = UIColor.clear.cgColor - layer.strokeColor = model.color.background.uiColor.cgColor - layer.lineCap = .round + layer.strokeColor = model.color.background.cgColor + layer.lineCap = model.lineCap.shapeLayerLineCap layer.lineWidth = model.circularLineWidth - layer.isHidden = model.isBackgroundLayerHidden } static func progressLayer( @@ -217,7 +209,7 @@ extension UKCircularProgress { ) { layer.fillColor = UIColor.clear.cgColor layer.strokeColor = model.color.main.uiColor.cgColor - layer.lineCap = .round + layer.lineCap = model.lineCap.shapeLayerLineCap layer.lineWidth = model.circularLineWidth } @@ -226,29 +218,8 @@ extension UKCircularProgress { model: CircularProgressVM ) { label.textAlignment = .center - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.5 label.font = model.titleFont.uiFont label.textColor = model.color.main.uiColor } - - static func stripesLayer( - _ layer: CAShapeLayer, - model: CircularProgressVM - ) { - layer.isHidden = model.isStripesLayerHidden - layer.strokeColor = model.color.main.uiColor.cgColor - layer.lineWidth = model.stripeWidth - } - - static func stripesMaskLayer( - _ layer: CAShapeLayer, - model: CircularProgressVM - ) { - layer.fillColor = UIColor.clear.cgColor - layer.strokeColor = model.color.background.uiColor.cgColor - layer.lineCap = .round - layer.lineWidth = model.circularLineWidth - } } } diff --git a/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift b/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift index ea7db580..4c780a01 100644 --- a/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift +++ b/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift @@ -5,6 +5,25 @@ public struct CountdownVM: ComponentVM { /// The color of the countdown. public var color: ComponentColor? + /// The locale used for localizing the countdown. + public var locale: Locale = .current + + /// A dictionary containing localized representations of time units (days, hours, minutes, seconds) for various locales. + /// + /// This property can be used to override the default localizations for supported languages or to add + /// localizations for unsupported languages. By default, the library provides strings for the following locales: + /// - English ("en") + /// - Spanish ("es") + /// - French ("fr") + /// - German ("de") + /// - Chinese ("zh") + /// - Japanese ("ja") + /// - Russian ("ru") + /// - Arabic ("ar") + /// - Hindi ("hi") + /// - Portuguese ("pt") + public var localization: [Locale: UnitsLocalization] = [:] + /// The font used for displaying the countdown numbers and trailing units. public var mainFont: UniversalFont? @@ -29,25 +48,6 @@ public struct CountdownVM: ComponentVM { /// The target date until which the countdown runs. public var until: Date = Date().addingTimeInterval(3600 * 85) - /// The locale used for localizing the countdown. - public var locale: Locale = .current - - /// A dictionary containing localized representations of time units (days, hours, minutes, seconds) for various locales. - /// - /// This property can be used to override the default localizations for supported languages or to add - /// localizations for unsupported languages. By default, the library provides strings for the following locales: - /// - English ("en") - /// - Spanish ("es") - /// - French ("fr") - /// - German ("de") - /// - Chinese ("zh") - /// - Japanese ("ja") - /// - Russian ("ru") - /// - Arabic ("ar") - /// - Hindi ("hi") - /// - Portuguese ("pt") - public var localization: [Locale: UnitsLocalization] = [:] - /// Initializes a new instance of `CountdownVM` with default values. public init() {} } diff --git a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift index 2830f296..5076f54e 100644 --- a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift +++ b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component that displays a countdown. +/// A SwiftUI timer component that counts down from a specified duration to zero. public struct SUCountdown: View { // MARK: - Properties diff --git a/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift b/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift index ad60f58b..e45df5c5 100644 --- a/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift +++ b/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift @@ -2,7 +2,7 @@ import AutoLayout import Combine import UIKit -/// A UIKit component that displays a countdown. +/// A UIKit timer component that counts down from a specified duration to zero. public class UKCountdown: UIView, UKComponent { // MARK: - Public Properties @@ -13,6 +13,8 @@ public class UKCountdown: UIView, UKComponent { } } + // MARK: - Subviews + /// The main container stack view containing all time labels and colon labels. public let stackView = UIStackView() diff --git a/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift index dd339bb1..7ffd3b73 100644 --- a/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift +++ b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift @@ -18,7 +18,7 @@ public struct InputFieldVM: ComponentVM { /// The font used for the input field's text. /// - /// If not provided, the font is automatically calculated based on the checkbox's size. + /// If not provided, the font is automatically calculated based on the input field's size. public var font: UniversalFont? /// A Boolean value indicating whether autocorrection is enabled for the input field. diff --git a/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift b/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift index b2fed090..e16a4f8a 100644 --- a/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift +++ b/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift @@ -31,11 +31,12 @@ public struct BottomModalVM: ModalVM { /// A Boolean value indicating whether the modal should hide when it is swiped down. /// /// Defaults to `true`. - public var hidesOnSwap: Bool = true + public var hidesOnSwipe: Bool = true /// A Boolean value indicating whether the modal is draggable. /// - /// If `true`, the modal can be dragged vertically. Defaults to `true`. + /// If `true`, the modal can be dragged vertically allowing the user to pull the modal up or down + /// to interact or dismiss it. Defaults to `true`. public var isDraggable: Bool = true /// The style of the overlay displayed behind the modal. diff --git a/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift b/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift index 17af179b..ca0e0b47 100644 --- a/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift +++ b/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift @@ -48,6 +48,6 @@ public struct CenterModalVM: ModalVM { /// Defaults to `.fast`. public var transition: ModalTransition = .fast - /// Initializes a new instance of `BottomModalVM` with default values. + /// Initializes a new instance of `CenterModalVM` with default values. public init() {} } diff --git a/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift b/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift index 190aba8d..cb279d26 100644 --- a/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift +++ b/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift @@ -10,7 +10,7 @@ enum ModalAnimation { static func bottomModalOffset(_ translation: CGFloat, model: BottomModalVM) -> CGFloat { if translation > 0 { - return model.hidesOnSwap + return model.hidesOnSwipe ? translation : (model.isDraggable ? Self.rubberBandClamp(translation) : 0) } else { @@ -26,7 +26,7 @@ enum ModalAnimation { velocity: CGFloat, model: BottomModalVM ) -> Bool { - guard model.hidesOnSwap else { + guard model.hidesOnSwipe else { return false } diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift index 3eb02ad4..9b0eebc7 100644 --- a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift @@ -23,8 +23,13 @@ struct ModalPresentationModifier: ViewModifier { func body(content: Content) -> some View { content - .onChange(of: self.isContentVisible) { newValue in - if newValue { + .onAppear { + if self.isContentVisible { + self.isPresented = true + } + } + .onChange(of: self.isContentVisible) { isVisible in + if isVisible { self.isPresented = true } else { DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift index 31e7cdc8..0ad9a283 100644 --- a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift @@ -6,12 +6,12 @@ struct ModalPresentationWithItemModifier: ViewM @ViewBuilder var content: (Item) -> Modal - let transitionDuration: TimeInterval + let transitionDuration: (Item) -> TimeInterval let onDismiss: (() -> Void)? init( item: Binding, - transitionDuration: TimeInterval, + transitionDuration: @escaping (Item) -> TimeInterval, onDismiss: (() -> Void)?, @ViewBuilder content: @escaping (Item) -> Modal ) { @@ -23,11 +23,17 @@ struct ModalPresentationWithItemModifier: ViewM func body(content: Content) -> some View { content - .onChange(of: self.visibleItem.isNotNil) { newValue in - if newValue { + .onAppear { + self.presentedItem = self.visibleItem + } + .onChange(of: self.visibleItem.isNotNil) { isVisible in + if isVisible { self.presentedItem = self.visibleItem } else { - DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { + let duration = self.presentedItem.map { item in + self.transitionDuration(item) + } ?? 0.3 + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { self.presentedItem = self.visibleItem } } @@ -49,7 +55,7 @@ struct ModalPresentationWithItemModifier: ViewM extension View { func modal( item: Binding, - transitionDuration: TimeInterval, + transitionDuration: @escaping (Item) -> TimeInterval, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Item) -> Modal ) -> some View { diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift index 1f379c1e..50b7fa55 100644 --- a/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift @@ -43,7 +43,7 @@ struct ModalContent: View { .padding(.top, self.bodyTopPadding) .padding(.bottom, self.bodyBottomPadding) } - .frame(maxHeight: self.scrollViewMaxHeight) + .frame(maxWidth: .infinity, maxHeight: self.scrollViewMaxHeight) .disableScrollWhenContentFits() self.contentFooter() diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift index 000a1466..8323bc39 100644 --- a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift @@ -195,7 +195,7 @@ extension View { /// } /// .bottomModal( /// item: $selectedItem, - /// model: BottomModalVM(), + /// model: { _ in BottomModalVM() }, /// onDismiss: { /// print("Modal dismissed") /// }, @@ -218,7 +218,7 @@ extension View { /// ``` public func bottomModal( item: Binding, - model: BottomModalVM = .init(), + model: @escaping (Item) -> BottomModalVM = { _ in .init() }, onDismiss: (() -> Void)? = nil, @ViewBuilder header: @escaping (Item) -> Header, @ViewBuilder body: @escaping (Item) -> Body, @@ -226,7 +226,7 @@ extension View { ) -> some View { return self.modal( item: item, - transitionDuration: model.transition.value, + transitionDuration: { model($0).transition.value }, onDismiss: onDismiss, content: { unwrappedItem in SUBottomModal( @@ -242,7 +242,7 @@ extension View { } } ), - model: model, + model: model(unwrappedItem), header: { header(unwrappedItem) }, body: { body(unwrappedItem) }, footer: { footer(unwrappedItem) } @@ -289,7 +289,7 @@ extension View { /// } /// .bottomModal( /// item: $selectedItem, - /// model: BottomModalVM(), + /// model: { _ in BottomModalVM() }, /// onDismiss: { /// print("Modal dismissed") /// }, @@ -302,7 +302,7 @@ extension View { /// ``` public func bottomModal( item: Binding, - model: BottomModalVM = .init(), + model: @escaping (Item) -> BottomModalVM = { _ in .init() }, onDismiss: (() -> Void)? = nil, @ViewBuilder body: @escaping (Item) -> Body ) -> some View { diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift index dd7c65d2..610a4933 100644 --- a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift @@ -157,7 +157,7 @@ extension View { /// } /// .centerModal( /// item: $selectedItem, - /// model: CenterModalVM(), + /// model: { _ in CenterModalVM() }, /// onDismiss: { /// print("Modal dismissed") /// }, @@ -180,7 +180,7 @@ extension View { /// ``` public func centerModal( item: Binding, - model: CenterModalVM = .init(), + model: @escaping (Item) -> CenterModalVM = { _ in .init() }, onDismiss: (() -> Void)? = nil, @ViewBuilder header: @escaping (Item) -> Header, @ViewBuilder body: @escaping (Item) -> Body, @@ -188,7 +188,7 @@ extension View { ) -> some View { return self.modal( item: item, - transitionDuration: model.transition.value, + transitionDuration: { model($0).transition.value }, onDismiss: onDismiss, content: { unwrappedItem in SUCenterModal( @@ -204,7 +204,7 @@ extension View { } } ), - model: model, + model: model(unwrappedItem), header: { header(unwrappedItem) }, body: { body(unwrappedItem) }, footer: { footer(unwrappedItem) } @@ -251,7 +251,7 @@ extension View { /// } /// .centerModal( /// item: $selectedItem, - /// model: CenterModalVM(), + /// model: { _ in CenterModalVM() }, /// onDismiss: { /// print("Modal dismissed") /// }, @@ -264,7 +264,7 @@ extension View { /// ``` public func centerModal( item: Binding, - model: CenterModalVM = .init(), + model: @escaping (Item) -> CenterModalVM = { _ in .init() }, onDismiss: (() -> Void)? = nil, @ViewBuilder body: @escaping (Item) -> Body ) -> some View { diff --git a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift index 902293e9..f5bb2e47 100644 --- a/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarVM.swift @@ -7,26 +7,29 @@ public struct ProgressBarVM: ComponentVM { /// 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. + /// The corner radius of the progress bar. /// /// Defaults to `.medium`. - public var size: ComponentSize = .medium + public var cornerRadius: ComponentRadius = .medium - /// The minimum value of the progress bar. - public var minValue: CGFloat = 0 + /// The current value of the progress bar. + public var currentValue: CGFloat = 0 /// The maximum value of the progress bar. public var maxValue: CGFloat = 100 - /// The corner radius of the progress bar. + /// The minimum value of the progress bar. + public var minValue: CGFloat = 0 + + /// The size of the progress bar. /// /// Defaults to `.medium`. - public var cornerRadius: ComponentRadius = .medium + public var size: ComponentSize = .medium + + /// The visual style of the progress bar component. + /// + /// Defaults to `.striped`. + public var style: Style = .striped /// Initializes a new instance of `ProgressBarVM` with default values. public init() {} @@ -139,6 +142,13 @@ extension ProgressBarVM { } extension ProgressBarVM { + var progress: CGFloat { + let range = self.maxValue - self.minValue + guard range > 0 else { return 0 } + let normalized = (self.currentValue - self.minValue) / range + return max(0, min(1, normalized)) + } + func progress(for currentValue: CGFloat) -> CGFloat { let range = self.maxValue - self.minValue guard range > 0 else { return 0 } diff --git a/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift index 756d49a4..96a1e0cc 100644 --- a/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift @@ -1,29 +1,37 @@ import SwiftUI -/// A SwiftUI component that displays a progress bar. +/// A SwiftUI component that visually represents the progress of a task or process using a horizontal 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 + /// The current progress value. + public var currentValue: CGFloat? private var progress: CGFloat { - self.model.progress(for: self.currentValue) + self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress } // MARK: - Initializer /// Initializer. /// - Parameters: - /// - currentValue: A binding to the current value. + /// - currentValue: The current progress value. /// - model: A model that defines the appearance properties. + @available(*, deprecated, message: "Set `currentValue` in the model instead.") public init( - currentValue: Binding, + currentValue: CGFloat, model: ProgressBarVM = .init() ) { - self._currentValue = currentValue + self.currentValue = currentValue + self.model = model + } + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: ProgressBarVM) { self.model = model } diff --git a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift index 3a14cc31..28943e6b 100644 --- a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift @@ -1,9 +1,9 @@ import AutoLayout import UIKit -/// A UIKit component that displays a progress bar. +/// A UIKit component that visually represents the progress of a task or process using a horizontal bar. open class UKProgressBar: UIView, UKComponent { - // MARK: - Properties + // MARK: - Public Properties /// A model that defines the appearance properties. public var model: ProgressBarVM { @@ -13,7 +13,7 @@ open class UKProgressBar: UIView, UKComponent { } /// The current progress value for the progress bar. - public var currentValue: CGFloat { + public var currentValue: CGFloat? { didSet { self.updateProgressWidthAndAppearance() } @@ -39,7 +39,7 @@ open class UKProgressBar: UIView, UKComponent { // MARK: - Private Properties private var progress: CGFloat { - self.model.progress(for: self.currentValue) + self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress } // MARK: - UIView Properties @@ -54,6 +54,7 @@ open class UKProgressBar: UIView, UKComponent { /// - Parameters: /// - initialValue: The initial progress value. Defaults to `0`. /// - model: A model that defines the appearance properties. + @available(*, deprecated, message: "Set `currentValue` in the model instead.") public init( initialValue: CGFloat = 0, model: ProgressBarVM = .init() @@ -67,6 +68,18 @@ open class UKProgressBar: UIView, UKComponent { self.layout() } + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: ProgressBarVM) { + 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") } @@ -138,9 +151,9 @@ open class UKProgressBar: UIView, UKComponent { self.setNeedsLayout() } - UIView.performWithoutAnimation { +// UIView.performWithoutAnimation { self.updateProgressWidthAndAppearance() - } +// } } private func updateProgressWidthAndAppearance() { @@ -182,7 +195,13 @@ open class UKProgressBar: UIView, UKComponent { // MARK: - UIView methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let width = self.superview?.bounds.width ?? size.width + let width: CGFloat + if let parentWidth = self.superview?.bounds.width, + parentWidth > 0 { + width = parentWidth + } else { + width = 10_000 + } return CGSize( width: min(size.width, width), height: min(size.height, self.model.backgroundHeight) diff --git a/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift index bc985340..5021a771 100644 --- a/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift @@ -1,7 +1,7 @@ import Foundation import UIKit -/// A model that defines the appearance of a radio group component. +/// A model that defines the data and appearance properties for a radio group component. public struct RadioGroupVM: ComponentVM { /// The scaling factor for the button's press animation, with a value between 0 and 1. /// diff --git a/Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift index 17efd00d..ebbe1ba4 100644 --- a/Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift @@ -1,6 +1,6 @@ import Foundation -/// A model that defines the appearance properties for an item in a radio group. +/// A model that defines the data and appearance properties for an item in a radio group. public struct RadioItemVM { /// The unique identifier for the radio item. public var id: ID diff --git a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift index 3ee1a78e..229f4cba 100644 --- a/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift @@ -15,7 +15,7 @@ open class UKRadioGroup: UIView, UKComponent, UIGestureRecognizerD } } - /// An identifier of the selected segment. + /// An identifier of the selected item. public var selectedId: ID? { didSet { guard self.selectedId != oldValue else { return } diff --git a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift index cae5b40c..b51f9f4b 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift @@ -1,6 +1,6 @@ import Foundation -/// A model that defines the appearance properties for an item in a segmented control. +/// A model that defines the data and appearance properties for an item in a segmented control. public struct SegmentedControlItemVM: Updatable { /// The unique identifier for the segmented control item. public var id: ID diff --git a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift index e95308a7..8f0f8617 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift @@ -1,7 +1,7 @@ import SwiftUI import UIKit -/// A model that defines the appearance properties for a segmented control component. +/// A model that defines the data and appearance properties for a segmented control component. public struct SegmentedControlVM: ComponentVM { /// The color of the segmented control. public var color: ComponentColor? @@ -19,7 +19,7 @@ public struct SegmentedControlVM: ComponentVM { /// Defaults to `true`. public var isEnabled: Bool = true - /// A Boolean value indicating whether the segmented control should take the full width of its superview. + /// A Boolean value indicating whether the segmented control should take the full width of its parent view. /// /// Defaults to `false`. public var isFullWidth: Bool = false @@ -56,7 +56,7 @@ extension SegmentedControlVM { } var selectedSegmentColor: UniversalColor { let color = self.color?.main ?? .themed( - light: UniversalColor.white.light, + light: UniversalColor.background.light, dark: UniversalColor.content2.dark ) return color.enabled(self.isEnabled) diff --git a/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift index 12e068e2..b001cae5 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component with multiple segments that allows users to select them. +/// A SwiftUI component that allows users to choose between multiple segments or options. public struct SUSegmentedControl: View { // MARK: Properties diff --git a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift index eced335a..f759f3da 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift @@ -1,7 +1,7 @@ import AutoLayout import UIKit -/// A UIKit component with multiple segments that allows users to select them. +/// A UIKit component that allows users to choose between multiple segments or options. open class UKSegmentedControl: UIView, UKComponent { // MARK: Properties @@ -261,6 +261,7 @@ open class UKSegmentedControl: UIView, UKComponent { let segment = self.segments.first(where: { segment in segment.bounds.contains(touch.location(in: segment)) }), + self.model.item(for: segment.id)?.isEnabled == true, self.selectedId != segment.id, let currentlySelectedSegment = self.segment(for: self.selectedId) else { diff --git a/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift index beb271b9..4384de5e 100644 --- a/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift +++ b/Sources/ComponentsKit/Components/Slider/Models/SliderVM.swift @@ -7,32 +7,32 @@ public struct SliderVM: ComponentVM { /// Defaults to `.accent`. public var color: ComponentColor = .accent - /// The visual style of the slider component. + /// The corner radius of the slider track and handle. /// - /// Defaults to `.light`. - public var style: Style = .light + /// Defaults to `.full`. + public var cornerRadius: ComponentRadius = .full - /// The size of the slider. - /// - /// Defaults to `.medium`. - public var size: ComponentSize = .medium + /// The maximum value of the slider. + public var maxValue: CGFloat = 100 /// 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. + /// The size of the slider. /// - /// Defaults to `.full`. - public var cornerRadius: ComponentRadius = .full + /// Defaults to `.medium`. + public var size: ComponentSize = .medium /// The step value for the slider. /// /// Defaults to `1`. public var step: CGFloat = 1 + /// The visual style of the slider component. + /// + /// Defaults to `.light`. + public var style: Style = .light + /// Initializes a new instance of `SliderVM` with default values. public init() {} } diff --git a/Sources/ComponentsKit/Components/Slider/SUSlider.swift b/Sources/ComponentsKit/Components/Slider/SUSlider.swift index 257a1c35..fbf43b16 100644 --- a/Sources/ComponentsKit/Components/Slider/SUSlider.swift +++ b/Sources/ComponentsKit/Components/Slider/SUSlider.swift @@ -1,6 +1,6 @@ import SwiftUI -/// A SwiftUI component that displays a slider. +/// A SwiftUI component that lets users select a value from a range by dragging a thumb along a track. public struct SUSlider: View { // MARK: - Properties diff --git a/Sources/ComponentsKit/Components/Slider/UKSlider.swift b/Sources/ComponentsKit/Components/Slider/UKSlider.swift index 07ce9b96..1a715eb6 100644 --- a/Sources/ComponentsKit/Components/Slider/UKSlider.swift +++ b/Sources/ComponentsKit/Components/Slider/UKSlider.swift @@ -1,7 +1,7 @@ import AutoLayout import UIKit -/// A UIKit component that displays a slider. +/// A UIKit component that lets users select a value from a range by dragging a thumb along a track. open class UKSlider: UIView, UKComponent { // MARK: - Properties @@ -193,7 +193,13 @@ open class UKSlider: UIView, UKComponent { // MARK: - UIView Methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let width = self.superview?.bounds.width ?? size.width + let width: CGFloat + if let parentWidth = self.superview?.bounds.width, + parentWidth > 0 { + width = parentWidth + } else { + width = 10_000 + } return CGSize( width: min(size.width, width), height: min(size.height, self.model.handleSize.height) diff --git a/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift index db000784..274cce4b 100644 --- a/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift +++ b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift @@ -69,23 +69,6 @@ public struct TextInputVM: ComponentVM { // MARK: - Shared Helpers extension TextInputVM { - var adaptedCornerRadius: ComponentRadius { - switch self.cornerRadius { - case .none: - return .none - case .small: - return .small - case .medium: - return .medium - case .large: - return .large - case .full: - return .custom(self.height(forRows: 1) / 2) - case .custom(let value): - return .custom(value) - } - } - var preferredFont: UniversalFont { if let font { return font @@ -140,6 +123,17 @@ extension TextInputVM { } } + func adaptedCornerRadius(for height: CGFloat = 10_000) -> CGFloat { + switch self.cornerRadius { + case .none, .small, .medium, .large, .full: + let value = self.cornerRadius.value(for: height) + let maxValue = ComponentRadius.custom(self.height(forRows: 1) / 2).value(for: height) + return min(value, maxValue) + case .custom(let value): + return ComponentRadius.custom(value).value(for: height) + } + } + private func height(forRows rows: Int) -> CGFloat { if rows < 1 { assertionFailure("Number of rows in TextInput must be greater than or equal to 1") @@ -162,8 +156,4 @@ extension TextInputVM { var autocorrectionType: UITextAutocorrectionType { return self.isAutocorrectionEnabled ? .yes : .no } - - func shouldUpdateCornerRadius(_ oldModel: Self) -> Bool { - return self.adaptedCornerRadius != oldModel.adaptedCornerRadius - } } diff --git a/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift index 3616297c..47e68014 100644 --- a/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift @@ -55,16 +55,14 @@ public struct SUTextInput: View { TextEditor(text: self.$text) .contentMargins(self.model.contentPadding) .transparentScrollBackground() - .frame( - minHeight: self.model.minTextInputHeight, - maxHeight: max( - self.model.minTextInputHeight, - min( - self.model.maxTextInputHeight, - self.textEditorPreferredHeight - ) + .frame(minHeight: self.model.minTextInputHeight) + .frame(height: max( + self.model.minTextInputHeight, + min( + self.model.maxTextInputHeight, + self.textEditorPreferredHeight ) - ) + )) .lineSpacing(0) .font(self.model.preferredFont.font) .foregroundStyle(self.model.foregroundColor.color) @@ -112,11 +110,18 @@ public struct SUTextInput: View { ) } } + .onChange(of: geometry.size.width) { newValue in + self.textEditorPreferredHeight = TextInputHeightCalculator.preferredHeight( + for: self.text, + model: self.model, + width: newValue + ) + } } ) .clipShape( RoundedRectangle( - cornerRadius: self.model.adaptedCornerRadius.value() + cornerRadius: self.model.adaptedCornerRadius() ) ) } diff --git a/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift b/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift index 1e539bc0..8e4b8519 100644 --- a/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift @@ -118,9 +118,6 @@ open class UKTextInput: UIView, UKComponent { self.style() - if self.model.shouldUpdateCornerRadius(oldModel) { - self.updateCornerRadius() - } if self.model.shouldUpdateLayout(oldModel) { self.invalidateIntrinsicContentSize() self.setNeedsLayout() @@ -171,7 +168,7 @@ open class UKTextInput: UIView, UKComponent { } private func updateCornerRadius() { - self.layer.cornerRadius = self.model.adaptedCornerRadius.value(for: self.bounds.height) + self.layer.cornerRadius = self.model.adaptedCornerRadius(for: self.bounds.height) } } @@ -189,7 +186,7 @@ extension UKTextInput { fileprivate enum Style { static func mainView(_ view: UIView, model: TextInputVM) { view.backgroundColor = model.backgroundColor.uiColor - view.layer.cornerRadius = model.adaptedCornerRadius.value(for: view.bounds.height) + view.layer.cornerRadius = model.adaptedCornerRadius(for: view.bounds.height) } static func textView( diff --git a/Sources/ComponentsKit/Configuration/Config.swift b/Sources/ComponentsKit/Configuration/Config.swift deleted file mode 100644 index c31c807a..00000000 --- a/Sources/ComponentsKit/Configuration/Config.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -/// A configuration structure for customizing colors and layout attributes of the components. -public struct ComponentsKitConfig: Initializable, Updatable { - // MARK: - Properties - - /// The palette of colors. - public var colors: Palette = .init() - - /// The layout configuration. - public var layout: Layout = .init() - - // MARK: - Initialization - - /// Initializes a new `ComponentsKitConfig` instance with default values. - public init() {} -} - -// MARK: - ComponentsKitConfig + Shared - -extension ComponentsKitConfig { - /// A shared instance of `ComponentsKitConfig` for global use. - public static var shared: Self = .init() -} diff --git a/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift new file mode 100644 index 00000000..bc590c8e --- /dev/null +++ b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// A SwiftUI wrapper that listens for theme changes and automatically refreshes its content. +/// +/// `ThemeChangeObserver` ensures that its child views **rebuild** whenever the theme changes, +/// helping to apply updated theme styles dynamically. +/// +/// ## Usage +/// +/// Wrap your view inside `ThemeChangeObserver` to make it responsive to theme updates: +/// +/// ```swift +/// @main +/// struct Root: App { +/// var body: some Scene { +/// WindowGroup { +/// ThemeChangeObserver { +/// Content() +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Performance Considerations +/// +/// - This approach forces a **full re-evaluation** of the wrapped content, which ensures all theme-dependent +/// properties are updated. +/// - Use it **at a high level** in your SwiftUI hierarchy (e.g., wrapping entire screens) rather than for small components. +public struct ThemeChangeObserver: View { + @State private var themeId = UUID() + @ViewBuilder var content: () -> Content + + public init(content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + self.content() + .onReceive(NotificationCenter.default.publisher( + for: Theme.didChangeThemeNotification, + object: nil + )) { _ in + self.themeId = UUID() + } + .id(self.themeId) + } +} diff --git a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift new file mode 100644 index 00000000..88eb1f2a --- /dev/null +++ b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift @@ -0,0 +1,82 @@ +import Combine +import Foundation + +extension NSObject { + /// Observes changes to the `.current` theme and updates dependent views. + /// + /// This method allows you to respond to theme changes by updating view properties that depend on the theme. + /// + /// You can invoke the ``observeThemeChange(_:)`` method a single time in the `viewDidLoad` + /// and update all the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// style() + /// + /// observeThemeChanges { [weak self] in + /// guard let self else { return } + /// + /// self.style() + /// } + /// } + /// + /// func style() { + /// view.backgroundColor = UniversalColor.background.uiColor + /// button.model = ButtonVM { + /// $0.title = "Tap me" + /// $0.color = .accent + /// } + /// // ... + /// } + /// ``` + /// + /// ## Cancellation + /// + /// The method returns an ``AnyCancellable`` that can be used to cancel observation. For + /// example, if you only want to observe while a view controller is visible, you can start + /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var cancellable: AnyCancellable? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// cancellable = observeThemeChange { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// cancellable?.cancel() + /// } + /// ``` + /// + /// - Parameter apply: A closure that will be called whenever the `.current` theme changes. + /// This should contain logic to update theme-dependent views. + /// - Returns: An `AnyCancellable` instance that can be used to stop observing the theme changes when needed. + @discardableResult + public func observeThemeChange(_ apply: @escaping () -> Void) -> AnyCancellable { + let cancellable = NotificationCenter.default.publisher( + for: Theme.didChangeThemeNotification + ) + .receive(on: DispatchQueue.main) + .sink { _ in + apply() + } + self.cancellables.append(cancellable) + return cancellable + } + + fileprivate var cancellables: [Any] { + get { + objc_getAssociatedObject(self, Self.cancellablesKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.cancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private static let cancellablesKey = "themeChangeObserverCancellables" +} diff --git a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift index 24be0319..03baf7b0 100644 --- a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift +++ b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift @@ -132,10 +132,10 @@ public struct UniversalColor: Hashable { // MARK: - Properties /// The color used in light mode. - let light: ColorRepresentable + public let light: ColorRepresentable /// The color used in dark mode. - let dark: ColorRepresentable + public let dark: ColorRepresentable // MARK: - Initialization @@ -206,7 +206,7 @@ public struct UniversalColor: Hashable { public func enabled(_ isEnabled: Bool) -> Self { return isEnabled ? self - : self.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity) + : self.withOpacity(Theme.current.layout.disabledOpacity) } /// Returns a new `UniversalColor` by blending the current color with another color. diff --git a/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift b/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift index cd1ec1f0..ccc6cd29 100644 --- a/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift +++ b/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift @@ -158,53 +158,53 @@ extension UniversalFont.Weight { extension UniversalFont { /// Small headline font. public static var smHeadline: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.headline.small + return Theme.current.layout.typography.headline.small } /// Medium headline font. public static var mdHeadline: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.headline.medium + return Theme.current.layout.typography.headline.medium } /// Large headline font. public static var lgHeadline: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.headline.large + return Theme.current.layout.typography.headline.large } /// Small body font. public static var smBody: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.body.small + return Theme.current.layout.typography.body.small } /// Medium body font. public static var mdBody: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.body.medium + return Theme.current.layout.typography.body.medium } /// Large body font. public static var lgBody: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.body.large + return Theme.current.layout.typography.body.large } /// Small button font. public static var smButton: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.button.small + return Theme.current.layout.typography.button.small } /// Medium button font. public static var mdButton: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.button.medium + return Theme.current.layout.typography.button.medium } /// Large button font. public static var lgButton: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.button.large + return Theme.current.layout.typography.button.large } /// Small caption font. public static var smCaption: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.caption.small + return Theme.current.layout.typography.caption.small } /// Medium caption font. public static var mdCaption: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.caption.medium + return Theme.current.layout.typography.caption.medium } /// Large caption font. public static var lgCaption: UniversalFont { - return ComponentsKitConfig.shared.layout.typography.caption.large + return Theme.current.layout.typography.caption.large } } diff --git a/Sources/ComponentsKit/Shared/Types/AnimationScale.swift b/Sources/ComponentsKit/Shared/Types/AnimationScale.swift index 9a6f0695..7d423ee5 100644 --- a/Sources/ComponentsKit/Shared/Types/AnimationScale.swift +++ b/Sources/ComponentsKit/Shared/Types/AnimationScale.swift @@ -21,7 +21,7 @@ extension AnimationScale { /// /// - Returns: /// - `1.0` for `.none` (no scaling). - /// - Predefined values from `ComponentsKitConfig` for `.small`, `.medium`, and `.large`. + /// - Predefined values from `Theme` for `.small`, `.medium`, and `.large`. /// - The custom value provided for `.custom`, constrained between `0.0` and `1.0`. /// - Note: If the custom value is outside the range `0.0–1.0`, an assertion failure occurs, /// and a default value of `1.0` is returned. @@ -30,11 +30,11 @@ extension AnimationScale { case .none: return 1.0 case .small: - return ComponentsKitConfig.shared.layout.animationScale.small + return Theme.current.layout.animationScale.small case .medium: - return ComponentsKitConfig.shared.layout.animationScale.medium + return Theme.current.layout.animationScale.medium case .large: - return ComponentsKitConfig.shared.layout.animationScale.large + return Theme.current.layout.animationScale.large case .custom(let value): guard value >= 0 && value <= 1.0 else { assertionFailure("Animation scale value should be between 0 and 1") diff --git a/Sources/ComponentsKit/Shared/Types/BorderWidth.swift b/Sources/ComponentsKit/Shared/Types/BorderWidth.swift index 2ebb7082..6110b233 100644 --- a/Sources/ComponentsKit/Shared/Types/BorderWidth.swift +++ b/Sources/ComponentsKit/Shared/Types/BorderWidth.swift @@ -19,11 +19,11 @@ extension BorderWidth { case .none: return 0.0 case .small: - return ComponentsKitConfig.shared.layout.borderWidth.small + return Theme.current.layout.borderWidth.small case .medium: - return ComponentsKitConfig.shared.layout.borderWidth.medium + return Theme.current.layout.borderWidth.medium case .large: - return ComponentsKitConfig.shared.layout.borderWidth.large + return Theme.current.layout.borderWidth.large } } } diff --git a/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift b/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift index f8af07c3..f62dad6a 100644 --- a/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift +++ b/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift @@ -24,13 +24,13 @@ extension ComponentRadius { /// /// - Parameter height: The height of the component. Defaults to a large number (10,000) for unrestricted calculations. /// - Returns: The calculated corner radius as a `CGFloat`, capped at half of the height for `full` rounding or custom values. - func value(for height: CGFloat = 10_000) -> CGFloat { + public func value(for height: CGFloat = 10_000) -> CGFloat { let maxValue = height / 2 let value = switch self { case .none: CGFloat(0) - case .small: ComponentsKitConfig.shared.layout.componentRadius.small - case .medium: ComponentsKitConfig.shared.layout.componentRadius.medium - case .large: ComponentsKitConfig.shared.layout.componentRadius.large + case .small: Theme.current.layout.componentRadius.small + case .medium: Theme.current.layout.componentRadius.medium + case .large: Theme.current.layout.componentRadius.large case .full: height / 2 case .custom(let value): value } diff --git a/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift b/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift index 419b988a..ca62838b 100644 --- a/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift +++ b/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift @@ -17,12 +17,12 @@ public enum ContainerRadius: Hashable { } extension ContainerRadius { - var value: CGFloat { + public var value: CGFloat { return switch self { case .none: CGFloat(0) - case .small: ComponentsKitConfig.shared.layout.containerRadius.small - case .medium: ComponentsKitConfig.shared.layout.containerRadius.medium - case .large: ComponentsKitConfig.shared.layout.containerRadius.large + case .small: Theme.current.layout.containerRadius.small + case .medium: Theme.current.layout.containerRadius.medium + case .large: Theme.current.layout.containerRadius.large case .custom(let value): value } } diff --git a/Sources/ComponentsKit/Shared/Types/Paddings.swift b/Sources/ComponentsKit/Shared/Types/Paddings.swift index f6674901..641fbf3c 100644 --- a/Sources/ComponentsKit/Shared/Types/Paddings.swift +++ b/Sources/ComponentsKit/Shared/Types/Paddings.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit /// Defines padding values for each edge. public struct Paddings: Hashable { @@ -54,7 +55,7 @@ public struct Paddings: Hashable { // MARK: - SwiftUI Helpers extension Paddings { - var edgeInsets: EdgeInsets { + public var edgeInsets: EdgeInsets { return EdgeInsets( top: self.top, leading: self.leading, @@ -63,3 +64,16 @@ extension Paddings { ) } } + +// MARK: - UIKit Helpers + +extension Paddings { + public var uiEdgeInsets: UIEdgeInsets { + return UIEdgeInsets( + top: self.top, + left: self.leading, + bottom: self.bottom, + right: self.trailing + ) + } +} diff --git a/Sources/ComponentsKit/Shared/Types/Shadow.swift b/Sources/ComponentsKit/Shared/Types/Shadow.swift index 2be6dd37..ed873a18 100644 --- a/Sources/ComponentsKit/Shared/Types/Shadow.swift +++ b/Sources/ComponentsKit/Shared/Types/Shadow.swift @@ -21,32 +21,32 @@ public enum Shadow: Hashable { } extension Shadow { - var radius: CGFloat { + public var radius: CGFloat { return switch self { case .none: CGFloat(0) - case .small: ComponentsKitConfig.shared.layout.shadow.small.radius - case .medium: ComponentsKitConfig.shared.layout.shadow.medium.radius - case .large: ComponentsKitConfig.shared.layout.shadow.large.radius + case .small: Theme.current.layout.shadow.small.radius + case .medium: Theme.current.layout.shadow.medium.radius + case .large: Theme.current.layout.shadow.large.radius case .custom(let radius, _, _): radius } } - var offset: CGSize { + public var offset: CGSize { return switch self { case .none: .zero - case .small: ComponentsKitConfig.shared.layout.shadow.small.offset - case .medium: ComponentsKitConfig.shared.layout.shadow.medium.offset - case .large: ComponentsKitConfig.shared.layout.shadow.large.offset + case .small: Theme.current.layout.shadow.small.offset + case .medium: Theme.current.layout.shadow.medium.offset + case .large: Theme.current.layout.shadow.large.offset case .custom(_, let offset, _): offset } } - var color: UniversalColor { + public var color: UniversalColor { return switch self { case .none: .clear - case .small: ComponentsKitConfig.shared.layout.shadow.small.color - case .medium: ComponentsKitConfig.shared.layout.shadow.medium.color - case .large: ComponentsKitConfig.shared.layout.shadow.large.color + case .small: Theme.current.layout.shadow.small.color + case .medium: Theme.current.layout.shadow.medium.color + case .large: Theme.current.layout.shadow.large.color case .custom(_, _, let color): color } } @@ -55,7 +55,7 @@ extension Shadow { // MARK: - UIKit + Shadow extension UIView { - func shadow(_ shadow: Shadow) { + public func shadow(_ shadow: Shadow) { self.layer.shadowRadius = shadow.radius self.layer.shadowOffset = shadow.offset self.layer.shadowColor = shadow.color.cgColor @@ -66,7 +66,7 @@ extension UIView { // MARK: - SwiftUI + Shadow extension View { - func shadow(_ shadow: Shadow) -> some View { + public func shadow(_ shadow: Shadow) -> some View { self.shadow( color: shadow.color.color, radius: shadow.radius, diff --git a/Sources/ComponentsKit/Shared/Types/SubmitType.swift b/Sources/ComponentsKit/Shared/Types/SubmitType.swift index 5169f4bb..aab22ce7 100644 --- a/Sources/ComponentsKit/Shared/Types/SubmitType.swift +++ b/Sources/ComponentsKit/Shared/Types/SubmitType.swift @@ -26,7 +26,7 @@ public enum SubmitType { // MARK: - UIKit Helpers extension SubmitType { - var returnKeyType: UIReturnKeyType { + public var returnKeyType: UIReturnKeyType { switch self { case .done: return .done @@ -53,7 +53,7 @@ extension SubmitType { // MARK: - SwiftUI Helpers extension SubmitType { - var submitLabel: SubmitLabel { + public var submitLabel: SubmitLabel { switch self { case .done: return .done diff --git a/Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift b/Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift index c8159e67..cedc6c77 100644 --- a/Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift +++ b/Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift @@ -14,7 +14,7 @@ public enum TextAutocapitalization { } extension TextAutocapitalization { - var textAutocapitalizationType: UITextAutocapitalizationType { + public var textAutocapitalizationType: UITextAutocapitalizationType { switch self { case .never: return .none @@ -29,7 +29,7 @@ extension TextAutocapitalization { } extension TextAutocapitalization { - var textInputAutocapitalization: TextInputAutocapitalization { + public var textInputAutocapitalization: TextInputAutocapitalization { switch self { case .never: return .never diff --git a/Sources/ComponentsKit/Configuration/Layout.swift b/Sources/ComponentsKit/Theme/Layout.swift similarity index 95% rename from Sources/ComponentsKit/Configuration/Layout.swift rename to Sources/ComponentsKit/Theme/Layout.swift index 24c83a28..c79f51a2 100644 --- a/Sources/ComponentsKit/Configuration/Layout.swift +++ b/Sources/ComponentsKit/Theme/Layout.swift @@ -1,12 +1,12 @@ import Foundation -extension ComponentsKitConfig { +extension Theme { /// A structure that defines the layout-related configurations for components in the framework. - public struct Layout: Initializable, Updatable { + public struct Layout: Initializable, Updatable, Equatable { // MARK: - Radius /// A structure representing radius values for components. - public struct Radius { + public struct Radius: Equatable { /// The small radius size. public var small: CGFloat /// The medium radius size. @@ -30,7 +30,7 @@ extension ComponentsKitConfig { // MARK: - BorderWidth /// A structure representing border width values for components. - public struct BorderWidth { + public struct BorderWidth: Equatable { /// The small border width. public var small: CGFloat /// The medium border width. @@ -56,7 +56,7 @@ extension ComponentsKitConfig { /// A structure representing animation scale values for components. /// /// The values must be between `0.0` and `1.0`. - public struct AnimationScale { + public struct AnimationScale: Equatable { /// The small animation scale. public var small: CGFloat /// The medium animation scale. @@ -88,7 +88,7 @@ extension ComponentsKitConfig { // MARK: - Shadow /// A structure that defines the parameters for a shadow effect. - public struct ShadowParams { + public struct ShadowParams: Equatable { /// The blur radius of the shadow. /// /// A larger radius results in a more diffuse shadow. @@ -119,7 +119,7 @@ extension ComponentsKitConfig { } /// A structure that defines shadow presets for small, medium, and large shadows. - public struct Shadow { + public struct Shadow: Equatable { /// The shadow parameters for a small shadow. public var small: ShadowParams @@ -147,7 +147,7 @@ extension ComponentsKitConfig { // MARK: - Typography /// A structure representing a set of fonts for different component sizes. - public struct FontSet { + public struct FontSet: Equatable { /// The small font. public var small: UniversalFont /// The medium font. @@ -169,7 +169,7 @@ extension ComponentsKitConfig { } /// A structure representing typography settings for various components. - public struct Typography { + public struct Typography: Equatable { /// The font set for headlines. public var headline: FontSet /// The font set for body text. @@ -260,7 +260,7 @@ extension ComponentsKitConfig { headline: .init( small: .system(size: 14, weight: .semibold), medium: .system(size: 20, weight: .semibold), - large: .system(size: 28, weight: .semibold) + large: .system(size: 24, weight: .semibold) ), body: .init( small: .system(size: 14, weight: .regular), diff --git a/Sources/ComponentsKit/Configuration/Palette.swift b/Sources/ComponentsKit/Theme/Palette.swift similarity index 72% rename from Sources/ComponentsKit/Configuration/Palette.swift rename to Sources/ComponentsKit/Theme/Palette.swift index 0d0bb5e1..fb435dfc 100644 --- a/Sources/ComponentsKit/Configuration/Palette.swift +++ b/Sources/ComponentsKit/Theme/Palette.swift @@ -1,8 +1,8 @@ import Foundation -extension ComponentsKitConfig { +extension Theme { /// Defines a set of colors that are used for styling components and interfaces. - public struct Palette: Initializable, Updatable { + public struct Palette: Initializable, Updatable, Equatable { /// The color for the main background of the interface. public var background: UniversalColor = .themed( light: .hex("#FFFFFF"), @@ -122,23 +122,23 @@ extension ComponentsKitConfig { extension ComponentColor { /// The primary color. public static var primary: Self { - return ComponentsKitConfig.shared.colors.primary + return Theme.current.colors.primary } /// The accent color. public static var accent: Self { - return ComponentsKitConfig.shared.colors.accent + return Theme.current.colors.accent } /// The success state color, used for indicating positive actions or statuses. public static var success: Self { - return ComponentsKitConfig.shared.colors.success + return Theme.current.colors.success } /// The warning state color, used for indicating caution or non-critical alerts. public static var warning: Self { - return ComponentsKitConfig.shared.colors.warning + return Theme.current.colors.warning } /// The danger state color, used for indicating errors, destructive actions, or critical alerts. public static var danger: Self { - return ComponentsKitConfig.shared.colors.danger + return Theme.current.colors.danger } } @@ -161,58 +161,98 @@ extension UniversalColor { extension UniversalColor { /// The color for the main background of the interface. public static var background: Self { - return ComponentsKitConfig.shared.colors.background + return Theme.current.colors.background } /// The color for the secondary background of the interface. public static var secondaryBackground: Self { - return ComponentsKitConfig.shared.colors.secondaryBackground + return Theme.current.colors.secondaryBackground } /// The color for text labels that contain primary content. public static var foreground: Self { - return ComponentsKitConfig.shared.colors.foreground + return Theme.current.colors.foreground } /// The color for text labels that contain secondary content. public static var secondaryForeground: Self { - return ComponentsKitConfig.shared.colors.secondaryForeground + return Theme.current.colors.secondaryForeground } /// The color for thin borders or divider lines. public static var divider: Self { - return ComponentsKitConfig.shared.colors.divider + return Theme.current.colors.divider } /// The first content color. public static var content1: Self { - return ComponentsKitConfig.shared.colors.content1 + return Theme.current.colors.content1 } /// The second content color. public static var content2: Self { - return ComponentsKitConfig.shared.colors.content2 + return Theme.current.colors.content2 } /// The third content color. public static var content3: Self { - return ComponentsKitConfig.shared.colors.content3 + return Theme.current.colors.content3 } /// The forth content color. public static var content4: Self { - return ComponentsKitConfig.shared.colors.content4 + return Theme.current.colors.content4 } /// The primary color. public static var primary: Self { - return ComponentsKitConfig.shared.colors.primary.main + return Theme.current.colors.primary.main + } + /// The primary background color. + public static var primaryBackground: Self { + return Theme.current.colors.primary.background + } + /// The primary contrast color. + public static var primaryContrast: Self { + return Theme.current.colors.primary.contrast } /// The accent color. public static var accent: Self { - return ComponentsKitConfig.shared.colors.accent.main + return Theme.current.colors.accent.main + } + /// The accent background color. + public static var accentBackground: Self { + return Theme.current.colors.accent.background + } + /// The accent contrast color. + public static var accentContrast: Self { + return Theme.current.colors.accent.contrast } /// The success state color, used for indicating positive actions or statuses. public static var success: Self { - return ComponentsKitConfig.shared.colors.success.main + return Theme.current.colors.success.main + } + /// The success background color. + public static var successBackground: Self { + return Theme.current.colors.success.background + } + /// The success contrast color. + public static var successContrast: Self { + return Theme.current.colors.success.contrast } /// The warning state color, used for indicating caution or non-critical alerts. public static var warning: Self { - return ComponentsKitConfig.shared.colors.warning.main + return Theme.current.colors.warning.main + } + /// The warning background color. + public static var warningBackground: Self { + return Theme.current.colors.warning.background + } + /// The warning contrast color. + public static var warningContrast: Self { + return Theme.current.colors.warning.contrast } /// The danger state color, used for indicating errors, destructive actions, or critical alerts. public static var danger: Self { - return ComponentsKitConfig.shared.colors.danger.main + return Theme.current.colors.danger.main + } + /// The danger background color. + public static var dangerBackground: Self { + return Theme.current.colors.danger.background + } + /// The danger contrast color. + public static var dangerContrast: Self { + return Theme.current.colors.danger.contrast } } diff --git a/Sources/ComponentsKit/Theme/Theme.swift b/Sources/ComponentsKit/Theme/Theme.swift new file mode 100644 index 00000000..01aac7e4 --- /dev/null +++ b/Sources/ComponentsKit/Theme/Theme.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A predefined set of colors and layout attributes that ensure visual consistency across the +/// application. +public struct Theme: Initializable, Updatable, Equatable { + // MARK: - Properties + + /// The palette of colors. + public var colors: Palette = .init() + + /// The layout configuration. + public var layout: Layout = .init() + + // MARK: - Initialization + + /// Initializes a new `Theme` instance with default values. + public init() {} +} + +// MARK: - Theme + Current + +extension Theme { + /// A notification that is triggered when a theme changes. + public static let didChangeThemeNotification = Notification.Name("didChangeThemeNotification") + + /// A current instance of `Theme` for global use. + /// + /// Triggers `Theme.didChangeThemeNotification` notification when the value changes. + public static var current = Self() { + didSet { + NotificationCenter.default.post( + name: Self.didChangeThemeNotification, + object: nil + ) + } + } +}