diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 00000000..4ac3e82b --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,18 @@ +name: SwiftLint + +on: + pull_request: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Run SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict diff --git a/.swiftlint.yml b/.swiftlint.yml index fe357b2e..68835cbc 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -166,7 +166,7 @@ only_rules: - is_disjoint # Tuples shouldn’t have too many members. Create a custom type instead - - large_tuple + # - large_tuple # Prefer using .last(where:) over .filter { }.last in collections - last_where @@ -409,7 +409,6 @@ function_body_length: included: - Sources - - Examples excluded: - .swiftpm diff --git a/ComponentsKit.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ComponentsKit.xcworkspace/xcshareddata/swiftpm/Package.resolved index 33f42961..0770c436 100644 --- a/ComponentsKit.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ComponentsKit.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,15 +8,6 @@ "revision" : "78e39facca2cc459a135655cae0e9feb5a587892", "version" : "1.0.0" } - }, - { - "identity" : "swiftlintplugins", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", - "state" : { - "revision" : "7c80ce6f142164b0201871e580b021d1b2c69804", - "version" : "0.57.0" - } } ], "version" : 2 diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift new file mode 100644 index 00000000..bc195431 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/ModalPreview+Helpers.swift @@ -0,0 +1,246 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct ModalPreviewHelpers { + // MARK: - Enums + + enum ContentBody { + case shortText + case longText + } + enum ContentFooter { + case button + case buttonAndCheckbox + } + + // MARK: - Preview Sections + + struct ContentSection: View { + @Binding var model: VM + @Binding var hasHeader: Bool + @Binding var contentBody: ContentBody + @Binding var contentFooter: ContentFooter? + + var body: some View { + Section("Content") { + Picker("Header", selection: self.$hasHeader) { + Text("Text").tag(true) + Text("None").tag(false) + } + Picker("Body", selection: self.$contentBody) { + Text("Short Text").tag(ContentBody.shortText) + Text("Long Text").tag(ContentBody.longText) + } + Picker("Footer", selection: .init( + get: { + return self.contentFooter + }, + set: { newValue in + if newValue == nil { + self.model.closesOnOverlayTap = true + } + self.contentFooter = newValue + } + )) { + Text("Button").tag(ContentFooter.button) + Text("Button and Checkbox").tag(ContentFooter.buttonAndCheckbox) + Text("None").tag(Optional.none) + } + } + } + } + + struct PropertiesSection: View { + @Binding var model: VM + @Binding var footer: ContentFooter? + @ViewBuilder var additionalPickers: () -> Pickers + + var body: some View { + 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) + } + BorderWidthPicker(selection: self.$model.borderWidth) + Toggle("Closes On Overlay Tap", isOn: self.$model.closesOnOverlayTap) + .disabled(self.footer == nil) + Picker("Outer Paddings", selection: self.$model.outerPaddings) { + Text("12px").tag(Paddings(padding: 12)) + Text("16px").tag(Paddings(padding: 16)) + Text("20px").tag(Paddings(padding: 20)) + } + Picker("Content Spacing", selection: self.$model.contentSpacing) { + Text("8px").tag(CGFloat(8)) + Text("12px").tag(CGFloat(12)) + Text("16px").tag(CGFloat(16)) + } + Picker("Content Paddings", selection: self.$model.contentPaddings) { + Text("12px").tag(Paddings(padding: 12)) + Text("16px").tag(Paddings(padding: 16)) + Text("20px").tag(Paddings(padding: 20)) + } + ContainerRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom 30px").tag(ContainerRadius.custom(30)) + } + OverlayStylePicker(selection: self.$model.overlayStyle) + Picker("Size", selection: self.$model.size) { + Text("Small").tag(ModalSize.small) + Text("Medium").tag(ModalSize.medium) + Text("Large").tag(ModalSize.large) + Text("Full").tag(ModalSize.full) + } + TransitionPicker(selection: self.$model.transition) + self.additionalPickers() + } + } + } + + // MARK: - Shared UI + + private static let headerTitle = "Header" + private static let headerFont: UniversalFont = .system(size: 20, weight: .bold) + + private static let bodyShortText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + private static let bodyLongText = """ +Lorem ipsum odor amet, consectetuer adipiscing elit. Vitae vehicula pellentesque lectus orci fames. Cras suscipit dui tortor penatibus turpis ultrices. Laoreet montes adipiscing ante dapibus facilisis. Lorem per fames nec duis quis eleifend imperdiet. Tincidunt id interdum adipiscing eros dis quis platea varius. Potenti eleifend eu molestie laoreet varius sapien. Adipiscing nascetur platea penatibus curabitur tempus nibh laoreet porttitor. Augue et curabitur cras sed semper inceptos nunc montes mollis. + +Lectus arcu pellentesque inceptos tempor fringilla nascetur. Erat curae convallis integer mi, quis facilisi tortor. Phasellus aliquam molestie vehicula odio in dis maximus diam elit. Rutrum gravida amet euismod feugiat fusce. Est egestas velit vulputate senectus sociosqu fringilla eget nibh. Nam pellentesque aenean mi platea tincidunt quam sem purus. Himenaeos suspendisse nec sapien habitasse ultricies maecenas libero odio. Rutrum senectus maximus ultrices, ad nam ultricies placerat. + +Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros risus nibh morbi eu aenean. Velit ligula magnis aliquet at luctus. Dapibus vestibulum consectetur euismod vitae per ultrices litora quis. Aptent eleifend dapibus urna lacinia felis nisl. Sit amet fusce nullam feugiat posuere. Urna amet curae velit fermentum interdum vestibulum penatibus. Penatibus vivamus sem ultricies pellentesque congue id mattis diam. Aliquam efficitur mi gravida sollicitudin; amet imperdiet. Rutrum mollis risus justo tortor in duis cursus. +""" + private static let bodyFont: UniversalFont = .system(size: 18, weight: .regular) + + private static let footerButtonVM = ButtonVM { + $0.title = "Close" + $0.isFullWidth = true + $0.color = .primary + } + private static let footerCheckboxVM = CheckboxVM { + $0.title = "Agree and continue" + } + + // MARK: - UIKit + + static func ukHeader(hasHeader: Bool) -> UKModalController.Content? { + guard hasHeader else { + return nil + } + + return { _ in + let title = UILabel() + title.text = self.headerTitle + title.font = self.headerFont.uiFont + return title + } + } + + static func ukBody(body: ContentBody) -> UKModalController.Content { + return { _ in + let subtitle = UILabel() + switch body { + case .shortText: + subtitle.text = self.bodyShortText + case .longText: + subtitle.text = self.bodyLongText + } + subtitle.numberOfLines = 0 + subtitle.font = self.bodyFont.uiFont + return subtitle + } + } + + static func ukFooter(footer: ContentFooter?) -> UKModalController.Content? { + return footer.map { footer in + return { dismiss in + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 16 + + let button = UKButton( + model: self.footerButtonVM, + action: { dismiss(true) } + ) + stackView.addArrangedSubview(button) + + switch footer { + case .button: + button.model.isEnabled = true + case .buttonAndCheckbox: + button.model.isEnabled = false + let checkbox = UKCheckbox( + initialValue: false, + model: self.footerCheckboxVM, + onValueChange: { isSelected in + button.model.isEnabled = isSelected + } + ) + stackView.insertArrangedSubview(checkbox, at: 0) + } + + return stackView + } + } + } + + // MARK: - SwiftUI + + static func suHeader(hasHeader: Bool) -> some View { + Group { + if hasHeader { + HStack { + Text(self.headerTitle) + .font(self.headerFont.font) + Spacer() + } + } else { + EmptyView() + } + } + } + + static func suBody(body: ContentBody) -> some View { + Group { + switch body { + case .shortText: + Text(self.bodyShortText) + case .longText: + Text(self.bodyLongText) + } + } + .font(self.bodyFont.font) + .multilineTextAlignment(.leading) + } + + static func suFooter( + isPresented: Binding, + isCheckboxSelected: Binding, + footer: ContentFooter? + ) -> some View { + Group { + switch footer { + case .none: + EmptyView() + case .button: + SUButton(model: self.footerButtonVM) { + isPresented.wrappedValue = false + } + case .buttonAndCheckbox: + VStack(alignment: .leading, spacing: 16) { + SUCheckbox( + isSelected: isCheckboxSelected, + model: self.footerCheckboxVM + ) + SUButton(model: self.footerButtonVM.updating { + $0.isEnabled = isCheckboxSelected.wrappedValue + }) { + isPresented.wrappedValue = false + } + } + } + } + } +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift index 621847a1..4645fa94 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift @@ -32,6 +32,21 @@ struct AutocapitalizationPicker: View { } } +// MARK: - BorderWidthPicker + +struct BorderWidthPicker: View { + @Binding var selection: BorderWidth + + var body: some View { + Picker("Border Width", selection: self.$selection) { + Text("None").tag(BorderWidth.none) + Text("Small").tag(BorderWidth.small) + Text("Medium").tag(BorderWidth.medium) + Text("Large").tag(BorderWidth.large) + } + } +} + // MARK: - ComponentColorPicker struct ComponentColorPicker: View { @@ -40,15 +55,14 @@ struct ComponentColorPicker: View { var body: some View { Picker("Color", selection: self.$selection) { Text("Primary").tag(ComponentColor.primary) - Text("Secondary").tag(ComponentColor.secondary) Text("Accent").tag(ComponentColor.accent) Text("Success").tag(ComponentColor.success) Text("Warning").tag(ComponentColor.warning) Text("Danger").tag(ComponentColor.danger) Text("Custom").tag(ComponentColor( main: .universal(.uiColor(.systemPurple)), - contrast: .universal(.uiColor(.systemYellow))) - ) + contrast: .universal(.uiColor(.systemYellow)) + )) } } } @@ -62,22 +76,21 @@ struct ComponentOptionalColorPicker: View { Picker("Color", selection: self.$selection) { Text("Default").tag(Optional.none) Text("Primary").tag(ComponentColor.primary) - Text("Secondary").tag(ComponentColor.secondary) Text("Accent").tag(ComponentColor.accent) Text("Success").tag(ComponentColor.success) Text("Warning").tag(ComponentColor.warning) Text("Danger").tag(ComponentColor.danger) Text("Custom").tag(ComponentColor( main: .universal(.uiColor(.systemPurple)), - contrast: .universal(.uiColor(.systemYellow))) - ) + contrast: .universal(.uiColor(.systemYellow)) + )) } } } // MARK: - CornerRadiusPicker -struct CornerRadiusPicker: View { +struct ComponentRadiusPicker: View { @Binding var selection: ComponentRadius @ViewBuilder var custom: () -> Custom @@ -93,18 +106,99 @@ struct CornerRadiusPicker: View { } } -// MARK: - FontPicker +struct ContainerRadiusPicker: View { + @Binding var selection: ContainerRadius + @ViewBuilder var custom: () -> Custom + + var body: some View { + Picker("Corner Radius", selection: self.$selection) { + Text("None").tag(ContainerRadius.none) + Text("Small").tag(ContainerRadius.small) + Text("Medium").tag(ContainerRadius.medium) + Text("Large").tag(ContainerRadius.large) + self.custom() + } + } +} + +// MARK: - FontPickers -struct FontPicker: View { +struct BodyFontPicker: View { + let title: String @Binding var selection: UniversalFont? + + init(title: String = "Font", selection: Binding) { + self.title = title + self._selection = selection + } var body: some View { - Picker("Font", selection: self.$selection) { + Picker(self.title, selection: self.$selection) { Text("Default").tag(Optional.none) - Text("Small").tag(UniversalFont.Component.small) - Text("Medium").tag(UniversalFont.Component.medium) - Text("Large").tag(UniversalFont.Component.large) - Text("Custom: system bold of size 18").tag(UniversalFont.system(size: 18, weight: .bold)) + Text("Small").tag(UniversalFont.smBody) + Text("Medium").tag(UniversalFont.mdBody) + Text("Large").tag(UniversalFont.lgBody) + Text("Custom: system semibold of size 16").tag(UniversalFont.system(size: 16, weight: .semibold)) + } + } +} + +struct ButtonFontPicker: View { + let title: String + @Binding var selection: UniversalFont? + + init(title: String = "Font", selection: Binding) { + self.title = title + self._selection = selection + } + + var body: some View { + Picker(self.title, selection: self.$selection) { + 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)) + } + } +} + +struct HeadlineFontPicker: View { + let title: String + @Binding var selection: UniversalFont? + + init(title: String = "Font", selection: Binding) { + self.title = title + self._selection = selection + } + + var body: some View { + Picker(self.title, selection: self.$selection) { + Text("Default").tag(Optional.none) + Text("Small").tag(UniversalFont.smHeadline) + Text("Medium").tag(UniversalFont.mdHeadline) + Text("Large").tag(UniversalFont.lgHeadline) + Text("Custom: system bold of size 20").tag(UniversalFont.system(size: 20, weight: .bold)) + } + } +} + +struct CaptionFontPicker: View { + let title: String + @Binding var selection: UniversalFont? + + init(title: String = "Font", selection: Binding) { + self.title = title + self._selection = selection + } + + var body: some View { + Picker(self.title, selection: self.$selection) { + Text("Default").tag(Optional.none) + Text("Small").tag(UniversalFont.smCaption) + Text("Medium").tag(UniversalFont.mdCaption) + Text("Large").tag(UniversalFont.lgCaption) + Text("Custom: system semibold of size 12").tag(UniversalFont.system(size: 12, weight: .semibold)) } } } @@ -132,6 +226,20 @@ struct KeyboardTypePicker: View { } } +// MARK: - OverlayStylePicker + +struct OverlayStylePicker: View { + @Binding var selection: ModalOverlayStyle + + var body: some View { + Picker("Overlay Style", selection: self.$selection) { + Text("Blurred").tag(ModalOverlayStyle.blurred) + Text("Dimmed").tag(ModalOverlayStyle.dimmed) + Text("Transparent").tag(ModalOverlayStyle.transparent) + } + } +} + // MARK: - SizePicker struct SizePicker: View { @@ -164,6 +272,21 @@ struct SubmitTypePicker: View { } } +// MARK: - TransitionPicker + +struct TransitionPicker: View { + @Binding var selection: ModalTransition + + var body: some View { + Picker("Transition", selection: self.$selection) { + Text("None").tag(ModalTransition.none) + Text("Fast").tag(ModalTransition.fast) + Text("Normal").tag(ModalTransition.normal) + Text("Slow").tag(ModalTransition.slow) + } + } +} + // MARK: - UniversalColorPicker struct UniversalColorPicker: View { @@ -173,7 +296,6 @@ struct UniversalColorPicker: View { var body: some View { Picker(self.title, selection: self.$selection) { Text("Primary").tag(UniversalColor.primary) - Text("Secondary").tag(UniversalColor.secondary) Text("Accent").tag(UniversalColor.accent) Text("Success").tag(UniversalColor.success) Text("Warning").tag(UniversalColor.warning) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewWrapper.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewWrapper.swift index 06497702..755174c8 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewWrapper.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewWrapper.swift @@ -5,8 +5,6 @@ struct PreviewWrapper: View { let title: String @ViewBuilder let content: () -> Content - @Environment(\.colorScheme) private var colorScheme - var body: some View { ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) { self.content() @@ -21,8 +19,8 @@ struct PreviewWrapper: View { LinearGradient( gradient: Gradient( colors: [ - Palette.Brand.blue.color(for: self.colorScheme), - Palette.Brand.purple.color(for: self.colorScheme) + UniversalColor.blue.color, + UniversalColor.purple.color, ] ), startPoint: .topLeading, @@ -42,3 +40,16 @@ struct PreviewWrapper: View { .padding(.horizontal) } } + +// MARK: - Colors + +extension UniversalColor { + fileprivate static let blue: Self = .themed( + light: .hex("#3684F8"), + dark: .hex("#0058DB") + ) + fileprivate static let purple: Self = .themed( + light: .hex("#A920FD"), + dark: .hex("#7800C1") + ) +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UIApplication+TopViewController.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UIApplication+TopViewController.swift new file mode 100644 index 00000000..0949f7ac --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UIApplication+TopViewController.swift @@ -0,0 +1,34 @@ +import UIKit + +extension UIApplication { + var topViewController: UIViewController? { + var topViewController: UIViewController? + + if #available(iOS 13, *) { + for scene in self.connectedScenes { + if let windowScene = scene as? UIWindowScene { + for window in windowScene.windows { + if window.isKeyWindow { + topViewController = window.rootViewController + } + } + } + } + } else { + topViewController = self.keyWindow?.rootViewController + } + + while true { + if let presented = topViewController?.presentedViewController { + topViewController = presented + } else if let navController = topViewController as? UINavigationController { + topViewController = navController.topViewController + } else if let tabBarController = topViewController as? UITabBarController { + topViewController = tabBarController.selectedViewController + } else { + break + } + } + return topViewController + } +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UKComponentPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UKComponentPreview.swift index f5e6d43c..b87bc7fd 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UKComponentPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UKComponentPreview.swift @@ -38,9 +38,9 @@ struct UKComponentPreview: UIViewRepresentable where View: UKCompon let model: Model let view: View - init(model: Model, view: @escaping () -> View) { - self.view = view() - self.model = model + init(view: View) { + self.view = view + self.model = view.model } func makeUIView(context: Context) -> Container { @@ -51,3 +51,9 @@ struct UKComponentPreview: UIViewRepresentable where View: UKCompon container.component.model = self.model } } + +extension UKComponent { + var preview: some View { + UKComponentPreview(view: self) + } +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift new file mode 100644 index 00000000..e791b250 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift @@ -0,0 +1,176 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct AlertPreview: View { + @State var isAlertPresented: Bool = false + @State private var model = AlertVM { + $0.title = Self.alertTitle + $0.message = AlertMessage.short.rawValue + $0.primaryButton = Self.initialPrimaryButton + $0.secondaryButton = Self.initialSecondaryButton + } + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKButton(model: .init { $0.title = "Show Alert" }) { + UIApplication.shared.topViewController?.present( + UKAlertController(model: self.model), + animated: true + ) + } + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUButton(model: .init { $0.title = "Show Alert" }) { + self.isAlertPresented = true + } + .suAlert( + isPresented: self.$isAlertPresented, + model: self.model + ) + } + Form { + Section("Title") { + Toggle("Has Title", isOn: .init( + get: { return self.model.title != nil }, + set: { newValue in + self.model.title = newValue ? Self.alertTitle : nil + } + )) + } + + Section("Message") { + Picker("Alert Message", selection: self.$model.message) { + Text("None").tag(Optional.none) + Text("Short").tag(AlertMessage.short.rawValue) + Text("Long").tag(AlertMessage.long.rawValue) + } + } + + Section("Primary Button") { + Toggle("Has Primary Button", isOn: .init( + get: { return self.model.primaryButton != nil }, + set: { newValue in + self.model.primaryButton = newValue ? Self.initialPrimaryButton : nil + } + )) + if self.model.primaryButton != nil { + Picker("Title", selection: self.primaryButtonVMOrDefault.title) { + Text("Short").tag(PrimaryButtonText.short.rawValue) + Text("Longer").tag(PrimaryButtonText.longer.rawValue) + } + self.buttonPickers(for: self.primaryButtonVMOrDefault) + } + } + + Section("Secondary Button") { + Toggle("Has Secondary Button", isOn: .init( + get: { return self.model.secondaryButton != nil }, + set: { newValue in + self.model.secondaryButton = newValue ? Self.initialSecondaryButton : nil + } + )) + if self.model.secondaryButton != nil { + Picker("Title", selection: self.secondaryButtonVMOrDefault.title) { + Text("Short").tag(SecondaryButtonText.short.rawValue) + Text("Longer").tag(SecondaryButtonText.longer.rawValue) + } + self.buttonPickers(for: self.secondaryButtonVMOrDefault) + } + } + + 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) + } + BorderWidthPicker(selection: self.$model.borderWidth) + Toggle("Closes On Overlay Tap", isOn: self.$model.closesOnOverlayTap) + Picker("Content Paddings", selection: self.$model.contentPaddings) { + Text("12px").tag(Paddings(padding: 12)) + Text("16px").tag(Paddings(padding: 16)) + Text("20px").tag(Paddings(padding: 20)) + } + ContainerRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom 30px").tag(ContainerRadius.custom(30)) + } + OverlayStylePicker(selection: self.$model.overlayStyle) + TransitionPicker(selection: self.$model.transition) + } + } + } + } + + // MARK: - Reusable Pickers + + private func buttonPickers(for buttonVM: Binding) -> some View { + Group { + AnimationScalePicker(selection: buttonVM.animationScale) + ComponentOptionalColorPicker(selection: buttonVM.color) + ComponentRadiusPicker(selection: buttonVM.cornerRadius) { + Text("Custom: 20px").tag(ComponentRadius.custom(20)) + } + Picker("Style", selection: buttonVM.style) { + Text("Filled").tag(ButtonStyle.filled) + Text("Plain").tag(ButtonStyle.plain) + Text("Light").tag(ButtonStyle.light) + Text("Bordered with small border").tag(ButtonStyle.bordered(.small)) + Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) + Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) + } + } + } + + // MARK: - Helpers + + enum AlertMessage: String { + case short = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + case long = """ +Lorem ipsum odor amet, consectetuer adipiscing elit. Vitae vehicula pellentesque lectus orci fames. Cras suscipit dui tortor penatibus turpis ultrices. Laoreet montes adipiscing ante dapibus facilisis. Lorem per fames nec duis quis eleifend imperdiet. Tincidunt id interdum adipiscing eros dis quis platea varius. Potenti eleifend eu molestie laoreet varius sapien. Adipiscing nascetur platea penatibus curabitur tempus nibh laoreet porttitor. Augue et curabitur cras sed semper inceptos nunc montes mollis. + +Lectus arcu pellentesque inceptos tempor fringilla nascetur. Erat curae convallis integer mi, quis facilisi tortor. Phasellus aliquam molestie vehicula odio in dis maximus diam elit. Rutrum gravida amet euismod feugiat fusce. Est egestas velit vulputate senectus sociosqu fringilla eget nibh. Nam pellentesque aenean mi platea tincidunt quam sem purus. Himenaeos suspendisse nec sapien habitasse ultricies maecenas libero odio. Rutrum senectus maximus ultrices, ad nam ultricies placerat. + +Enim habitant laoreet inceptos scelerisque senectus, tellus molestie ut. Eros risus nibh morbi eu aenean. Velit ligula magnis aliquet at luctus. Dapibus vestibulum consectetur euismod vitae per ultrices litora quis. Aptent eleifend dapibus urna lacinia felis nisl. Sit amet fusce nullam feugiat posuere. Urna amet curae velit fermentum interdum vestibulum penatibus. Penatibus vivamus sem ultricies pellentesque congue id mattis diam. Aliquam efficitur mi gravida sollicitudin; amet imperdiet. Rutrum mollis risus justo tortor in duis cursus. +""" + } + enum PrimaryButtonText: String { + case short = "Continue" + case longer = "Remind me later" + } + enum SecondaryButtonText: String { + case short = "Cancel" + case longer = "Cancel, Don't Do That" + } + static let alertTitle = "Alert Title" + static let initialPrimaryButton = AlertButtonVM { + $0.title = PrimaryButtonText.short.rawValue + $0.style = .filled + $0.color = .primary + } + static let initialSecondaryButton = AlertButtonVM { + $0.title = SecondaryButtonText.short.rawValue + $0.style = .light + } + + var primaryButtonVMOrDefault: Binding { + return .init( + get: { self.model.primaryButton ?? Self.initialPrimaryButton }, + set: { self.model.primaryButton = $0 } + ) + } + var secondaryButtonVMOrDefault: Binding { + return .init( + get: { self.model.secondaryButton ?? Self.initialSecondaryButton }, + set: { self.model.secondaryButton = $0 } + ) + } +} + +#Preview { + AlertPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift new file mode 100644 index 00000000..88931092 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift @@ -0,0 +1,75 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct BottomModalPreview: View { + @State var model = BottomModalVM() + + @State var isModalPresented: Bool = false + @State var isCheckboxSelected: Bool = false + + @State var hasHeader = true + @State var contentBody: ModalPreviewHelpers.ContentBody = .shortText + @State var contentFooter: ModalPreviewHelpers.ContentFooter? = .buttonAndCheckbox + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKButton(model: .init { $0.title = "Show Modal" }) { + UIApplication.shared.topViewController?.present( + UKBottomModalController( + model: self.model, + header: ModalPreviewHelpers.ukHeader(hasHeader: self.hasHeader), + body: ModalPreviewHelpers.ukBody(body: self.contentBody), + footer: ModalPreviewHelpers.ukFooter(footer: self.contentFooter) + ), + animated: true + ) + } + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUButton(model: .init { $0.title = "Show Modal" }) { + self.isModalPresented = true + } + .bottomModal( + isPresented: self.$isModalPresented, + model: self.model, + header: { + ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) + }, + body: { + ModalPreviewHelpers.suBody(body: self.contentBody) + }, + footer: { + ModalPreviewHelpers.suFooter( + isPresented: self.$isModalPresented, + isCheckboxSelected: self.$isCheckboxSelected, + footer: self.contentFooter + ) + } + ) + } + Form { + ModalPreviewHelpers.ContentSection( + model: self.$model, + hasHeader: self.$hasHeader, + contentBody: self.$contentBody, + contentFooter: self.$contentFooter + ) + ModalPreviewHelpers.PropertiesSection( + model: self.$model, + footer: self.$contentFooter, + additionalPickers: { + Toggle("Draggable", isOn: self.$model.isDraggable) + Toggle("Hides On Swap", isOn: self.$model.hidesOnSwap) + } + ) + } + } + } +} + +#Preview { + BottomModalPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index c095f040..df58ad2e 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift @@ -10,30 +10,29 @@ struct ButtonPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKButton(model: self.model) - } + UKButton(model: self.model) + .preview } PreviewWrapper(title: "SwiftUI") { SUButton(model: self.model) } Form { AnimationScalePicker(selection: self.$model.animationScale) - ComponentColorPicker(selection: self.$model.color) - CornerRadiusPicker(selection: self.$model.cornerRadius) { + ComponentOptionalColorPicker(selection: self.$model.color) + ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) } - FontPicker(selection: self.$model.font) + ButtonFontPicker(selection: self.$model.font) Toggle("Enabled", isOn: self.$model.isEnabled) Toggle("Full Width", isOn: self.$model.isFullWidth) SizePicker(selection: self.$model.size) Picker("Style", selection: self.$model.style) { Text("Filled").tag(ButtonStyle.filled) Text("Plain").tag(ButtonStyle.plain) + Text("Light").tag(ButtonStyle.light) Text("Bordered with small border").tag(ButtonStyle.bordered(.small)) Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) - Text("Bordered with custom border: 6px").tag(ButtonStyle.bordered(.custom(6))) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift new file mode 100644 index 00000000..c64ae340 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift @@ -0,0 +1,83 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct CardPreview: View { + @State private var model = CardVM() + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKCard(model: self.model, content: self.ukCardContent) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUCard(model: self.model, content: self.suCardContent) + } + Form { + 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) + } + BorderWidthPicker(selection: self.$model.borderWidth) + Picker("Content Paddings", selection: self.$model.contentPaddings) { + Text("12px").tag(Paddings(padding: 12)) + Text("16px").tag(Paddings(padding: 16)) + Text("20px").tag(Paddings(padding: 20)) + } + ContainerRadiusPicker(selection: self.$model.cornerRadius) { + Text("Custom 4px").tag(ContainerRadius.custom(4)) + } + Picker("Shadow", selection: self.$model.shadow) { + Text("None").tag(Shadow.none) + 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)) + } + } + } + } + + // MARK: - Helpers + + private func ukCardContent() -> UIView { + let titleLabel = UILabel() + titleLabel.text = "Card" + titleLabel.font = UniversalFont.mdHeadline.uiFont + titleLabel.textColor = UniversalColor.foreground.uiColor + titleLabel.numberOfLines = 0 + + let subtitleLabel = UILabel() + subtitleLabel.text = "Card is a container for text, images, and other content." + subtitleLabel.font = UniversalFont.mdBody.uiFont + subtitleLabel.textColor = UniversalColor.secondaryForeground.uiColor + subtitleLabel.numberOfLines = 0 + + let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stackView.axis = .vertical + stackView.spacing = 8 + + return stackView + } + + private func suCardContent() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Card") + .foregroundStyle(UniversalColor.foreground.color) + .font(UniversalFont.mdHeadline.font) + + Text("Card is a container for text, images, and other content.") + .foregroundStyle(UniversalColor.secondaryForeground.color) + .font(UniversalFont.mdBody.font) + } + } +} + +#Preview { + CardPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift new file mode 100644 index 00000000..24db6a02 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift @@ -0,0 +1,74 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct CenterModalPreview: View { + @State var model = CenterModalVM() + + @State var isModalPresented: Bool = false + @State var isCheckboxSelected: Bool = false + + @State var hasHeader = true + @State var contentBody: ModalPreviewHelpers.ContentBody = .shortText + @State var contentFooter: ModalPreviewHelpers.ContentFooter? = .buttonAndCheckbox + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKButton(model: .init { $0.title = "Show Modal" }) { + UIApplication.shared.topViewController?.present( + UKCenterModalController( + model: self.model, + header: ModalPreviewHelpers.ukHeader(hasHeader: self.hasHeader), + body: ModalPreviewHelpers.ukBody(body: self.contentBody), + footer: ModalPreviewHelpers.ukFooter(footer: self.contentFooter) + ), + animated: true + ) + } + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUButton(model: .init { $0.title = "Show Modal" }) { + self.isModalPresented = true + } + .centerModal( + isPresented: self.$isModalPresented, + model: self.model, + header: { + ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) + }, + body: { + ModalPreviewHelpers.suBody(body: self.contentBody) + }, + footer: { + ModalPreviewHelpers.suFooter( + isPresented: self.$isModalPresented, + isCheckboxSelected: self.$isCheckboxSelected, + footer: self.contentFooter + ) + } + ) + } + Form { + ModalPreviewHelpers.ContentSection( + model: self.$model, + hasHeader: self.$hasHeader, + contentBody: self.$contentBody, + contentFooter: self.$contentFooter + ) + ModalPreviewHelpers.PropertiesSection( + model: self.$model, + footer: self.$contentFooter, + additionalPickers: { + EmptyView() + } + ) + } + } + } +} + +#Preview { + CenterModalPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CheckboxPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CheckboxPreview.swift index e30bdfe6..a656f4b2 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CheckboxPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CheckboxPreview.swift @@ -12,12 +12,11 @@ struct CheckboxPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKCheckbox( - initialValue: false, - model: self.model - ) - } + UKCheckbox( + initialValue: false, + model: self.model + ) + .preview } PreviewWrapper(title: "SwiftUI") { SUCheckbox( @@ -32,10 +31,10 @@ struct CheckboxPreview: View { Text("Long").tag("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") } ComponentColorPicker(selection: self.$model.color) - CornerRadiusPicker(selection: self.$model.cornerRadius) { + ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 2px").tag(ComponentRadius.custom(2)) } - FontPicker(selection: self.$model.font) + BodyFontPicker(selection: self.$model.font) Toggle("Enabled", isOn: self.$model.isEnabled) SizePicker(selection: self.$model.size) } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift new file mode 100644 index 00000000..d95cd978 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift @@ -0,0 +1,53 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct CountdownPreview: View { + @State private var model = CountdownVM() + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKCountdown(model: self.model) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUCountdown(model: self.model) + } + Form { + ComponentOptionalColorPicker(selection: self.$model.color) + Picker("Locale", selection: self.$model.locale) { + Text("Current").tag(Locale.current) + Text("EN").tag(Locale(identifier: "en")) + Text("ES").tag(Locale(identifier: "es")) + Text("FR").tag(Locale(identifier: "fr")) + Text("DE").tag(Locale(identifier: "de")) + Text("ZH").tag(Locale(identifier: "zh")) + Text("JA").tag(Locale(identifier: "ja")) + Text("RU").tag(Locale(identifier: "ru")) + Text("AR").tag(Locale(identifier: "ar")) + Text("HI").tag(Locale(identifier: "hi")) + Text("PT").tag(Locale(identifier: "pt")) + } + HeadlineFontPicker(title: "Main Font", selection: self.$model.mainFont) + CaptionFontPicker(title: "Secondary Font", selection: self.$model.secondaryFont) + SizePicker(selection: self.$model.size) + Picker("Style", selection: self.$model.style) { + Text("Plain").tag(CountdownVM.Style.plain) + Text("Light").tag(CountdownVM.Style.light) + } + Picker("Units Style", selection: self.$model.unitsStyle) { + Text("None").tag(CountdownVM.UnitsStyle.hidden) + Text("Bottom").tag(CountdownVM.UnitsStyle.bottom) + Text("Trailing").tag(CountdownVM.UnitsStyle.trailing) + } + DatePicker("Until Date", selection: self.$model.until, in: Date()..., displayedComponents: [.date, .hourAndMinute]) + .datePickerStyle(.compact) + } + } + } +} + +#Preview { + CountdownPreview() +} diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/DividerPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/DividerPreview.swift index e1712595..971e01cd 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/DividerPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/DividerPreview.swift @@ -8,29 +8,30 @@ struct DividerPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKDivider(model: self.model) - } + UKDivider(model: self.model) + .preview } PreviewWrapper(title: "SwiftUI") { SUDivider(model: self.model) } Form { + Picker("Color", selection: self.$model.color) { + Text("Default").tag(Optional.none) + Text("Primary").tag(ComponentColor.primary) + Text("Accent").tag(ComponentColor.accent) + Text("Success").tag(ComponentColor.success) + Text("Warning").tag(ComponentColor.warning) + Text("Danger").tag(ComponentColor.danger) + Text("Custom").tag(ComponentColor( + main: .universal(.uiColor(.systemPurple)), + contrast: .universal(.uiColor(.systemYellow)) + )) + } Picker("Orientation", selection: self.$model.orientation) { - Text("Horizontal").tag(DividerVM.DividerOrientation.horizontal) - Text("Vertical").tag(DividerVM.DividerOrientation.vertical) + Text("Horizontal").tag(DividerVM.Orientation.horizontal) + Text("Vertical").tag(DividerVM.Orientation.vertical) } SizePicker(selection: self.$model.size) - Picker("Color", selection: self.$model.color) { - Text("Default").tag(Palette.Base.divider) - Text("Primary").tag(UniversalColor.primary) - Text("Secondary").tag(UniversalColor.secondary) - Text("Accent").tag(UniversalColor.accent) - Text("Success").tag(UniversalColor.success) - Text("Warning").tag(UniversalColor.warning) - Text("Danger").tag(UniversalColor.danger) - Text("Custom").tag(UniversalColor.universal(.uiColor(.systemPurple))) - } } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift index 8889c82d..3851467d 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift @@ -4,21 +4,25 @@ import SwiftUI import UIKit struct InputFieldPreview: View { - @State private var model = InputFieldVM { - $0.title = "Title" - } + @State private var model = Self.initialModel @State private var text: String = "" @FocusState private var isFocused: Bool - @ObservedObject private var inputField = PreviewInputField() + @ObservedObject private var inputField = PreviewInputField(model: Self.initialModel) var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - self.inputField - } + self.inputField + .preview + .onAppear { + self.inputField.text = "" + self.inputField.model = Self.initialModel + } + .onChange(of: self.model) { newValue in + self.inputField.model = newValue + } } PreviewWrapper(title: "SwiftUI") { SUInputField( @@ -31,11 +35,11 @@ struct InputFieldPreview: View { AutocapitalizationPicker(selection: self.$model.autocapitalization) Toggle("Autocorrection Enabled", isOn: self.$model.isAutocorrectionEnabled) ComponentOptionalColorPicker(selection: self.$model.color) - CornerRadiusPicker(selection: self.$model.cornerRadius) { + ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) } Toggle("Enabled", isOn: self.$model.isEnabled) - FontPicker(selection: self.$model.font) + BodyFontPicker(selection: self.$model.font) KeyboardTypePicker(selection: self.$model.keyboardType) Toggle("Placeholder", isOn: .init( get: { @@ -74,6 +78,12 @@ struct InputFieldPreview: View { } } } + + private static var initialModel: InputFieldVM { + return .init { + $0.title = "Title" + } + } } private final class PreviewInputField: UKInputField, ObservableObject, UITextFieldDelegate { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift index bc5d1e5f..3ee062f6 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift @@ -8,9 +8,8 @@ struct LoadingPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKLoading(model: self.model) - } + UKLoading(model: self.model) + .preview } PreviewWrapper(title: "SwiftUI") { SULoading(model: self.model) @@ -23,7 +22,7 @@ struct LoadingPreview: View { } SizePicker(selection: self.$model.size) Picker("Style", selection: self.$model.style) { - Text("Spinner").tag(LoadingStyle.spinner) + Text("Spinner").tag(LoadingVM.Style.spinner) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift index 613968d6..3fdfc9e5 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift @@ -23,9 +23,8 @@ struct RadioGroupPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKRadioGroup(model: self.model) - } + UKRadioGroup(model: self.model) + .preview } PreviewWrapper(title: "SwiftUI") { SURadioGroup(selectedId: $selectedId, model: self.model) @@ -34,7 +33,7 @@ struct RadioGroupPreview: View { AnimationScalePicker(selection: self.$model.animationScale) UniversalColorPicker(title: "Color", selection: self.$model.color) Toggle("Enabled", isOn: self.$model.isEnabled) - FontPicker(selection: self.$model.font) + BodyFontPicker(selection: self.$model.font) SizePicker(selection: self.$model.size) Picker("Spacing", selection: self.$model.spacing) { Text("8px").tag(CGFloat(8)) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift index e780da90..6566bdeb 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift @@ -28,12 +28,11 @@ struct SegmentedControlPreview: View { var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - UKSegmentedControl( - selectedId: .iPad, - model: self.model - ) - } + UKSegmentedControl( + selectedId: .iPad, + model: self.model + ) + .preview } PreviewWrapper(title: "SwiftUI") { SUSegmentedControl( @@ -43,11 +42,11 @@ struct SegmentedControlPreview: View { } Form { ComponentOptionalColorPicker(selection: self.$model.color) - CornerRadiusPicker(selection: self.$model.cornerRadius) { + ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 4px").tag(ComponentRadius.custom(4)) } Toggle("Enabled", isOn: self.$model.isEnabled) - FontPicker(selection: self.$model.font) + BodyFontPicker(selection: self.$model.font) Toggle("Full Width", isOn: self.$model.isFullWidth) SizePicker(selection: self.$model.size) } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift index 78df725c..978ee110 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift @@ -4,23 +4,25 @@ import SwiftUI import UIKit struct TextInputPreviewPreview: View { - @State private var model = TextInputVM { - $0.placeholder = "Placeholder" - $0.minRows = 1 - $0.maxRows = nil - } + @State private var model = Self.initialModel @State private var text: String = "" @FocusState private var isFocused: Bool - @ObservedObject private var textInput = PreviewTextInput() + @ObservedObject private var textInput = PreviewTextInput(model: Self.initialModel) var body: some View { VStack { PreviewWrapper(title: "UIKit") { - UKComponentPreview(model: self.model) { - self.textInput - } + self.textInput + .preview + .onAppear { + self.textInput.text = "" + self.textInput.model = Self.initialModel + } + .onChange(of: self.model) { newValue in + self.textInput.model = newValue + } } PreviewWrapper(title: "SwiftUI") { SUTextInput( @@ -33,20 +35,21 @@ struct TextInputPreviewPreview: View { AutocapitalizationPicker(selection: self.$model.autocapitalization) Toggle("Autocorrection Enabled", isOn: self.$model.isAutocorrectionEnabled) ComponentOptionalColorPicker(selection: self.$model.color) - CornerRadiusPicker(selection: self.$model.cornerRadius) { + ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) } Toggle("Enabled", isOn: self.$model.isEnabled) - FontPicker(selection: self.$model.font) + BodyFontPicker(selection: self.$model.font) KeyboardTypePicker(selection: self.$model.keyboardType) Picker("Max Rows", selection: self.$model.maxRows) { - Text("2 Rows").tag(2) Text("3 Rows").tag(3) + Text("4 Rows").tag(4) Text("No Limit").tag(Optional.none) } Picker("Min Rows", selection: self.$model.minRows) { Text("1 Row").tag(1) Text("2 Rows").tag(2) + Text("3 Rows").tag(3) } Toggle("Placeholder", isOn: .init( get: { @@ -75,6 +78,14 @@ struct TextInputPreviewPreview: View { } } } + + private static var initialModel: TextInputVM { + return .init { + $0.placeholder = "Placeholder" + $0.minRows = 2 + $0.maxRows = nil + } + } } private final class PreviewTextInput: UKTextInput, ObservableObject { diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index de547a4d..1ffaa23f 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -5,45 +5,47 @@ struct App: View { var body: some View { NavigationView { List { - Section("Components") { - NavigationLinkWithTitle("Button") { - ButtonPreview() - } - NavigationLinkWithTitle("Checkbox") { - CheckboxPreview() - } - NavigationLinkWithTitle("Divider") { - DividerPreview() - } - NavigationLinkWithTitle("Input Field") { - InputFieldPreview() - } - NavigationLinkWithTitle("Loading") { - LoadingPreview() - } - NavigationLinkWithTitle("Radio Group") { - RadioGroupPreview() - } - NavigationLinkWithTitle("Segmented Control") { - SegmentedControlPreview() - } - NavigationLinkWithTitle("Text Field") { - TextInputPreviewPreview() - } + NavigationLinkWithTitle("Alert") { + AlertPreview() } - - Section("Login Demo") { - NavigationLinkWithTitle("SwiftUI") { - SwiftUILogin() - } - NavigationLinkWithTitle("UIKit") { - UIViewControllerRepresenting { - UIKitLogin() - } - } + NavigationLinkWithTitle("Button") { + ButtonPreview() + } + NavigationLinkWithTitle("Card") { + CardPreview() + } + NavigationLinkWithTitle("Checkbox") { + CheckboxPreview() + } + NavigationLinkWithTitle("Countdown") { + CountdownPreview() + } + NavigationLinkWithTitle("Divider") { + DividerPreview() + } + NavigationLinkWithTitle("Input Field") { + InputFieldPreview() + } + NavigationLinkWithTitle("Loading") { + LoadingPreview() + } + NavigationLinkWithTitle("Modal (Bottom)") { + BottomModalPreview() + } + NavigationLinkWithTitle("Modal (Center)") { + CenterModalPreview() + } + NavigationLinkWithTitle("Radio Group") { + RadioGroupPreview() + } + NavigationLinkWithTitle("Segmented Control") { + SegmentedControlPreview() + } + NavigationLinkWithTitle("Text Input") { + TextInputPreviewPreview() } } - .navigationTitle("Examples") + .navigationTitle("Components") .navigationBarTitleDisplayMode(.inline) } } diff --git a/Examples/DemosApp/DemosApp/Demos/Login/SwiftUILogin.swift b/Examples/DemosApp/DemosApp/Demos/Login/SwiftUILogin.swift deleted file mode 100644 index f04208c9..00000000 --- a/Examples/DemosApp/DemosApp/Demos/Login/SwiftUILogin.swift +++ /dev/null @@ -1,164 +0,0 @@ -import ComponentsKit -import SwiftUI - -struct SwiftUILogin: View { - enum Pages { - case signIn - case signUp - } - enum Input { - case name - case email - case password - case bio - } - - @State private var selectedPage = Pages.signIn - - @State private var name = "" - @State private var email = "" - @State private var password = "" - @State private var bio = "" - - @FocusState private var focusedInput: Input? - @State private var isConsented: Bool = false - @State private var isLoading = false - - private var isButtonEnabled: Bool { - return !self.email.isEmpty - && !self.password.isEmpty - && self.isConsented - && ( - self.selectedPage == .signUp && !self.name.isEmpty - || self.selectedPage == .signIn - ) - } - - @Environment(\.colorScheme) var colorScheme - - var body: some View { - ZStack { - Palette.Base.background.color(for: self.colorScheme) - - ScrollView { - VStack(spacing: 20) { - SUSegmentedControl( - selectedId: self.$selectedPage, - model: .init { - $0.items = [ - .init(id: .signIn) { - $0.title = "Sign In" - }, - .init(id: .signUp) { - $0.title = "Sign Up" - } - ] - $0.isEnabled = !self.isLoading - } - ) - - Text( - self.selectedPage == .signIn - ? "Welcome back" - : "Create an account" - ) - .font(.system(size: 30, weight: .bold)) - .padding(.vertical, 30) - - if self.selectedPage == .signUp { - SUInputField( - text: self.$name, - globalFocus: self.$focusedInput, - localFocus: .name, - model: .init { - $0.title = "Name" - $0.isRequired = true - $0.isEnabled = !self.isLoading - } - ) - } - SUInputField( - text: self.$email, - globalFocus: self.$focusedInput, - localFocus: .email, - model: .init { - $0.title = "Email" - $0.isRequired = true - $0.isEnabled = !self.isLoading - } - ) - SUInputField( - text: self.$password, - globalFocus: self.$focusedInput, - localFocus: .password, - model: .init { - $0.title = "Password" - $0.isRequired = true - $0.isSecureInput = true - $0.isEnabled = !self.isLoading - } - ) - - if self.selectedPage == .signUp { - SUTextInput( - text: self.$bio, - globalFocus: self.$focusedInput, - localFocus: .bio, - model: .init { - $0.placeholder = "Tell about yourself" - $0.minRows = 3 - $0.maxRows = 5 - $0.isEnabled = !self.isLoading - } - ) - } - - SUCheckbox( - isSelected: self.$isConsented, - model: .init { - $0.title = "By continuing, you accept our Terms of Service and Privacy Policy" - $0.isEnabled = !self.isLoading - } - ) - - Group { - if self.isLoading { - SULoading() - } else { - SUButton( - model: .init { - $0.title = "Continue" - $0.isFullWidth = true - $0.isEnabled = self.isButtonEnabled - }, - action: { - self.isLoading = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.isLoading = false - UINotificationFeedbackGenerator().notificationOccurred(.success) - } - } - ) - } - } - .padding(.top, 10) - } - .padding() - } - } - .frame(maxWidth: 500) - .onChange(of: self.selectedPage) { newValue in - if newValue == .signIn, - self.focusedInput == .name || self.focusedInput == .bio { - self.focusedInput = .email - } - } - .onTapGesture { - self.focusedInput = nil - } - } -} - -#Preview { - SwiftUILogin() -} diff --git a/Examples/DemosApp/DemosApp/Demos/Login/UIKitLogin.swift b/Examples/DemosApp/DemosApp/Demos/Login/UIKitLogin.swift deleted file mode 100644 index 8630464a..00000000 --- a/Examples/DemosApp/DemosApp/Demos/Login/UIKitLogin.swift +++ /dev/null @@ -1,233 +0,0 @@ -import ComponentsKit -import SwiftUI -import UIKit - -final class UIKitLogin: UIViewController { - enum Pages { - case signIn - case signUp - } - - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.delaysContentTouches = false - return scrollView - }() - private lazy var stackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.spacing = 20 - stackView.alignment = .center - return stackView - }() - - private let pageControl = UKSegmentedControl( - selectedId: .signIn, - model: .init { - $0.items = [ - .init(id: .signIn) { - $0.title = "Sign In" - }, - .init(id: .signUp) { - $0.title = "Sign Up" - } - ] - } - ) - private let titleLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 30, weight: .bold) - return label - }() - private let nameInput = UKInputField( - model: .init { - $0.title = "Name" - $0.isRequired = true - } - ) - private let emailInput = UKInputField( - model: .init { - $0.title = "Email" - $0.isRequired = true - } - ) - private let passwordInput = UKInputField( - model: .init { - $0.title = "Password" - $0.isRequired = true - $0.isSecureInput = true - } - ) - private let bioInput = UKTextInput( - model: .init { - $0.placeholder = "Tell about yourself" - $0.minRows = 3 - $0.maxRows = 5 - } - ) - private let consentCheckbox = UKCheckbox( - model: .init { - $0.title = "By continuing, you accept our Terms of Service and Privacy Policy" - } - ) - private let continueButton = UKButton( - model: .init { - $0.title = "Continue" - $0.isFullWidth = true - } - ) - private let loader = UKLoading() - - private var isLoading = false { - didSet { self.update() } - } - - private var isButtonEnabled: Bool { - return !self.emailInput.text.isEmpty - && !self.passwordInput.text.isEmpty - && self.consentCheckbox.isSelected - && ( - self.pageControl.selectedId == .signUp && !self.nameInput.text.isEmpty - || self.pageControl.selectedId == .signIn - ) - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.setup() - self.style() - self.layout() - self.update() - } - - private func setup() { - self.view.addSubview(self.scrollView) - self.scrollView.addSubview(self.stackView) - self.scrollView.addSubview(self.loader) - - self.stackView.addArrangedSubview(self.pageControl) - self.stackView.addArrangedSubview(self.titleLabel) - self.stackView.addArrangedSubview(self.nameInput) - self.stackView.addArrangedSubview(self.emailInput) - self.stackView.addArrangedSubview(self.passwordInput) - self.stackView.addArrangedSubview(self.bioInput) - self.stackView.addArrangedSubview(self.consentCheckbox) - self.stackView.addArrangedSubview(self.continueButton) - - self.pageControl.onSelectionChange = { [weak self] _ in - guard let self else { return } - - self.dismissKeyboard() - self.update() - } - self.nameInput.onValueChange = { [weak self] _ in - self?.update() - } - self.emailInput.onValueChange = { [weak self] _ in - self?.update() - } - self.passwordInput.onValueChange = { [weak self] _ in - self?.update() - } - self.consentCheckbox.onValueChange = { [weak self] _ in - self?.update() - } - self.continueButton.action = { - self.isLoading = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.isLoading = false - UINotificationFeedbackGenerator().notificationOccurred(.success) - } - } - - self.scrollView.addGestureRecognizer( - UITapGestureRecognizer( - target: self, - action: #selector(self.dismissKeyboard) - ) - ) - } - - @objc private func dismissKeyboard() { - self.nameInput.resignFirstResponder() - self.emailInput.resignFirstResponder() - self.passwordInput.resignFirstResponder() - self.bioInput.resignFirstResponder() - } - - private func style() { - self.scrollView.backgroundColor = Palette.Base.background.uiColor - - self.stackView.setCustomSpacing(50, after: self.pageControl) - self.stackView.setCustomSpacing(50, after: self.titleLabel) - self.stackView.setCustomSpacing(50, after: self.consentCheckbox) - } - - private func layout() { - self.scrollView.allEdges() - - self.stackView.top(20) - self.stackView.bottom(20) - - self.stackView.leadingAnchor.constraint( - greaterThanOrEqualTo: self.view.leadingAnchor, - constant: 20 - ).isActive = true - self.stackView.trailingAnchor.constraint( - lessThanOrEqualTo: self.view.trailingAnchor, - constant: -20 - ).isActive = true - self.stackView.widthAnchor.constraint( - lessThanOrEqualToConstant: 500 - ).isActive = true - self.stackView.centerHorizontally() - - self.loader.below(self.stackView, padding: 50) - self.loader.centerHorizontally() - } - - private func update() { - switch self.pageControl.selectedId { - case .signIn: - self.nameInput.isHidden = true - self.bioInput.isHidden = true - self.titleLabel.text = "Welcome back" - case .signUp: - self.nameInput.isHidden = false - self.bioInput.isHidden = false - self.titleLabel.text = "Create an account" - } - - self.pageControl.model.update { - $0.isEnabled = !self.isLoading - } - self.nameInput.model.update { - $0.isEnabled = !self.isLoading - } - self.emailInput.model.update { - $0.isEnabled = !self.isLoading - } - self.passwordInput.model.update { - $0.isEnabled = !self.isLoading - } - self.consentCheckbox.model.update { - $0.isEnabled = !self.isLoading - } - self.bioInput.model.update { - $0.isEnabled = !self.isLoading - } - self.continueButton.model.update { [weak self] in - guard let self else { return } - $0.isEnabled = self.isButtonEnabled - } - self.loader.isHidden = !self.isLoading - self.continueButton.isHidden = self.isLoading - } -} - -#Preview { - UIViewControllerRepresenting { - UIKitLogin() - } -} diff --git a/Examples/DemosApp/DemosApp/Helpers/Palette+Colors.swift b/Examples/DemosApp/DemosApp/Helpers/Palette+Colors.swift deleted file mode 100644 index b6010622..00000000 --- a/Examples/DemosApp/DemosApp/Helpers/Palette+Colors.swift +++ /dev/null @@ -1,14 +0,0 @@ -import ComponentsKit - -extension Palette { - enum Brand { - static let blue: UniversalColor = .themed( - light: .hex("#3684F8"), - dark: .hex("#0058DB") - ) - static let purple: UniversalColor = .themed( - light: .hex("#A920FD"), - dark: .hex("#7800C1") - ) - } -} diff --git a/Examples/DemosApp/DemosApp/Helpers/UIViewControllerRepresenting.swift b/Examples/DemosApp/DemosApp/Helpers/UIViewControllerRepresenting.swift deleted file mode 100644 index 6533405a..00000000 --- a/Examples/DemosApp/DemosApp/Helpers/UIViewControllerRepresenting.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI -import UIKit - -struct UIViewControllerRepresenting: UIViewControllerRepresentable { - private let controller: ViewController - - init(_ controller: () -> ViewController) { - self.controller = controller() - } - - func makeUIViewController(context: Context) -> some UIViewController { - return self.controller - } - func updateUIViewController( - _ uiViewController: UIViewControllerType, - context: Context - ) {} -} diff --git a/Package.swift b/Package.swift index 12b83793..3bed349e 100644 --- a/Package.swift +++ b/Package.swift @@ -15,16 +15,12 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/componentskit/AutoLayout", from: "1.0.0"), - .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.57.0"), ], targets: [ .target( name: "ComponentsKit", dependencies: [ .product(name: "AutoLayout", package: "AutoLayout") - ], - plugins: [ - .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") ] ) ] diff --git a/README.md b/README.md index ec3fcb90..fd707200 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ inputField.resignFirstResponder() **Config** -The library comes with predefined 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 config: ```swift ComponentsKitConfig.shared.update { @@ -89,7 +89,7 @@ ComponentsKitConfig.shared.update { $0.colors.primary = ... // Update layout - $0.layout.componentFont.medium = ... + $0.layout.componentRadius.medium = ... } ``` @@ -120,47 +120,42 @@ All colors from the config can be used within the app. For example: ```swift // in UIKit -view.backgroundColor = Palette.Base.background.uiColor +view.backgroundColor = UniversalColor.background.uiColor // in SwiftUI -@Environment(\.colorScheme) var colorScheme -Palette.Base.background.color(for: colorScheme) +UniversalColor.background.color ``` -If you want to use additional colors that are not included in the config, you can extend `Palette`: +If you want to use additional colors that are not included in the config, you can extend `UniversalColor`: ```swift -extension Palette { - enum MyColors { - static var special: UniversalColor { - if selectedTheme == .halloween { - return ... - } else { - return ... - } +extension UniversalColor { + static var special: UniversalColor { + if selectedTheme == .halloween { + return ... + } else { + return ... } } } // Then in your class let view = UIView() -view.backgroundColor = Palette.MyColors.special.uiColor +view.backgroundColor = UniversalColor.special.uiColor ``` **Extend Fonts** -The config defines only three font sizes, but if you want to use semantic font values in your app, you can extend the `UniversalFont` struct: +If you want to use additional fonts that are not included in the config, you can extend `UniversalFont`: ```swift extension UniversalFont { - enum Text { - static let body: UniversalFont = .system(size: 16, weight: .regular) - } + static let title: UniversalFont = .system(size: 16, weight: .regular) } // Then in your view Text("Hello, World") - .font(UniversalFont.Text.body.font) + .font(UniversalFont.title.font) ``` You can also extend `UniversalFont` for easier access to custom fonts: diff --git a/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift new file mode 100644 index 00000000..3312394f --- /dev/null +++ b/Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift @@ -0,0 +1,47 @@ +import UIKit + +struct AlertButtonsOrientationCalculator { + enum Orientation { + case vertical + case horizontal + } + + private static let primaryButton = UKButton() + private static let secondaryButton = UKButton() + + private init() {} + + static func preferredOrientation(model: AlertVM) -> Orientation { + guard let primaryButtonVM = model.primaryButtonVM, + let secondaryButtonVM = model.secondaryButtonVM else { + return .vertical + } + + self.primaryButton.model = primaryButtonVM.updating { + $0.isFullWidth = false + } + self.secondaryButton.model = secondaryButtonVM.updating { + $0.isFullWidth = false + } + + let primaryButtonWidth = self.primaryButton.intrinsicContentSize.width + let secondaryButtonWidth = self.secondaryButton.intrinsicContentSize.width + + // Since the `maxWidth` of the alert is always less than the width of the + // screen, we can assume that the width of the container is equal to this + // `maxWidth` value. + let containerWidth = model.modalVM.size.maxWidth + let availableButtonsWidth = containerWidth + - AlertVM.buttonsSpacing + - model.contentPaddings.leading + - model.contentPaddings.trailing + let availableButtonWidth = availableButtonsWidth / 2 + + if primaryButtonWidth <= availableButtonWidth, + secondaryButtonWidth <= availableButtonWidth { + return .horizontal + } else { + return .vertical + } + } +} diff --git a/Sources/ComponentsKit/Components/Alert/Models/AlertButtonVM.swift b/Sources/ComponentsKit/Components/Alert/Models/AlertButtonVM.swift new file mode 100644 index 00000000..10289e80 --- /dev/null +++ b/Sources/ComponentsKit/Components/Alert/Models/AlertButtonVM.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A model that defines the appearance properties for a button in the alert. +public struct AlertButtonVM: ComponentVM { + /// The text displayed on the button. + public var title: String = "" + + /// The scaling factor for the button's press animation, with a value between 0 and 1. + /// + /// Defaults to `.medium`. + public var animationScale: AnimationScale = .medium + + /// The color of the button. + public var color: ComponentColor? + + /// The corner radius of the button. + /// + /// Defaults to `.medium`. + public var cornerRadius: ComponentRadius = .medium + + /// The visual style of the button. + /// + /// Defaults to `.filled`. + public var style: ButtonStyle = .filled + + /// Initializes a new instance of `AlertButtonVM` with default values. + public init() {} +} diff --git a/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift new file mode 100644 index 00000000..3fdbd192 --- /dev/null +++ b/Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift @@ -0,0 +1,108 @@ +import Foundation + +/// A model that defines the appearance properties for an alert. +public struct AlertVM: ComponentVM { + /// The title of the alert. + public var title: String? + + /// The message of the alert. + public var message: String? + + /// The modal that defines the appearance properties for a primary button in the alert. + /// + /// If it is `nil`, the primary button will not be displayed. + public var primaryButton: AlertButtonVM? + + /// The modal that defines the appearance properties for a secondary button in the alert. + /// + /// If it is `nil`, the secondary button will not be displayed. + public var secondaryButton: AlertButtonVM? + + /// The background color of the modal. + public var backgroundColor: UniversalColor? + + /// The border thickness of the alert. + /// + /// Defaults to `.small`. + public var borderWidth: BorderWidth = .small + + /// A Boolean value indicating whether the modal should close when tapping on the overlay. + /// + /// Defaults to `false`. + public var closesOnOverlayTap: Bool = false + + /// The padding applied to the modal's content area. + /// + /// Defaults to a padding value of `16` for all sides. + public var contentPaddings: Paddings = .init(padding: 16) + + /// The corner radius of the modal. + /// + /// Defaults to `.medium`. + public var cornerRadius: ContainerRadius = .medium + + /// The style of the overlay displayed behind the modal. + /// + /// Defaults to `.dimmed`. + public var overlayStyle: ModalOverlayStyle = .dimmed + + /// The transition duration of the modal's appearance and dismissal animations. + /// + /// Defaults to `.fast`. + public var transition: ModalTransition = .fast + + /// Initializes a new instance of `AlertVM` with default values. + public init() {} +} + +// MARK: - Helpers + +extension AlertVM { + var modalVM: CenterModalVM { + return CenterModalVM { + $0.backgroundColor = self.backgroundColor + $0.borderWidth = self.borderWidth + $0.closesOnOverlayTap = self.closesOnOverlayTap + $0.contentPaddings = self.contentPaddings + $0.cornerRadius = self.cornerRadius + $0.overlayStyle = self.overlayStyle + $0.transition = self.transition + $0.size = .small + } + } + + var primaryButtonVM: ButtonVM? { + let buttonVM = self.primaryButton.map(self.mapAlertButtonVM) + if self.secondaryButton.isNotNil { + return buttonVM + } else { + return buttonVM ?? Self.defaultButtonVM + } + } + + var secondaryButtonVM: ButtonVM? { + return self.secondaryButton.map(self.mapAlertButtonVM) + } + + private func mapAlertButtonVM(_ model: AlertButtonVM) -> ButtonVM { + return ButtonVM { + $0.title = model.title + $0.animationScale = model.animationScale + $0.color = model.color + $0.cornerRadius = model.cornerRadius + $0.style = model.style + $0.isFullWidth = true + } + } +} + +extension AlertVM { + static let buttonsSpacing: CGFloat = 12 + + static let defaultButtonVM = ButtonVM { + $0.title = "OK" + $0.color = .primary + $0.style = .filled + $0.isFullWidth = true + } +} diff --git a/Sources/ComponentsKit/Components/Alert/SUAlert.swift b/Sources/ComponentsKit/Components/Alert/SUAlert.swift new file mode 100644 index 00000000..517612c9 --- /dev/null +++ b/Sources/ComponentsKit/Components/Alert/SUAlert.swift @@ -0,0 +1,147 @@ +import SwiftUI + +extension View { + /// 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. + /// - model: A model that defines the appearance properties for an alert. + /// - 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 + /// SomeView() + /// .suAlert( + /// isPresented: $isAlertPresented, + /// model: .init { alertVM in + /// alertVM.title = "My Alert" + /// alertVM.message = "This is an alert." + /// alertVM.primaryButton = .init { buttonVM in + /// buttonVM.title = "OK" + /// buttonVM.color = .primary + /// buttonVM.style = .filled + /// } + /// alertVM.secondaryButton = .init { buttonVM in + /// buttonVM.title = "Cancel" + /// buttonVM.style = .light + /// } + /// }, + /// primaryAction: { + /// NSLog("Primary button tapped") + /// }, + /// secondaryAction: { + /// NSLog("Secondary button tapped") + /// }, + /// onDismiss: { + /// print("Alert dismissed") + /// } + /// ) + /// ``` + public func suAlert( + isPresented: Binding, + model: AlertVM, + primaryAction: (() -> Void)? = nil, + secondaryAction: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) -> some View { + return self.modal( + isVisible: isPresented, + 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 + ) + } + } + } + ) + } + ) + } +} + +// 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 + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift new file mode 100644 index 00000000..beae4539 --- /dev/null +++ b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift @@ -0,0 +1,180 @@ +import UIKit + +/// A controller 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. +/// +/// - Example: +/// ```swift +/// let alert = UKAlertController( +/// model: .init { alertVM in +/// alertVM.title = "My Alert" +/// alertVM.message = "This is an alert." +/// alertVM.primaryButton = .init { buttonVM in +/// buttonVM.title = "OK" +/// buttonVM.color = .primary +/// buttonVM.style = .filled +/// } +/// alertVM.secondaryButton = .init { buttonVM in +/// buttonVM.title = "Cancel" +/// buttonVM.style = .light +/// } +/// }, +/// primaryAction: { +/// NSLog("Primary button tapped") +/// }, +/// secondaryAction: { +/// NSLog("Secondary button tapped") +/// } +/// ) +/// +/// vc.present(alert, animated: true) +/// ``` +public class UKAlertController: UKCenterModalController { + // MARK: - Properties + + /// A model that defines the appearance properties for an alert. + public let alertVM: AlertVM + + /// The primary action to be executed when the primary button is tapped. + public var primaryAction: (() -> Void)? + /// The secondary action to be executed when the secondary button is tapped. + public var secondaryAction: (() -> Void)? + + // MARK: - Subviews + + /// The label used to display the title of the alert. + public let titleLabel = UILabel() + /// The label used to display the subtitle or message of the alert. + public let subtitleLabel = UILabel() + /// The button representing the primary action in the alert. + public let primaryButton = UKButton() + /// The button representing the secondary action in the alert. + public let secondaryButton = UKButton() + /// A stack view that arranges the primary and secondary buttons. + public let buttonsStackView = UIStackView() + + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - model: A model that defines the appearance properties for an alert. + /// - primaryAction: An optional closure executed when the primary button is tapped. + /// - secondaryAction: An optional closure executed when the secondary button is tapped. + public init( + model: AlertVM, + primaryAction: (() -> Void)? = nil, + secondaryAction: (() -> Void)? = nil + ) { + self.alertVM = model + + self.primaryAction = primaryAction + self.secondaryAction = secondaryAction + + super.init(model: model.modalVM) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + public override func setup() { + if self.alertVM.title.isNotNilAndEmpty, + self.alertVM.message.isNotNilAndEmpty { + self.header = self.titleLabel + self.body = self.subtitleLabel + } else if self.alertVM.title.isNotNilAndEmpty { + self.body = self.titleLabel + } else { + self.body = self.subtitleLabel + } + self.footer = self.buttonsStackView + + if self.alertVM.primaryButtonVM.isNotNil { + self.buttonsStackView.addArrangedSubview(self.primaryButton) + } + if self.alertVM.secondaryButtonVM.isNotNil { + self.buttonsStackView.addArrangedSubview(self.secondaryButton) + } + + self.primaryButton.action = { [weak self] in + self?.primaryAction?() + self?.dismiss(animated: true) + } + self.secondaryButton.action = { [weak self] in + self?.secondaryAction?() + self?.dismiss(animated: true) + } + + // NOTE: Labels and stack view should be assigned to `header`, `body` + // and `footer` before calling the superview's method, otherwise they + // won't be added to the list of subviews. + super.setup() + } + + // MARK: - Style + + public override func style() { + super.style() + + Self.Style.titleLabel(self.titleLabel, text: self.alertVM.title) + Self.Style.subtitleLabel(self.subtitleLabel, text: self.alertVM.message) + Self.Style.buttonsStackView(self.buttonsStackView) + + if let primaryButtonVM = self.alertVM.primaryButtonVM { + self.primaryButton.model = primaryButtonVM + } + if let secondaryButtonVM = self.alertVM.secondaryButtonVM { + self.secondaryButton.model = secondaryButtonVM + } + } + + // MARK: - Layout + + public override func updateViewConstraints() { + super.updateViewConstraints() + + if self.buttonsStackView.arrangedSubviews.count == 2 { + switch AlertButtonsOrientationCalculator.preferredOrientation(model: self.alertVM) { + case .horizontal: + self.buttonsStackView.removeArrangedSubview(self.secondaryButton) + self.buttonsStackView.insertArrangedSubview(self.secondaryButton, at: 0) + self.buttonsStackView.axis = .horizontal + case .vertical: + self.buttonsStackView.axis = .vertical + } + } else { + self.buttonsStackView.axis = .vertical + } + } +} + +// MARK: - Style Helpers + +extension UKAlertController { + fileprivate enum Style { + static func titleLabel(_ label: UILabel, text: String?) { + label.text = text + label.font = UniversalFont.mdHeadline.uiFont + label.textColor = UniversalColor.foreground.uiColor + label.textAlignment = .center + label.numberOfLines = 0 + } + + static func subtitleLabel(_ label: UILabel, text: String?) { + label.text = text + label.font = UniversalFont.mdBody.uiFont + label.textColor = UniversalColor.secondaryForeground.uiColor + label.textAlignment = .center + label.numberOfLines = 0 + } + + static func buttonsStackView(_ stackView: UIStackView) { + stackView.distribution = .fillEqually + stackView.spacing = AlertVM.buttonsSpacing + } + } +} diff --git a/Sources/ComponentsKit/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift similarity index 75% rename from Sources/ComponentsKit/Button/Models/ButtonVM.swift rename to Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index ad9d8280..1b7ea7a6 100644 --- a/Sources/ComponentsKit/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -11,9 +11,7 @@ public struct ButtonVM: ComponentVM { public var animationScale: AnimationScale = .medium /// The color of the button. - /// - /// Defaults to `.primary`. - public var color: ComponentColor = .primary + public var color: ComponentColor? /// The corner radius of the button. /// @@ -52,41 +50,30 @@ public struct ButtonVM: ComponentVM { // MARK: Shared Helpers extension ButtonVM { - private var mainColor: UniversalColor { - return self.isEnabled - ? self.color.main - : self.color.main.withOpacity( - ComponentsKitConfig.shared.layout.disabledOpacity - ) - } - private var contrastColor: UniversalColor { - return self.isEnabled - ? self.color.contrast - : self.color.contrast.withOpacity( - ComponentsKitConfig.shared.layout.disabledOpacity - ) - } var backgroundColor: UniversalColor? { switch self.style { case .filled: - return self.mainColor + let color = self.color?.main ?? .content2 + return color.enabled(self.isEnabled) + case .light: + let color = self.color?.background ?? .content1 + return color.enabled(self.isEnabled) case .plain, .bordered: return nil } } var foregroundColor: UniversalColor { - switch self.style { + let color = switch self.style { case .filled: - return self.contrastColor - case .plain: - return self.mainColor - case .bordered: - return self.mainColor + self.color?.contrast ?? .foreground + case .plain, .light, .bordered: + self.color?.main ?? .foreground } + return color.enabled(self.isEnabled) } var borderWidth: CGFloat { switch self.style { - case .filled, .plain: + case .filled, .plain, .light: return 0.0 case .bordered(let borderWidth): return borderWidth.value @@ -94,10 +81,14 @@ extension ButtonVM { } var borderColor: UniversalColor? { switch self.style { - case .filled, .plain: + case .filled, .plain, .light: return nil case .bordered: - return self.mainColor + if let color { + return color.main.enabled(self.isEnabled) + } else { + return .divider + } } } var preferredFont: UniversalFont { @@ -107,25 +98,25 @@ extension ButtonVM { switch self.size { case .small: - return UniversalFont.Component.small + return .smButton case .medium: - return UniversalFont.Component.medium + return .mdButton case .large: - return UniversalFont.Component.large + return .mdButton } } var height: CGFloat { return switch self.size { case .small: 36 - case .medium: 50 - case .large: 70 + case .medium: 44 + case .large: 52 } } var horizontalPadding: CGFloat { return switch self.size { - case .small: 8 - case .medium: 12 - case .large: 16 + case .small: 16 + case .medium: 20 + case .large: 24 } } } diff --git a/Sources/ComponentsKit/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift similarity index 62% rename from Sources/ComponentsKit/Button/SUButton.swift rename to Sources/ComponentsKit/Components/Button/SUButton.swift index 9e641844..f9e4ed73 100644 --- a/Sources/ComponentsKit/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -12,9 +12,6 @@ public struct SUButton: View { /// A Boolean value indicating whether the button is pressed. @State public var isPressed: Bool = false - @State private var viewFrame: CGRect = .zero - @Environment(\.colorScheme) private var colorScheme - // MARK: Initialization /// Initializer. @@ -32,36 +29,39 @@ public struct SUButton: View { // MARK: Body public var body: some View { - Text(self.model.title) - .font(self.model.preferredFont.font) - .lineLimit(1) - .padding(.leading, self.model.horizontalPadding) - .padding(.trailing, self.model.horizontalPadding) - .frame(maxWidth: self.model.width) - .frame(height: self.model.height) - .foregroundStyle(self.model.foregroundColor.color(for: self.colorScheme)) - .background( - GeometryReader { proxy in - (self.model.backgroundColor?.color(for: self.colorScheme) ?? Color.clear) - .preference(key: ViewFrameKey.self, value: proxy.frame(in: .local)) - } - ) - .onPreferenceChange(ViewFrameKey.self) { value in - self.viewFrame = value - } - .gesture(DragGesture(minimumDistance: 0.0) + Button(self.model.title, action: self.action) + .buttonStyle(CustomButtonStyle(model: self.model)) + .simultaneousGesture(DragGesture(minimumDistance: 0.0) .onChanged { _ in self.isPressed = true } - .onEnded { value in - defer { self.isPressed = false } - - if self.viewFrame.contains(value.location) { - self.action() - } + .onEnded { _ in + self.isPressed = false } ) .disabled(!self.model.isEnabled) + .scaleEffect( + self.isPressed ? self.model.animationScale.value : 1, + anchor: .center + ) + } +} + +// MARK: - Helpers + +private struct CustomButtonStyle: SwiftUI.ButtonStyle { + let model: ButtonVM + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(self.model.preferredFont.font) + .lineLimit(1) + .padding(.leading, self.model.horizontalPadding) + .padding(.trailing, self.model.horizontalPadding) + .frame(maxWidth: self.model.width) + .frame(height: self.model.height) + .foregroundStyle(self.model.foregroundColor.color) + .background(self.model.backgroundColor?.color ?? .clear) .clipShape( RoundedRectangle( cornerRadius: self.model.cornerRadius.value() @@ -72,23 +72,9 @@ public struct SUButton: View { cornerRadius: self.model.cornerRadius.value() ) .stroke( - self.model.borderColor?.color(for: self.colorScheme) ?? .clear, + self.model.borderColor?.color ?? .clear, lineWidth: self.model.borderWidth ) } - .scaleEffect( - self.isPressed ? self.model.animationScale.value : 1, - anchor: .center - ) - } -} - -// MARK: - Helpers - -private struct ViewFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() } } diff --git a/Sources/ComponentsKit/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift similarity index 96% rename from Sources/ComponentsKit/Button/UKButton.swift rename to Sources/ComponentsKit/Components/Button/UKButton.swift index e2f75257..d2b35b4f 100644 --- a/Sources/ComponentsKit/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -157,13 +157,7 @@ open class UKButton: UIView, UKComponent { ) { super.touchesCancelled(touches, with: event) - defer { self.isPressed = false } - - if self.model.isEnabled, - let location = touches.first?.location(in: self), - self.bounds.contains(location) { - self.action() - } + self.isPressed = false } open override func traitCollectionDidChange( diff --git a/Sources/ComponentsKit/Components/Card/Models/CardVM.swift b/Sources/ComponentsKit/Components/Card/Models/CardVM.swift new file mode 100644 index 00000000..a6baf586 --- /dev/null +++ b/Sources/ComponentsKit/Components/Card/Models/CardVM.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A model that defines the appearance properties for a card component. +public struct CardVM: ComponentVM { + /// The background color of the card. + public var backgroundColor: UniversalColor? + + /// The border thickness of the card. + /// + /// Defaults to `.medium`. + public var borderWidth: BorderWidth = .medium + + /// The padding applied to the card's content area. + /// + /// Defaults to a padding value of `16` for all sides. + public var contentPaddings: Paddings = .init(padding: 16) + + /// The corner radius of the card. + /// + /// Defaults to `.medium`. + public var cornerRadius: ContainerRadius = .medium + + /// The shadow of the card. + /// + /// Defaults to `.medium`. + public var shadow: Shadow = .medium + + /// Initializes a new instance of `CardVM` with default values. + public init() {} +} + +// MARK: - Helpers + +extension CardVM { + var preferredBackgroundColor: UniversalColor { + return self.backgroundColor ?? .background + } +} diff --git a/Sources/ComponentsKit/Components/Card/SUCard.swift b/Sources/ComponentsKit/Components/Card/SUCard.swift new file mode 100644 index 00000000..3c0f57e6 --- /dev/null +++ b/Sources/ComponentsKit/Components/Card/SUCard.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// A SwiftUI component that serves as a container for provided content. +/// +/// - Example: +/// ```swift +/// SUCard( +/// model: .init(), +/// content: { +/// Text("This is the content of the card.") +/// } +/// ) +/// ``` +public struct SUCard: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public let model: CardVM + + @ViewBuilder private let content: () -> Content + + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - model: A model that defines the appearance properties. + /// - content: The content that is displayed in the card. + public init( + model: CardVM, + content: @escaping () -> Content + ) { + self.model = model + self.content = content + } + + // MARK: - Body + + public var body: some View { + self.content() + .padding(self.model.contentPaddings.edgeInsets) + .background(self.model.preferredBackgroundColor.color) + .cornerRadius(self.model.cornerRadius.value) + .overlay( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value) + .stroke(UniversalColor.divider.color, lineWidth: self.model.borderWidth.value) + ) + .shadow(self.model.shadow) + } +} diff --git a/Sources/ComponentsKit/Components/Card/UKCard.swift b/Sources/ComponentsKit/Components/Card/UKCard.swift new file mode 100644 index 00000000..7e379647 --- /dev/null +++ b/Sources/ComponentsKit/Components/Card/UKCard.swift @@ -0,0 +1,142 @@ +import AutoLayout +import UIKit + +/// A UIKit component that serves as a container for provided content. +/// +/// - Example: +/// ```swift +/// let banner = UKCard( +/// model: .init(), +/// content: { +/// let label = UILabel() +/// label.text = "This is the content of the card." +/// label.numberOfLines = 0 +/// return label +/// } +/// ) +/// ``` +open class UKCard: UIView, UKComponent { + // MARK: - Typealiases + + /// A closure that returns the content view to be displayed inside the card. + public typealias Content = () -> UIView + + // MARK: - Subviews + + /// The primary content of the card, provided as a custom view. + public let content: UIView + + // MARK: - Properties + + private var contentConstraints = LayoutConstraints() + + /// A model that defines the appearance properties. + public var model: CardVM { + didSet { + self.update(oldValue) + } + } + + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - model: A model that defines the appearance properties. + /// - content: The content that is displayed in the card. + public init(model: CardVM, content: @escaping Content) { + self.model = model + self.content = content() + + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + /// Sets up the card's subviews. + open func setup() { + self.addSubview(self.content) + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } + } + + // MARK: - Style + + /// Applies styling to the card's subviews. + open func style() { + Self.Style.mainView(self, model: self.model) + } + + // MARK: - Layout + + /// Configures the layout. + open func layout() { + self.contentConstraints = LayoutConstraints.merged { + self.content.top(self.model.contentPaddings.top) + self.content.bottom(self.model.contentPaddings.bottom) + self.content.leading(self.model.contentPaddings.leading) + self.content.trailing(self.model.contentPaddings.trailing) + } + } + + open override func layoutSubviews() { + super.layoutSubviews() + + self.layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath + } + + /// Updates appearance when the model changes. + open func update(_ oldValue: CardVM) { + guard self.model != oldValue else { return } + + self.style() + + if self.model.contentPaddings != oldValue.contentPaddings { + self.contentConstraints.top?.constant = self.model.contentPaddings.top + self.contentConstraints.bottom?.constant = -self.model.contentPaddings.bottom + self.contentConstraints.leading?.constant = self.model.contentPaddings.leading + self.contentConstraints.trailing?.constant = -self.model.contentPaddings.trailing + + self.layoutIfNeeded() + } + } + + // MARK: - UIView Methods + + open override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + // MARK: - Helpers + + @objc private func handleTraitChanges() { + Self.Style.mainView(self, model: self.model) + } +} + +extension UKCard { + fileprivate enum Style { + static func mainView(_ view: UIView, model: Model) { + view.backgroundColor = model.preferredBackgroundColor.uiColor + view.layer.cornerRadius = model.cornerRadius.value + view.layer.borderWidth = model.borderWidth.value + view.layer.borderColor = UniversalColor.divider.cgColor + view.shadow(model.shadow) + } + } +} diff --git a/Sources/ComponentsKit/Checkbox/Models/CheckboxAnimationDurations.swift b/Sources/ComponentsKit/Components/Checkbox/Helpers/CheckboxAnimationDurations.swift similarity index 100% rename from Sources/ComponentsKit/Checkbox/Models/CheckboxAnimationDurations.swift rename to Sources/ComponentsKit/Components/Checkbox/Helpers/CheckboxAnimationDurations.swift diff --git a/Sources/ComponentsKit/Checkbox/Models/CheckboxVM.swift b/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift similarity index 79% rename from Sources/ComponentsKit/Checkbox/Models/CheckboxVM.swift rename to Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift index 352e6fff..37ffb131 100644 --- a/Sources/ComponentsKit/Checkbox/Models/CheckboxVM.swift +++ b/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift @@ -38,32 +38,16 @@ public struct CheckboxVM: ComponentVM { extension CheckboxVM { var backgroundColor: UniversalColor { - return self.color.main.withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + return self.color.main.enabled(self.isEnabled) } var foregroundColor: UniversalColor { - return self.color.contrast.withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + return self.color.contrast.enabled(self.isEnabled) } var titleColor: UniversalColor { - return Palette.Text.primary.withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + return .foreground.enabled(self.isEnabled) } var borderColor: UniversalColor { - return .universal(.uiColor(.lightGray)).withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + return .divider } var borderWidth: CGFloat { return 2.0 @@ -84,11 +68,11 @@ extension CheckboxVM { var checkboxSide: CGFloat { switch self.size { case .small: - return 18.0 + return 16.0 case .medium: - return 24.0 + return 20.0 case .large: - return 32.0 + return 24.0 } } var checkboxCornerRadius: CGFloat { @@ -114,11 +98,11 @@ extension CheckboxVM { switch self.size { case .small: - return UniversalFont.Component.small + return .smBody case .medium: - return UniversalFont.Component.medium + return .mdBody case .large: - return UniversalFont.Component.large + return .lgBody } } } diff --git a/Sources/ComponentsKit/Checkbox/SUCheckbox.swift b/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift similarity index 90% rename from Sources/ComponentsKit/Checkbox/SUCheckbox.swift rename to Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift index ec9315a6..0029acab 100644 --- a/Sources/ComponentsKit/Checkbox/SUCheckbox.swift +++ b/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift @@ -12,7 +12,6 @@ public struct SUCheckbox: View { @State private var checkmarkStroke: CGFloat @State private var borderOpacity: CGFloat - @Environment(\.colorScheme) private var colorScheme // MARK: Initialization @@ -35,7 +34,7 @@ public struct SUCheckbox: View { public var body: some View { HStack(spacing: self.model.spacing) { ZStack { - self.model.backgroundColor.color(for: self.colorScheme) + self.model.backgroundColor.color .clipShape( RoundedRectangle(cornerRadius: self.model.checkboxCornerRadius) ) @@ -66,12 +65,12 @@ public struct SUCheckbox: View { lineCap: .round, lineJoin: .round )) - .foregroundStyle(self.model.foregroundColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.foregroundColor.color) } .overlay { RoundedRectangle(cornerRadius: self.model.checkboxCornerRadius) .stroke( - self.model.borderColor.color(for: self.colorScheme), + self.model.borderColor.color, lineWidth: self.model.borderWidth ) .opacity(self.borderOpacity) @@ -84,7 +83,7 @@ public struct SUCheckbox: View { if let title = self.model.title { Text(title) - .foregroundStyle(self.model.titleColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.titleColor.color) .font(self.model.titleFont.font) } } diff --git a/Sources/ComponentsKit/Checkbox/UKCheckbox.swift b/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift similarity index 98% rename from Sources/ComponentsKit/Checkbox/UKCheckbox.swift rename to Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift index 0a4b727b..bf97064d 100644 --- a/Sources/ComponentsKit/Checkbox/UKCheckbox.swift +++ b/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift @@ -262,6 +262,7 @@ open class UKCheckbox: UIView, UKComponent { self.checkboxContainer.layer.borderColor = self.isSelected ? UIColor.clear.cgColor : self.model.borderColor.uiColor.cgColor + Self.Style.checkmarkLayer(self.checkmarkLayer, model: self.model) } } @@ -275,7 +276,7 @@ extension UKCheckbox { stackView.alignment = .center } static func titleLabel(_ label: UILabel, model: Model) { - label.textColor = Palette.Text.primary.uiColor + label.textColor = model.titleColor.uiColor label.numberOfLines = 0 label.text = model.title label.textColor = model.titleColor.uiColor diff --git a/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownHelpers.swift b/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownHelpers.swift new file mode 100644 index 00000000..29b27cd3 --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownHelpers.swift @@ -0,0 +1,15 @@ +import Foundation + +enum CountdownHelpers { + enum Unit { + case days + case hours + case minutes + case seconds + } + + enum UnitLength { + case short + case long + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownWidthCalculator.swift b/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownWidthCalculator.swift new file mode 100644 index 00000000..293fe222 --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Helpers/CountdownWidthCalculator.swift @@ -0,0 +1,23 @@ +import UIKit + +struct CountdownWidthCalculator { + private static let label = UILabel() + + private init() {} + + static func preferredWidth( + for attributedText: NSAttributedString, + model: CountdownVM + ) -> CGFloat { + self.style(label, with: model) + self.label.attributedText = attributedText + + let estimatedSize = self.label.sizeThatFits(UIView.layoutFittingExpandedSize) + + return estimatedSize.width + } + + private static func style(_ label: UILabel, with model: CountdownVM) { + label.numberOfLines = 0 + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/Localization/UnitsLocalization.swift b/Sources/ComponentsKit/Components/Countdown/Localization/UnitsLocalization.swift new file mode 100644 index 00000000..b51343fd --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Localization/UnitsLocalization.swift @@ -0,0 +1,154 @@ +import Foundation + +// MARK: - UnitsLocalization + +/// A structure that provides localized representations of time units (seconds, minutes, hours, days). +public struct UnitsLocalization: Equatable { + /// A structure that represents the localized short and long forms of a single time unit. + public struct UnitItemLocalization: Equatable { + /// The short-form representation of the time unit (e.g., "s" for seconds). + public let short: String + /// The long-form representation of the time unit (e.g., "Seconds"). + public let long: String + + /// Initializes a new `UnitItemLocalization` with specified short and long forms. + /// + /// - Parameters: + /// - short: The short-form representation of the time unit. + /// - long: The long-form representation of the time unit. + public init(short: String, long: String) { + self.short = short + self.long = long + } + } + + // MARK: - Properties + + /// The localized representation for seconds. + public let seconds: UnitItemLocalization + + /// The localized representation for minutes. + public let minutes: UnitItemLocalization + + /// The localized representation for hours. + public let hours: UnitItemLocalization + + /// The localized representation for days. + public let days: UnitItemLocalization + + // MARK: - Initialization + + /// Initializes a new `UnitsLocalization` with localized representations for all time units. + /// + /// - Parameters: + /// - seconds: The localization for seconds. + /// - minutes: The localization for minutes. + /// - hours: The localization for hours. + /// - days: The localization for days. + public init( + seconds: UnitItemLocalization, + minutes: UnitItemLocalization, + hours: UnitItemLocalization, + days: UnitItemLocalization + ) { + self.seconds = seconds + self.minutes = minutes + self.hours = hours + self.days = days + } +} + +// MARK: - Localizations + +extension UnitsLocalization { + static let defaultLocalizations: [Locale: UnitsLocalization] = [ + // English (en) + Locale(identifier: "en"): UnitsLocalization( + seconds: .init(short: "s", long: "Seconds"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Hours"), + days: .init(short: "d", long: "Days") + ), + + // Spanish (es) + Locale(identifier: "es"): UnitsLocalization( + seconds: .init(short: "s", long: "Segundos"), + minutes: .init(short: "m", long: "Minutos"), + hours: .init(short: "h", long: "Horas"), + days: .init(short: "d", long: "Días") + ), + + // French (fr) + Locale(identifier: "fr"): UnitsLocalization( + seconds: .init(short: "s", long: "Secondes"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Heures"), + days: .init(short: "j", long: "Jours") + ), + + // German (de) + Locale(identifier: "de"): UnitsLocalization( + seconds: .init(short: "s", long: "Sekunden"), + minutes: .init(short: "m", long: "Minuten"), + hours: .init(short: "h", long: "Stunden"), + days: .init(short: "t", long: "Tage") + ), + + // Chinese (zh) + Locale(identifier: "zh"): UnitsLocalization( + seconds: .init(short: "秒", long: "秒"), + minutes: .init(short: "分", long: "分钟"), + hours: .init(short: "时", long: "小时"), + days: .init(short: "天", long: "天") + ), + + // Japanese (ja) + Locale(identifier: "ja"): UnitsLocalization( + seconds: .init(short: "秒", long: "秒"), + minutes: .init(short: "分", long: "分"), + hours: .init(short: "時", long: "時間"), + days: .init(short: "日", long: "日") + ), + + // Russian (ru) + Locale(identifier: "ru"): UnitsLocalization( + seconds: .init(short: "с", long: "Секунд"), + minutes: .init(short: "м", long: "Минут"), + hours: .init(short: "ч", long: "Часов"), + days: .init(short: "д", long: "Дней") + ), + + // Arabic (ar) + Locale(identifier: "ar"): UnitsLocalization( + seconds: .init(short: "ث", long: "ثوانٍ"), + minutes: .init(short: "د", long: "دقائق"), + hours: .init(short: "س", long: "ساعات"), + days: .init(short: "ي", long: "أيام") + ), + + // Hindi (hi) + Locale(identifier: "hi"): UnitsLocalization( + seconds: .init(short: "से", long: "सेकंड"), + minutes: .init(short: "मि", long: "मिनट"), + hours: .init(short: "घं", long: "घंटे"), + days: .init(short: "दि", long: "दिन") + ), + + // Portuguese (pt) + Locale(identifier: "pt"): UnitsLocalization( + seconds: .init(short: "s", long: "Segundos"), + minutes: .init(short: "m", long: "Minutos"), + hours: .init(short: "h", long: "Horas"), + days: .init(short: "d", long: "Dias") + ) + ] + + static var localizationFallback: UnitsLocalization { + return UnitsLocalization( + seconds: .init(short: "s", long: "Seconds"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Hours"), + days: .init(short: "d", long: "Days") + ) + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/Manager/CountdownManager.swift b/Sources/ComponentsKit/Components/Countdown/Manager/CountdownManager.swift new file mode 100644 index 00000000..9303fb7b --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Manager/CountdownManager.swift @@ -0,0 +1,50 @@ +import SwiftUI + +class CountdownManager: ObservableObject { + // MARK: - Published Properties + + @Published var days: Int = 0 + @Published var hours: Int = 0 + @Published var minutes: Int = 0 + @Published var seconds: Int = 0 + + // MARK: - Properties + + private var timer: Timer? + private var until: Date? + + // MARK: - Methods + + func start(until: Date) { + self.until = until + self.updateUnitValues() + self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + self?.updateUnitValues() + } + } + + func stop() { + self.timer?.invalidate() + self.timer = nil + } + + private func updateUnitValues() { + guard let until = self.until else { return } + + let now = Date() + let calendar = Calendar.current + let components = calendar.dateComponents( + [.day, .hour, .minute, .second], + from: now, + to: until + ) + self.days = max(0, components.day ?? 0) + self.hours = max(0, components.hour ?? 0) + self.minutes = max(0, components.minute ?? 0) + self.seconds = max(0, components.second ?? 0) + + if now >= until { + self.stop() + } + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/Models/CountdownStyle.swift b/Sources/ComponentsKit/Components/Countdown/Models/CountdownStyle.swift new file mode 100644 index 00000000..3f2dcad3 --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Models/CountdownStyle.swift @@ -0,0 +1,16 @@ +import Foundation + +extension CountdownVM { + /// Defines the visual styles for the countdown component. + public enum Style: Equatable { + case plain + case light + } + + /// Defines the units style for the countdown component. + public enum UnitsStyle: Equatable { + case hidden + case bottom + case trailing + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift b/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift new file mode 100644 index 00000000..ea7db580 --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/Models/CountdownVM.swift @@ -0,0 +1,248 @@ +import SwiftUI + +/// A model that defines the appearance properties for a countdown component. +public struct CountdownVM: ComponentVM { + /// The color of the countdown. + public var color: ComponentColor? + + /// The font used for displaying the countdown numbers and trailing units. + public var mainFont: UniversalFont? + + /// The font used for displaying the countdown bottom units. + public var secondaryFont: UniversalFont? + + /// The predefined size of the countdown. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The visual style of the countdown component. + /// + /// Defaults to `.light`. + public var style: Style = .light + + /// The visual style of the units. + /// + /// Defaults to `.bottom`. + public var unitsStyle: UnitsStyle = .bottom + + /// 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() {} +} + +// MARK: - Shared Helpers + +extension CountdownVM { + var preferredMainFont: UniversalFont { + if let mainFont { + return mainFont + } + + switch self.size { + case .small: + return .smHeadline + case .medium: + return .mdHeadline + case .large: + return .lgHeadline + } + } + private var preferredSecondaryFont: UniversalFont { + if let secondaryFont { + return secondaryFont + } + + switch self.size { + case .small: + return .smCaption + case .medium: + return .mdCaption + case .large: + return .lgCaption + } + } + var backgroundColor: UniversalColor { + return self.color?.background ?? .content1 + } + var foregroundColor: UniversalColor { + return self.color?.main ?? .foreground + } + var colonColor: UniversalColor { + return self.color?.main ?? .secondaryForeground + } + var defaultMinWidth: CGFloat { + return switch self.size { + case .small: 20 + case .medium: 25 + case .large: 30 + } + } + var lightBackgroundMinHight: CGFloat { + return switch self.size { + case .small: 45 + case .medium: 55 + case .large: 65 + } + } + var lightBackgroundMinWidth: CGFloat { + return switch self.size { + case .small: 45 + case .medium: 55 + case .large: 60 + } + } + var horizontalPadding: CGFloat { + switch self.style { + case .light: + return 4 + case .plain: + return 0 + } + } + var spacing: CGFloat { + switch self.style { + case .light: + return 10 + case .plain: + return 6 + } + } +} + +extension CountdownVM { + func localizedUnit( + _ unit: CountdownHelpers.Unit, + length: CountdownHelpers.UnitLength + ) -> String { + let localization = self.localization[self.locale] + ?? UnitsLocalization.defaultLocalizations[self.locale] + ?? UnitsLocalization.localizationFallback + + switch (unit, length) { + case (.days, .long): + return localization.days.long + case (.days, .short): + return localization.days.short + + case (.hours, .long): + return localization.hours.long + case (.hours, .short): + return localization.hours.short + + case (.minutes, .long): + return localization.minutes.long + case (.minutes, .short): + return localization.minutes.short + + case (.seconds, .long): + return localization.seconds.long + case (.seconds, .short): + return localization.seconds.short + } + } + + func timeText( + value: Int, + unit: CountdownHelpers.Unit + ) -> NSAttributedString { + let mainTextAttributes: [NSAttributedString.Key: Any] = [ + .font: self.preferredMainFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + + let formattedValue = String(format: "%02d", value) + let result = NSMutableAttributedString(string: formattedValue, attributes: mainTextAttributes) + + switch self.unitsStyle { + case .hidden: + return result + + case .trailing: + let localized = self.localizedUnit(unit, length: .short) + let trailingString = " " + localized + let trailingAttributes: [NSAttributedString.Key: Any] = [ + .font: self.preferredMainFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + result.append(NSAttributedString(string: trailingString, attributes: trailingAttributes)) + return result + + case .bottom: + let localized = self.localizedUnit(unit, length: .long) + let bottomString = "\n" + localized + let bottomAttributes: [NSAttributedString.Key: Any] = [ + .font: self.preferredSecondaryFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + result.append(NSAttributedString(string: bottomString, attributes: bottomAttributes)) + return result + } + } +} + +extension CountdownVM { + func shouldRecalculateWidth(_ oldModel: Self) -> Bool { + return self.unitsStyle != oldModel.unitsStyle + || self.style != oldModel.style + || self.mainFont != oldModel.mainFont + || self.secondaryFont != oldModel.secondaryFont + || self.size != oldModel.size + || self.locale != oldModel.locale + } + + func timeWidth(manager: CountdownManager) -> CGFloat { + let values: [(Int, CountdownHelpers.Unit)] = [ + (manager.days, .days), + (manager.hours, .hours), + (manager.minutes, .minutes), + (manager.seconds, .seconds) + ] + + let widths = values.map { value, unit -> CGFloat in + let attributedString = self.timeText(value: value, unit: unit) + return CountdownWidthCalculator.preferredWidth(for: attributedString, model: self) + } + + return (widths.max() ?? self.defaultMinWidth) + self.horizontalPadding * 2 + } +} + +// MARK: - UIKit Helpers + +extension CountdownVM { + var isColumnLabelVisible: Bool { + switch self.style { + case .plain: + return true + case .light: + return false + } + } + + func shouldUpdateHeight(_ oldModel: Self) -> Bool { + return self.style != oldModel.style + || self.size != oldModel.size + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift new file mode 100644 index 00000000..b9a6fe6c --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/SUCountdown.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// A SwiftUI component that displays a countdown. +public struct SUCountdown: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: CountdownVM + + @State private var timeWidth: CGFloat = 70 + + /// The countdown manager handling the countdown logic. + @StateObject private var manager = CountdownManager() + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: CountdownVM = .init()) { + self.model = model + } + + // MARK: - Body + + public var body: some View { + HStack(alignment: .top, spacing: self.model.spacing) { + switch (self.model.style, self.model.unitsStyle) { + case (.plain, .bottom): + self.styledTime(value: self.manager.days, unit: .days) + colonView + self.styledTime(value: self.manager.hours, unit: .hours) + colonView + self.styledTime(value: self.manager.minutes, unit: .minutes) + colonView + self.styledTime(value: self.manager.seconds, unit: .seconds) + + case (.plain, .hidden), (.plain, .trailing): + self.styledTime(value: self.manager.days, unit: .days) + self.colonView + self.styledTime(value: self.manager.hours, unit: .hours) + self.colonView + self.styledTime(value: self.manager.minutes, unit: .minutes) + self.colonView + self.styledTime(value: self.manager.seconds, unit: .seconds) + + case (.light, _): + self.lightStyledTime(value: self.manager.days, unit: .days) + self.lightStyledTime(value: self.manager.hours, unit: .hours) + self.lightStyledTime(value: self.manager.minutes, unit: .minutes) + self.lightStyledTime(value: self.manager.seconds, unit: .seconds) + } + } + .onAppear { + self.manager.start(until: self.model.until) + self.timeWidth = self.model.timeWidth(manager: self.manager) + } + .onChange(of: self.model.until) { newDate in + self.manager.stop() + self.manager.start(until: newDate) + } + .onChange(of: self.model) { newValue in + if newValue.shouldRecalculateWidth(self.model) { + self.timeWidth = newValue.timeWidth(manager: self.manager) + } + } + .onDisappear { + self.manager.stop() + } + } + + // MARK: - Subviews + + private func styledTime( + value: Int, + unit: CountdownHelpers.Unit + ) -> some View { + let attributedString = AttributedString(self.model.timeText(value: value, unit: unit)) + return Text(attributedString) + .multilineTextAlignment(.center) + .frame(width: self.timeWidth) + } + + private var colonView: some View { + Text(":") + .font(self.model.preferredMainFont.font) + .foregroundColor(self.model.colonColor.color) + } + + private func lightStyledTime( + value: Int, + unit: CountdownHelpers.Unit + ) -> some View { + return self.styledTime(value: value, unit: unit) + .frame(minHeight: self.model.lightBackgroundMinHight) + .frame(minWidth: self.model.lightBackgroundMinWidth) + .background(RoundedRectangle(cornerRadius: 8) + .fill(self.model.backgroundColor.color) + ) + } +} diff --git a/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift b/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift new file mode 100644 index 00000000..ad60f58b --- /dev/null +++ b/Sources/ComponentsKit/Components/Countdown/UKCountdown.swift @@ -0,0 +1,263 @@ +import AutoLayout +import Combine +import UIKit + +/// A UIKit component that displays a countdown. +public class UKCountdown: UIView, UKComponent { + // MARK: - Public Properties + + /// A model that defines the appearance properties. + public var model: CountdownVM { + didSet { + self.update(oldValue) + } + } + + /// The main container stack view containing all time labels and colon labels. + public let stackView = UIStackView() + + /// A label showing the number of days remaining. + public let daysLabel = UILabel() + + /// A label showing the number of hours remaining. + public let hoursLabel = UILabel() + + /// A label showing the number of minutes remaining. + public let minutesLabel = UILabel() + + /// A label showing the number of seconds remaining. + public let secondsLabel = UILabel() + + /// An array of colon labels used as separators between the time segments (days/hours/minutes/seconds). + public let colonLabels: [UILabel] = [ + UILabel(), + UILabel(), + UILabel() + ] + + // MARK: - Private Properties + + /// Constraints specifically applied to the "days" label. + private var daysConstraints = LayoutConstraints() + + private let manager = CountdownManager() + + private var cancellables: Set = [] + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: CountdownVM) { + self.model = model + + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.manager.stop() + self.cancellables.forEach { + $0.cancel() + } + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.stackView) + + self.stackView.addArrangedSubview(self.daysLabel) + self.stackView.addArrangedSubview(self.colonLabels[0]) + self.stackView.addArrangedSubview(self.hoursLabel) + self.stackView.addArrangedSubview(self.colonLabels[1]) + self.stackView.addArrangedSubview(self.minutesLabel) + self.stackView.addArrangedSubview(self.colonLabels[2]) + self.stackView.addArrangedSubview(self.secondsLabel) + + self.setupSubscriptions() + self.manager.start(until: self.model.until) + } + + private func setupSubscriptions() { + self.manager.$days + .sink { [weak self] newValue in + guard let self else { return } + self.daysLabel.attributedText = self.model.timeText(value: newValue, unit: .days) + } + .store(in: &self.cancellables) + + self.manager.$hours + .sink { [weak self] newValue in + guard let self else { return } + self.hoursLabel.attributedText = self.model.timeText(value: newValue, unit: .hours) + } + .store(in: &self.cancellables) + + self.manager.$minutes + .sink { [weak self] newValue in + guard let self else { return } + self.minutesLabel.attributedText = self.model.timeText(value: newValue, unit: .minutes) + } + .store(in: &self.cancellables) + + self.manager.$seconds + .sink { [weak self] newValue in + guard let self else { return } + self.secondsLabel.attributedText = self.model.timeText(value: newValue, unit: .seconds) + } + .store(in: &self.cancellables) + } + + // MARK: - Style + + private func style() { + Self.Style.mainView(self, model: self.model) + Self.Style.stackView(self.stackView, model: self.model) + + Self.Style.timeLabel(self.daysLabel, model: self.model) + Self.Style.timeLabel(self.hoursLabel, model: self.model) + Self.Style.timeLabel(self.minutesLabel, model: self.model) + Self.Style.timeLabel(self.secondsLabel, model: self.model) + + self.colonLabels.forEach { + Self.Style.colonLabel($0, model: self.model) + } + + self.daysLabel.attributedText = self.model.timeText(value: self.manager.days, unit: .days) + self.hoursLabel.attributedText = self.model.timeText(value: self.manager.hours, unit: .hours) + self.minutesLabel.attributedText = self.model.timeText(value: self.manager.minutes, unit: .minutes) + self.secondsLabel.attributedText = self.model.timeText(value: self.manager.seconds, unit: .seconds) + } + + // MARK: - Layout + + private func layout() { + self.stackView.centerVertically() + self.stackView.centerHorizontally() + + self.stackView.topAnchor.constraint( + greaterThanOrEqualTo: self.topAnchor + ).isActive = true + self.stackView.bottomAnchor.constraint( + lessThanOrEqualTo: self.bottomAnchor + ).isActive = true + self.stackView.leadingAnchor.constraint( + greaterThanOrEqualTo: self.leadingAnchor + ).isActive = true + self.stackView.trailingAnchor.constraint( + lessThanOrEqualTo: self.trailingAnchor + ).isActive = true + + self.daysConstraints.width = self.daysLabel.widthAnchor.constraint( + equalToConstant: self.model.defaultMinWidth + ) + self.daysConstraints.width?.isActive = true + + self.daysConstraints.height = self.daysLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: self.model.lightBackgroundMinHight) + self.daysConstraints.height?.isActive = true + + self.hoursLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.hoursLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + self.minutesLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.minutesLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + self.secondsLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.secondsLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + switch self.model.style { + case .plain: + self.daysConstraints.height?.isActive = false + self.daysConstraints.width?.constant = self.model.timeWidth(manager: self.manager) + case .light: + self.daysConstraints.width?.constant = max( + self.model.timeWidth(manager: self.manager), + self.model.lightBackgroundMinWidth + ) + } + } + + // MARK: - Update + + public func update(_ oldModel: CountdownVM) { + guard self.model != oldModel else { return } + + if self.model.until != oldModel.until { + self.manager.stop() + self.manager.start(until: self.model.until) + } + + if self.model.shouldUpdateHeight(oldModel) { + switch self.model.style { + case .plain: + self.daysConstraints.height?.isActive = false + case .light: + self.daysConstraints.height?.isActive = true + self.daysConstraints.height?.constant = self.model.lightBackgroundMinHight + } + } + + if self.model.shouldRecalculateWidth(oldModel) { + let newWidth = self.model.timeWidth(manager: self.manager) + switch self.model.style { + case .plain: + self.daysConstraints.width?.constant = newWidth + case .light: + self.daysConstraints.width?.constant = max(newWidth, self.model.lightBackgroundMinWidth) + } + } + + self.style() + + self.layoutIfNeeded() + } +} + +// MARK: - Style Helpers + +extension UKCountdown { + fileprivate enum Style { + static func mainView(_ view: UIView, model: CountdownVM) { + view.backgroundColor = .clear + } + + static func stackView(_ stackView: UIStackView, model: CountdownVM) { + stackView.axis = .horizontal + stackView.alignment = .top + stackView.spacing = model.spacing + } + + static func timeLabel(_ label: UILabel, model: CountdownVM) { + switch model.style { + case .plain: + label.backgroundColor = .clear + label.layer.cornerRadius = 0 + case .light: + label.backgroundColor = model.backgroundColor.uiColor + label.layer.cornerRadius = 8 + label.clipsToBounds = true + } + label.textColor = model.foregroundColor.uiColor + label.textAlignment = .center + label.numberOfLines = 0 + label.lineBreakMode = .byClipping + } + + static func colonLabel(_ label: UILabel, model: CountdownVM) { + label.text = ":" + label.font = model.preferredMainFont.uiFont + label.textColor = model.colonColor.uiColor + label.textAlignment = .center + label.isVisible = model.isColumnLabelVisible + } + } +} diff --git a/Sources/ComponentsKit/Divider/Models/DividerStyle.swift b/Sources/ComponentsKit/Components/Divider/Models/DividerOrientation.swift similarity index 80% rename from Sources/ComponentsKit/Divider/Models/DividerStyle.swift rename to Sources/ComponentsKit/Components/Divider/Models/DividerOrientation.swift index e9817bcf..b19df750 100644 --- a/Sources/ComponentsKit/Divider/Models/DividerStyle.swift +++ b/Sources/ComponentsKit/Components/Divider/Models/DividerOrientation.swift @@ -2,7 +2,7 @@ import Foundation extension DividerVM { /// Defines the possible orientations for the divider. - public enum DividerOrientation { + public enum Orientation { case horizontal case vertical } diff --git a/Sources/ComponentsKit/Divider/Models/DividerVM.swift b/Sources/ComponentsKit/Components/Divider/Models/DividerVM.swift similarity index 82% rename from Sources/ComponentsKit/Divider/Models/DividerVM.swift rename to Sources/ComponentsKit/Components/Divider/Models/DividerVM.swift index da7985d7..ab3b4056 100644 --- a/Sources/ComponentsKit/Divider/Models/DividerVM.swift +++ b/Sources/ComponentsKit/Components/Divider/Models/DividerVM.swift @@ -5,12 +5,12 @@ public struct DividerVM: ComponentVM { /// The orientation of the divider (horizontal or vertical). /// /// Defaults to `.horizontal`. - public var orientation: DividerOrientation = .horizontal + public var orientation: Orientation = .horizontal /// The color of the divider. /// - /// Defaults to `Palette.Base.divider`. - public var color: UniversalColor = Palette.Base.divider + /// Defaults to `.divider`. + public var color: ComponentColor? /// The predefined size of the divider, which affects its thickness. /// @@ -24,6 +24,9 @@ public struct DividerVM: ComponentVM { // MARK: - Shared Helpers extension DividerVM { + var lineColor: UniversalColor { + return self.color?.background ?? .divider + } var lineSize: CGFloat { switch self.size { case .small: diff --git a/Sources/ComponentsKit/Divider/SUDivider.swift b/Sources/ComponentsKit/Components/Divider/SUDivider.swift similarity index 85% rename from Sources/ComponentsKit/Divider/SUDivider.swift rename to Sources/ComponentsKit/Components/Divider/SUDivider.swift index 21be76b7..c9b00e26 100644 --- a/Sources/ComponentsKit/Divider/SUDivider.swift +++ b/Sources/ComponentsKit/Components/Divider/SUDivider.swift @@ -7,8 +7,6 @@ public struct SUDivider: View { /// A model that defines the appearance properties. public var model: DividerVM - @Environment(\.colorScheme) private var colorScheme - // MARK: - Initialization /// Initializer. @@ -22,7 +20,7 @@ public struct SUDivider: View { public var body: some View { Rectangle() - .fill(self.model.color.color(for: self.colorScheme)) + .fill(self.model.lineColor.color) .frame( maxWidth: self.model.orientation == .vertical ? self.model.lineSize : nil, maxHeight: self.model.orientation == .horizontal ? self.model.lineSize : nil diff --git a/Sources/ComponentsKit/Divider/UKDivider.swift b/Sources/ComponentsKit/Components/Divider/UKDivider.swift similarity index 93% rename from Sources/ComponentsKit/Divider/UKDivider.swift rename to Sources/ComponentsKit/Components/Divider/UKDivider.swift index af491b0a..9f667846 100644 --- a/Sources/ComponentsKit/Divider/UKDivider.swift +++ b/Sources/ComponentsKit/Components/Divider/UKDivider.swift @@ -35,7 +35,7 @@ open class UKDivider: UIView, UKComponent { // MARK: - Setup private func style() { - self.backgroundColor = self.model.color.uiColor + self.backgroundColor = self.model.lineColor.uiColor self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) self.setContentCompressionResistancePriority(.defaultLow, for: .vertical) } @@ -45,7 +45,7 @@ open class UKDivider: UIView, UKComponent { public func update(_ oldModel: DividerVM) { guard self.model != oldModel else { return } - self.backgroundColor = self.model.color.uiColor + self.backgroundColor = self.model.lineColor.uiColor if self.model.shouldUpdateLayout(oldModel) { self.invalidateIntrinsicContentSize() diff --git a/Sources/ComponentsKit/InputField/Models/InputFieldVM.swift b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift similarity index 88% rename from Sources/ComponentsKit/InputField/Models/InputFieldVM.swift rename to Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift index edc707da..dd339bb1 100644 --- a/Sources/ComponentsKit/InputField/Models/InputFieldVM.swift +++ b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift @@ -81,18 +81,18 @@ extension InputFieldVM { switch self.size { case .small: - return UniversalFont.Component.medium + return .smBody case .medium: - return UniversalFont.Component.medium + return .mdBody case .large: - return UniversalFont.Component.large + return .lgBody } } var height: CGFloat { return switch self.size { case .small: 40 - case .medium: 60 - case .large: 80 + case .medium: 48 + case .large: 56 } } var horizontalPadding: CGFloat { @@ -107,26 +107,18 @@ extension InputFieldVM { return self.title.isNotNilAndEmpty ? 12 : 0 } var backgroundColor: UniversalColor { - if let color { - return color.main.withOpacity(0.25) - } else { - return .init( - light: .rgba(r: 244, g: 244, b: 245, a: 1.0), - dark: .rgba(r: 39, g: 39, b: 42, a: 1.0) - ) - } + return self.color?.background ?? .content1 } var foregroundColor: UniversalColor { - let foregroundColor = self.color?.main ?? .init( - light: .rgba(r: 0, g: 0, b: 0, a: 1.0), - dark: .rgba(r: 255, g: 255, b: 255, a: 1.0) - ) - return foregroundColor.withOpacity( - self.isEnabled ? 1.0 : 0.5 - ) + let color = self.color?.main ?? .foreground + return color.enabled(self.isEnabled) } var placeholderColor: UniversalColor { - return self.foregroundColor.withOpacity(self.isEnabled ? 0.7 : 0.3) + if let color { + return color.main.withOpacity(self.isEnabled ? 0.7 : 0.3) + } else { + return .secondaryForeground.enabled(self.isEnabled) + } } } diff --git a/Sources/ComponentsKit/InputField/SUInputField.swift b/Sources/ComponentsKit/Components/InputField/SUInputField.swift similarity index 90% rename from Sources/ComponentsKit/InputField/SUInputField.swift rename to Sources/ComponentsKit/Components/InputField/SUInputField.swift index 9828faf6..a217a258 100644 --- a/Sources/ComponentsKit/InputField/SUInputField.swift +++ b/Sources/ComponentsKit/Components/InputField/SUInputField.swift @@ -26,8 +26,6 @@ public struct SUInputField: View { /// text inputs and input fields within the same view can be independently focused based on the shared `globalFocus`. public var localFocus: FocusValue - @Environment(\.colorScheme) private var colorScheme - // MARK: Initialization /// Initializer. @@ -55,27 +53,24 @@ public struct SUInputField: View { if let title = self.model.attributedTitle { Text(title) .font(self.model.preferredFont.font) - .foregroundStyle( - self.model.foregroundColor.color(for: self.colorScheme) - ) } Group { if self.model.isSecureInput { SecureField(text: self.$text, label: { Text(self.model.placeholder ?? "") - .foregroundStyle(self.model.placeholderColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.placeholderColor.color) }) } else { TextField(text: self.$text, label: { Text(self.model.placeholder ?? "") - .foregroundStyle(self.model.placeholderColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.placeholderColor.color) }) } } - .tint(self.model.tintColor.color(for: self.colorScheme)) + .tint(self.model.tintColor.color) .font(self.model.preferredFont.font) - .foregroundStyle(self.model.foregroundColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.foregroundColor.color) .focused(self.$globalFocus, equals: self.localFocus) .disabled(!self.model.isEnabled) .keyboardType(self.model.keyboardType) @@ -85,7 +80,7 @@ public struct SUInputField: View { } .padding(.horizontal, self.model.horizontalPadding) .frame(height: self.model.height) - .background(self.model.backgroundColor.color(for: self.colorScheme)) + .background(self.model.backgroundColor.color) .onTapGesture { self.globalFocus = self.localFocus } diff --git a/Sources/ComponentsKit/InputField/UKInputField.swift b/Sources/ComponentsKit/Components/InputField/UKInputField.swift similarity index 100% rename from Sources/ComponentsKit/InputField/UKInputField.swift rename to Sources/ComponentsKit/Components/InputField/UKInputField.swift diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift new file mode 100644 index 00000000..71031529 --- /dev/null +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift @@ -0,0 +1,8 @@ +import Foundation + +extension LoadingVM { + /// The loading appearance style. + public enum Style { + case spinner + } +} diff --git a/Sources/ComponentsKit/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift similarity index 93% rename from Sources/ComponentsKit/Loading/Models/LoadingVM.swift rename to Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index 9d673968..c9f7f30b 100644 --- a/Sources/ComponentsKit/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -4,8 +4,8 @@ import Foundation public struct LoadingVM: ComponentVM { /// The color of the loading indicator. /// - /// Defaults to `.primary`. - public var color: ComponentColor = .primary + /// Defaults to `.accent`. + public var color: ComponentColor = .accent /// The width of the lines used in the loading indicator. /// @@ -20,7 +20,7 @@ public struct LoadingVM: ComponentVM { /// The style of the loading indicator (e.g., spinner, bar). /// /// Defaults to `.spinner`. - public var style: LoadingStyle = .spinner + public var style: Style = .spinner /// Initializes a new instance of `LoadingVM` with default values. public init() {} diff --git a/Sources/ComponentsKit/Loading/SULoading.swift b/Sources/ComponentsKit/Components/Loading/SULoading.swift similarity index 92% rename from Sources/ComponentsKit/Loading/SULoading.swift rename to Sources/ComponentsKit/Components/Loading/SULoading.swift index fbce0cd4..553e7644 100644 --- a/Sources/ComponentsKit/Loading/SULoading.swift +++ b/Sources/ComponentsKit/Components/Loading/SULoading.swift @@ -9,7 +9,6 @@ public struct SULoading: View { public var model: LoadingVM @State private var rotationAngle: CGFloat = 0.0 - @Environment(\.colorScheme) private var colorScheme // MARK: Initialization @@ -34,7 +33,7 @@ public struct SULoading: View { } .trim(from: 0, to: 0.75) .stroke( - self.model.color.main.color(for: self.colorScheme), + self.model.color.main.color, style: StrokeStyle( lineWidth: self.model.loadingLineWidth, lineCap: .round, diff --git a/Sources/ComponentsKit/Loading/UKLoading.swift b/Sources/ComponentsKit/Components/Loading/UKLoading.swift similarity index 100% rename from Sources/ComponentsKit/Loading/UKLoading.swift rename to Sources/ComponentsKit/Components/Loading/UKLoading.swift diff --git a/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift b/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift new file mode 100644 index 00000000..b2fed090 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A model that defines the appearance properties for a bottom modal component. +public struct BottomModalVM: ModalVM { + /// The background color of the modal. + public var backgroundColor: UniversalColor? + + /// The border thickness of the modal. + /// + /// Defaults to `.small`. + public var borderWidth: BorderWidth = .small + + /// A Boolean value indicating whether the modal should close when tapping on the overlay. + /// + /// Defaults to `true`. + public var closesOnOverlayTap: Bool = true + + /// The padding applied to the modal's content area. + /// + /// Defaults to a padding value of `16` for all sides. + public var contentPaddings: Paddings = .init(padding: 16) + + /// The spacing between header, body and footer. + public var contentSpacing: CGFloat = 16 + + /// The corner radius of the modal. + /// + /// Defaults to `.medium`. + public var cornerRadius: ContainerRadius = .medium + + /// A Boolean value indicating whether the modal should hide when it is swiped down. + /// + /// Defaults to `true`. + public var hidesOnSwap: Bool = true + + /// A Boolean value indicating whether the modal is draggable. + /// + /// If `true`, the modal can be dragged vertically. Defaults to `true`. + public var isDraggable: Bool = true + + /// The style of the overlay displayed behind the modal. + /// + /// Defaults to `.dimmed`. + public var overlayStyle: ModalOverlayStyle = .dimmed + + /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. + /// + /// Defaults to a padding value of `20` for all sides. + public var outerPaddings: Paddings = .init(padding: 20) + + /// The predefined maximum size of the modal. + /// + /// Defaults to `.medium`. + public var size: ModalSize = .medium + + /// The transition duration of the modal's appearance and dismissal animations. + /// + /// Defaults to `.fast`. + public var transition: ModalTransition = .fast + + /// Initializes a new instance of `BottomModalVM` with default values. + public init() {} +} diff --git a/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift b/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift new file mode 100644 index 00000000..17af179b --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift @@ -0,0 +1,53 @@ +import Foundation + +/// A model that defines the appearance properties for a center modal component. +public struct CenterModalVM: ModalVM { + /// The background color of the modal. + public var backgroundColor: UniversalColor? + + /// The border thickness of the modal. + /// + /// Defaults to `.small`. + public var borderWidth: BorderWidth = .small + + /// A Boolean value indicating whether the modal should close when tapping on the overlay. + /// + /// Defaults to `true`. + public var closesOnOverlayTap: Bool = true + + /// The padding applied to the modal's content area. + /// + /// Defaults to a padding value of `16` for all sides. + public var contentPaddings: Paddings = .init(padding: 16) + + /// The spacing between header, body and footer. + public var contentSpacing: CGFloat = 16 + + /// The corner radius of the modal. + /// + /// Defaults to `.medium`. + public var cornerRadius: ContainerRadius = .medium + + /// The style of the overlay displayed behind the modal. + /// + /// Defaults to `.dimmed`. + public var overlayStyle: ModalOverlayStyle = .dimmed + + /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. + /// + /// Defaults to a padding value of `20` for all sides. + public var outerPaddings: Paddings = .init(padding: 20) + + /// The predefined maximum size of the modal. + /// + /// Defaults to `.medium`. + public var size: ModalSize = .medium + + /// The transition duration of the modal's appearance and dismissal animations. + /// + /// Defaults to `.fast`. + public var transition: ModalTransition = .fast + + /// Initializes a new instance of `BottomModalVM` with default values. + public init() {} +} diff --git a/Sources/ComponentsKit/Components/Modal/Models/ModalOverlayStyle.swift b/Sources/ComponentsKit/Components/Modal/Models/ModalOverlayStyle.swift new file mode 100644 index 00000000..48d8f485 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/ModalOverlayStyle.swift @@ -0,0 +1,9 @@ +/// Defines the style of the overlay displayed behind a modal. +public enum ModalOverlayStyle { + /// A dimmed overlay that darkens the background behind the modal. + case dimmed + /// A blurred overlay that applies a blur effect to the background behind the modal. + case blurred + /// A transparent overlay that leaves the background fully visible behind the modal. + case transparent +} diff --git a/Sources/ComponentsKit/Components/Modal/Models/ModalSize.swift b/Sources/ComponentsKit/Components/Modal/Models/ModalSize.swift new file mode 100644 index 00000000..5ef27461 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/ModalSize.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Defines the size options for a modal. +public enum ModalSize { + /// A small modal size. + case small + /// A medium modal size. + case medium + /// A large modal size. + case large + /// A full-screen modal that occupies the entire screen. + case full +} + +extension ModalSize { + public var maxWidth: CGFloat { + switch self { + case .small: + return 300 + case .medium: + return 400 + case .large: + return 600 + case .full: + return 10_000 + } + } +} diff --git a/Sources/ComponentsKit/Components/Modal/Models/ModalTransition.swift b/Sources/ComponentsKit/Components/Modal/Models/ModalTransition.swift new file mode 100644 index 00000000..f4c41c46 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/ModalTransition.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Defines the transition speed options for a modal's appearance and dismissal animations. +public enum ModalTransition: Hashable { + /// No transition is applied; the modal appears and disappears instantly. + case none + /// A slow transition speed. + case slow + /// A normal transition speed. + case normal + /// A fast transition speed. + case fast + /// A custom transition speed defined by a specific time interval. + /// + /// - Parameter duration: The duration of the custom transition in seconds. + case custom(TimeInterval) +} + +extension ModalTransition { + var value: TimeInterval { + switch self { + case .none: + return 0.0 + case .slow: + return 0.5 + case .normal: + return 0.3 + case .fast: + return 0.2 + case .custom(let value): + return max(0, value) + } + } +} diff --git a/Sources/ComponentsKit/Components/Modal/Models/ModalVM.swift b/Sources/ComponentsKit/Components/Modal/Models/ModalVM.swift new file mode 100644 index 00000000..55af7ea3 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/Models/ModalVM.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A model that defines generic appearance properties that can be in any modal component. +public protocol ModalVM: ComponentVM { + /// The background color of the modal. + var backgroundColor: UniversalColor? { get set } + + /// The border thickness of the modal. + var borderWidth: BorderWidth { get set } + + /// A Boolean value indicating whether the modal should close when tapping on the overlay. + var closesOnOverlayTap: Bool { get set } + + /// The padding applied to the modal's content area. + var contentPaddings: Paddings { get set } + + /// The spacing between header, body and footer. + var contentSpacing: CGFloat { get set } + + /// The corner radius of the modal. + var cornerRadius: ContainerRadius { get set } + + /// The style of the overlay displayed behind the modal. + var overlayStyle: ModalOverlayStyle { get set } + + /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. + var outerPaddings: Paddings { get set } + + /// The predefined maximum size of the modal. + var size: ModalSize { get set } + + /// The transition duration of the modal's appearance and dismissal animations. + var transition: ModalTransition { get set } +} + +// MARK: - Helpers + +extension ModalVM { + var preferredBackgroundColor: UniversalColor { + return self.backgroundColor ?? .themed( + light: UniversalColor.background.light, + dark: UniversalColor.secondaryBackground.dark + ) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift b/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift new file mode 100644 index 00000000..190aba8d --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift @@ -0,0 +1,35 @@ +import Foundation + +enum ModalAnimation { + /// Calculates an offset with rubber band effect. + static func rubberBandClamp(_ translation: CGFloat) -> CGFloat { + let dim: CGFloat = 20 + let coef: CGFloat = 0.2 + return (1.0 - (1.0 / ((translation * coef / dim) + 1.0))) * dim + } + + static func bottomModalOffset(_ translation: CGFloat, model: BottomModalVM) -> CGFloat { + if translation > 0 { + return model.hidesOnSwap + ? translation + : (model.isDraggable ? Self.rubberBandClamp(translation) : 0) + } else { + return model.isDraggable + ? -Self.rubberBandClamp(abs(translation)) + : 0 + } + } + + static func shouldHideBottomModal( + offset: CGFloat, + height: CGFloat, + velocity: CGFloat, + model: BottomModalVM + ) -> Bool { + guard model.hidesOnSwap else { + return false + } + + return abs(offset) > height / 2 || velocity > 250 + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift new file mode 100644 index 00000000..3eb02ad4 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ModalPresentationModifier: ViewModifier { + @State var isPresented: Bool = false + @Binding var isContentVisible: Bool + + @ViewBuilder var content: () -> Modal + + let transitionDuration: TimeInterval + let onDismiss: (() -> Void)? + + init( + isVisible: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)?, + @ViewBuilder content: @escaping () -> Modal + ) { + self._isContentVisible = isVisible + self.transitionDuration = transitionDuration + self.onDismiss = onDismiss + self.content = content + } + + func body(content: Content) -> some View { + content + .onChange(of: self.isContentVisible) { newValue in + if newValue { + self.isPresented = true + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { + self.isPresented = false + } + } + } + .fullScreenCover( + isPresented: self.$isPresented, + onDismiss: self.onDismiss, + content: { + self.content() + .transparentPresentationBackground() + } + ) + .transaction { + $0.disablesAnimations = true + } + } +} + +extension View { + func modal( + isVisible: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Modal + ) -> some View { + modifier(ModalPresentationModifier( + isVisible: isVisible, + transitionDuration: transitionDuration, + onDismiss: onDismiss, + content: content + )) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift new file mode 100644 index 00000000..31e7cdc8 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ModalPresentationWithItemModifier: ViewModifier { + @State var presentedItem: Item? + @Binding var visibleItem: Item? + + @ViewBuilder var content: (Item) -> Modal + + let transitionDuration: TimeInterval + let onDismiss: (() -> Void)? + + init( + item: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)?, + @ViewBuilder content: @escaping (Item) -> Modal + ) { + self._visibleItem = item + self.transitionDuration = transitionDuration + self.onDismiss = onDismiss + self.content = content + } + + func body(content: Content) -> some View { + content + .onChange(of: self.visibleItem.isNotNil) { newValue in + if newValue { + self.presentedItem = self.visibleItem + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { + self.presentedItem = self.visibleItem + } + } + } + .fullScreenCover( + item: self.$presentedItem, + onDismiss: self.onDismiss, + content: { item in + self.content(item) + .transparentPresentationBackground() + } + ) + .transaction { + $0.disablesAnimations = true + } + } +} + +extension View { + func modal( + item: Binding, + transitionDuration: TimeInterval, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Modal + ) -> some View { + modifier(ModalPresentationWithItemModifier( + item: item, + transitionDuration: transitionDuration, + onDismiss: onDismiss, + content: content + )) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/View+Helpers.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/View+Helpers.swift new file mode 100644 index 00000000..5f874ce9 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/View+Helpers.swift @@ -0,0 +1,38 @@ +import SwiftUI + +// MARK: - Transparent Presentation Background + +fileprivate struct TransparentBackground: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { + view.superview?.superview?.backgroundColor = .clear + } + return view + } + func updateUIView(_ uiView: UIView, context: Context) {} +} + +extension View { + func transparentPresentationBackground() -> some View { + if #available(iOS 16.4, *) { + return self.presentationBackground(Color.clear) + } else { + return self.background(TransparentBackground()) + } + } +} + +// MARK: - Disable Scroll When Content Fits + +extension View { + func disableScrollWhenContentFits() -> some View { + if #available(iOS 16.4, *) { + return self.scrollBounceBehavior(.basedOnSize, axes: [.vertical]) + } else { + return self.onAppear { + UIScrollView.appearance().bounces = false + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift new file mode 100644 index 00000000..1f379c1e --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct ModalContent: View { + let model: VM + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var headerSize: CGSize = .zero + @State private var bodySize: CGSize = .zero + @State private var footerSize: CGSize = .zero + + init( + model: VM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + VStack(spacing: self.model.contentSpacing) { + self.contentHeader() + .observeSize { + self.headerSize = $0 + } + .padding(.top, self.model.contentPaddings.top) + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + + ScrollView { + self.contentBody() + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + .observeSize { + self.bodySize = $0 + } + .padding(.top, self.bodyTopPadding) + .padding(.bottom, self.bodyBottomPadding) + } + .frame(maxHeight: self.scrollViewMaxHeight) + .disableScrollWhenContentFits() + + self.contentFooter() + .observeSize { + self.footerSize = $0 + } + .padding(.leading, self.model.contentPaddings.leading) + .padding(.trailing, self.model.contentPaddings.trailing) + .padding(.bottom, self.model.contentPaddings.bottom) + } + .frame(maxWidth: self.model.size.maxWidth, alignment: .leading) + .background(self.model.preferredBackgroundColor.color) + .clipShape(RoundedRectangle(cornerRadius: self.model.cornerRadius.value)) + .overlay( + RoundedRectangle(cornerRadius: self.model.cornerRadius.value) + .stroke(UniversalColor.divider.color, lineWidth: self.model.borderWidth.value) + ) + .padding(self.model.outerPaddings.edgeInsets) + } + + private var bodyTopPadding: CGFloat { + return self.headerSize.height > 0 ? 0 : self.model.contentPaddings.top + } + private var bodyBottomPadding: CGFloat { + return self.footerSize.height > 0 ? 0 : self.model.contentPaddings.bottom + } + private var scrollViewMaxHeight: CGFloat { + return self.bodySize.height + self.bodyTopPadding + self.bodyBottomPadding + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalOverlay.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalOverlay.swift new file mode 100644 index 00000000..87f549c5 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/ModalOverlay.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ModalOverlay: View { + let model: VM + + @Binding var isVisible: Bool + + init( + isVisible: Binding, + model: VM + ) { + self._isVisible = isVisible + self.model = model + } + + var body: some View { + Group { + switch self.model.overlayStyle { + case .dimmed: + Color.black.opacity(0.7) + case .blurred: + Color.clear.background(.ultraThinMaterial) + case .transparent: + // Note: The tap gesture isn't recognized when a completely transparent + // color is clicked. This can be fixed by calling contentShape, which + // defines the interactive area of the underlying view. + Color.clear.contentShape(.rect) + } + } + .ignoresSafeArea(.all) + .onTapGesture { + if self.model.closesOnOverlayTap { + self.isVisible = false + } + } + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift new file mode 100644 index 00000000..000a1466 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUBottomModal.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct SUBottomModal: View { + let model: BottomModalVM + + @Binding var isVisible: Bool + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var contentHeight: CGFloat = 0 + @State private var contentOffsetY: CGFloat = 0 + @State private var overlayOpacity: CGFloat = 0 + + init( + isVisible: Binding, + model: BottomModalVM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self._isVisible = isVisible + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + ZStack(alignment: .bottom) { + ModalOverlay(isVisible: self.$isVisible, model: self.model) + .opacity(self.overlayOpacity) + + ModalContent(model: self.model, header: self.contentHeader, body: self.contentBody, footer: self.contentFooter) + .observeSize { + self.contentHeight = $0.height + } + .offset(y: self.contentOffsetY) + .gesture( + DragGesture() + .onChanged { gesture in + let translation = gesture.translation.height + self.contentOffsetY = ModalAnimation.bottomModalOffset(translation, model: self.model) + } + .onEnded { gesture in + if ModalAnimation.shouldHideBottomModal( + offset: self.contentOffsetY, + height: self.contentHeight, + velocity: gesture.velocity.height, + model: self.model + ) { + self.isVisible = false + } else { + withAnimation(.linear(duration: 0.2)) { + self.contentOffsetY = 0 + } + } + } + ) + } + .onAppear { + self.contentOffsetY = self.screenHeight + + withAnimation(.linear(duration: self.model.transition.value)) { + self.overlayOpacity = 1.0 + self.contentOffsetY = 0 + } + } + .onChange(of: self.isVisible) { newValue in + withAnimation(.linear(duration: self.model.transition.value)) { + if newValue { + self.overlayOpacity = 1.0 + self.contentOffsetY = 0 + } else { + self.overlayOpacity = 0.0 + self.contentOffsetY = self.screenHeight + } + } + } + } + + // MARK: - Helpers + + private var screenHeight: CGFloat { + return UIScreen.main.bounds.height + } +} + +// MARK: - Presentation Helpers + +extension View { + /// A SwiftUI view modifier that presents a bottom-aligned modal. + /// + /// This modifier allows you to attach a bottom modal to any SwiftUI view, providing a structured way to display modals + /// with a header, body, and footer, all styled and laid out according to the provided `BottomModalVM` model. + /// + /// - Parameters: + /// - isPresented: A binding that determines whether the modal is presented. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. + /// - header: A closure that provides the view for the modal's header. + /// - body: A closure that provides the view for the modal's main content. + /// - footer: A closure that provides the view for the modal's footer. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// SomeView() + /// .bottomModal( + /// isPresented: $isModalPresented, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { + /// Text("Header") + /// }, + /// body: { + /// Text("Body content goes here") + /// }, + /// footer: { + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// isModalPresented = false + /// } + /// } + /// ) + /// ``` + public func bottomModal( + isPresented: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping () -> Header = { EmptyView() }, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } + ) -> some View { + return self.modal( + isVisible: isPresented, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { + SUBottomModal( + isVisible: isPresented, + model: model, + header: header, + body: body, + footer: footer + ) + } + ) + } +} + +extension View { + /// A SwiftUI view modifier that presents a bottom-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - header: A closure that provides the view for the modal's header, based on the unwrapped `Item`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// - footer: A closure that provides the view for the modal's footer, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .bottomModal( + /// item: $selectedItem, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { item in + /// Text("Header for \(item.text)") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// }, + /// footer: { _ in + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// selectedItem = nil + /// } + /// } + /// ) + /// } + /// } + /// ``` + public func bottomModal( + item: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping (Item) -> Header, + @ViewBuilder body: @escaping (Item) -> Body, + @ViewBuilder footer: @escaping (Item) -> Footer + ) -> some View { + return self.modal( + item: item, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { unwrappedItem in + SUBottomModal( + isVisible: .init( + get: { + return item.wrappedValue.isNotNil + }, + set: { isPresented in + if isPresented { + item.wrappedValue = unwrappedItem + } else { + item.wrappedValue = nil + } + } + ), + model: model, + header: { header(unwrappedItem) }, + body: { body(unwrappedItem) }, + footer: { footer(unwrappedItem) } + ) + } + ) + } + + /// A SwiftUI view modifier that presents a bottom-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a bottom modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .bottomModal( + /// item: $selectedItem, + /// model: BottomModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// } + /// ) + /// } + /// } + /// ``` + public func bottomModal( + item: Binding, + model: BottomModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder body: @escaping (Item) -> Body + ) -> some View { + return self.bottomModal( + item: item, + model: model, + header: { _ in EmptyView() }, + body: body, + footer: { _ in EmptyView() } + ) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift new file mode 100644 index 00000000..dd7c65d2 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/SwiftUI/SUCenterModal.swift @@ -0,0 +1,279 @@ +import SwiftUI + +struct SUCenterModal: View { + let model: CenterModalVM + + @Binding var isVisible: Bool + + @ViewBuilder let contentHeader: () -> Header + @ViewBuilder let contentBody: () -> Body + @ViewBuilder let contentFooter: () -> Footer + + @State private var contentOpacity: CGFloat = 0 + + init( + isVisible: Binding, + model: CenterModalVM, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer + ) { + self._isVisible = isVisible + self.model = model + self.contentHeader = header + self.contentBody = body + self.contentFooter = footer + } + + var body: some View { + ZStack(alignment: .center) { + ModalOverlay(isVisible: self.$isVisible, model: self.model) + + ModalContent(model: self.model, header: self.contentHeader, body: self.contentBody, footer: self.contentFooter) + } + .opacity(self.contentOpacity) + .onAppear { + withAnimation(.linear(duration: self.model.transition.value)) { + self.contentOpacity = 1.0 + } + } + .onChange(of: self.isVisible) { newValue in + withAnimation(.linear(duration: self.model.transition.value)) { + if newValue { + self.contentOpacity = 1.0 + } else { + self.contentOpacity = 0.0 + } + } + } + } +} + +// MARK: - Presentation Helpers + +extension View { + /// A SwiftUI view modifier that presents a center-aligned modal. + /// + /// This modifier allows you to attach a center modal to any SwiftUI view, providing a structured way to display modals + /// with a header, body, and footer, all styled and laid out according to the provided `CenterModalVM` model. + /// + /// - Parameters: + /// - isPresented: A binding that determines whether the modal is presented. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. + /// - header: A closure that provides the view for the modal's header. + /// - body: A closure that provides the view for the modal's main content. + /// - footer: A closure that provides the view for the modal's footer. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// SomeView() + /// .centerModal( + /// isPresented: $isModalPresented, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { + /// Text("Header") + /// }, + /// body: { + /// Text("Body content goes here") + /// }, + /// footer: { + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// isModalPresented = false + /// } + /// } + /// ) + /// ``` + public func centerModal( + isPresented: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping () -> Header = { EmptyView() }, + @ViewBuilder body: @escaping () -> Body, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() } + ) -> some View { + return self.modal( + isVisible: isPresented, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { + SUCenterModal( + isVisible: isPresented, + model: model, + header: header, + body: body, + footer: footer + ) + } + ) + } +} + +extension View { + /// A SwiftUI view modifier that presents a center-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - header: A closure that provides the view for the modal's header, based on the unwrapped `Item`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// - footer: A closure that provides the view for the modal's footer, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .centerModal( + /// item: $selectedItem, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// header: { item in + /// Text("Header for \(item.text)") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// }, + /// footer: { _ in + /// SUButton(model: .init { + /// $0.title = "Close" + /// }) { + /// selectedItem = nil + /// } + /// } + /// ) + /// } + /// } + /// ``` + public func centerModal( + item: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder header: @escaping (Item) -> Header, + @ViewBuilder body: @escaping (Item) -> Body, + @ViewBuilder footer: @escaping (Item) -> Footer + ) -> some View { + return self.modal( + item: item, + transitionDuration: model.transition.value, + onDismiss: onDismiss, + content: { unwrappedItem in + SUCenterModal( + isVisible: .init( + get: { + return item.wrappedValue.isNotNil + }, + set: { isPresented in + if isPresented { + item.wrappedValue = unwrappedItem + } else { + item.wrappedValue = nil + } + } + ), + model: model, + header: { header(unwrappedItem) }, + body: { body(unwrappedItem) }, + footer: { footer(unwrappedItem) } + ) + } + ) + } + + /// A SwiftUI view modifier that presents a center-aligned modal bound to an optional identifiable item. + /// + /// This modifier allows you to attach a modal to any SwiftUI view, which is displayed when the `item` binding + /// is non-`nil`. The modal content is dynamically generated based on the unwrapped `Item`. + /// + /// - Parameters: + /// - item: A binding to an optional `Item` that determines whether the modal is presented. + /// When `item` is `nil`, the modal is hidden. + /// - model: A model that defines the appearance properties. + /// - onDismiss: An optional closure executed when the modal is dismissed. Defaults to `nil`. + /// - body: A closure that provides the view for the modal's main content, based on the unwrapped `Item`. + /// + /// - Returns: A modified `View` with a center modal attached. + /// + /// - Example: + /// ```swift + /// struct ContentView: View { + /// struct ModalData: Identifiable { + /// var id: String { + /// return text + /// } + /// let text: String + /// } + /// + /// @State private var selectedItem: ModalData? + /// private let items: [ModalData] = [ + /// ModalData(text: "data 1"), + /// ModalData(text: "data 2") + /// ] + /// + /// var body: some View { + /// List(items) { item in + /// Button("Show Modal") { + /// selectedItem = item + /// } + /// } + /// .centerModal( + /// item: $selectedItem, + /// model: CenterModalVM(), + /// onDismiss: { + /// print("Modal dismissed") + /// }, + /// body: { item in + /// Text("Body content for \(item.text)") + /// } + /// ) + /// } + /// } + /// ``` + public func centerModal( + item: Binding, + model: CenterModalVM = .init(), + onDismiss: (() -> Void)? = nil, + @ViewBuilder body: @escaping (Item) -> Body + ) -> some View { + return self.centerModal( + item: item, + model: model, + header: { _ in EmptyView() }, + body: body, + footer: { _ in EmptyView() } + ) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/Helpers/ContentSizedScrollView.swift b/Sources/ComponentsKit/Components/Modal/UIKit/Helpers/ContentSizedScrollView.swift new file mode 100644 index 00000000..4478cb81 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/UIKit/Helpers/ContentSizedScrollView.swift @@ -0,0 +1,19 @@ +import UIKit + +/// A custom `UIScrollView` subclass that automatically adjusts its intrinsic content size +/// based on its content size, ensuring it fits its content vertically. +final class ContentSizedScrollView: UIScrollView { + override var contentSize: CGSize { + didSet { + self.invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + self.layoutIfNeeded() + return CGSize( + width: UIView.noIntrinsicMetric, + height: self.contentSize.height + ) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift new file mode 100644 index 00000000..db5b9a10 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift @@ -0,0 +1,156 @@ +import UIKit + +/// A bottom-aligned modal controller. +/// +/// - Example: +/// ```swift +/// let bottomModal = UKBottomModalController( +/// model: BottomModalVM(), +/// header: { _ in +/// let headerLabel = UILabel() +/// headerLabel.text = "Header" +/// return headerLabel +/// }, +/// body: { _ in +/// let bodyLabel = UILabel() +/// bodyLabel.text = "This is the body content of the modal." +/// bodyLabel.numberOfLines = 0 +/// return bodyLabel +/// }, +/// footer: { dismiss in +/// return UKButton(model: .init { +/// $0.title = "Close" +/// }) { +/// dismiss(true) +/// } +/// } +/// ) +/// +/// vc.present(bottomModal, animated: true) +/// ``` +public class UKBottomModalController: UKModalController { + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - model: A model that defines the appearance properties. + /// - header: An optional content block for the modal's header. + /// - body: The main content block for the modal. + /// - footer: An optional content block for the modal's footer. + public init( + model: BottomModalVM = .init(), + header: Content? = nil, + body: Content, + footer: Content? = nil + ) { + super.init(model: model) + + self.header = header?({ [weak self] animated in + self?.dismiss(animated: animated) + }) + self.body = body({ [weak self] animated in + self?.dismiss(animated: animated) + }) + self.footer = footer?({ [weak self] animated in + self?.dismiss(animated: animated) + }) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.contentView.transform = .init(translationX: 0, y: self.view.screenBounds.height) + self.overlay.alpha = 0 + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.animate(withDuration: self.model.transition.value) { + self.contentView.transform = .identity + self.overlay.alpha = 1 + } + } + + // MARK: - Setup + + public override func setup() { + super.setup() + + self.contentView.addGestureRecognizer(UIPanGestureRecognizer( + target: self, + action: #selector(self.handleDragGesture) + )) + } + + // MARK: - Layout + + public override func layout() { + super.layout() + + self.contentViewBottomConstraint = self.contentView.bottom(self.model.outerPaddings.bottom, safeArea: true).bottom + } + + // MARK: - UIViewController Methods + + public override func dismiss( + animated flag: Bool, + completion: (() -> Void)? = nil + ) { + UIView.animate(withDuration: self.model.transition.value) { + self.contentView.transform = .init(translationX: 0, y: self.view.screenBounds.height) + self.overlay.alpha = 0 + } completion: { _ in + super.dismiss(animated: false) + } + } +} + +// MARK: - Interactions + +extension UKBottomModalController { + @objc private func handleDragGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: self.contentView).y + let velocity = gesture.velocity(in: self.contentView).y + let offset = ModalAnimation.bottomModalOffset(translation, model: self.model) + + switch gesture.state { + case .changed: + self.contentView.transform = .init(translationX: 0, y: offset) + case .ended: + let viewHeight = self.contentView.frame.height + if ModalAnimation.shouldHideBottomModal(offset: offset, height: viewHeight, velocity: velocity, model: self.model) { + self.dismiss(animated: true) + } else { + UIView.animate(withDuration: 0.2) { + self.contentView.transform = .identity + } + } + case .failed, .cancelled: + UIView.animate(withDuration: 0.2) { + self.contentView.transform = .identity + } + default: + break + } + } +} + +// MARK: - UIViewController + Present Bottom Modal + +extension UIViewController { + public func present( + _ vc: UKBottomModalController, + animated: Bool, + completion: (() -> Void)? = nil + ) { + self.present(vc as UIViewController, animated: false) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift new file mode 100644 index 00000000..c31e8704 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift @@ -0,0 +1,127 @@ +import UIKit + +/// A center-aligned modal controller. +/// +/// - Example: +/// ```swift +/// let centerModal = UKCenterModalController( +/// model: CenterModalVM(), +/// header: { _ in +/// let headerLabel = UILabel() +/// headerLabel.text = "Header" +/// return headerLabel +/// }, +/// body: { _ in +/// let bodyLabel = UILabel() +/// bodyLabel.text = "This is the body content of the modal." +/// bodyLabel.numberOfLines = 0 +/// return bodyLabel +/// }, +/// footer: { dismiss in +/// return UKButton(model: .init { +/// $0.title = "Close" +/// }) { +/// dismiss(true) +/// } +/// } +/// ) +/// +/// vc.present(centerModal, animated: true) +/// ``` +public class UKCenterModalController: UKModalController { + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - model: A model that defines the appearance properties. + /// - header: An optional content block for the modal's header. + /// - body: The main content block for the modal. + /// - footer: An optional content block for the modal's footer. + public init( + model: CenterModalVM = .init(), + header: Content? = nil, + body: Content, + footer: Content? = nil + ) { + super.init(model: model) + + self.header = header?({ [weak self] animated in + self?.dismiss(animated: animated) + }) + self.body = body({ [weak self] animated in + self?.dismiss(animated: animated) + }) + self.footer = footer?({ [weak self] animated in + self?.dismiss(animated: animated) + }) + } + + override init(model: CenterModalVM) { + super.init(model: model) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.overlay.alpha = 0 + self.contentView.alpha = 0 + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.animate(withDuration: self.model.transition.value) { + self.overlay.alpha = 1 + self.contentView.alpha = 1 + } + } + + // MARK: - Layout + + public override func layout() { + super.layout() + + self.contentViewBottomConstraint = self.contentView.bottomAnchor.constraint( + lessThanOrEqualTo: self.view.safeAreaLayoutGuide.bottomAnchor, + constant: -self.model.outerPaddings.bottom + ) + self.contentViewBottomConstraint?.isActive = true + + let verticalConstraint = self.contentView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor) + verticalConstraint.isActive = true + verticalConstraint.priority = .defaultLow + } + + // MARK: - UIViewController Methods + + public override func dismiss( + animated flag: Bool, + completion: (() -> Void)? = nil + ) { + UIView.animate(withDuration: self.model.transition.value) { + self.overlay.alpha = 0 + self.contentView.alpha = 0 + } completion: { _ in + super.dismiss(animated: false) + } + } +} + +// MARK: - UIViewController + Present Center Modal + +extension UIViewController { + public func present( + _ vc: UKCenterModalController, + animated: Bool, + completion: (() -> Void)? = nil + ) { + self.present(vc as UIViewController, animated: false) + } +} diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKModalController.swift new file mode 100644 index 00000000..51e3e238 --- /dev/null +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKModalController.swift @@ -0,0 +1,274 @@ +import AutoLayout +import UIKit + +/// A generic class that defines shared behavior for modal controllers. +open class UKModalController: UIViewController { + // MARK: - Typealiases + + /// A typealias for content providers, which create views for the header, body, or footer. + /// The content provider closure receives a dismiss action that can be called to close the modal. + public typealias Content = (_ dismiss: @escaping (_ animated: Bool) -> Void) -> UIView + + // MARK: - Properties + + /// A model that defines the appearance properties. + public let model: VM + + private var contentViewWidthConstraint: NSLayoutConstraint? + var contentViewBottomConstraint: NSLayoutConstraint? + + // MARK: - Subviews + + /// The optional header view of the modal. + public var header: UIView? + /// The main body view of the modal. + public var body = UIView() + /// The optional footer view of the modal. + public var footer: UIView? + /// The content view, holding the header, body, and footer. + public let contentView = UIView() + /// A scrollable wrapper for the body content. + public let bodyWrapper: UIScrollView = ContentSizedScrollView() + /// The overlay view that appears behind the modal. + public let overlay: UIView + + // MARK: - Initialization + + init(model: VM) { + self.model = model + + switch model.overlayStyle { + case .dimmed, .transparent: + self.overlay = UIView() + case .blurred: + self.overlay = UIVisualEffectView() + } + + super.init(nibName: nil, bundle: nil) + + self.modalPresentationStyle = .overFullScreen + self.modalTransitionStyle = .crossDissolve + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Deinitialization + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Lifecycle + + open override func viewDidLoad() { + super.viewDidLoad() + + self.setup() + self.style() + self.layout() + } + + // MARK: - Setup + + /// Sets up the modal's subviews, gesture recognizers and observers. + open func setup() { + self.view.addSubview(self.overlay) + self.view.addSubview(self.contentView) + if let header { + self.contentView.addSubview(header) + } + self.contentView.addSubview(self.bodyWrapper) + if let footer { + self.contentView.addSubview(footer) + } + + self.bodyWrapper.addSubview(self.body) + + self.overlay.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(self.handleOverlayTap) + )) + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (controller: Self, _: UITraitCollection) in + controller.handleTraitChanges() + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleKeyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleKeyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc func handleOverlayTap() { + guard self.model.closesOnOverlayTap else { return } + self.dismiss(animated: true) + } + + @objc func handleKeyboardWillShow(notification: NSNotification) { + if let keyboardHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? CGFloat ?? 0.25 + UIView.animate(withDuration: duration) { + self.contentViewBottomConstraint?.constant = -keyboardHeight - self.model.contentPaddings.bottom + self.view.safeAreaInsets.bottom + self.view.layoutIfNeeded() + } + } + } + + @objc func handleKeyboardWillHide(notification: NSNotification) { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? CGFloat ?? 0.25 + UIView.animate(withDuration: duration) { + self.contentViewBottomConstraint?.constant = -self.model.contentPaddings.bottom + self.view.layoutIfNeeded() + } + } + + // MARK: - Style + + /// Applies styling to the modal's subviews. + open func style() { + Self.Style.overlay(self.overlay, model: self.model) + Self.Style.contentView(self.contentView, model: self.model) + Self.Style.bodyWrapper(self.bodyWrapper) + } + + // MARK: - Layout + + /// Configures the layout of the modal's subviews. + open func layout() { + self.overlay.allEdges() + + if let header { + header.top(self.model.contentPaddings.top) + header.leading(self.model.contentPaddings.leading) + header.trailing(self.model.contentPaddings.trailing) + header.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + self.bodyWrapper.below(header, padding: self.model.contentSpacing) + self.body.top() + } else { + self.bodyWrapper.top() + self.body.top(self.model.contentPaddings.top) + } + + if let footer { + footer.bottom(self.model.contentPaddings.bottom) + footer.leading(self.model.contentPaddings.leading) + footer.trailing(self.model.contentPaddings.trailing) + footer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + self.bodyWrapper.above(footer, padding: self.model.contentSpacing) + self.body.bottom() + } else { + self.bodyWrapper.bottom() + self.body.bottom(self.model.contentPaddings.top) + } + + self.bodyWrapper.horizontally() + self.bodyWrapper.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + self.body.leading(self.model.contentPaddings.leading, to: self.contentView) + self.body.trailing(self.model.contentPaddings.trailing, to: self.contentView) + + self.contentView.topAnchor.constraint( + greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.topAnchor, + constant: self.model.outerPaddings.top + ).isActive = true + self.contentView.leadingAnchor.constraint( + greaterThanOrEqualTo: self.view.safeAreaLayoutGuide.leadingAnchor, + constant: self.model.outerPaddings.leading + ).isActive = true + self.contentView.trailingAnchor.constraint( + lessThanOrEqualTo: self.view.safeAreaLayoutGuide.trailingAnchor, + constant: -self.model.outerPaddings.trailing + ).isActive = true + self.contentView.heightAnchor.constraint( + greaterThanOrEqualToConstant: 80 + ).isActive = true + + self.contentViewWidthConstraint = self.contentView.width(self.model.size.maxWidth).width + self.contentViewWidthConstraint?.priority = .defaultHigh + + self.bodyWrapper.widthAnchor.constraint(equalTo: self.contentView.widthAnchor).isActive = true + + self.contentView.centerHorizontally() + } + + open override func viewWillTransition( + to size: CGSize, + with coordinator: any UIViewControllerTransitionCoordinator + ) { + self.contentViewWidthConstraint?.isActive = false + super.viewWillTransition(to: size, with: coordinator) + } + + open override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + let availableWidth = self.view.bounds.width + let requiredWidth = self.model.size.maxWidth + + self.model.outerPaddings.leading + + self.model.outerPaddings.trailing + if availableWidth > requiredWidth { + self.contentViewWidthConstraint?.priority = .required + } else { + self.contentViewWidthConstraint?.priority = .defaultHigh + } + self.contentViewWidthConstraint?.isActive = true + } + + // MARK: - UIViewController Methods + + open override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + // MARK: - Helpers + + @objc private func handleTraitChanges() { + Self.Style.contentView(self.contentView, model: self.model) + } +} + +// MARK: - Style Helpers + +extension UKModalController { + enum Style { + static func overlay(_ view: UIView, model: VM) { + switch model.overlayStyle { + case .dimmed: + view.backgroundColor = .black.withAlphaComponent(0.7) + case .transparent: + view.backgroundColor = .clear + case .blurred: + (view as? UIVisualEffectView)?.effect = UIBlurEffect(style: .systemUltraThinMaterial) + } + } + static func contentView(_ view: UIView, model: VM) { + view.backgroundColor = model.preferredBackgroundColor.uiColor + view.layer.cornerRadius = model.cornerRadius.value + view.layer.borderColor = UniversalColor.divider.cgColor + view.layer.borderWidth = model.borderWidth.value + } + static func bodyWrapper(_ scrollView: UIScrollView) { + scrollView.delaysContentTouches = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + } +} diff --git a/Sources/ComponentsKit/RadioGroup/Models/RadioGroupVM.swift b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift similarity index 84% rename from Sources/ComponentsKit/RadioGroup/Models/RadioGroupVM.swift rename to Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift index ab205cf9..bc985340 100644 --- a/Sources/ComponentsKit/RadioGroup/Models/RadioGroupVM.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift @@ -9,7 +9,7 @@ public struct RadioGroupVM: ComponentVM { public var animationScale: AnimationScale = .medium /// The color of the selected radio button. - public var color: UniversalColor = .primary + public var color: UniversalColor = .accent /// The font used for the radio items' titles. public var font: UniversalFont? @@ -93,11 +93,11 @@ extension RadioGroupVM { switch self.size { case .small: - return UniversalFont.Component.small + return .smBody case .medium: - return UniversalFont.Component.medium + return .mdBody case .large: - return UniversalFont.Component.large + return .lgBody } } @@ -114,18 +114,15 @@ extension RadioGroupVM { } func radioItemColor(for item: RadioItemVM, isSelected: Bool) -> UniversalColor { - let defaultColor = UniversalColor.universal(.uiColor(.lightGray)) - let color = isSelected ? self.color : defaultColor - return self.isItemEnabled(item) - ? color - : color.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity) + if isSelected { + return self.color.enabled(self.isItemEnabled(item)) + } else { + return .divider + } } func textColor(for item: RadioItemVM) -> UniversalColor { - let baseColor = Palette.Text.primary - return self.isItemEnabled(item) - ? baseColor - : baseColor.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity) + return .foreground.enabled(self.isItemEnabled(item)) } } diff --git a/Sources/ComponentsKit/RadioGroup/Models/RadioItemVM.swift b/Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift similarity index 100% rename from Sources/ComponentsKit/RadioGroup/Models/RadioItemVM.swift rename to Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift diff --git a/Sources/ComponentsKit/RadioGroup/SwiftUI/SURadioGroup.swift b/Sources/ComponentsKit/Components/RadioGroup/SwiftUI/SURadioGroup.swift similarity index 90% rename from Sources/ComponentsKit/RadioGroup/SwiftUI/SURadioGroup.swift rename to Sources/ComponentsKit/Components/RadioGroup/SwiftUI/SURadioGroup.swift index 732d4c10..2ed80164 100644 --- a/Sources/ComponentsKit/RadioGroup/SwiftUI/SURadioGroup.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/SwiftUI/SURadioGroup.swift @@ -11,7 +11,6 @@ public struct SURadioGroup: View { @Binding public var selectedId: ID? @State private var viewSizes: [ID: CGSize] = [:] - @Environment(\.colorScheme) private var colorScheme @State private var tappingId: ID? // MARK: Initialization @@ -37,14 +36,14 @@ public struct SURadioGroup: View { ZStack { Circle() .strokeBorder( - self.model.radioItemColor(for: item, isSelected: self.selectedId == item.id).color(for: self.colorScheme), + self.model.radioItemColor(for: item, isSelected: self.selectedId == item.id).color, lineWidth: self.model.lineWidth ) .frame(width: self.model.circleSize, height: self.model.circleSize) if self.selectedId == item.id { Circle() .fill( - self.model.radioItemColor(for: item, isSelected: true).color(for: self.colorScheme) + self.model.radioItemColor(for: item, isSelected: true).color ) .frame(width: self.model.innerCircleSize, height: self.model.innerCircleSize) .transition(.scale) @@ -54,9 +53,7 @@ public struct SURadioGroup: View { .scaleEffect(self.tappingId == item.id ? self.model.animationScale.value : 1.0) Text(item.title) .font(self.model.preferredFont(for: item.id).font) - .foregroundColor( - self.model.textColor(for: item).color(for: self.colorScheme) - ) + .foregroundColor(self.model.textColor(for: item).color) } .background( GeometryReader { proxy in diff --git a/Sources/ComponentsKit/RadioGroup/UIKit/RadioGroupItemView.swift b/Sources/ComponentsKit/Components/RadioGroup/UIKit/RadioGroupItemView.swift similarity index 89% rename from Sources/ComponentsKit/RadioGroup/UIKit/RadioGroupItemView.swift rename to Sources/ComponentsKit/Components/RadioGroup/UIKit/RadioGroupItemView.swift index 055ad5ee..532ecf7b 100644 --- a/Sources/ComponentsKit/RadioGroup/UIKit/RadioGroupItemView.swift +++ b/Sources/ComponentsKit/Components/RadioGroup/UIKit/RadioGroupItemView.swift @@ -57,6 +57,12 @@ public class RadioGroupItemView: UIView { self.addSubview(self.radioView) self.radioView.addSubview(self.innerCircle) self.addSubview(self.titleLabel) + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } } // MARK: Style @@ -152,6 +158,26 @@ public class RadioGroupItemView: UIView { completion: nil ) } + + // MARK: UIView Methods + + public override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + // MARK: Helpers + + @objc private func handleTraitChanges() { + Self.Style.radioView( + self.radioView, + itemVM: self.itemVM, + groupVM: self.groupVM, + isSelected: self.isSelected + ) + } } // MARK: - Style Helpers diff --git a/Sources/ComponentsKit/RadioGroup/UIKit/UKRadioGroup.swift b/Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift similarity index 100% rename from Sources/ComponentsKit/RadioGroup/UIKit/UKRadioGroup.swift rename to Sources/ComponentsKit/Components/RadioGroup/UIKit/UKRadioGroup.swift diff --git a/Sources/ComponentsKit/SegmentedControl/Models/SegmentedControlItemVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift similarity index 100% rename from Sources/ComponentsKit/SegmentedControl/Models/SegmentedControlItemVM.swift rename to Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift diff --git a/Sources/ComponentsKit/SegmentedControl/Models/SegmentedControlVM.swift b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift similarity index 72% rename from Sources/ComponentsKit/SegmentedControl/Models/SegmentedControlVM.swift rename to Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift index dfd3c55e..6aab2aad 100644 --- a/Sources/ComponentsKit/SegmentedControl/Models/SegmentedControlVM.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlVM.swift @@ -52,26 +52,14 @@ public struct SegmentedControlVM: ComponentVM { extension SegmentedControlVM { var backgroundColor: UniversalColor { - return .init( - light: .rgba(r: 244, g: 244, b: 245, a: 1.0), - dark: .rgba(r: 39, g: 39, b: 42, a: 1.0) - ) - .withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + return .content1 } var selectedSegmentColor: UniversalColor { - let selectedSegmentColor = self.color?.main ?? .init( - light: .rgba(r: 255, g: 255, b: 255, a: 1.0), - dark: .rgba(r: 62, g: 62, b: 69, a: 1.0) - ) - return selectedSegmentColor.withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity + let color = self.color?.main ?? .themed( + light: UniversalColor.white.light, + dark: UniversalColor.content2.dark ) + return color.enabled(self.isEnabled) } func item(for id: ID) -> SegmentedControlItemVM? { return self.items.first(where: { $0.id == id }) @@ -79,25 +67,11 @@ extension SegmentedControlVM { func foregroundColor(id: ID, selectedId: ID) -> UniversalColor { let isItemEnabled = self.item(for: id)?.isEnabled == true let isSelected = id == selectedId && isItemEnabled - let defaultColor = UniversalColor( - light: .rgba(r: 0, g: 0, b: 0, a: 1.0), - dark: .rgba(r: 255, g: 255, b: 255, a: 1.0) - ) - guard isSelected else { - return defaultColor.withOpacity( - self.isEnabled && isItemEnabled - ? 0.7 - : 0.7 * ComponentsKitConfig.shared.layout.disabledOpacity - ) - } - - let foregroundColor = self.color?.contrast ?? defaultColor - return foregroundColor.withOpacity( - self.isEnabled - ? 1.0 - : ComponentsKitConfig.shared.layout.disabledOpacity - ) + let color = isSelected + ? self.color?.contrast ?? .foreground + : .secondaryForeground + return color.enabled(self.isEnabled && isItemEnabled) } var horizontalInnerPaddings: CGFloat? { guard !self.isFullWidth else { @@ -118,8 +92,17 @@ extension SegmentedControlVM { var height: CGFloat { return switch self.size { case .small: 36 - case .medium: 50 - case .large: 70 + case .medium: 44 + case .large: 52 + } + } + func selectedSegmentCornerRadius(for height: CGFloat = 10_000) -> CGFloat { + let componentRadius = self.cornerRadius.value(for: height) + switch self.cornerRadius { + case .none, .full, .custom: + return componentRadius + case .small, .medium, .large: + return max(0, componentRadius - self.outerPaddings / 2) } } func preferredFont(for id: ID) -> UniversalFont { @@ -131,11 +114,11 @@ extension SegmentedControlVM { switch self.size { case .small: - return UniversalFont.Component.small + return .smBody case .medium: - return UniversalFont.Component.medium + return .mdBody case .large: - return UniversalFont.Component.large + return .lgBody } } } diff --git a/Sources/ComponentsKit/SegmentedControl/SUSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift similarity index 79% rename from Sources/ComponentsKit/SegmentedControl/SUSegmentedControl.swift rename to Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift index 9754fb78..12e068e2 100644 --- a/Sources/ComponentsKit/SegmentedControl/SUSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift @@ -11,7 +11,6 @@ public struct SUSegmentedControl: View { @Binding public var selectedId: ID @Namespace private var animationNamespace - @Environment(\.colorScheme) private var colorScheme // MARK: Initialization @@ -35,12 +34,8 @@ public struct SUSegmentedControl: View { Text(itemVM.title) .lineLimit(1) .font(self.model.preferredFont(for: itemVM.id).font) - .foregroundStyle(self.model - .foregroundColor( - id: itemVM.id, - selectedId: self.selectedId - ) - .color(for: self.colorScheme) + .foregroundStyle( + self.model.foregroundColor(id: itemVM.id, selectedId: self.selectedId).color ) .frame(maxWidth: self.model.width, maxHeight: self.model.height) .padding(.horizontal, self.model.horizontalInnerPaddings) @@ -55,11 +50,9 @@ public struct SUSegmentedControl: View { ZStack { if itemVM.isEnabled, self.selectedId == itemVM.id { RoundedRectangle( - cornerRadius: self.model.cornerRadius.value() + cornerRadius: self.model.selectedSegmentCornerRadius() ) - .fill(self.model.selectedSegmentColor.color( - for: self.colorScheme - )) + .fill(self.model.selectedSegmentColor.color) .matchedGeometryEffect( id: "segment", in: self.animationNamespace @@ -71,7 +64,7 @@ public struct SUSegmentedControl: View { } .padding(.all, self.model.outerPaddings) .frame(height: self.model.height) - .background(self.model.backgroundColor.color(for: self.colorScheme)) + .background(self.model.backgroundColor.color) .clipShape( RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) ) diff --git a/Sources/ComponentsKit/SegmentedControl/UKSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift similarity index 98% rename from Sources/ComponentsKit/SegmentedControl/UKSegmentedControl.swift rename to Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift index d5e74ca0..15fd9588 100644 --- a/Sources/ComponentsKit/SegmentedControl/UKSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift @@ -171,7 +171,7 @@ open class UKSegmentedControl: UIView, UKComponent { open override func layoutSubviews() { super.layoutSubviews() - self.selectedSegment.layer.cornerRadius = self.model.cornerRadius.value( + self.selectedSegment.layer.cornerRadius = self.model.selectedSegmentCornerRadius( for: self.container.bounds.height ) self.layer.cornerRadius = self.model.cornerRadius.value( @@ -317,9 +317,7 @@ extension UKSegmentedControl { static func selectedSegment(_ view: UIView, model: Model) { view.backgroundColor = model.selectedSegmentColor.uiColor - view.layer.cornerRadius = model.cornerRadius.value( - for: view.bounds.height - ) + view.layer.cornerRadius = model.selectedSegmentCornerRadius(for: view.bounds.height) } static func segment( diff --git a/Sources/ComponentsKit/TextInput/Helpers/TextInputHeightCalculator.swift b/Sources/ComponentsKit/Components/TextInput/Helpers/TextInputHeightCalculator.swift similarity index 100% rename from Sources/ComponentsKit/TextInput/Helpers/TextInputHeightCalculator.swift rename to Sources/ComponentsKit/Components/TextInput/Helpers/TextInputHeightCalculator.swift diff --git a/Sources/ComponentsKit/TextInput/Models/TextInputVM.swift b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift similarity index 85% rename from Sources/ComponentsKit/TextInput/Models/TextInputVM.swift rename to Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift index 0d524f7b..db000784 100644 --- a/Sources/ComponentsKit/TextInput/Models/TextInputVM.swift +++ b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift @@ -93,11 +93,11 @@ extension TextInputVM { switch self.size { case .small: - return UniversalFont.Component.small + return .smBody case .medium: - return UniversalFont.Component.medium + return .mdBody case .large: - return UniversalFont.Component.large + return .lgBody } } @@ -106,28 +106,20 @@ extension TextInputVM { } var backgroundColor: UniversalColor { - if let color { - return color.main.withOpacity(0.25) - } else { - return .init( - light: .rgba(r: 244, g: 244, b: 245, a: 1.0), - dark: .rgba(r: 39, g: 39, b: 42, a: 1.0) - ) - } + return self.color?.background ?? .content1 } var foregroundColor: UniversalColor { - let foregroundColor = self.color?.main ?? .init( - light: .rgba(r: 0, g: 0, b: 0, a: 1.0), - dark: .rgba(r: 255, g: 255, b: 255, a: 1.0) - ) - return foregroundColor.withOpacity( - self.isEnabled ? 1.0 : 0.5 - ) + let color = self.color?.main ?? .foreground + return color.enabled(self.isEnabled) } var placeholderColor: UniversalColor { - return self.foregroundColor.withOpacity(self.isEnabled ? 0.7 : 0.3) + if let color { + return color.main.withOpacity(self.isEnabled ? 0.7 : 0.3) + } else { + return .secondaryForeground.enabled(self.isEnabled) + } } var minTextInputHeight: CGFloat { @@ -149,7 +141,9 @@ extension TextInputVM { } private func height(forRows rows: Int) -> CGFloat { - // TODO: [2] Show a warning if number of rows less than 1 + if rows < 1 { + assertionFailure("Number of rows in TextInput must be greater than or equal to 1") + } let numberOfRows = max(1, rows) return self.preferredFont.uiFont.lineHeight * CGFloat(numberOfRows) + 2 * self.contentPadding } diff --git a/Sources/ComponentsKit/TextInput/SUTextInput.swift b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift similarity index 94% rename from Sources/ComponentsKit/TextInput/SUTextInput.swift rename to Sources/ComponentsKit/Components/TextInput/SUTextInput.swift index 70add76f..3616297c 100644 --- a/Sources/ComponentsKit/TextInput/SUTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift @@ -26,7 +26,6 @@ public struct SUTextInput: View { /// text inputs and input fields within the same view can be independently focused based on the shared `globalFocus`. public var localFocus: FocusValue - @Environment(\.colorScheme) private var colorScheme @State private var textEditorPreferredHeight: CGFloat = 0 // MARK: - Initialization @@ -68,8 +67,8 @@ public struct SUTextInput: View { ) .lineSpacing(0) .font(self.model.preferredFont.font) - .foregroundStyle(self.model.foregroundColor.color(for: self.colorScheme)) - .tint(self.model.tintColor.color(for: self.colorScheme)) + .foregroundStyle(self.model.foregroundColor.color) + .tint(self.model.tintColor.color) .focused(self.$globalFocus, equals: self.localFocus) .disabled(!self.model.isEnabled) .keyboardType(self.model.keyboardType) @@ -82,14 +81,14 @@ public struct SUTextInput: View { Text(placeholder) .font(self.model.preferredFont.font) .foregroundStyle( - self.model.placeholderColor.color(for: self.colorScheme) + self.model.placeholderColor.color ) .padding(self.model.contentPadding) } } .background( GeometryReader { geometry in - self.model.backgroundColor.color(for: self.colorScheme) + self.model.backgroundColor.color .onAppear { self.textEditorPreferredHeight = TextInputHeightCalculator.preferredHeight( for: self.text, diff --git a/Sources/ComponentsKit/TextInput/UKTextInput.swift b/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift similarity index 100% rename from Sources/ComponentsKit/TextInput/UKTextInput.swift rename to Sources/ComponentsKit/Components/TextInput/UKTextInput.swift diff --git a/Sources/ComponentsKit/Configuration/Config.swift b/Sources/ComponentsKit/Configuration/Config.swift new file mode 100644 index 00000000..c31c807a --- /dev/null +++ b/Sources/ComponentsKit/Configuration/Config.swift @@ -0,0 +1,24 @@ +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/Configuration/Layout.swift b/Sources/ComponentsKit/Configuration/Layout.swift new file mode 100644 index 00000000..24c83a28 --- /dev/null +++ b/Sources/ComponentsKit/Configuration/Layout.swift @@ -0,0 +1,287 @@ +import Foundation + +extension ComponentsKitConfig { + /// A structure that defines the layout-related configurations for components in the framework. + public struct Layout: Initializable, Updatable { + // MARK: - Radius + + /// A structure representing radius values for components. + public struct Radius { + /// The small radius size. + public var small: CGFloat + /// The medium radius size. + public var medium: CGFloat + /// The large radius size. + public var large: CGFloat + + /// Initializes a new `Radius` instance. + /// + /// - Parameters: + /// - small: The small radius size. + /// - medium: The medium radius size. + /// - large: The large radius size. + public init(small: CGFloat, medium: CGFloat, large: CGFloat) { + self.small = small + self.medium = medium + self.large = large + } + } + + // MARK: - BorderWidth + + /// A structure representing border width values for components. + public struct BorderWidth { + /// The small border width. + public var small: CGFloat + /// The medium border width. + public var medium: CGFloat + /// The large border width. + public var large: CGFloat + + /// Initializes a new `BorderWidth` instance. + /// + /// - Parameters: + /// - small: The small border width. + /// - medium: The medium border width. + /// - large: The large border width. + public init(small: CGFloat, medium: CGFloat, large: CGFloat) { + self.small = small + self.medium = medium + self.large = large + } + } + + // MARK: - AnimationScale + + /// A structure representing animation scale values for components. + /// + /// The values must be between `0.0` and `1.0`. + public struct AnimationScale { + /// The small animation scale. + public var small: CGFloat + /// The medium animation scale. + public var medium: CGFloat + /// The large animation scale. + public var large: CGFloat + + /// Initializes a new `AnimationScale` instance. + /// + /// - Parameters: + /// - small: The small animation scale (0.0–1.0). + /// - medium: The medium animation scale (0.0–1.0). + /// - large: The large animation scale (0.0–1.0). + /// - Warning: This initializer will crash if the values are outside the range of `0.0` to `1.0`. + public init(small: CGFloat, medium: CGFloat, large: CGFloat) { + guard small >= 0 && small <= 1.0, + medium >= 0 && medium <= 1.0, + large >= 0 && large <= 1.0 + else { + fatalError("Animation scale values should be between 0 and 1") + } + + self.small = small + self.medium = medium + self.large = large + } + } + + // MARK: - Shadow + + /// A structure that defines the parameters for a shadow effect. + public struct ShadowParams { + /// The blur radius of the shadow. + /// + /// A larger radius results in a more diffuse shadow. + public var radius: CGFloat + + /// The offset of the shadow, defining its position relative to the component. + /// + /// - `width`: Moves the shadow horizontally. + /// - `height`: Moves the shadow vertically. + public var offset: CGSize + + /// The color of the shadow. + public var color: UniversalColor + + // MARK: - Initialization + + /// Initializes a new `ShadowParams` instance with the specified radius, offset, and color. + /// + /// - Parameters: + /// - radius: The blur radius of the shadow. + /// - offset: The offset of the shadow as a `CGSize`. + /// - color: The color of the shadow. + public init(radius: CGFloat, offset: CGSize, color: UniversalColor) { + self.radius = radius + self.offset = offset + self.color = color + } + } + + /// A structure that defines shadow presets for small, medium, and large shadows. + public struct Shadow { + /// The shadow parameters for a small shadow. + public var small: ShadowParams + + /// The shadow parameters for a medium shadow. + public var medium: ShadowParams + + /// The shadow parameters for a large shadow. + public var large: ShadowParams + + // MARK: - Initialization + + /// Initializes a new `Shadow` instance with the specified small, medium, and large shadow parameters. + /// + /// - Parameters: + /// - small: The parameters for a small shadow. + /// - medium: The parameters for a medium shadow. + /// - large: The parameters for a large shadow. + public init(small: ShadowParams, medium: ShadowParams, large: ShadowParams) { + self.small = small + self.medium = medium + self.large = large + } + } + + // MARK: - Typography + + /// A structure representing a set of fonts for different component sizes. + public struct FontSet { + /// The small font. + public var small: UniversalFont + /// The medium font. + public var medium: UniversalFont + /// The large font. + public var large: UniversalFont + + /// Initializes a new `FontSet` instance. + /// + /// - Parameters: + /// - small: The small font. + /// - medium: The medium font. + /// - large: The large font. + public init(small: UniversalFont, medium: UniversalFont, large: UniversalFont) { + self.small = small + self.medium = medium + self.large = large + } + } + + /// A structure representing typography settings for various components. + public struct Typography { + /// The font set for headlines. + public var headline: FontSet + /// The font set for body text. + public var body: FontSet + /// The font set for buttons. + public var button: FontSet + /// The font set for captions. + public var caption: FontSet + + /// Initializes a new `Typography` instance. + /// + /// - Parameters: + /// - headline: The font set for headlines. + /// - body: The font set for body text. + /// - button: The font set for buttons. + /// - caption: The font set for captions. + public init(headline: FontSet, body: FontSet, button: FontSet, caption: FontSet) { + self.headline = headline + self.body = body + self.button = button + self.caption = caption + } + } + + // MARK: - Properties + + /// The opacity level for disabled components. + public var disabledOpacity: CGFloat = 0.5 + + /// The radius configuration for components. + public var componentRadius: Radius = .init( + small: 10.0, + medium: 12.0, + large: 16.0 + ) + + /// The radius configuration for content containers such as modals, cards, etc. + public var containerRadius: Radius = .init( + small: 16.0, + medium: 20.0, + large: 26.0 + ) + + /// The shadow configuration for components. + public var shadow: Shadow = .init( + small: .init( + radius: 10.0, + offset: .init(width: 0, height: 6), + color: .themed( + light: .rgba(r: 0, g: 0, b: 0, a: 0.1), + dark: .rgba(r: 255, g: 255, b: 255, a: 0.1) + ) + ), + medium: .init( + radius: 16.0, + offset: .init(width: 0, height: 10), + color: .themed( + light: .rgba(r: 0, g: 0, b: 0, a: 0.15), + dark: .rgba(r: 255, g: 255, b: 255, a: 0.15) + ) + ), + large: .init( + radius: 20.0, + offset: .init(width: 0, height: 12), + color: .themed( + light: .rgba(r: 0, g: 0, b: 0, a: 0.2), + dark: .rgba(r: 255, g: 255, b: 255, a: 0.2) + ) + ) + ) + + /// The border width configuration for components. + public var borderWidth: BorderWidth = .init( + small: 0.5, + medium: 1.0, + large: 2.0 + ) + + /// The animation scale configuration for components. + public var animationScale: AnimationScale = .init( + small: 0.99, + medium: 0.98, + large: 0.95 + ) + + /// The typography configuration for components. + public var typography: Typography = .init( + headline: .init( + small: .system(size: 14, weight: .semibold), + medium: .system(size: 20, weight: .semibold), + large: .system(size: 28, weight: .semibold) + ), + body: .init( + small: .system(size: 14, weight: .regular), + medium: .system(size: 16, weight: .regular), + large: .system(size: 18, weight: .regular) + ), + button: .init( + small: .system(size: 14, weight: .medium), + medium: .system(size: 16, weight: .medium), + large: .system(size: 20, weight: .medium) + ), + caption: .init( + small: .system(size: 10, weight: .regular), + medium: .system(size: 12, weight: .regular), + large: .system(size: 14, weight: .regular) + ) + ) + + // MARK: - Initialization + + /// Initializes a new `Layout` instance with default values. + public init() {} + } +} diff --git a/Sources/ComponentsKit/Configuration/Palette.swift b/Sources/ComponentsKit/Configuration/Palette.swift new file mode 100644 index 00000000..0d0bb5e1 --- /dev/null +++ b/Sources/ComponentsKit/Configuration/Palette.swift @@ -0,0 +1,218 @@ +import Foundation + +extension ComponentsKitConfig { + /// Defines a set of colors that are used for styling components and interfaces. + public struct Palette: Initializable, Updatable { + /// The color for the main background of the interface. + public var background: UniversalColor = .themed( + light: .hex("#FFFFFF"), + dark: .hex("#000000") + ) + /// The color for the secondary background of the interface. + public var secondaryBackground: UniversalColor = .themed( + light: .hex("#F5F5F5"), + dark: .hex("#323335") + ) + /// The color for text labels that contain primary content. + public var foreground: UniversalColor = .themed( + light: .hex("#0B0C0E"), + dark: .hex("#FFFFFF") + ) + /// The color for text labels that contain secondary content. + public var secondaryForeground: UniversalColor = .themed( + light: .hex("#424355"), + dark: .hex("#D6D6D7") + ) + /// The first content color. + public var content1: UniversalColor = .themed( + light: .hex("#EFEFF0"), + dark: .hex("#27272a") + ) + /// The second content color. + public var content2: UniversalColor = .themed( + light: .hex("#D4D4D8"), + dark: .hex("#3F3F46") + ) + /// The third content color. + public var content3: UniversalColor = .themed( + light: .hex("#B4BDC8"), + dark: .hex("#52525b") + ) + /// The forth content color. + public var content4: UniversalColor = .themed( + light: .hex("#8C9197"), + dark: .hex("#86898B") + ) + /// The color for thin borders or divider lines. + public var divider: UniversalColor = .themed( + light: .rgba(r: 11, g: 12, b: 14, a: 0.12), + dark: .rgba(r: 255, g: 255, b: 255, a: 0.15) + ) + /// The primary color. + public var primary: ComponentColor = .init( + main: .themed( + light: .hex("#0B0C0E"), + dark: .hex("#FFFFFF") + ), + contrast: .themed( + light: .hex("#FFFFFF"), + dark: .hex("#0B0C0E") + ), + background: .themed( + light: .hex("#D9D9D9"), + dark: .hex("#515253") + ) + ) + /// The accent color. + public var accent: ComponentColor = .init( + main: .universal(.hex("#007AFF")), + contrast: .universal(.hex("#FFFFFF")), + background: .themed( + light: .hex("#E1EEFE"), + dark: .hex("#2B3E53") + ) + ) + /// The success state color, used for indicating positive actions or statuses. + public var success: ComponentColor = .init( + main: .themed( + light: .hex("#37D45C"), + dark: .hex("#1EC645") + ), + contrast: .themed( + light: .hex("#FFFFFF"), + dark: .hex("#0B0C0E") + ), + background: .themed( + light: .hex("#E1FBE7"), + dark: .hex("#344B3C") + ) + ) + /// The warning state color, used for indicating caution or non-critical alerts. + public var warning: ComponentColor = .init( + main: .themed( + light: .hex("#F4B300"), + dark: .hex("#F4B300") + ), + contrast: .universal(.hex("#0B0C0E")), + background: .themed( + light: .hex("#FFF6DD"), + dark: .hex("#514A35") + ) + ) + /// The danger state color, used for indicating errors, destructive actions, or critical alerts. + public var danger: ComponentColor = .init( + main: .themed( + light: .hex("#F03E53"), + dark: .hex("#D22338") + ), + contrast: .universal(.hex("#FFFFFF")), + background: .themed( + light: .hex("#FFE5E8"), + dark: .hex("#4F353A") + ) + ) + + /// Initializes a new instance of `Palette` with default values. + public init() {} + } +} + +// MARK: - ComponentColor + Palette Colors + +extension ComponentColor { + /// The primary color. + public static var primary: Self { + return ComponentsKitConfig.shared.colors.primary + } + /// The accent color. + public static var accent: Self { + return ComponentsKitConfig.shared.colors.accent + } + /// The success state color, used for indicating positive actions or statuses. + public static var success: Self { + return ComponentsKitConfig.shared.colors.success + } + /// The warning state color, used for indicating caution or non-critical alerts. + public static var warning: Self { + return ComponentsKitConfig.shared.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 + } +} + +// MARK: - UniversalColor + Neutral Colors + +extension UniversalColor { + public static var black: Self { + return .universal(.hex("#000000")) + } + public static var white: Self { + return .universal(.hex("#FFFFFF")) + } + public static var clear: Self { + return .universal(.uiColor(.clear)) + } +} + +// MARK: - UniversalColor + Palette Colors + +extension UniversalColor { + /// The color for the main background of the interface. + public static var background: Self { + return ComponentsKitConfig.shared.colors.background + } + /// The color for the secondary background of the interface. + public static var secondaryBackground: Self { + return ComponentsKitConfig.shared.colors.secondaryBackground + } + /// The color for text labels that contain primary content. + public static var foreground: Self { + return ComponentsKitConfig.shared.colors.foreground + } + /// The color for text labels that contain secondary content. + public static var secondaryForeground: Self { + return ComponentsKitConfig.shared.colors.secondaryForeground + } + /// The color for thin borders or divider lines. + public static var divider: Self { + return ComponentsKitConfig.shared.colors.divider + } + /// The first content color. + public static var content1: Self { + return ComponentsKitConfig.shared.colors.content1 + } + /// The second content color. + public static var content2: Self { + return ComponentsKitConfig.shared.colors.content2 + } + /// The third content color. + public static var content3: Self { + return ComponentsKitConfig.shared.colors.content3 + } + /// The forth content color. + public static var content4: Self { + return ComponentsKitConfig.shared.colors.content4 + } + /// The primary color. + public static var primary: Self { + return ComponentsKitConfig.shared.colors.primary.main + } + /// The accent color. + public static var accent: Self { + return ComponentsKitConfig.shared.colors.accent.main + } + /// The success state color, used for indicating positive actions or statuses. + public static var success: Self { + return ComponentsKitConfig.shared.colors.success.main + } + /// The warning state color, used for indicating caution or non-critical alerts. + public static var warning: Self { + return ComponentsKitConfig.shared.colors.warning.main + } + /// The danger state color, used for indicating errors, destructive actions, or critical alerts. + public static var danger: Self { + return ComponentsKitConfig.shared.colors.danger.main + } +} diff --git a/Sources/ComponentsKit/Helpers/Array+Safe.swift b/Sources/ComponentsKit/Helpers/Swift/Array+Safe.swift similarity index 100% rename from Sources/ComponentsKit/Helpers/Array+Safe.swift rename to Sources/ComponentsKit/Helpers/Swift/Array+Safe.swift diff --git a/Sources/ComponentsKit/Helpers/Collection+Helpers.swift b/Sources/ComponentsKit/Helpers/Swift/Collection+Helpers.swift similarity index 100% rename from Sources/ComponentsKit/Helpers/Collection+Helpers.swift rename to Sources/ComponentsKit/Helpers/Swift/Collection+Helpers.swift diff --git a/Sources/ComponentsKit/Helpers/Optional+Helpers.swift b/Sources/ComponentsKit/Helpers/Swift/Optional+Helpers.swift similarity index 100% rename from Sources/ComponentsKit/Helpers/Optional+Helpers.swift rename to Sources/ComponentsKit/Helpers/Swift/Optional+Helpers.swift diff --git a/Sources/ComponentsKit/Helpers/SwiftUI/View+Observe.swift b/Sources/ComponentsKit/Helpers/SwiftUI/View+Observe.swift new file mode 100644 index 00000000..45e25d79 --- /dev/null +++ b/Sources/ComponentsKit/Helpers/SwiftUI/View+Observe.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension View { + func observeSize(_ closure: @escaping (_ size: CGSize) -> Void) -> some View { + return self.overlay( + GeometryReader { geometry in + Color.clear + .onAppear { + closure(geometry.size) + } + .onChange(of: geometry.size) { newValue in + closure(newValue) + } + } + ) + } +} diff --git a/Sources/ComponentsKit/Helpers/UIEdgeInsets+Helpers.swift b/Sources/ComponentsKit/Helpers/UIKit/UIEdgeInsets+Helpers.swift similarity index 100% rename from Sources/ComponentsKit/Helpers/UIEdgeInsets+Helpers.swift rename to Sources/ComponentsKit/Helpers/UIKit/UIEdgeInsets+Helpers.swift diff --git a/Sources/ComponentsKit/Helpers/UIKit/UIView+Helpers.swift b/Sources/ComponentsKit/Helpers/UIKit/UIView+Helpers.swift new file mode 100644 index 00000000..794d53fb --- /dev/null +++ b/Sources/ComponentsKit/Helpers/UIKit/UIView+Helpers.swift @@ -0,0 +1,20 @@ +import UIKit + +extension UIView { + /// Whether the view is visible. + var isVisible: Bool { + get { + return !self.isHidden + } + set { + self.isHidden = !newValue + } + } +} + +extension UIView { + /// A helper to get bounds of the device's screen. + public var screenBounds: CGRect { + return self.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds + } +} diff --git a/Sources/ComponentsKit/Helpers/UIView+Helpers.swift b/Sources/ComponentsKit/Helpers/UIView+Helpers.swift deleted file mode 100644 index 6ec15b4b..00000000 --- a/Sources/ComponentsKit/Helpers/UIView+Helpers.swift +++ /dev/null @@ -1,8 +0,0 @@ -import UIKit - -extension UIView { - /// Whether the view is visible. - var isVisible: Bool { - return !self.isHidden - } -} diff --git a/Sources/ComponentsKit/Loading/Models/LoadingStyle.swift b/Sources/ComponentsKit/Loading/Models/LoadingStyle.swift deleted file mode 100644 index d4bb3f34..00000000 --- a/Sources/ComponentsKit/Loading/Models/LoadingStyle.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -/// The loading appearance style. -public enum LoadingStyle { - case spinner -} diff --git a/Sources/ComponentsKit/Resources/PrivacyInfo.xcprivacy b/Sources/ComponentsKit/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..6e868087 --- /dev/null +++ b/Sources/ComponentsKit/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + \ No newline at end of file diff --git a/Sources/ComponentsKit/Shared/AnimationScale.swift b/Sources/ComponentsKit/Shared/AnimationScale.swift deleted file mode 100644 index 542f90f8..00000000 --- a/Sources/ComponentsKit/Shared/AnimationScale.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -public struct AnimationScale: Hashable { - var value: CGFloat - - init(_ value: CGFloat) { - self.value = value - } -} - -extension AnimationScale { - public static var none: Self { - return Self(1.0) - } - public static var small: Self { - return Self(ComponentsKitConfig.shared.layout.animationScale.small) - } - public static var medium: Self { - return Self(ComponentsKitConfig.shared.layout.animationScale.medium) - } - public static var large: Self { - return Self(ComponentsKitConfig.shared.layout.animationScale.large) - } - public static func custom(_ value: CGFloat) -> Self { - guard value >= 0 && value <= 1.0 else { - fatalError("Animation scale value should be between 0 and 1") - } - return Self(value) - } -} diff --git a/Sources/ComponentsKit/Shared/BorderWidth.swift b/Sources/ComponentsKit/Shared/BorderWidth.swift deleted file mode 100644 index e181af39..00000000 --- a/Sources/ComponentsKit/Shared/BorderWidth.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -public struct BorderWidth: Hashable { - var value: CGFloat - - init(_ value: CGFloat) { - self.value = value - } -} - -extension BorderWidth { - public static var small: Self { - return Self(ComponentsKitConfig.shared.layout.borderWidth.small) - } - public static var medium: Self { - return Self(ComponentsKitConfig.shared.layout.borderWidth.medium) - } - public static var large: Self { - return Self(ComponentsKitConfig.shared.layout.borderWidth.large) - } - public static func custom(_ value: CGFloat) -> Self { - return Self(value) - } -} diff --git a/Sources/ComponentsKit/Shared/Colors/ComponentColor.swift b/Sources/ComponentsKit/Shared/Colors/ComponentColor.swift new file mode 100644 index 00000000..be4238a5 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Colors/ComponentColor.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A structure that defines a color set for components. +public struct ComponentColor: Hashable { + // MARK: - Properties + + /// The primary color used for the component. + public let main: UniversalColor + + /// The contrast color, typically used for text or elements displayed on top of the `main` color. + public let contrast: UniversalColor + + /// The background color for the component. + public var background: UniversalColor { + return self._background ?? self.main.withOpacity(0.15).blended(with: .background) + } + + private let _background: UniversalColor? + + // MARK: - Initialization + + /// Initializer. + /// + /// - Parameters: + /// - main: The primary color for the component. + /// - contrast: The color that contrasts with the `main` color, typically used for text or icons. + /// - background: The background color for the component. Defaults to `main` color with 15% opacity if `nil`. + public init( + main: UniversalColor, + contrast: UniversalColor, + background: UniversalColor? = nil + ) { + self.main = main + self.contrast = contrast + self._background = background + } +} diff --git a/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift new file mode 100644 index 00000000..7792ba04 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Colors/UniversalColor.swift @@ -0,0 +1,231 @@ +import SwiftUI +import UIKit + +/// A structure that represents an universal color that can be used in both UIKit and SwiftUI, +/// with light and dark theme variants. +public struct UniversalColor: Hashable { + // MARK: - ColorRepresentable + + /// An enumeration that defines the possible representations of a color. + public enum ColorRepresentable: Hashable { + /// A color defined by its RGBA components. + /// + /// - Parameters: + /// - r: The red component (0–255). + /// - g: The green component (0–255). + /// - b: The blue component (0–255). + /// - a: The alpha (opacity) component (0.0–1.0). + case rgba(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) + + /// A color represented by a `UIColor` instance. + case uiColor(UIColor) + + /// A color represented by a SwiftUI `Color` instance. + case color(Color) + + /// Creates a `ColorRepresentable` instance from a hexadecimal string. + /// + /// - Parameter value: A hex string representing the color (e.g., `"#FFFFFF"` or `"FFFFFF"`). + /// - Returns: A `ColorRepresentable` instance with the corresponding RGBA values. + /// - Note: This method assumes the input string has exactly six hexadecimal characters. + /// - Warning: This method will trigger an assertion failure if the input is invalid. + public static func hex(_ value: String) -> Self { + let start: String.Index + if value.hasPrefix("#") { + start = value.index(value.startIndex, offsetBy: 1) + } else { + start = value.startIndex + } + + let hexColor = String(value[start...]) + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + if hexColor.count == 6 && scanner.scanHexInt64(&hexNumber) { + let r = CGFloat((hexNumber & 0x00ff0000) >> 16) + let g = CGFloat((hexNumber & 0x0000ff00) >> 8) + let b = CGFloat(hexNumber & 0x000000ff) + + return .rgba(r: r, g: g, b: b, a: 1.0) + } else { + assertionFailure( + "Unable to initialize color from the provided hex value: \(value)" + ) + return .rgba(r: 0, g: 0, b: 0, a: 1.0) + } + } + + /// Returns a new `ColorRepresentable` with the specified opacity. + /// + /// - Parameter alpha: The desired opacity (0.0–1.0). + /// - Returns: A `ColorRepresentable` instance with the adjusted opacity. + fileprivate func withOpacity(_ alpha: CGFloat) -> Self { + switch self { + case .rgba(let r, let g, let b, _): + return .rgba(r: r, g: g, b: b, a: alpha) + case .uiColor(let uiColor): + return .uiColor(uiColor.withAlphaComponent(alpha)) + case .color(let color): + return .color(color.opacity(alpha)) + } + } + + /// Converts the `ColorRepresentable` to a `UIColor` instance. + fileprivate var uiColor: UIColor { + switch self { + case .rgba(let red, let green, let blue, let alpha): + return UIColor( + red: red / 255, + green: green / 255, + blue: blue / 255, + alpha: alpha + ) + case .uiColor(let uiColor): + return uiColor + case .color(let color): + return UIColor(color) + } + } + + /// Returns a tuple containing the red, green, blue, and alpha components of the color. + private var rgba: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) { + switch self { + case let .rgba(r, g, b, a): + return (r, g, b, a) + case .uiColor, .color: + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + self.uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return (red, green, blue, alpha) + } + } + + /// Returns a new `ColorRepresentable` by blending the current color with another color. + /// + /// The blending is performed using the alpha value of the current color, + /// where the second color is treated as fully opaque (alpha = `1.0 - self.alpha`). + /// + /// The resulting color's RGBA components are calculated as: + /// - `red = self.red * self.alpha + other.red * (1.0 - self.alpha)` + /// - `green = self.green * self.alpha + other.green * (1.0 - self.alpha)` + /// - `blue = self.blue * self.alpha + other.blue * (1.0 - self.alpha)` + /// - The resulting color's alpha will always be `1.0`. + /// + /// - Parameter other: The `ColorRepresentable` to blend with the current color. + /// - Returns: A new `ColorRepresentable` instance representing the blended color. + fileprivate func blended(with other: Self) -> Self { + let rgba = self.rgba + let otherRgba = other.rgba + + let red = rgba.r * rgba.a + otherRgba.r * (1.0 - rgba.a) + let green = rgba.g * rgba.a + otherRgba.g * (1.0 - rgba.a) + let blue = rgba.b * rgba.a + otherRgba.b * (1.0 - rgba.a) + + return .rgba(r: red, g: green, b: blue, a: 1.0) + } + } + + // MARK: - Properties + + /// The color used in light mode. + let light: ColorRepresentable + + /// The color used in dark mode. + let dark: ColorRepresentable + + // MARK: - Initialization + + /// Creates a `UniversalColor` with distinct light and dark mode colors. + /// + /// - Parameters: + /// - light: The color to use in light mode. + /// - dark: The color to use in dark mode. + /// - Returns: A new `UniversalColor` instance. + public static func themed( + light: ColorRepresentable, + dark: ColorRepresentable + ) -> Self { + return Self(light: light, dark: dark) + } + + /// Creates a `UniversalColor` with a single color used for both light and dark modes. + /// + /// - Parameter universal: The universal color to use. + /// - Returns: A new `UniversalColor` instance. + public static func universal(_ universal: ColorRepresentable) -> Self { + return Self(light: universal, dark: universal) + } + + // MARK: - Colors + + /// Returns the `UIColor` representation of the color. + public var uiColor: UIColor { + return UIColor { trait in + switch trait.userInterfaceStyle { + case.light: + return self.light.uiColor + case .dark: + return self.dark.uiColor + default: + return self.light.uiColor + } + } + } + + /// Returns the `Color` representation of the color. + public var color: Color { + return Color(self.uiColor) + } + + /// Returns the `CGColor` representation of the color. + public var cgColor: CGColor { + return self.uiColor.cgColor + } + + // MARK: - Methods + + /// Returns a new `UniversalColor` with the specified opacity. + /// + /// - Parameter alpha: The desired opacity (0.0–1.0). + /// - Returns: A new `UniversalColor` instance with the adjusted opacity. + public func withOpacity(_ alpha: CGFloat) -> Self { + return .init( + light: self.light.withOpacity(alpha), + dark: self.dark.withOpacity(alpha) + ) + } + + /// Returns a disabled version of the color based on a global opacity configuration. + /// + /// - Parameter isEnabled: A Boolean value indicating whether the color should be enabled. + /// - Returns: A new `UniversalColor` instance with reduced opacity if `isEnabled` is `false`. + public func enabled(_ isEnabled: Bool) -> Self { + return isEnabled + ? self + : self.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity) + } + + /// Returns a new `UniversalColor` by blending the current color with another color. + /// + /// The blending is performed using the alpha value of the current color, + /// where the second color is treated as fully opaque (alpha = `1.0 - self.alpha`). + /// + /// The resulting color's RGBA components are calculated as: + /// - `red = self.red * self.alpha + other.red * (1.0 - self.alpha)` + /// - `green = self.green * self.alpha + other.green * (1.0 - self.alpha)` + /// - `blue = self.blue * self.alpha + other.blue * (1.0 - self.alpha)` + /// - The resulting color's alpha will always be `1.0`. + /// + /// - Parameter other: The `UniversalColor` to blend with the current color. + /// - Returns: A new `UniversalColor` instance representing the blended color. + public func blended(with other: Self) -> Self { + return .init( + light: self.light.blended(with: other.light), + dark: self.dark.blended(with: other.dark) + ) + } +} diff --git a/Sources/ComponentsKit/Shared/ComponentColor.swift b/Sources/ComponentsKit/Shared/ComponentColor.swift deleted file mode 100644 index 11d1dc20..00000000 --- a/Sources/ComponentsKit/Shared/ComponentColor.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -public struct ComponentColor: Hashable { - // MARK: Properties - - let main: UniversalColor - let contrast: UniversalColor - - // MARK: Initialization - - public init(main: UniversalColor, contrast: UniversalColor) { - self.main = main - self.contrast = contrast - } -} diff --git a/Sources/ComponentsKit/Shared/ComponentRadius.swift b/Sources/ComponentsKit/Shared/ComponentRadius.swift deleted file mode 100644 index d6fcb991..00000000 --- a/Sources/ComponentsKit/Shared/ComponentRadius.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import SwiftUI - -public enum ComponentRadius: Hashable { - case none - case small - case medium - case large - case full - case custom(CGFloat) -} - -extension ComponentRadius { - 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 .full: height / 2 - case .custom(let value): value - } - return min(value, maxValue) - } -} diff --git a/Sources/ComponentsKit/Shared/ComponentSize.swift b/Sources/ComponentsKit/Shared/ComponentSize.swift deleted file mode 100644 index 80280067..00000000 --- a/Sources/ComponentsKit/Shared/ComponentSize.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public enum ComponentSize: Hashable { - case small - case medium - case large -} diff --git a/Sources/ComponentsKit/Shared/Config.swift b/Sources/ComponentsKit/Shared/Config.swift deleted file mode 100644 index 2f9f7632..00000000 --- a/Sources/ComponentsKit/Shared/Config.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -public struct ComponentsKitConfig: Initializable, Updatable { - public var colors: Palette = .init() - public var layout: Layout = .init() - - public init() {} -} - -// MARK: - ComponentsKitConfig + Shared - -extension ComponentsKitConfig { - public static var shared: Self = .init() -} diff --git a/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift b/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift new file mode 100644 index 00000000..cd1ec1f0 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Fonts/UniversalFont.swift @@ -0,0 +1,210 @@ +import SwiftUI +import UIKit + +/// A structure that represents an universal font that can be used in both UIKit and SwiftUI, +/// with support for custom and system fonts. +public enum UniversalFont: Hashable { + /// An enumeration that defines the weight of a font. + public enum Weight: Hashable { + /// Ultra-light font weight. + case ultraLight + /// Thin font weight. + case thin + /// Light font weight. + case light + /// Regular font weight. + case regular + /// Medium font weight. + case medium + /// Semi-bold font weight. + case semibold + /// Bold font weight. + case bold + /// Heavy font weight. + case heavy + /// Black (extra-bold) font weight. + case black + } + + /// A custom font with a specific name and size. + /// + /// - Parameters: + /// - name: The name of the font. + /// - size: The size of the font. + case custom(name: String, size: CGFloat) + + /// A system font with a specific size and weight. + /// + /// - Parameters: + /// - size: The size of the font. + /// - weight: The weight of the font, defined by `UniversalFont.Weight`. + case system(size: CGFloat, weight: Weight) + + // MARK: Fonts + + /// Converts the `UniversalFont` to a `UIFont` instance. + /// + /// - Returns: A `UIFont` representation of the `UniversalFont`. + public var uiFont: UIFont { + switch self { + case .custom(let name, let size): + guard let font = UIFont(name: name, size: size) else { + assertionFailure("Unable to initialize font '\(name)'") + return UIFont.systemFont(ofSize: size) + } + return font + case let .system(size, weight): + return UIFont.systemFont(ofSize: size, weight: weight.uiFontWeight) + } + } + + /// Converts the `UniversalFont` to a SwiftUI `Font` instance. + /// + /// - Returns: A `Font` representation of the `UniversalFont`. + public var font: Font { + switch self { + case .custom(let name, let size): + return Font.custom(name, size: size) + case .system(let size, let weight): + return Font.system(size: size, weight: weight.swiftUIFontWeight) + } + } + + // MARK: Helpers + + /// Returns a new `UniversalFont` with the specified size. + /// + /// - Parameter size: The new size for the font. + /// - Returns: A new `UniversalFont` instance with the updated size. + public func withSize(_ size: CGFloat) -> Self { + switch self { + case .custom(let name, _): + return .custom(name: name, size: size) + case .system(_, let weight): + return .system(size: size, weight: weight) + } + } + + /// Returns a new `UniversalFont` with a size adjusted by a relative value. + /// + /// - Parameter shift: The amount to adjust the font size by. + /// - Returns: A new `UniversalFont` instance with the adjusted size. + public func withRelativeSize(_ shift: CGFloat) -> Self { + switch self { + case .custom(let name, let size): + return .custom(name: name, size: size + shift) + case .system(let size, let weight): + return .system(size: size + shift, weight: weight) + } + } +} + +// MARK: Helpers + +extension UniversalFont.Weight { + /// Converts `UniversalFont.Weight` to `UIFont.Weight`. + var uiFontWeight: UIFont.Weight { + switch self { + case .ultraLight: + return .ultraLight + case .thin: + return .thin + case .light: + return .light + case .regular: + return .regular + case .medium: + return .medium + case .semibold: + return .semibold + case .bold: + return .bold + case .heavy: + return .heavy + case .black: + return .black + } + } +} + +extension UniversalFont.Weight { + /// Converts `UniversalFont.Weight` to SwiftUI `Font.Weight`. + var swiftUIFontWeight: Font.Weight { + switch self { + case .ultraLight: + return .ultraLight + case .thin: + return .thin + case .light: + return .light + case .regular: + return .regular + case .medium: + return .medium + case .semibold: + return .semibold + case .bold: + return .bold + case .heavy: + return .heavy + case .black: + return .black + } + } +} + +// MARK: - UniversalFont + Config + +extension UniversalFont { + /// Small headline font. + public static var smHeadline: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.headline.small + } + /// Medium headline font. + public static var mdHeadline: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.headline.medium + } + /// Large headline font. + public static var lgHeadline: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.headline.large + } + + /// Small body font. + public static var smBody: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.body.small + } + /// Medium body font. + public static var mdBody: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.body.medium + } + /// Large body font. + public static var lgBody: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.body.large + } + + /// Small button font. + public static var smButton: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.button.small + } + /// Medium button font. + public static var mdButton: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.button.medium + } + /// Large button font. + public static var lgButton: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.button.large + } + + /// Small caption font. + public static var smCaption: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.caption.small + } + /// Medium caption font. + public static var mdCaption: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.caption.medium + } + /// Large caption font. + public static var lgCaption: UniversalFont { + return ComponentsKitConfig.shared.layout.typography.caption.large + } +} diff --git a/Sources/ComponentsKit/Shared/Layout.swift b/Sources/ComponentsKit/Shared/Layout.swift deleted file mode 100644 index 6dc7eb23..00000000 --- a/Sources/ComponentsKit/Shared/Layout.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation - -public struct Layout: Initializable, Updatable { - // MARK: Radius - - public struct Radius { - public var small: CGFloat - public var medium: CGFloat - public var large: CGFloat - - public init(small: CGFloat, medium: CGFloat, large: CGFloat) { - self.small = small - self.medium = medium - self.large = large - } - } - - // MARK: BorderWidth - - public struct BorderWidth { - public var small: CGFloat - public var medium: CGFloat - public var large: CGFloat - - public init(small: CGFloat, medium: CGFloat, large: CGFloat) { - self.small = small - self.medium = medium - self.large = large - } - } - - // MARK: AnimationScale - - public struct AnimationScale { - public var small: CGFloat - public var medium: CGFloat - public var large: CGFloat - - public init(small: CGFloat, medium: CGFloat, large: CGFloat) { - guard small >= 0 && small <= 1.0, - medium >= 0 && medium <= 1.0, - large >= 0 && large <= 1.0 - else { - fatalError("Animation scale values should be between 0 and 1") - } - - self.small = small - self.medium = medium - self.large = large - } - } - - // MARK: Font - - public struct Font { - public var small: UniversalFont - public var medium: UniversalFont - public var large: UniversalFont - - public init(small: UniversalFont, medium: UniversalFont, large: UniversalFont) { - self.small = small - self.medium = medium - self.large = large - } - } - - // MARK: Properties - - public var disabledOpacity: CGFloat = 0.5 - public var componentRadius: Radius = .init( - small: 10.0, - medium: 14.0, - large: 18.0 - ) - public var borderWidth: BorderWidth = .init( - small: 1.0, - medium: 2.0, - large: 3.0 - ) - public var animationScale: AnimationScale = .init( - small: 0.99, - medium: 0.98, - large: 0.95 - ) - public var componentFont: Font = .init( - small: .system(size: 14, weight: .regular), - medium: .system(size: 18, weight: .regular), - large: .system(size: 22, weight: .regular) - ) - - public init() {} -} diff --git a/Sources/ComponentsKit/Shared/Palette.swift b/Sources/ComponentsKit/Shared/Palette.swift deleted file mode 100644 index cb8602fa..00000000 --- a/Sources/ComponentsKit/Shared/Palette.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation - -public struct Palette: Initializable, Updatable { - /// The UniversalColor for the main background of your interface. - public var background: UniversalColor = .universal(.uiColor(.systemBackground)) - /// The UniversalColor for content layered on top of the main background. - public var secondaryBackground: UniversalColor = .universal(.uiColor(.secondarySystemBackground)) - /// The UniversalColor for text labels that contain primary content. - public var label: UniversalColor = .universal(.uiColor(.label)) - /// The UniversalColor for text labels that contain secondary content. - public var secondaryLabel: UniversalColor = .universal(.uiColor(.secondaryLabel)) - /// The UniversalColor for thin borders or divider lines that allows some underlying content to be visible. - public var divider: UniversalColor = .universal(.uiColor(.separator)) - public var primary: ComponentColor = .init( - main: .universal(.uiColor(.label)), - contrast: .universal(.uiColor(.systemBackground)) - ) - public var secondary: ComponentColor = .init( - main: .universal(.uiColor(.lightGray)), - contrast: .universal(.uiColor(.black)) - ) - public var accent: ComponentColor = .init( - main: .universal(.uiColor(.systemBlue)), - contrast: .universal(.uiColor(.white)) - ) - public var success: ComponentColor = .init( - main: .universal(.uiColor(.systemGreen)), - contrast: .universal(.uiColor(.black)) - ) - public var warning: ComponentColor = .init( - main: .universal(.uiColor(.systemOrange)), - contrast: .universal(.uiColor(.black)) - ) - public var danger: ComponentColor = .init( - main: .universal(.uiColor(.systemRed)), - contrast: .universal(.uiColor(.white)) - ) - - public init() {} -} - -// MARK: - Palette + Config - -extension Palette { - public enum Base { - public static var background: UniversalColor { - return ComponentsKitConfig.shared.colors.background - } - public static var secondaryBackground: UniversalColor { - return ComponentsKitConfig.shared.colors.background - } - public static var divider: UniversalColor { - return ComponentsKitConfig.shared.colors.divider - } - } - public enum Text { - public static var primary: UniversalColor { - return ComponentsKitConfig.shared.colors.label - } - public static var secondary: UniversalColor { - return ComponentsKitConfig.shared.colors.secondaryLabel - } - public static var accent: UniversalColor { - return ComponentsKitConfig.shared.colors.accent.main - } - } - public enum Components { - public static var primary: ComponentColor { - return .primary - } - public static var secondary: ComponentColor { - return .secondary - } - public static var accent: ComponentColor { - return .accent - } - public static var success: ComponentColor { - return .success - } - public static var warning: ComponentColor { - return .warning - } - public static var danger: ComponentColor { - return .danger - } - } -} - -extension ComponentColor { - public static var primary: Self { - return ComponentsKitConfig.shared.colors.primary - } - public static var secondary: Self { - return ComponentsKitConfig.shared.colors.secondary - } - public static var accent: Self { - return ComponentsKitConfig.shared.colors.accent - } - public static var success: Self { - return ComponentsKitConfig.shared.colors.success - } - public static var warning: Self { - return ComponentsKitConfig.shared.colors.warning - } - public static var danger: Self { - return ComponentsKitConfig.shared.colors.danger - } -} - -extension UniversalColor { - public static var primary: Self { - return ComponentsKitConfig.shared.colors.primary.main - } - public static var secondary: Self { - return ComponentsKitConfig.shared.colors.secondary.main - } - public static var accent: Self { - return ComponentsKitConfig.shared.colors.accent.main - } - public static var success: Self { - return ComponentsKitConfig.shared.colors.success.main - } - public static var warning: Self { - return ComponentsKitConfig.shared.colors.warning.main - } - public static var danger: Self { - return ComponentsKitConfig.shared.colors.danger.main - } -} diff --git a/Sources/ComponentsKit/Shared/ComponentVM.swift b/Sources/ComponentsKit/Shared/Protocols/ComponentVM.swift similarity index 100% rename from Sources/ComponentsKit/Shared/ComponentVM.swift rename to Sources/ComponentsKit/Shared/Protocols/ComponentVM.swift diff --git a/Sources/ComponentsKit/Shared/Initializable.swift b/Sources/ComponentsKit/Shared/Protocols/Initializable.swift similarity index 100% rename from Sources/ComponentsKit/Shared/Initializable.swift rename to Sources/ComponentsKit/Shared/Protocols/Initializable.swift diff --git a/Sources/ComponentsKit/Shared/Protocols/UKComponent.swift b/Sources/ComponentsKit/Shared/Protocols/UKComponent.swift new file mode 100644 index 00000000..b47e46dc --- /dev/null +++ b/Sources/ComponentsKit/Shared/Protocols/UKComponent.swift @@ -0,0 +1,21 @@ +import UIKit + +/// A protocol that defines a UIKit component with a configurable model. +/// +/// Types conforming to `UKComponent` are responsible for updating their appearance +/// based on changes to their associated model. +public protocol UKComponent: UIView { + /// A type of the model that defines the appearance properties. + associatedtype Model + + /// A model that defines the appearance properties. + var model: Model { get set } + + /// Updates the component when the model changes. + /// + /// This method is called when the `model` property changes, providing an opportunity + /// to compare the new and old models and update the component's appearance. + /// + /// - Parameter oldModel: The previous model before the update. + func update(_ oldModel: Model) +} diff --git a/Sources/ComponentsKit/Shared/Updatable.swift b/Sources/ComponentsKit/Shared/Protocols/Updatable.swift similarity index 100% rename from Sources/ComponentsKit/Shared/Updatable.swift rename to Sources/ComponentsKit/Shared/Protocols/Updatable.swift diff --git a/Sources/ComponentsKit/Shared/Types/AnimationScale.swift b/Sources/ComponentsKit/Shared/Types/AnimationScale.swift new file mode 100644 index 00000000..9a6f0695 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/AnimationScale.swift @@ -0,0 +1,46 @@ +import Foundation + +/// An enumeration that defines how much a component shrinks or expands during animations. +public enum AnimationScale: Hashable { + /// No scaling is applied, meaning the component remains at its original size. + case none + /// A small scaling effect is applied, using a predefined value from the configuration. + case small + /// A medium scaling effect is applied, using a predefined value from the configuration. + case medium + /// A large scaling effect is applied, using a predefined value from the configuration. + case large + /// A custom scaling value. + /// + /// - Parameter value: The custom scale value (0.0–1.0). + case custom(_ value: CGFloat) +} + +extension AnimationScale { + /// The scaling value represented as a `CGFloat`. + /// + /// - Returns: + /// - `1.0` for `.none` (no scaling). + /// - Predefined values from `ComponentsKitConfig` 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. + public var value: CGFloat { + switch self { + case .none: + return 1.0 + case .small: + return ComponentsKitConfig.shared.layout.animationScale.small + case .medium: + return ComponentsKitConfig.shared.layout.animationScale.medium + case .large: + return ComponentsKitConfig.shared.layout.animationScale.large + case .custom(let value): + guard value >= 0 && value <= 1.0 else { + assertionFailure("Animation scale value should be between 0 and 1") + return 1.0 + } + return value + } + } +} diff --git a/Sources/ComponentsKit/Shared/Types/BorderWidth.swift b/Sources/ComponentsKit/Shared/Types/BorderWidth.swift new file mode 100644 index 00000000..2ebb7082 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/BorderWidth.swift @@ -0,0 +1,29 @@ +import Foundation + +/// An enumeration that defines border thickness for components. +public enum BorderWidth: Hashable { + /// No border. + case none + /// A small border width. + case small + /// A medium border width. + case medium + /// A large border width. + case large +} + +extension BorderWidth { + /// The numeric value of the border width as a `CGFloat`. + public var value: CGFloat { + switch self { + case .none: + return 0.0 + case .small: + return ComponentsKitConfig.shared.layout.borderWidth.small + case .medium: + return ComponentsKitConfig.shared.layout.borderWidth.medium + case .large: + return ComponentsKitConfig.shared.layout.borderWidth.large + } + } +} diff --git a/Sources/ComponentsKit/Button/Models/ButtonStyle.swift b/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift similarity index 80% rename from Sources/ComponentsKit/Button/Models/ButtonStyle.swift rename to Sources/ComponentsKit/Shared/Types/ButtonStyle.swift index 33cfb5b8..bc598997 100644 --- a/Sources/ComponentsKit/Button/Models/ButtonStyle.swift +++ b/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift @@ -6,6 +6,8 @@ public enum ButtonStyle: Hashable { case filled /// A button with a transparent background. case plain + /// A button with a partially transparent background. + case light /// A button with a transparent background and a border. case bordered(BorderWidth) } diff --git a/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift b/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift new file mode 100644 index 00000000..f8af07c3 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/ComponentRadius.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftUI + +/// An enumeration that defines the corner radius options for components. +public enum ComponentRadius: Hashable { + /// No corner radius, resulting in sharp edges. + case none + /// A small corner radius. + case small + /// A medium corner radius. + case medium + /// A large corner radius. + case large + /// A fully rounded corner radius, where the radius is half of the component's height. + case full + /// A custom corner radius with a specific value. + /// + /// - Parameter value: The radius value as a `CGFloat`. + case custom(CGFloat) +} + +extension ComponentRadius { + /// Returns the numeric value of the corner radius, ensuring it does not exceed half the component's height. + /// + /// - 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 { + 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 .full: height / 2 + case .custom(let value): value + } + return min(value, maxValue) + } +} diff --git a/Sources/ComponentsKit/Shared/Types/ComponentSize.swift b/Sources/ComponentsKit/Shared/Types/ComponentSize.swift new file mode 100644 index 00000000..14e0360f --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/ComponentSize.swift @@ -0,0 +1,11 @@ +import Foundation + +/// An enumeration that defines size options for a component. +public enum ComponentSize: Hashable { + /// A small-sized component. + case small + /// A medium-sized component. + case medium + /// A large-sized component. + case large +} diff --git a/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift b/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift new file mode 100644 index 00000000..419b988a --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/ContainerRadius.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Defines the corner radius options for a container's content area. +public enum ContainerRadius: Hashable { + /// No corner radius is applied, resulting in sharp edges. + case none + /// A small corner radius is applied. + case small + /// A medium corner radius is applied. + case medium + /// A large corner radius is applied. + case large + /// A custom corner radius specified by a `CGFloat` value. + /// + /// - Parameter value: The custom radius value to be applied. + case custom(CGFloat) +} + +extension ContainerRadius { + 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 .custom(let value): value + } + } +} diff --git a/Sources/ComponentsKit/Shared/Types/Paddings.swift b/Sources/ComponentsKit/Shared/Types/Paddings.swift new file mode 100644 index 00000000..f6674901 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/Paddings.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// Defines padding values for each edge. +public struct Paddings: Hashable { + /// The padding value for the top edge. + public var top: CGFloat + + /// The padding value for the leading edge. + public var leading: CGFloat + + /// The padding value for the bottom edge. + public var bottom: CGFloat + + /// The padding value for the trailing edge. + public var trailing: CGFloat + + /// Initializes a new `Paddings` instance with specific values for all edges. + /// + /// - Parameters: + /// - top: The padding value for the top edge. + /// - leading: The padding value for the leading edge. + /// - bottom: The padding value for the bottom edge. + /// - trailing: The padding value for the trailing edge. + public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { + self.top = top + self.leading = leading + self.bottom = bottom + self.trailing = trailing + } + + /// Initializes a new `Paddings` instance with uniform horizontal and vertical values. + /// + /// - Parameters: + /// - horizontal: The padding value applied to both the leading and trailing edges. + /// - vertical: The padding value applied to both the top and bottom edges. + public init(horizontal: CGFloat, vertical: CGFloat) { + self.top = vertical + self.leading = horizontal + self.bottom = vertical + self.trailing = horizontal + } + + /// Initializes a new `Paddings` instance with the same padding value applied to all edges. + /// + /// - Parameter padding: The uniform padding value for the top, leading, bottom, and trailing edges. + public init(padding: CGFloat) { + self.top = padding + self.leading = padding + self.bottom = padding + self.trailing = padding + } +} + +// MARK: - SwiftUI Helpers + +extension Paddings { + var edgeInsets: EdgeInsets { + return EdgeInsets( + top: self.top, + leading: self.leading, + bottom: self.bottom, + trailing: self.trailing + ) + } +} diff --git a/Sources/ComponentsKit/Shared/Types/Shadow.swift b/Sources/ComponentsKit/Shared/Types/Shadow.swift new file mode 100644 index 00000000..2be6dd37 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/Shadow.swift @@ -0,0 +1,77 @@ +import SwiftUI +import UIKit + +/// Defines shadow options for components. +public enum Shadow: Hashable { + /// No shadow is applied. + case none + /// A small shadow. + case small + /// A medium shadow. + case medium + /// A large shadow. + case large + /// A custom shadow with specific parameters. + /// + /// - Parameters: + /// - radius: The blur radius of the shadow. + /// - offset: The offset of the shadow. + /// - color: The color of the shadow. + case custom(_ radius: CGFloat, _ offset: CGSize, _ color: UniversalColor) +} + +extension Shadow { + 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 .custom(let radius, _, _): radius + } + } + + 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 .custom(_, let offset, _): offset + } + } + + 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 .custom(_, _, let color): color + } + } +} + +// MARK: - UIKit + Shadow + +extension UIView { + func shadow(_ shadow: Shadow) { + self.layer.shadowRadius = shadow.radius + self.layer.shadowOffset = shadow.offset + self.layer.shadowColor = shadow.color.cgColor + self.layer.shadowOpacity = 1 + } +} + +// MARK: - SwiftUI + Shadow + +extension View { + func shadow(_ shadow: Shadow) -> some View { + self.shadow( + color: shadow.color.color, + radius: shadow.radius, + x: shadow.offset.width, + y: shadow.offset.height + ) + } +} diff --git a/Sources/ComponentsKit/Shared/SubmitType.swift b/Sources/ComponentsKit/Shared/Types/SubmitType.swift similarity index 100% rename from Sources/ComponentsKit/Shared/SubmitType.swift rename to Sources/ComponentsKit/Shared/Types/SubmitType.swift diff --git a/Sources/ComponentsKit/Shared/TextAutocapitalization.swift b/Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift similarity index 100% rename from Sources/ComponentsKit/Shared/TextAutocapitalization.swift rename to Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift diff --git a/Sources/ComponentsKit/Shared/UKComponent.swift b/Sources/ComponentsKit/Shared/UKComponent.swift deleted file mode 100644 index 93e6de9f..00000000 --- a/Sources/ComponentsKit/Shared/UKComponent.swift +++ /dev/null @@ -1,9 +0,0 @@ -import UIKit - -public protocol UKComponent: UIView { - associatedtype Model - - var model: Model { get set } - - func update(_ oldModel: Model) -} diff --git a/Sources/ComponentsKit/Shared/UniversalColor.swift b/Sources/ComponentsKit/Shared/UniversalColor.swift deleted file mode 100644 index 09f63e57..00000000 --- a/Sources/ComponentsKit/Shared/UniversalColor.swift +++ /dev/null @@ -1,131 +0,0 @@ -import SwiftUI -import UIKit - -public struct UniversalColor: Hashable { - // MARK: ColorRepresentable - - public enum ColorRepresentable: Hashable { - case rgba(r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) - case uiColor(UIColor) - case color(Color) - - public static func hex(_ value: String) -> Self { - let start: String.Index - if value.hasPrefix("#") { - start = value.index(value.startIndex, offsetBy: 1) - } else { - start = value.startIndex - } - - let hexColor = String(value[start...]) - let scanner = Scanner(string: hexColor) - var hexNumber: UInt64 = 0 - - if hexColor.count == 6 && scanner.scanHexInt64(&hexNumber) { - let r = CGFloat((hexNumber & 0x00ff0000) >> 16) - let g = CGFloat((hexNumber & 0x0000ff00) >> 8) - let b = CGFloat(hexNumber & 0x000000ff) - - return .rgba(r: r, g: g, b: b, a: 1.0) - } else { - fatalError("Unable to initialize color from the provided hex value: \(value)") - } - } - - fileprivate func withOpacity(_ alpha: CGFloat) -> Self { - switch self { - case .rgba(let r, let g, let b, _): - return .rgba(r: r, g: g, b: b, a: alpha) - case .uiColor(let uiColor): - return .uiColor(uiColor.withAlphaComponent(alpha)) - case .color(let color): - return .color(color.opacity(alpha)) - } - } - - fileprivate var uiColor: UIColor { - switch self { - case .rgba(let red, let green, let blue, let alpha): - return UIColor( - red: red / 255, - green: green / 255, - blue: blue / 255, - alpha: alpha - ) - case .uiColor(let uiColor): - return uiColor - case .color(let color): - return UIColor(color) - } - } - - fileprivate var color: Color { - switch self { - case .rgba(let r, let g, let b, let a): - return Color( - red: r / 255, - green: g / 255, - blue: b / 255, - opacity: a - ) - case .uiColor(let uiColor): - return Color(uiColor: uiColor) - case .color(let color): - return color - } - } - } - - // MARK: Properties - - let light: ColorRepresentable - let dark: ColorRepresentable - - // MARK: Initialization - - public static func themed( - light: ColorRepresentable, - dark: ColorRepresentable - ) -> Self { - return Self(light: light, dark: dark) - } - - public static func universal(_ universal: ColorRepresentable) -> Self { - return Self(light: universal, dark: universal) - } - - // MARK: Methods - - public func withOpacity(_ alpha: CGFloat) -> Self { - return .init( - light: self.light.withOpacity(alpha), - dark: self.dark.withOpacity(alpha) - ) - } - - // MARK: Colors - - public var uiColor: UIColor { - return UIColor { trait in - switch trait.userInterfaceStyle { - case.light: - return self.light.uiColor - case .dark: - return self.dark.uiColor - default: - return self.light.uiColor - } - } - } - - public func color(for colorScheme: ColorScheme) -> Color { - switch colorScheme { - case .light: - return self.light.color - case .dark: - return self.dark.color - @unknown default: - return self.light.color - } - } -} diff --git a/Sources/ComponentsKit/Shared/UniversalFont.swift b/Sources/ComponentsKit/Shared/UniversalFont.swift deleted file mode 100644 index b5ea0f0c..00000000 --- a/Sources/ComponentsKit/Shared/UniversalFont.swift +++ /dev/null @@ -1,129 +0,0 @@ -import SwiftUI -import UIKit - -public enum UniversalFont: Hashable { - public enum Weight: Hashable { - case ultraLight - case thin - case light - case regular - case medium - case semibold - case bold - case heavy - case black - } - case custom(name: String, size: CGFloat) - case system(size: CGFloat, weight: Weight) - - // MARK: Fonts - - public var uiFont: UIFont { - switch self { - case .custom(let name, let size): - guard let font = UIFont(name: name, size: size) else { - fatalError("Unable to initialize font '\(name)'") - } - return font - case let .system(size, weight): - return UIFont.systemFont(ofSize: size, weight: weight.uiFontWeight) - } - } - - public var font: Font { - switch self { - case .custom(let name, let size): - return Font.custom(name, size: size) - case .system(let size, let weight): - return Font.system(size: size, weight: weight.swiftUIFontWeight) - } - } - - // MARK: Helpers - - public func withSize(_ size: CGFloat) -> Self { - switch self { - case .custom(let name, _): - return .custom(name: name, size: size) - case .system(_, let weight): - return .system(size: size, weight: weight) - } - } - - public func withRelativeSize(_ shift: CGFloat) -> Self { - switch self { - case .custom(let name, let size): - return .custom(name: name, size: size + shift) - case .system(let size, let weight): - return .system(size: size + shift, weight: weight) - } - } -} - -// MARK: Helpers - -extension UniversalFont.Weight { - var uiFontWeight: UIFont.Weight { - switch self { - case .ultraLight: - return .ultraLight - case .thin: - return .thin - case .light: - return .light - case .regular: - return .regular - case .medium: - return .medium - case .semibold: - return .semibold - case .bold: - return .bold - case .heavy: - return .heavy - case .black: - return .black - } - } -} - -extension UniversalFont.Weight { - var swiftUIFontWeight: Font.Weight { - switch self { - case .ultraLight: - return .ultraLight - case .thin: - return .thin - case .light: - return .light - case .regular: - return .regular - case .medium: - return .medium - case .semibold: - return .semibold - case .bold: - return .bold - case .heavy: - return .heavy - case .black: - return .black - } - } -} - -// MARK: - UniversalFont + Config - -extension UniversalFont { - public enum Component { - public static var small: UniversalFont { - return ComponentsKitConfig.shared.layout.componentFont.small - } - public static var medium: UniversalFont { - return ComponentsKitConfig.shared.layout.componentFont.medium - } - public static var large: UniversalFont { - return ComponentsKitConfig.shared.layout.componentFont.large - } - } -}