├── .github ├── cover.webp └── workflows │ └── swiftlint.yml ├── .gitignore ├── Examples ├── DemosApp │ ├── DemosApp │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── avatar_image.imageset │ │ │ │ ├── avatar.png │ │ │ │ └── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── avatar_placeholder.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── avatar_placeholder.svg │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Core │ │ │ ├── Root.swift │ │ │ └── App.swift │ │ └── ComponentsPreview │ │ │ ├── PreviewPages │ │ │ ├── LoadingPreview.swift │ │ │ ├── DividerPreview.swift │ │ │ ├── SliderPreview.swift │ │ │ ├── AvatarPreview.swift │ │ │ ├── RadioGroupPreview.swift │ │ │ ├── SegmentedControlPreview.swift │ │ │ ├── CheckboxPreview.swift │ │ │ ├── BadgePreview.swift │ │ │ ├── ProgressBarPreview.swift │ │ │ ├── AvatarGroupPreview.swift │ │ │ ├── CountdownPreview.swift │ │ │ ├── CenterModalPreview.swift │ │ │ ├── ButtonPreview.swift │ │ │ ├── CircularProgressPreview.swift │ │ │ ├── BottomModalPreview.swift │ │ │ ├── TextInputPreview.swift │ │ │ └── CardPreview.swift │ │ │ └── Helpers │ │ │ ├── UIApplication+TopViewController.swift │ │ │ ├── PreviewWrapper.swift │ │ │ └── UKComponentPreview.swift │ ├── README.md │ └── DemosApp.xcodeproj │ │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── swiftpm │ │ │ └── Package.resolved │ │ └── xcshareddata │ │ └── xcschemes │ │ └── DemosApp.xcscheme └── Package.swift ├── Sources └── ComponentsKit │ ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── avatar_placeholder.imageset │ │ │ ├── Contents.json │ │ │ └── avatar_placeholder.svg │ └── PrivacyInfo.xcprivacy │ ├── Shared │ ├── Protocols │ │ ├── ComponentVM.swift │ │ ├── UKComponent.swift │ │ ├── Initializable.swift │ │ └── Updatable.swift │ ├── Types │ │ ├── ComponentSize.swift │ │ ├── InputStyle.swift │ │ ├── ButtonStyle.swift │ │ ├── BorderWidth.swift │ │ ├── ContainerRadius.swift │ │ ├── TextAutocapitalization.swift │ │ ├── ImageRenderingMode.swift │ │ ├── ComponentRadius.swift │ │ ├── AnimationScale.swift │ │ ├── SubmitType.swift │ │ ├── Shadow.swift │ │ └── Paddings.swift │ └── Colors │ │ └── ComponentColor.swift │ ├── Components │ ├── Loading │ │ ├── Models │ │ │ ├── LoadingStyle.swift │ │ │ └── LoadingVM.swift │ │ └── SULoading.swift │ ├── Slider │ │ ├── Models │ │ │ └── SliderStyle.swift │ │ └── SUSlider.swift │ ├── Badge │ │ ├── Models │ │ │ ├── BadgeStyle.swift │ │ │ └── BadgeVM.swift │ │ ├── SUBadge.swift │ │ └── UKBadge.swift │ ├── Divider │ │ ├── Models │ │ │ ├── DividerOrientation.swift │ │ │ └── DividerVM.swift │ │ ├── SUDivider.swift │ │ └── UKDivider.swift │ ├── ProgressBar │ │ ├── Models │ │ │ └── ProgressBarStyle.swift │ │ └── SUProgressBar.swift │ ├── Countdown │ │ ├── Helpers │ │ │ ├── CountdownHelpers.swift │ │ │ └── CountdownWidthCalculator.swift │ │ ├── Models │ │ │ └── CountdownStyle.swift │ │ ├── Manager │ │ │ └── CountdownManager.swift │ │ └── SUCountdown.swift │ ├── Button │ │ └── Models │ │ │ ├── ButtonImageLocation.swift │ │ │ └── ButtonImageSource.swift │ ├── InputField │ │ └── Models │ │ │ └── InputFieldTitlePosition.swift │ ├── CircularProgress │ │ ├── Models │ │ │ ├── CircularProgressShape.swift │ │ │ ├── CircularProgressLineCap.swift │ │ │ └── CircularProgressVM.swift │ │ └── SUCircularProgress.swift │ ├── Checkbox │ │ ├── Helpers │ │ │ └── CheckboxAnimationDurations.swift │ │ ├── SUCheckbox.swift │ │ └── Models │ │ │ └── CheckboxVM.swift │ ├── Modal │ │ ├── Models │ │ │ ├── ModalOverlayStyle.swift │ │ │ ├── ModalSize.swift │ │ │ ├── ModalTransition.swift │ │ │ ├── ModalVM.swift │ │ │ ├── CenterModalVM.swift │ │ │ └── BottomModalVM.swift │ │ ├── UIKit │ │ │ ├── Helpers │ │ │ │ └── ContentSizedScrollView.swift │ │ │ └── UKCenterModalController.swift │ │ ├── SwiftUI │ │ │ ├── ModalOverlay.swift │ │ │ ├── Helpers │ │ │ │ ├── View+Helpers.swift │ │ │ │ ├── ModalPresentationModifier.swift │ │ │ │ └── ModalPresentationWithItemModifier.swift │ │ │ └── ModalContent.swift │ │ └── SharedHelpers │ │ │ └── ModalAnimation.swift │ ├── AvatarGroup │ │ ├── Models │ │ │ ├── AvatarItemVM.swift │ │ │ └── AvatarGroupVM.swift │ │ ├── SwiftUI │ │ │ └── SUAvatarGroup.swift │ │ └── UIKit │ │ │ ├── AvatarContainer.swift │ │ │ └── UKAvatarGroup.swift │ ├── Avatar │ │ ├── Models │ │ │ ├── AvatarImageSource.swift │ │ │ ├── AvatarPlaceholder.swift │ │ │ └── AvatarVM.swift │ │ ├── SwiftUI │ │ │ ├── SUAvatar.swift │ │ │ └── AvatarContent.swift │ │ ├── Helpers │ │ │ └── AvatarImageManager.swift │ │ └── UIKit │ │ │ └── UKAvatar.swift │ ├── Alert │ │ ├── Models │ │ │ ├── AlertButtonVM.swift │ │ │ └── AlertVM.swift │ │ └── Helpers │ │ │ └── AlertButtonsOrientationCalculator.swift │ ├── TextInput │ │ └── Helpers │ │ │ └── TextInputHeightCalculator.swift │ ├── RadioGroup │ │ ├── Models │ │ │ ├── RadioItemVM.swift │ │ │ └── RadioGroupVM.swift │ │ └── SwiftUI │ │ │ └── SURadioGroup.swift │ ├── Card │ │ ├── Models │ │ │ └── CardVM.swift │ │ └── SUCard.swift │ └── SegmentedControl │ │ ├── Models │ │ └── SegmentedControlItemVM.swift │ │ └── SUSegmentedControl.swift │ ├── Helpers │ ├── Swift │ │ ├── Collection+Helpers.swift │ │ ├── Optional+Helpers.swift │ │ └── Array+Safe.swift │ ├── UIKit │ │ ├── UIEdgeInsets+Helpers.swift │ │ ├── UIView+Helpers.swift │ │ ├── FullWidthComponent.swift │ │ └── NSObject+ObserveThemeChange.swift │ └── SwiftUI │ │ ├── View+Observe.swift │ │ └── ThemeChangeObserver.swift │ └── Theme │ └── Theme.swift ├── .editorconfig ├── ComponentsKit.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── Package.resolved ├── Package.swift ├── LICENSE └── README.md /.github/cover.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/componentskit/ComponentsKit/HEAD/.github/cover.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | .swiftpm 4 | /Packages 5 | /*.swiftinterface 6 | /*.xcodeproj 7 | xcuserdata/ 8 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/DemosApp/README.md: -------------------------------------------------------------------------------- 1 | # ComponentsKit Demos 2 | 3 | This project includes examples of how to use UI elements from ComponentsKit. 4 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Protocols/ComponentVM.swift: -------------------------------------------------------------------------------- 1 | /// A protocol that defines a component view model. 2 | public protocol ComponentVM: Equatable, Initializable, Updatable {} 3 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "App", 7 | products: [], 8 | targets: [] 9 | ) 10 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/componentskit/ComponentsKit/HEAD/Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/avatar.png -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Core/Root.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct Root: SwiftUI.App { 5 | var body: some Scene { 6 | WindowGroup { 7 | App() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension LoadingVM { 4 | /// The loading appearance style. 5 | public enum Style { 6 | case spinner 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/Swift/Collection+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | /// Whether the collection is not empty. 5 | var isNotEmpty: Bool { 6 | return !self.isEmpty 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Slider/Models/SliderStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SliderVM { 4 | /// Defines the visual styles for the slider component. 5 | public enum Style { 6 | case light 7 | case striped 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Badge/Models/BadgeStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension BadgeVM { 4 | /// Defines the available visual styles for a badge. 5 | public enum Style: Equatable { 6 | case filled 7 | case light 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Divider/Models/DividerOrientation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DividerVM { 4 | /// Defines the possible orientations for the divider. 5 | public enum Orientation { 6 | case horizontal 7 | case vertical 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/UIKit/UIEdgeInsets+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIEdgeInsets { 4 | /// Creates an instance of `UIEdgeInsets` with equal insets. 5 | init(inset: CGFloat) { 6 | self.init(top: inset, left: inset, bottom: inset, right: inset) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/ProgressBar/Models/ProgressBarStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension ProgressBarVM { 4 | /// Defines the visual styles for the progress bar component. 5 | public enum Style { 6 | case light 7 | case filled 8 | case striped 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ComponentsKit.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Countdown/Helpers/CountdownHelpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CountdownHelpers { 4 | enum Unit { 5 | case days 6 | case hours 7 | case minutes 8 | case seconds 9 | } 10 | 11 | enum UnitLength { 12 | case short 13 | case long 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/ComponentSize.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An enumeration that defines size options for a component. 4 | public enum ComponentSize: Hashable { 5 | /// A small-sized component. 6 | case small 7 | /// A medium-sized component. 8 | case medium 9 | /// A large-sized component. 10 | case large 11 | } 12 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "autolayout", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/componentskit/AutoLayout", 7 | "state" : { 8 | "revision" : "78e39facca2cc459a135655cae0e9feb5a587892", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Button/Models/ButtonImageLocation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Specifies the position of the image relative to the button's title. 4 | extension ButtonVM { 5 | public enum ImageLocation { 6 | /// The image is displayed before the title. 7 | case leading 8 | /// The image is displayed after the title. 9 | case trailing 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/InputStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The appearance style of inputs. 4 | public enum InputStyle: Hashable { 5 | /// An input with a partially transparent background. 6 | case light 7 | /// An input with a transparent background and a border. 8 | case bordered 9 | /// An input with a partially transparent background and a border. 10 | case faded 11 | } 12 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/InputField/Models/InputFieldTitlePosition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension InputFieldVM { 4 | /// Specifies the position of the title relative to the input field. 5 | public enum TitlePosition { 6 | /// The title is displayed inside the input field. 7 | case inside 8 | /// The title is displayed above the input field. 9 | case outside 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ComponentsKit.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "autolayout", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/componentskit/AutoLayout", 7 | "state" : { 8 | "revision" : "78e39facca2cc459a135655cae0e9feb5a587892", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressShape.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension CircularProgressVM { 4 | /// Defines the shapes for the circular progress component. 5 | public enum Shape { 6 | /// Renders a complete circle to represent the progress. 7 | case circle 8 | /// Renders only a portion of the circle (an arc) to represent progress. 9 | case arc 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "dark" 11 | } 12 | ], 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/swiftlint.yml' 7 | - '.swiftlint.yml' 8 | - '**/*.swift' 9 | 10 | jobs: 11 | SwiftLint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Run SwiftLint 16 | uses: norio-nomura/action-swiftlint@3.2.1 17 | with: 18 | args: --strict 19 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "autolayout", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/componentskit/AutoLayout", 7 | "state" : { 8 | "revision" : "78e39facca2cc459a135655cae0e9feb5a587892", 9 | "version" : "1.0.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Countdown/Models/CountdownStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension CountdownVM { 4 | /// Defines the visual styles for the countdown component. 5 | public enum Style: Equatable { 6 | case plain 7 | case light 8 | } 9 | 10 | /// Defines the units style for the countdown component. 11 | public enum UnitsStyle: Equatable { 12 | case hidden 13 | case bottom 14 | case trailing 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/avatar_image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "avatar.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Checkbox/Helpers/CheckboxAnimationDurations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CheckboxAnimationDurations { 4 | static let background: CGFloat = 0.3 5 | static let checkmarkStroke: CGFloat = 0.2 6 | static let borderOpacity: CGFloat = 0.1 7 | static var checkmarkStrokeDelay: CGFloat { 8 | return self.background 9 | } 10 | static var selectedBorderDelay: CGFloat { 11 | return self.background * 2 / 3 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/ModalOverlayStyle.swift: -------------------------------------------------------------------------------- 1 | /// Defines the style of the overlay displayed behind a modal. 2 | public enum ModalOverlayStyle { 3 | /// A dimmed overlay that darkens the background behind the modal. 4 | case dimmed 5 | /// A blurred overlay that applies a blur effect to the background behind the modal. 6 | case blurred 7 | /// A transparent overlay that leaves the background fully visible behind the modal. 8 | case transparent 9 | } 10 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "avatar_placeholder.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "avatar_placeholder.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/SwiftUI/View+Observe.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func observeSize(_ closure: @escaping (_ size: CGSize) -> Void) -> some View { 5 | return self.overlay( 6 | GeometryReader { geometry in 7 | Color.clear 8 | .onAppear { 9 | closure(geometry.size) 10 | } 11 | .onChange(of: geometry.size) { newValue in 12 | closure(newValue) 13 | } 14 | } 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Resources/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/UIKit/UIView+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | /// Whether the view is visible. 5 | var isVisible: Bool { 6 | get { 7 | return !self.isHidden 8 | } 9 | set { 10 | self.isHidden = !newValue 11 | } 12 | } 13 | } 14 | 15 | extension UIView { 16 | /// A helper to get bounds of the device's screen. 17 | public var screenBounds: CGRect { 18 | return self.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/ButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The buttons appearance style. 4 | public enum ButtonStyle: Hashable { 5 | /// A button with a filled background. 6 | case filled 7 | /// A button with a transparent background. 8 | case plain 9 | /// A button with a partially transparent background. 10 | case light 11 | /// A button with a transparent background and a border. 12 | case bordered(BorderWidth) 13 | /// A button with no background or padding, sized strictly to fit its content. 14 | case minimal 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A model that defines the appearance properties for an avatar in the group. 4 | public struct AvatarItemVM: ComponentVM { 5 | /// The source of the image to be displayed. 6 | public var imageSrc: AvatarVM.ImageSource? 7 | 8 | /// The placeholder that is displayed if the image is not provided or fails to load. 9 | public var placeholder: AvatarVM.Placeholder = .icon("avatar_placeholder", Bundle.module) 10 | 11 | /// Initializes a new instance of `AvatarItemVM` with default values. 12 | public init() {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/UIKit/Helpers/ContentSizedScrollView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A custom `UIScrollView` subclass that automatically adjusts its intrinsic content size 4 | /// based on its content size, ensuring it fits its content vertically. 5 | final class ContentSizedScrollView: UIScrollView { 6 | override var contentSize: CGSize { 7 | didSet { 8 | self.invalidateIntrinsicContentSize() 9 | } 10 | } 11 | 12 | override var intrinsicContentSize: CGSize { 13 | self.layoutIfNeeded() 14 | return CGSize( 15 | width: UIView.noIntrinsicMetric, 16 | height: self.contentSize.height 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/ModalSize.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the size options for a modal. 4 | public enum ModalSize { 5 | /// A small modal size. 6 | case small 7 | /// A medium modal size. 8 | case medium 9 | /// A large modal size. 10 | case large 11 | /// A full-screen modal that occupies the entire screen. 12 | case full 13 | } 14 | 15 | extension ModalSize { 16 | public var maxWidth: CGFloat { 17 | switch self { 18 | case .small: 19 | return 300 20 | case .medium: 21 | return 400 22 | case .large: 23 | return 600 24 | case .full: 25 | return 10_000 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Button/Models/ButtonImageSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension ButtonVM { 4 | /// Defines the image source options for a button. 5 | public enum ImageSource: Hashable { 6 | /// An image loaded from a system SF Symbol. 7 | /// 8 | /// - Parameter name: The name of the SF Symbol. 9 | case sfSymbol(String) 10 | 11 | /// An image loaded from a local asset. 12 | /// 13 | /// - Parameters: 14 | /// - name: The name of the local image asset. 15 | /// - bundle: The bundle containing the image resource. Defaults to `nil` to use the main bundle. 16 | case local(String, bundle: Bundle? = nil) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ComponentsKit", 7 | platforms: [ 8 | .iOS(.v15) 9 | ], 10 | products: [ 11 | .library( 12 | name: "ComponentsKit", 13 | targets: ["ComponentsKit"] 14 | ) 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/componentskit/AutoLayout", from: "1.0.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "ComponentsKit", 22 | dependencies: [ 23 | .product(name: "AutoLayout", package: "AutoLayout") 24 | ], 25 | resources: [ 26 | .process("Resources/PrivacyInfo.xcprivacy") 27 | ] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Countdown/Helpers/CountdownWidthCalculator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct CountdownWidthCalculator { 4 | private static let label = UILabel() 5 | 6 | private init() {} 7 | 8 | static func preferredWidth( 9 | for attributedText: NSAttributedString, 10 | model: CountdownVM 11 | ) -> CGFloat { 12 | self.style(self.label, with: model) 13 | self.label.attributedText = attributedText 14 | 15 | let estimatedSize = self.label.sizeThatFits(UIView.layoutFittingExpandedSize) 16 | 17 | return estimatedSize.width + 2 18 | } 19 | 20 | private static func style(_ label: UILabel, with model: CountdownVM) { 21 | label.textAlignment = .center 22 | label.numberOfLines = 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/Swift/Optional+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Optional { 4 | /// Whether the value is nil. 5 | var isNil: Bool { 6 | return self == nil 7 | } 8 | 9 | /// Whether the value is not nil. 10 | var isNotNil: Bool { 11 | return self != nil 12 | } 13 | } 14 | 15 | extension Optional where Wrapped: Collection { 16 | /// Whether the value is not nil and empty. 17 | var isNotNilAndEmpty: Bool { 18 | if let self { 19 | return self.isNotEmpty 20 | } else { 21 | return false 22 | } 23 | } 24 | 25 | /// Whether the value is nil or empty. 26 | var isNilOrEmpty: Bool { 27 | if let self { 28 | return self.isEmpty 29 | } else { 30 | return true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the source options for an avatar image. 4 | extension AvatarVM { 5 | public enum ImageSource: Hashable { 6 | /// An image loaded from a remote URL. 7 | /// 8 | /// - Parameter url: The URL pointing to the remote image resource. 9 | /// - Note: Ensure the URL is valid and accessible to prevent errors during image fetching. 10 | case remote(_ url: URL) 11 | 12 | /// An image loaded from a local asset. 13 | /// 14 | /// - Parameters: 15 | /// - name: The name of the local image asset. 16 | /// - bundle: The bundle containing the image resource. Defaults to `nil`, which uses the main bundle. 17 | case local(_ name: String, _ bundle: Bundle? = nil) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/SwiftUI/SUAvatar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that displays a profile picture, initials or fallback icon for a user. 4 | public struct SUAvatar: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: AvatarVM 9 | 10 | // MARK: - Initialization 11 | 12 | /// Initializer. 13 | /// - Parameters: 14 | /// - model: A model that defines the appearance properties. 15 | public init(model: AvatarVM) { 16 | self.model = model 17 | } 18 | 19 | // MARK: - Body 20 | 21 | public var body: some View { 22 | AvatarContent(model: self.model) 23 | .frame( 24 | width: self.model.preferredSize.width, 25 | height: self.model.preferredSize.height 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/Swift/Array+Safe.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array { 4 | /// Returns the element at the specified index if it is within bounds, nil otherwise. 5 | /// 6 | /// - Parameter index: The index of the element to be returned. 7 | /// - Returns: The value that corresponds to the index. nil if the value cannot be found. 8 | subscript(safe index: Index) -> Iterator.Element? { 9 | return self.isIndexValid(index) ? self[index] : nil 10 | } 11 | 12 | /// Checks whether the index is valid for the array. 13 | /// 14 | /// - Parameter index: The index to be checked. 15 | /// - Returns: true if the index is valid for the collection, false otherwise. 16 | func isIndexValid(_ index: Index) -> Bool { 17 | return index >= self.startIndex && index < self.endIndex 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/BorderWidth.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An enumeration that defines border thickness for components. 4 | public enum BorderWidth: Hashable { 5 | /// No border. 6 | case none 7 | /// A small border width. 8 | case small 9 | /// A medium border width. 10 | case medium 11 | /// A large border width. 12 | case large 13 | } 14 | 15 | extension BorderWidth { 16 | /// The numeric value of the border width as a `CGFloat`. 17 | public var value: CGFloat { 18 | switch self { 19 | case .none: 20 | return 0.0 21 | case .small: 22 | return Theme.current.layout.borderWidth.small 23 | case .medium: 24 | return Theme.current.layout.borderWidth.medium 25 | case .large: 26 | return Theme.current.layout.borderWidth.large 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Divider/SUDivider.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that displays a separating line. 4 | public struct SUDivider: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: DividerVM 9 | 10 | // MARK: - Initialization 11 | 12 | /// Initializer. 13 | /// - Parameters: 14 | /// - model: A model that defines the appearance properties. 15 | public init(model: DividerVM = .init()) { 16 | self.model = model 17 | } 18 | 19 | // MARK: - Body 20 | 21 | public var body: some View { 22 | Rectangle() 23 | .fill(self.model.lineColor.color) 24 | .frame( 25 | maxWidth: self.model.orientation == .vertical ? self.model.lineSize : nil, 26 | maxHeight: self.model.orientation == .horizontal ? self.model.lineSize : nil 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Protocols/UKComponent.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A protocol that defines a UIKit component with a configurable model. 4 | /// 5 | /// Types conforming to `UKComponent` are responsible for updating their appearance 6 | /// based on changes to their associated model. 7 | public protocol UKComponent: UIView { 8 | /// A type of the model that defines the appearance properties. 9 | associatedtype Model 10 | 11 | /// A model that defines the appearance properties. 12 | var model: Model { get set } 13 | 14 | /// Updates the component when the model changes. 15 | /// 16 | /// This method is called when the `model` property changes, providing an opportunity 17 | /// to compare the new and old models and update the component's appearance. 18 | /// 19 | /// - Parameter oldModel: The previous model before the update. 20 | func update(_ oldModel: Model) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Alert/Models/AlertButtonVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a button in the alert. 4 | public struct AlertButtonVM: ComponentVM { 5 | /// The text displayed on the button. 6 | public var title: String = "" 7 | 8 | /// The scaling factor for the button's press animation, with a value between 0 and 1. 9 | /// 10 | /// Defaults to `.medium`. 11 | public var animationScale: AnimationScale = .medium 12 | 13 | /// The color of the button. 14 | public var color: ComponentColor? 15 | 16 | /// The corner radius of the button. 17 | /// 18 | /// Defaults to `.medium`. 19 | public var cornerRadius: ComponentRadius = .medium 20 | 21 | /// The visual style of the button. 22 | /// 23 | /// Defaults to `.filled`. 24 | public var style: ButtonStyle = .filled 25 | 26 | /// Initializes a new instance of `AlertButtonVM` with default values. 27 | public init() {} 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Badge/SUBadge.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that is used to display status, notification counts, or labels. 4 | public struct SUBadge: View { 5 | // MARK: Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: BadgeVM 9 | 10 | // MARK: Initialization 11 | 12 | /// Initializes a new instance of `SUBadge`. 13 | /// - Parameter model: A model that defines the appearance properties. 14 | public init(model: BadgeVM) { 15 | self.model = model 16 | } 17 | 18 | // MARK: Body 19 | 20 | public var body: some View { 21 | Text(self.model.title) 22 | .font(self.model.font.font) 23 | .padding(self.model.paddings.edgeInsets) 24 | .foregroundStyle(self.model.foregroundColor.color) 25 | .background(self.model.backgroundColor.color) 26 | .clipShape( 27 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/TextInput/Helpers/TextInputHeightCalculator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct TextInputHeightCalculator { 4 | private static let textView = UITextView() 5 | 6 | private init() {} 7 | 8 | static func preferredHeight( 9 | for text: String, 10 | model: TextInputVM, 11 | width: CGFloat 12 | ) -> CGFloat { 13 | self.textView.text = text 14 | self.style(self.textView, with: model) 15 | 16 | let targetSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) 17 | let estimatedHeight = self.textView.sizeThatFits(targetSize).height 18 | 19 | return estimatedHeight 20 | } 21 | 22 | private static func style(_ textView: UITextView, with model: TextInputVM) { 23 | self.textView.isScrollEnabled = false 24 | self.textView.font = model.preferredFont.uiFont 25 | self.textView.textContainerInset = .init(inset: model.contentPadding) 26 | self.textView.textContainer.lineFragmentPadding = 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | extension CircularProgressVM { 5 | /// Defines the style of line endings. 6 | public enum LineCap { 7 | /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. 8 | case rounded 9 | /// The line ends exactly at the endpoint with a flat edge. 10 | case square 11 | } 12 | } 13 | 14 | // MARK: - UIKit Helpers 15 | 16 | extension CircularProgressVM.LineCap { 17 | var shapeLayerLineCap: CAShapeLayerLineCap { 18 | switch self { 19 | case .rounded: 20 | return .round 21 | case .square: 22 | return .butt 23 | } 24 | } 25 | } 26 | 27 | // MARK: - SwiftUI Helpers 28 | 29 | extension CircularProgressVM.LineCap { 30 | var cgLineCap: CGLineCap { 31 | switch self { 32 | case .rounded: 33 | return .round 34 | case .square: 35 | return .butt 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/ContainerRadius.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the corner radius options for a container's content area. 4 | public enum ContainerRadius: Hashable { 5 | /// No corner radius is applied, resulting in sharp edges. 6 | case none 7 | /// A small corner radius is applied. 8 | case small 9 | /// A medium corner radius is applied. 10 | case medium 11 | /// A large corner radius is applied. 12 | case large 13 | /// A custom corner radius specified by a `CGFloat` value. 14 | /// 15 | /// - Parameter value: The custom radius value to be applied. 16 | case custom(CGFloat) 17 | } 18 | 19 | extension ContainerRadius { 20 | public var value: CGFloat { 21 | return switch self { 22 | case .none: CGFloat(0) 23 | case .small: Theme.current.layout.containerRadius.small 24 | case .medium: Theme.current.layout.containerRadius.medium 25 | case .large: Theme.current.layout.containerRadius.large 26 | case .custom(let value): value 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct LoadingPreview: View { 6 | @State private var model = LoadingVM() 7 | 8 | var body: some View { 9 | VStack { 10 | PreviewWrapper(title: "UIKit") { 11 | UKLoading(model: self.model) 12 | .preview 13 | } 14 | PreviewWrapper(title: "SwiftUI") { 15 | SULoading(model: self.model) 16 | } 17 | Form { 18 | ComponentColorPicker(selection: self.$model.color) 19 | Picker("Line Width", selection: self.$model.lineWidth) { 20 | Text("Default").tag(Optional.none) 21 | Text("Custom: 6px").tag(CGFloat(6.0)) 22 | } 23 | SizePicker(selection: self.$model.size) 24 | Picker("Style", selection: self.$model.style) { 25 | Text("Spinner").tag(LoadingVM.Style.spinner) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | LoadingPreview() 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/ModalTransition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the transition speed options for a modal's appearance and dismissal animations. 4 | public enum ModalTransition: Hashable { 5 | /// No transition is applied; the modal appears and disappears instantly. 6 | case none 7 | /// A slow transition speed. 8 | case slow 9 | /// A normal transition speed. 10 | case normal 11 | /// A fast transition speed. 12 | case fast 13 | /// A custom transition speed defined by a specific time interval. 14 | /// 15 | /// - Parameter duration: The duration of the custom transition in seconds. 16 | case custom(TimeInterval) 17 | } 18 | 19 | extension ModalTransition { 20 | var value: TimeInterval { 21 | switch self { 22 | case .none: 23 | return 0.0 24 | case .slow: 25 | return 0.5 26 | case .normal: 27 | return 0.3 28 | case .fast: 29 | return 0.2 30 | case .custom(let value): 31 | return max(0, value) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SwiftUI/ModalOverlay.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModalOverlay: View { 4 | let model: VM 5 | 6 | @Binding var isVisible: Bool 7 | 8 | init( 9 | isVisible: Binding, 10 | model: VM 11 | ) { 12 | self._isVisible = isVisible 13 | self.model = model 14 | } 15 | 16 | var body: some View { 17 | Group { 18 | switch self.model.overlayStyle { 19 | case .dimmed: 20 | Color.black.opacity(0.7) 21 | case .blurred: 22 | Color.clear.background(.ultraThinMaterial) 23 | case .transparent: 24 | // Note: The tap gesture isn't recognized when a completely transparent 25 | // color is clicked. This can be fixed by calling contentShape, which 26 | // defines the interactive area of the underlying view. 27 | Color.clear.contentShape(.rect) 28 | } 29 | } 30 | .ignoresSafeArea(.all) 31 | .onTapGesture { 32 | if self.model.closesOnOverlayTap { 33 | self.isVisible = false 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Protocols/Initializable.swift: -------------------------------------------------------------------------------- 1 | /// A type that can be initialized with an empty initializer or with a transformation closure that modifies default parameters. 2 | public protocol Initializable { 3 | /// Initializes a new instance with default values. 4 | init() 5 | 6 | /// Initializes a new instance by applying a transformation closure to the default values. 7 | /// 8 | /// - Parameter transform: A closure that defines the transformation. 9 | init(_ transform: (_ value: inout Self) -> Void) 10 | } 11 | 12 | extension Initializable { 13 | /// Initializes a new instance by applying a transformation closure to the default values. 14 | /// 15 | /// This default implementation creates a new instance using the default initializer and applies 16 | /// the provided transformation closure to the new instance. 17 | /// 18 | /// - Parameter transform: A closure that defines the transformation. 19 | public init(_ transform: (_ value: inout Self) -> Void) { 20 | var defaultValue = Self() 21 | transform(&defaultValue) 22 | self = defaultValue 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SharedHelpers/ModalAnimation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ModalAnimation { 4 | /// Calculates an offset with rubber band effect. 5 | static func rubberBandClamp(_ translation: CGFloat) -> CGFloat { 6 | let dim: CGFloat = 20 7 | let coef: CGFloat = 0.2 8 | return (1.0 - (1.0 / ((translation * coef / dim) + 1.0))) * dim 9 | } 10 | 11 | static func bottomModalOffset(_ translation: CGFloat, model: BottomModalVM) -> CGFloat { 12 | if translation > 0 { 13 | return model.hidesOnSwipe 14 | ? translation 15 | : (model.isDraggable ? Self.rubberBandClamp(translation) : 0) 16 | } else { 17 | return model.isDraggable 18 | ? -Self.rubberBandClamp(abs(translation)) 19 | : 0 20 | } 21 | } 22 | 23 | static func shouldHideBottomModal( 24 | offset: CGFloat, 25 | height: CGFloat, 26 | velocity: CGFloat, 27 | model: BottomModalVM 28 | ) -> Bool { 29 | guard model.hidesOnSwipe else { 30 | return false 31 | } 32 | 33 | return abs(offset) > height / 2 || velocity > 250 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ComponentsKit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/UIKit/FullWidthComponent.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A base-class for views whose intrinsic content size depends on the 4 | /// width of their super-view (e.g. full width button, input field, etc.). 5 | /// 6 | /// By inheriting from `FullWidthComponent` the component gets automatic 7 | /// `invalidateIntrinsicContentSize()` calls whenever the device rotates, the 8 | /// window is resized (iPad multitasking, Stage Manager) or the view moves 9 | /// into a different container with a new width. 10 | open class FullWidthComponent: UIView { 11 | private var lastKnownParentWidth: CGFloat = .nan 12 | 13 | open override func layoutSubviews() { 14 | super.layoutSubviews() 15 | 16 | guard let parentWidth = self.superview?.bounds.width else { return } 17 | 18 | if parentWidth != self.lastKnownParentWidth { 19 | self.lastKnownParentWidth = parentWidth 20 | 21 | // Defer to the next run-loop tick so the current layout pass 22 | // finishes with the new parent size first. 23 | DispatchQueue.main.async { 24 | self.invalidateIntrinsicContentSize() 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/View+Helpers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Transparent Presentation Background 4 | 5 | fileprivate struct TransparentBackground: UIViewRepresentable { 6 | func makeUIView(context: Context) -> UIView { 7 | let view = UIView() 8 | DispatchQueue.main.async { 9 | view.superview?.superview?.backgroundColor = .clear 10 | } 11 | return view 12 | } 13 | func updateUIView(_ uiView: UIView, context: Context) {} 14 | } 15 | 16 | extension View { 17 | func transparentPresentationBackground() -> some View { 18 | if #available(iOS 16.4, *) { 19 | return self.presentationBackground(Color.clear) 20 | } else { 21 | return self.background(TransparentBackground()) 22 | } 23 | } 24 | } 25 | 26 | // MARK: - Disable Scroll When Content Fits 27 | 28 | extension View { 29 | func disableScrollWhenContentFits() -> some View { 30 | if #available(iOS 16.4, *) { 31 | return self.scrollBounceBehavior(.basedOnSize, axes: [.vertical]) 32 | } else { 33 | return self.onAppear { 34 | UIScrollView.appearance().bounces = false 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Theme/Theme.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A predefined set of colors and layout attributes that ensure visual consistency across the 4 | /// application. 5 | public struct Theme: Initializable, Updatable, Equatable { 6 | // MARK: - Properties 7 | 8 | /// The palette of colors. 9 | public var colors: Palette = .init() 10 | 11 | /// The layout configuration. 12 | public var layout: Layout = .init() 13 | 14 | // MARK: - Initialization 15 | 16 | /// Initializes a new `Theme` instance with default values. 17 | public init() {} 18 | } 19 | 20 | // MARK: - Theme + Current 21 | 22 | extension Theme { 23 | /// A notification that is triggered when a theme changes. 24 | public static let didChangeThemeNotification = Notification.Name("didChangeThemeNotification") 25 | 26 | /// A current instance of `Theme` for global use. 27 | /// 28 | /// Triggers `Theme.didChangeThemeNotification` notification when the value changes. 29 | public static var current = Self() { 30 | didSet { 31 | NotificationCenter.default.post( 32 | name: Self.didChangeThemeNotification, 33 | object: nil 34 | ) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/AvatarGroup/SwiftUI/SUAvatarGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that displays a group of avatars. 4 | public struct SUAvatarGroup: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: AvatarGroupVM 9 | 10 | // MARK: - Initialization 11 | 12 | /// Initializer. 13 | /// - Parameters: 14 | /// - model: A model that defines the appearance properties. 15 | public init(model: AvatarGroupVM) { 16 | self.model = model 17 | } 18 | 19 | // MARK: - Body 20 | 21 | public var body: some View { 22 | HStack(spacing: self.model.spacing) { 23 | ForEach(self.model.identifiedAvatarVMs, id: \.0) { _, avatarVM in 24 | AvatarContent(model: avatarVM) 25 | .padding(self.model.padding) 26 | .background(self.model.borderColor.color) 27 | .clipShape( 28 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) 29 | ) 30 | .frame( 31 | width: self.model.itemSize.width, 32 | height: self.model.itemSize.height 33 | ) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/TextAutocapitalization.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | /// The autocapitalization behavior applied during text input. 5 | public enum TextAutocapitalization { 6 | /// Do not capitalize anything. 7 | case never 8 | /// Capitalize every letter. 9 | case characters 10 | /// Capitalize the first letter of every word. 11 | case words 12 | /// Capitalize the first letter in every sentence. 13 | case sentences 14 | } 15 | 16 | extension TextAutocapitalization { 17 | public var textAutocapitalizationType: UITextAutocapitalizationType { 18 | switch self { 19 | case .never: 20 | return .none 21 | case .characters: 22 | return .allCharacters 23 | case .words: 24 | return .words 25 | case .sentences: 26 | return .sentences 27 | } 28 | } 29 | } 30 | 31 | extension TextAutocapitalization { 32 | public var textInputAutocapitalization: TextInputAutocapitalization { 33 | switch self { 34 | case .never: 35 | return .never 36 | case .characters: 37 | return .characters 38 | case .words: 39 | return .words 40 | case .sentences: 41 | return .sentences 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UIApplication+TopViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIApplication { 4 | var topViewController: UIViewController? { 5 | var topViewController: UIViewController? 6 | 7 | if #available(iOS 13, *) { 8 | for scene in self.connectedScenes { 9 | if let windowScene = scene as? UIWindowScene { 10 | for window in windowScene.windows { 11 | if window.isKeyWindow { 12 | topViewController = window.rootViewController 13 | } 14 | } 15 | } 16 | } 17 | } else { 18 | topViewController = self.keyWindow?.rootViewController 19 | } 20 | 21 | while true { 22 | if let presented = topViewController?.presentedViewController { 23 | topViewController = presented 24 | } else if let navController = topViewController as? UINavigationController { 25 | topViewController = navController.topViewController 26 | } else if let tabBarController = topViewController as? UITabBarController { 27 | topViewController = tabBarController.selectedViewController 28 | } else { 29 | break 30 | } 31 | } 32 | return topViewController 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/SwiftUI/AvatarContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AvatarContent: View { 4 | // MARK: - Properties 5 | 6 | var model: AvatarVM 7 | 8 | @StateObject private var imageManager: AvatarImageManager 9 | @Environment(\.colorScheme) private var colorScheme 10 | 11 | // MARK: - Initialization 12 | 13 | init(model: AvatarVM) { 14 | self.model = model 15 | self._imageManager = StateObject( 16 | wrappedValue: AvatarImageManager(model: model) 17 | ) 18 | } 19 | 20 | // MARK: - Body 21 | 22 | var body: some View { 23 | GeometryReader { geometry in 24 | Image(uiImage: self.imageManager.avatarImage) 25 | .resizable() 26 | .aspectRatio(contentMode: .fill) 27 | .clipShape( 28 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) 29 | ) 30 | .onAppear { 31 | self.imageManager.update(model: self.model, size: geometry.size) 32 | } 33 | .onChange(of: self.model) { newValue in 34 | self.imageManager.update(model: newValue, size: geometry.size) 35 | } 36 | .onChange(of: self.colorScheme) { _ in 37 | self.imageManager.update(model: self.model, size: geometry.size) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Colors/ComponentColor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A structure that defines a color set for components. 4 | public struct ComponentColor: Hashable { 5 | // MARK: - Properties 6 | 7 | /// The primary color used for the component. 8 | public let main: UniversalColor 9 | 10 | /// The contrast color, typically used for text or elements displayed on top of the `main` color. 11 | public let contrast: UniversalColor 12 | 13 | /// The background color for the component. 14 | public var background: UniversalColor { 15 | return self._background ?? self.main.withOpacity(0.15).blended(with: .background) 16 | } 17 | 18 | private let _background: UniversalColor? 19 | 20 | // MARK: - Initialization 21 | 22 | /// Initializer. 23 | /// 24 | /// - Parameters: 25 | /// - main: The primary color for the component. 26 | /// - contrast: The color that contrasts with the `main` color, typically used for text or icons. 27 | /// - background: The background color for the component. Defaults to `main` color with 15% opacity if `nil`. 28 | public init( 29 | main: UniversalColor, 30 | contrast: UniversalColor, 31 | background: UniversalColor? = nil 32 | ) { 33 | self.main = main 34 | self.contrast = contrast 35 | self._background = background 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | /// A type that indicates how images are rendered. 5 | public enum ImageRenderingMode { 6 | /// A mode that renders all non-transparent pixels as the foreground 7 | /// color. 8 | case template 9 | /// A mode that renders pixels of bitmap images as-is. 10 | /// 11 | /// For system images created from the SF Symbol set, multicolor symbols 12 | /// respect the current foreground and accent colors. 13 | case original 14 | } 15 | 16 | // MARK: - UIKit Helpers 17 | 18 | extension ImageRenderingMode { 19 | var uiImageRenderingMode: UIImage.RenderingMode { 20 | switch self { 21 | case .template: 22 | return .alwaysTemplate 23 | case .original: 24 | return .alwaysOriginal 25 | } 26 | } 27 | } 28 | 29 | extension UIImage { 30 | func withRenderingMode(_ mode: ImageRenderingMode?) -> UIImage { 31 | if let mode { 32 | return self.withRenderingMode(mode.uiImageRenderingMode) 33 | } else { 34 | return self 35 | } 36 | } 37 | } 38 | 39 | // MARK: - SwiftUI Helpers 40 | 41 | extension ImageRenderingMode { 42 | var imageRenderingModel: Image.TemplateRenderingMode { 43 | switch self { 44 | case .template: 45 | return .template 46 | case .original: 47 | return .original 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Divider/Models/DividerVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a divider component. 4 | public struct DividerVM: ComponentVM { 5 | /// The orientation of the divider (horizontal or vertical). 6 | /// 7 | /// Defaults to `.horizontal`. 8 | public var orientation: Orientation = .horizontal 9 | 10 | /// The color of the divider. 11 | /// 12 | /// Defaults to `.divider`. 13 | public var color: ComponentColor? 14 | 15 | /// The predefined size of the divider, which affects its thickness. 16 | /// 17 | /// Defaults to `.medium`. 18 | public var size: ComponentSize = .medium 19 | 20 | /// Initializes a new instance of `DividerVM` with default values. 21 | public init() {} 22 | } 23 | 24 | // MARK: - Shared Helpers 25 | 26 | extension DividerVM { 27 | var lineColor: UniversalColor { 28 | return self.color?.background ?? .divider 29 | } 30 | var lineSize: CGFloat { 31 | switch self.size { 32 | case .small: 33 | return 0.5 34 | case .medium: 35 | return 1.0 36 | case .large: 37 | return 2.0 38 | } 39 | } 40 | } 41 | 42 | // MARK: - UIKit Helpers 43 | 44 | extension DividerVM { 45 | func shouldUpdateLayout(_ oldModel: Self) -> Bool { 46 | return self.orientation != oldModel.orientation 47 | || self.size != oldModel.size 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Countdown/Manager/CountdownManager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class CountdownManager: ObservableObject { 4 | // MARK: - Published Properties 5 | 6 | @Published var days: Int = 0 7 | @Published var hours: Int = 0 8 | @Published var minutes: Int = 0 9 | @Published var seconds: Int = 0 10 | 11 | // MARK: - Properties 12 | 13 | private var timer: Timer? 14 | private var until: Date? 15 | 16 | // MARK: - Methods 17 | 18 | func start(until: Date) { 19 | self.until = until 20 | self.updateUnitValues() 21 | self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in 22 | self?.updateUnitValues() 23 | } 24 | } 25 | 26 | func stop() { 27 | self.timer?.invalidate() 28 | self.timer = nil 29 | } 30 | 31 | private func updateUnitValues() { 32 | guard let until = self.until else { return } 33 | 34 | let now = Date() 35 | let calendar = Calendar.current 36 | let components = calendar.dateComponents( 37 | [.day, .hour, .minute, .second], 38 | from: now, 39 | to: until 40 | ) 41 | self.days = max(0, components.day ?? 0) 42 | self.hours = max(0, components.hour ?? 0) 43 | self.minutes = max(0, components.minute ?? 0) 44 | self.seconds = max(0, components.second ?? 0) 45 | 46 | if now >= until { 47 | self.stop() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/RadioGroup/Models/RadioItemVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the data and appearance properties for an item in a radio group. 4 | public struct RadioItemVM { 5 | /// The unique identifier for the radio item. 6 | public var id: ID 7 | 8 | /// The text displayed next to the radio button. 9 | public var title: String = "" 10 | 11 | /// The font used for the item's title. 12 | public var font: UniversalFont? 13 | 14 | /// A Boolean value indicating whether the item is enabled or disabled. 15 | /// 16 | /// Defaults to `true`. 17 | public var isEnabled: Bool = true 18 | 19 | /// Initializes a new instance of `RadioItemVM` with the specified identifier. 20 | /// 21 | /// - Parameter id: The unique identifier for the radio item. 22 | public init(id: ID) { 23 | self.id = id 24 | } 25 | 26 | /// Initializes a new instance of `RadioItemVM` with a closure for custom configuration. 27 | /// 28 | /// - Parameters: 29 | /// - id: The unique identifier for the radio item. 30 | /// - transform: A closure that allows for custom configuration of the model's properties. 31 | public init(id: ID, _ transform: (_ value: inout Self) -> Void) { 32 | var defaultValue = Self(id: id) 33 | transform(&defaultValue) 34 | self = defaultValue 35 | } 36 | } 37 | 38 | extension RadioItemVM: Equatable, Identifiable {} 39 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/DividerPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct DividerPreview: View { 6 | @State private var model = DividerVM() 7 | 8 | var body: some View { 9 | VStack { 10 | PreviewWrapper(title: "UIKit") { 11 | UKDivider(model: self.model) 12 | .preview 13 | } 14 | PreviewWrapper(title: "SwiftUI") { 15 | SUDivider(model: self.model) 16 | } 17 | Form { 18 | Picker("Color", selection: self.$model.color) { 19 | Text("Default").tag(Optional.none) 20 | Text("Primary").tag(ComponentColor.primary) 21 | Text("Accent").tag(ComponentColor.accent) 22 | Text("Success").tag(ComponentColor.success) 23 | Text("Warning").tag(ComponentColor.warning) 24 | Text("Danger").tag(ComponentColor.danger) 25 | Text("Custom").tag(ComponentColor( 26 | main: .universal(.uiColor(.systemPurple)), 27 | contrast: .universal(.uiColor(.systemYellow)) 28 | )) 29 | } 30 | Picker("Orientation", selection: self.$model.orientation) { 31 | Text("Horizontal").tag(DividerVM.Orientation.horizontal) 32 | Text("Vertical").tag(DividerVM.Orientation.vertical) 33 | } 34 | SizePicker(selection: self.$model.size) 35 | } 36 | } 37 | } 38 | } 39 | 40 | #Preview { 41 | DividerPreview() 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Card/Models/CardVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a card component. 4 | public struct CardVM: ComponentVM { 5 | /// The scaling factor for the card's tap animation, with a value between 0 and 1. 6 | /// 7 | /// Defaults to `.medium`. 8 | public var animationScale: AnimationScale = .medium 9 | 10 | /// The background color of the card. 11 | public var backgroundColor: UniversalColor = .background 12 | 13 | /// The border color of the card. 14 | public var borderColor: UniversalColor = .divider 15 | 16 | /// The border thickness of the card. 17 | /// 18 | /// Defaults to `.medium`. 19 | public var borderWidth: BorderWidth = .medium 20 | 21 | /// The padding applied to the card's content area. 22 | /// 23 | /// Defaults to a padding value of `16` for all sides. 24 | public var contentPaddings: Paddings = .init(padding: 16) 25 | 26 | /// The corner radius of the card. 27 | /// 28 | /// Defaults to `.medium`. 29 | public var cornerRadius: ContainerRadius = .medium 30 | 31 | /// A Boolean value indicating whether the card should allow to be tapped. 32 | /// 33 | /// Defaults to `true`. 34 | public var isTappable: Bool = false 35 | 36 | /// The shadow of the card. 37 | /// 38 | /// Defaults to `.medium`. 39 | public var shadow: Shadow = .medium 40 | 41 | /// Initializes a new instance of `CardVM` with default values. 42 | public init() {} 43 | } 44 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SliderPreview.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComponentsKit 3 | 4 | struct SliderPreview: View { 5 | @State private var model = SliderVM { 6 | $0.style = .light 7 | $0.minValue = 0 8 | $0.maxValue = 100 9 | $0.cornerRadius = .full 10 | } 11 | @State private var currentValue: CGFloat = 30 12 | 13 | var body: some View { 14 | VStack { 15 | PreviewWrapper(title: "UIKit") { 16 | UKSlider(initialValue: self.currentValue, model: self.model) 17 | .preview 18 | } 19 | PreviewWrapper(title: "SwiftUI") { 20 | SUSlider(currentValue: self.$currentValue, model: self.model) 21 | } 22 | Form { 23 | ComponentColorPicker(selection: self.$model.color) 24 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 25 | Text("Custom: 2px").tag(ComponentRadius.custom(2)) 26 | } 27 | SizePicker(selection: self.$model.size) 28 | Picker("Step", selection: self.$model.step) { 29 | Text("1").tag(CGFloat(1)) 30 | Text("5").tag(CGFloat(5)) 31 | Text("10").tag(CGFloat(10)) 32 | Text("25").tag(CGFloat(25)) 33 | Text("50").tag(CGFloat(50)) 34 | } 35 | Picker("Style", selection: self.$model.style) { 36 | Text("Light").tag(SliderVM.Style.light) 37 | Text("Striped").tag(SliderVM.Style.striped) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | #Preview { 45 | SliderPreview() 46 | } 47 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Defines the placeholder options for an avatar. 4 | /// 5 | /// It is used to provide a fallback or alternative visual representation when an image is not provided or fails to load. 6 | extension AvatarVM { 7 | public enum Placeholder: Hashable { 8 | /// A placeholder that displays a text string. 9 | /// 10 | /// This option is typically used to show initials, names, or other textual representations. 11 | /// 12 | /// - Parameter text: The text to display as the placeholder. 13 | /// - Note: Only 3 first letters are displayed. 14 | case text(String) 15 | 16 | /// A placeholder that displays an SF Symbol. 17 | /// 18 | /// This option allows you to use Apple's system-provided icons as placeholders. 19 | /// 20 | /// - Parameter name: The name of the SF Symbol to display. 21 | /// - Note: Ensure that the SF Symbol name corresponds to an existing icon in the system's symbol library. 22 | case sfSymbol(_ name: String) 23 | 24 | /// A placeholder that displays a custom icon from an asset catalog. 25 | /// 26 | /// This option allows you to use icons from your app's bundled resources or a specified bundle. 27 | /// 28 | /// - Parameters: 29 | /// - name: The name of the icon asset to use as the placeholder. 30 | /// - bundle: The bundle containing the icon resource. Defaults to `nil`, which uses the main bundle. 31 | case icon(_ name: String, _ bundle: Bundle? = nil) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct AvatarPreview: View { 6 | @State private var model = AvatarVM { 7 | $0.placeholder = .icon("avatar_placeholder") 8 | } 9 | 10 | var body: some View { 11 | VStack { 12 | PreviewWrapper(title: "UIKit") { 13 | UKAvatar(model: self.model) 14 | .preview 15 | } 16 | PreviewWrapper(title: "SwiftUI") { 17 | SUAvatar(model: self.model) 18 | } 19 | Form { 20 | ComponentOptionalColorPicker(selection: self.$model.color) 21 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 22 | Text("Custom: 4px").tag(ComponentRadius.custom(4)) 23 | } 24 | Picker("Image Source", selection: self.$model.imageSrc) { 25 | Text("Remote").tag(AvatarVM.ImageSource.remote(URL(string: "https://i.pravatar.cc/150?img=12")!)) 26 | Text("Local").tag(AvatarVM.ImageSource.local("avatar_image")) 27 | Text("None").tag(Optional.none) 28 | } 29 | Picker("Placeholder", selection: self.$model.placeholder) { 30 | Text("Text").tag(AvatarVM.Placeholder.text("IM")) 31 | Text("SF Symbol").tag(AvatarVM.Placeholder.sfSymbol("person")) 32 | Text("Icon").tag(AvatarVM.Placeholder.icon("avatar_placeholder")) 33 | } 34 | SizePicker(selection: self.$model.size) 35 | } 36 | } 37 | } 38 | } 39 | 40 | #Preview { 41 | AvatarPreview() 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/SegmentedControl/Models/SegmentedControlItemVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the data and appearance properties for an item in a segmented control. 4 | public struct SegmentedControlItemVM: Updatable { 5 | /// The unique identifier for the segmented control item. 6 | public var id: ID 7 | 8 | /// The text displayed for the segmented control item. 9 | public var title: String = "" 10 | 11 | /// The font used for the item's title. 12 | public var font: UniversalFont? 13 | 14 | /// A Boolean value indicating whether the item is enabled or disabled. 15 | /// 16 | /// Defaults to `true`. 17 | public var isEnabled: Bool = true 18 | 19 | /// Initializes a new instance of `SegmentedControlItemVM` with the specified identifier. 20 | /// 21 | /// - Parameter id: The unique identifier for the segmented control item. 22 | public init(id: ID) { 23 | self.id = id 24 | } 25 | 26 | /// Initializes a new instance of `SegmentedControlItemVM` with a specified identifier and 27 | /// a closure for custom configuration. 28 | /// 29 | /// - Parameters: 30 | /// - id: The unique identifier for the segmented control item. 31 | /// - transform: A closure that allows for custom configuration of the model's properties. 32 | public init(id: ID, _ transform: (_ value: inout Self) -> Void) { 33 | var defaultValue = Self(id: id) 34 | transform(&defaultValue) 35 | self = defaultValue 36 | } 37 | } 38 | 39 | extension SegmentedControlItemVM: Equatable, Identifiable {} 40 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct RadioGroupPreview: View { 6 | @State private var selectedId: String? 7 | @State private var model: RadioGroupVM = { 8 | var model = RadioGroupVM() 9 | model.items = [ 10 | RadioItemVM(id: "option1") { item in 11 | item.title = "Option 1" 12 | }, 13 | RadioItemVM(id: "option2") { item in 14 | item.title = "Option 2" 15 | }, 16 | RadioItemVM(id: "option3") { item in 17 | item.title = "Option 3" 18 | } 19 | ] 20 | return model 21 | }() 22 | 23 | var body: some View { 24 | VStack { 25 | PreviewWrapper(title: "UIKit") { 26 | UKRadioGroup(model: self.model) 27 | .preview 28 | } 29 | PreviewWrapper(title: "SwiftUI") { 30 | SURadioGroup(selectedId: $selectedId, model: self.model) 31 | } 32 | Form { 33 | AnimationScalePicker(selection: self.$model.animationScale) 34 | UniversalColorPicker(title: "Color", selection: self.$model.color) 35 | Toggle("Enabled", isOn: self.$model.isEnabled) 36 | BodyFontPicker(selection: self.$model.font) 37 | SizePicker(selection: self.$model.size) 38 | Picker("Spacing", selection: self.$model.spacing) { 39 | Text("8px").tag(CGFloat(8)) 40 | Text("10px").tag(CGFloat(10)) 41 | Text("14px").tag(CGFloat(14)) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | RadioGroupPreview() 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI wrapper that listens for theme changes and automatically refreshes its content. 4 | /// 5 | /// `ThemeChangeObserver` ensures that its child views **rebuild** whenever the theme changes, 6 | /// helping to apply updated theme styles dynamically. 7 | /// 8 | /// ## Usage 9 | /// 10 | /// Wrap your view inside `ThemeChangeObserver` to make it responsive to theme updates: 11 | /// 12 | /// ```swift 13 | /// @main 14 | /// struct Root: App { 15 | /// var body: some Scene { 16 | /// WindowGroup { 17 | /// ThemeChangeObserver { 18 | /// Content() 19 | /// } 20 | /// } 21 | /// } 22 | /// } 23 | /// ``` 24 | /// 25 | /// ## Performance Considerations 26 | /// 27 | /// - This approach forces a **full re-evaluation** of the wrapped content, which ensures all theme-dependent 28 | /// properties are updated. 29 | /// - Use it **at a high level** in your SwiftUI hierarchy (e.g., wrapping entire screens) rather than for small components. 30 | public struct ThemeChangeObserver: View { 31 | @State private var themeId = UUID() 32 | @ViewBuilder var content: () -> Content 33 | 34 | public init(content: @escaping () -> Content) { 35 | self.content = content 36 | } 37 | 38 | public var body: some View { 39 | self.content() 40 | .onReceive(NotificationCenter.default.publisher( 41 | for: Theme.didChangeThemeNotification, 42 | object: nil 43 | )) { _ in 44 | self.themeId = UUID() 45 | } 46 | .id(self.themeId) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewWrapper.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | 4 | struct PreviewWrapper: View { 5 | let title: String 6 | @ViewBuilder let content: () -> Content 7 | 8 | var body: some View { 9 | ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) { 10 | self.content() 11 | .padding(.all) 12 | .frame(height: 150) 13 | .frame(maxWidth: .infinity) 14 | .overlay { 15 | RoundedRectangle( 16 | cornerRadius: 25 17 | ) 18 | .stroke( 19 | LinearGradient( 20 | gradient: Gradient( 21 | colors: [ 22 | UniversalColor.blue.color, 23 | UniversalColor.purple.color, 24 | ] 25 | ), 26 | startPoint: .topLeading, 27 | endPoint: .bottomTrailing 28 | ), 29 | lineWidth: 2 30 | ) 31 | } 32 | .padding(.top, 20) 33 | 34 | Text(self.title) 35 | .padding(.horizontal) 36 | .background(Color(.systemBackground)) 37 | .font(.system(size: 30, weight: .bold)) 38 | .padding(.leading, 30) 39 | } 40 | .padding(.horizontal) 41 | } 42 | } 43 | 44 | // MARK: - Colors 45 | 46 | extension UniversalColor { 47 | fileprivate static let blue: Self = .themed( 48 | light: .hex("#3684F8"), 49 | dark: .hex("#0058DB") 50 | ) 51 | fileprivate static let purple: Self = .themed( 52 | light: .hex("#A920FD"), 53 | dark: .hex("#7800C1") 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/ComponentRadius.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// An enumeration that defines the corner radius options for components. 5 | public enum ComponentRadius: Hashable { 6 | /// No corner radius, resulting in sharp edges. 7 | case none 8 | /// A small corner radius. 9 | case small 10 | /// A medium corner radius. 11 | case medium 12 | /// A large corner radius. 13 | case large 14 | /// A fully rounded corner radius, where the radius is half of the component's height. 15 | case full 16 | /// A custom corner radius with a specific value. 17 | /// 18 | /// - Parameter value: The radius value as a `CGFloat`. 19 | case custom(CGFloat) 20 | } 21 | 22 | extension ComponentRadius { 23 | /// Returns the numeric value of the corner radius, ensuring it does not exceed half the component's height. 24 | /// 25 | /// - Parameter height: The height of the component. Defaults to a large number (10,000) for unrestricted calculations. 26 | /// - Returns: The calculated corner radius as a `CGFloat`, capped at half of the height for `full` rounding or custom values. 27 | public func value(for height: CGFloat = 10_000) -> CGFloat { 28 | let maxValue = height / 2 29 | let value = switch self { 30 | case .none: CGFloat(0) 31 | case .small: Theme.current.layout.componentRadius.small 32 | case .medium: Theme.current.layout.componentRadius.medium 33 | case .large: Theme.current.layout.componentRadius.large 34 | case .full: height / 2 35 | case .custom(let value): value 36 | } 37 | return min(value, maxValue) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct SegmentedControlPreview: View { 6 | enum Item { 7 | case iPhone 8 | case iPad 9 | case mac 10 | } 11 | 12 | @State private var model = SegmentedControlVM { 13 | $0.items = [ 14 | .init(id: .iPhone) { 15 | $0.title = "iPhone" 16 | }, 17 | .init(id: .iPad) { 18 | $0.title = "iPad" 19 | }, 20 | .init(id: .mac) { 21 | $0.title = "Mackbook" 22 | } 23 | ] 24 | } 25 | 26 | @State private var selectedId: Item = .iPad 27 | 28 | var body: some View { 29 | VStack { 30 | PreviewWrapper(title: "UIKit") { 31 | UKSegmentedControl( 32 | selectedId: .iPad, 33 | model: self.model 34 | ) 35 | .preview 36 | } 37 | PreviewWrapper(title: "SwiftUI") { 38 | SUSegmentedControl( 39 | selectedId: self.$selectedId, 40 | model: self.model 41 | ) 42 | } 43 | Form { 44 | ComponentOptionalColorPicker(selection: self.$model.color) 45 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 46 | Text("Custom: 4px").tag(ComponentRadius.custom(4)) 47 | } 48 | Toggle("Enabled", isOn: self.$model.isEnabled) 49 | BodyFontPicker(selection: self.$model.font) 50 | Toggle("Full Width", isOn: self.$model.isFullWidth) 51 | SizePicker(selection: self.$model.size) 52 | } 53 | } 54 | } 55 | } 56 | 57 | #Preview { 58 | SegmentedControlPreview() 59 | } 60 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CheckboxPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct CheckboxPreview: View { 6 | @State private var model = CheckboxVM { 7 | $0.title = "Checkbox" 8 | } 9 | 10 | @State private var isSelected: Bool = false 11 | 12 | var body: some View { 13 | VStack { 14 | PreviewWrapper(title: "UIKit") { 15 | UKCheckbox( 16 | initialValue: false, 17 | model: self.model 18 | ) 19 | .preview 20 | } 21 | PreviewWrapper(title: "SwiftUI") { 22 | SUCheckbox( 23 | isSelected: self.$isSelected, 24 | model: self.model 25 | ) 26 | } 27 | Form { 28 | Picker("Title", selection: self.$model.title) { 29 | Text("None").tag(Optional.none) 30 | Text("Short").tag("Checkbox") 31 | 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.") 32 | } 33 | ComponentColorPicker(selection: self.$model.color) 34 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 35 | Text("Custom: 2px").tag(ComponentRadius.custom(2)) 36 | } 37 | BodyFontPicker(selection: self.$model.font) 38 | Toggle("Enabled", isOn: self.$model.isEnabled) 39 | SizePicker(selection: self.$model.size) 40 | } 41 | } 42 | } 43 | } 44 | 45 | #Preview { 46 | CheckboxPreview() 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/ModalVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines generic appearance properties that can be in any modal component. 4 | public protocol ModalVM: ComponentVM { 5 | /// The background color of the modal. 6 | var backgroundColor: UniversalColor? { get set } 7 | 8 | /// The border thickness of the modal. 9 | var borderWidth: BorderWidth { get set } 10 | 11 | /// A Boolean value indicating whether the modal should close when tapping on the overlay. 12 | var closesOnOverlayTap: Bool { get set } 13 | 14 | /// The padding applied to the modal's content area. 15 | var contentPaddings: Paddings { get set } 16 | 17 | /// The spacing between header, body and footer. 18 | var contentSpacing: CGFloat { get set } 19 | 20 | /// The corner radius of the modal. 21 | var cornerRadius: ContainerRadius { get set } 22 | 23 | /// The style of the overlay displayed behind the modal. 24 | var overlayStyle: ModalOverlayStyle { get set } 25 | 26 | /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. 27 | var outerPaddings: Paddings { get set } 28 | 29 | /// The predefined maximum size of the modal. 30 | var size: ModalSize { get set } 31 | 32 | /// The transition duration of the modal's appearance and dismissal animations. 33 | var transition: ModalTransition { get set } 34 | } 35 | 36 | // MARK: - Helpers 37 | 38 | extension ModalVM { 39 | var preferredBackgroundColor: UniversalColor { 40 | return self.backgroundColor ?? .themed( 41 | light: UniversalColor.background.light, 42 | dark: UniversalColor.secondaryBackground.dark 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Protocols/Updatable.swift: -------------------------------------------------------------------------------- 1 | /// A type whose values can be updated or can create and updated copy. 2 | public protocol Updatable { 3 | /// Returns a new instance by applying a transformation to a copy of the current instance. 4 | /// 5 | /// - Parameter transform: A closure that defines the transformation. 6 | /// - Returns: A new instance with the changes applied. 7 | func updating(_ transform: (_ value: inout Self) -> Void) -> Self 8 | 9 | /// Modifies the current instance by applying a transformation closure. 10 | /// 11 | /// - Parameter transform: A closure that defines the transformation. 12 | mutating func update(_ transform: (_ value: inout Self) -> Void) 13 | } 14 | 15 | extension Updatable { 16 | /// Returns a new instance by applying a transformation to a copy of the current instance. 17 | /// 18 | /// This default implementation makes a copy of the current instance, applies the transformation closure to the copy, and returns the updated copy. 19 | /// 20 | /// - Parameter transform: A closure that defines the transformation. 21 | /// - Returns: A new instance with the changes applied. 22 | public func updating(_ transform: (_ value: inout Self) -> Void) -> Self { 23 | var copy = self 24 | transform(©) 25 | return copy 26 | } 27 | 28 | /// Modifies the current instance by applying a transformation closure. 29 | /// 30 | /// This default implementation applies the transformation closure to the current instance. 31 | /// 32 | /// - Parameter transform: A closure that defines the transformation. 33 | public mutating func update(_ transform: (_ value: inout Self) -> Void) { 34 | transform(&self) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Alert/Helpers/AlertButtonsOrientationCalculator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct AlertButtonsOrientationCalculator { 4 | enum Orientation { 5 | case vertical 6 | case horizontal 7 | } 8 | 9 | private static let primaryButton = UKButton(model: .init()) 10 | private static let secondaryButton = UKButton(model: .init()) 11 | 12 | private init() {} 13 | 14 | static func preferredOrientation(model: AlertVM) -> Orientation { 15 | guard let primaryButtonVM = model.primaryButtonVM, 16 | let secondaryButtonVM = model.secondaryButtonVM else { 17 | return .vertical 18 | } 19 | 20 | self.primaryButton.model = primaryButtonVM.updating { 21 | $0.isFullWidth = false 22 | } 23 | self.secondaryButton.model = secondaryButtonVM.updating { 24 | $0.isFullWidth = false 25 | } 26 | 27 | let primaryButtonWidth = self.primaryButton.intrinsicContentSize.width 28 | let secondaryButtonWidth = self.secondaryButton.intrinsicContentSize.width 29 | 30 | // Since the `maxWidth` of the alert is always less than the width of the 31 | // screen, we can assume that the width of the container is equal to this 32 | // `maxWidth` value. 33 | let containerWidth = model.modalVM.size.maxWidth 34 | let availableButtonsWidth = containerWidth 35 | - AlertVM.buttonsSpacing 36 | - model.contentPaddings.leading 37 | - model.contentPaddings.trailing 38 | let availableButtonWidth = availableButtonsWidth / 2 39 | 40 | if primaryButtonWidth <= availableButtonWidth, 41 | secondaryButtonWidth <= availableButtonWidth { 42 | return .horizontal 43 | } else { 44 | return .vertical 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/UKComponentPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct UKComponentPreview: UIViewRepresentable where View: UKComponent, Model == View.Model { 6 | class Container: UIView { 7 | let component: View 8 | 9 | init(component: View) { 10 | self.component = component 11 | 12 | super.init(frame: .zero) 13 | 14 | self.addSubview(self.component) 15 | 16 | self.component.centerVertically() 17 | self.component.centerHorizontally() 18 | 19 | self.component.topAnchor.constraint( 20 | greaterThanOrEqualTo: self.topAnchor 21 | ).isActive = true 22 | self.component.bottomAnchor.constraint( 23 | lessThanOrEqualTo: self.bottomAnchor 24 | ).isActive = true 25 | self.component.leadingAnchor.constraint( 26 | greaterThanOrEqualTo: self.leadingAnchor 27 | ).isActive = true 28 | self.component.trailingAnchor.constraint( 29 | lessThanOrEqualTo: self.trailingAnchor 30 | ).isActive = true 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | } 37 | 38 | let model: Model 39 | let view: View 40 | 41 | init(view: View) { 42 | self.view = view 43 | self.model = view.model 44 | } 45 | 46 | func makeUIView(context: Context) -> Container { 47 | return Container(component: self.view) 48 | } 49 | 50 | func updateUIView(_ container: Container, context: Context) { 51 | container.component.model = self.model 52 | } 53 | } 54 | 55 | extension UKComponent { 56 | var preview: some View { 57 | UKComponentPreview(view: self) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Loading/SULoading.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /// A SwiftUI component that shows that a task is in progress. 5 | public struct SULoading: View { 6 | // MARK: Properties 7 | 8 | /// A model that defines the appearance properties. 9 | public var model: LoadingVM 10 | 11 | @State private var rotationAngle: CGFloat = 0.0 12 | 13 | // MARK: Initialization 14 | 15 | /// Initializer. 16 | /// - Parameters: 17 | /// - model: A model that defines the appearance properties. 18 | public init(model: LoadingVM = .init()) { 19 | self.model = model 20 | } 21 | 22 | // MARK: Body 23 | 24 | public var body: some View { 25 | Path { path in 26 | path.addArc( 27 | center: self.model.center, 28 | radius: self.model.radius, 29 | startAngle: .radians(0), 30 | endAngle: .radians(2 * .pi), 31 | clockwise: true 32 | ) 33 | } 34 | .trim(from: 0, to: 0.75) 35 | .stroke( 36 | self.model.color.main.color, 37 | style: StrokeStyle( 38 | lineWidth: self.model.loadingLineWidth, 39 | lineCap: .round, 40 | lineJoin: .round, 41 | miterLimit: 0 42 | ) 43 | ) 44 | .rotationEffect(.radians(self.rotationAngle)) 45 | .animation( 46 | .linear(duration: 1.0) 47 | .repeatForever(autoreverses: false), 48 | value: self.rotationAngle 49 | ) 50 | .frame( 51 | width: self.model.preferredSize.width, 52 | height: self.model.preferredSize.height, 53 | alignment: .center 54 | ) 55 | .onAppear { 56 | DispatchQueue.main.async { 57 | self.rotationAngle = 2 * .pi 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BadgePreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct BadgePreview: View { 6 | @State private var model = BadgeVM { 7 | $0.title = "Badge" 8 | } 9 | 10 | var body: some View { 11 | VStack { 12 | PreviewWrapper(title: "UIKit") { 13 | UKBadge(model: self.model) 14 | .preview 15 | } 16 | PreviewWrapper(title: "SwiftUI") { 17 | SUBadge(model: self.model) 18 | } 19 | Form { 20 | ComponentOptionalColorPicker(selection: self.$model.color) 21 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 22 | Text("Custom: 4px").tag(ComponentRadius.custom(4)) 23 | } 24 | Toggle("Enabled", isOn: self.$model.isEnabled) 25 | Picker("Font", selection: self.$model.font) { 26 | Text("Small").tag(UniversalFont.smButton) 27 | Text("Medium").tag(UniversalFont.mdButton) 28 | Text("Large").tag(UniversalFont.lgButton) 29 | Text("Custom: system bold of size 16").tag(UniversalFont.system(size: 16, weight: .bold)) 30 | } 31 | Picker("Paddings", selection: self.$model.paddings) { 32 | Text("8px; 6px") 33 | .tag(Paddings(top: 6, leading: 8, bottom: 6, trailing: 8)) 34 | Text("10px; 8px") 35 | .tag(Paddings(top: 8, leading: 10, bottom: 8, trailing: 10)) 36 | Text("12px; 10px") 37 | .tag(Paddings(top: 10, leading: 12, bottom: 10, trailing: 12)) 38 | } 39 | Picker("Style", selection: self.$model.style) { 40 | Text("Filled").tag(BadgeVM.Style.filled) 41 | Text("Light").tag(BadgeVM.Style.light) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | BadgePreview() 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/AnimationScale.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An enumeration that defines how much a component shrinks or expands during animations. 4 | public enum AnimationScale: Hashable { 5 | /// No scaling is applied, meaning the component remains at its original size. 6 | case none 7 | /// A small scaling effect is applied, using a predefined value from the configuration. 8 | case small 9 | /// A medium scaling effect is applied, using a predefined value from the configuration. 10 | case medium 11 | /// A large scaling effect is applied, using a predefined value from the configuration. 12 | case large 13 | /// A custom scaling value. 14 | /// 15 | /// - Parameter value: The custom scale value (0.0–1.0). 16 | case custom(_ value: CGFloat) 17 | } 18 | 19 | extension AnimationScale { 20 | /// The scaling value represented as a `CGFloat`. 21 | /// 22 | /// - Returns: 23 | /// - `1.0` for `.none` (no scaling). 24 | /// - Predefined values from `Theme` for `.small`, `.medium`, and `.large`. 25 | /// - The custom value provided for `.custom`, constrained between `0.0` and `1.0`. 26 | /// - Note: If the custom value is outside the range `0.0–1.0`, an assertion failure occurs, 27 | /// and a default value of `1.0` is returned. 28 | public var value: CGFloat { 29 | switch self { 30 | case .none: 31 | return 1.0 32 | case .small: 33 | return Theme.current.layout.animationScale.small 34 | case .medium: 35 | return Theme.current.layout.animationScale.medium 36 | case .large: 37 | return Theme.current.layout.animationScale.large 38 | case .custom(let value): 39 | guard value >= 0 && value <= 1.0 else { 40 | assertionFailure("Animation scale value should be between 0 and 1") 41 | return 1.0 42 | } 43 | return value 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/CenterModalVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a center modal component. 4 | public struct CenterModalVM: ModalVM { 5 | /// The background color of the modal. 6 | public var backgroundColor: UniversalColor? 7 | 8 | /// The border thickness of the modal. 9 | /// 10 | /// Defaults to `.small`. 11 | public var borderWidth: BorderWidth = .small 12 | 13 | /// A Boolean value indicating whether the modal should close when tapping on the overlay. 14 | /// 15 | /// Defaults to `true`. 16 | public var closesOnOverlayTap: Bool = true 17 | 18 | /// The padding applied to the modal's content area. 19 | /// 20 | /// Defaults to a padding value of `16` for all sides. 21 | public var contentPaddings: Paddings = .init(padding: 16) 22 | 23 | /// The spacing between header, body and footer. 24 | public var contentSpacing: CGFloat = 16 25 | 26 | /// The corner radius of the modal. 27 | /// 28 | /// Defaults to `.medium`. 29 | public var cornerRadius: ContainerRadius = .medium 30 | 31 | /// The style of the overlay displayed behind the modal. 32 | /// 33 | /// Defaults to `.dimmed`. 34 | public var overlayStyle: ModalOverlayStyle = .dimmed 35 | 36 | /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. 37 | /// 38 | /// Defaults to a padding value of `20` for all sides. 39 | public var outerPaddings: Paddings = .init(padding: 20) 40 | 41 | /// The predefined maximum size of the modal. 42 | /// 43 | /// Defaults to `.medium`. 44 | public var size: ModalSize = .medium 45 | 46 | /// The transition duration of the modal's appearance and dismissal animations. 47 | /// 48 | /// Defaults to `.fast`. 49 | public var transition: ModalTransition = .fast 50 | 51 | /// Initializes a new instance of `CenterModalVM` with default values. 52 | public init() {} 53 | } 54 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Divider/UKDivider.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A UIKit component that displays a separating line. 4 | open class UKDivider: UIView, UKComponent { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: DividerVM { 9 | didSet { 10 | self.update(oldValue) 11 | } 12 | } 13 | 14 | // MARK: - UIView Properties 15 | 16 | open override var intrinsicContentSize: CGSize { 17 | return self.sizeThatFits(UIView.layoutFittingExpandedSize) 18 | } 19 | 20 | // MARK: - Initializers 21 | 22 | /// Initializer. 23 | /// - Parameters: 24 | /// - model: A model that defines the appearance properties. 25 | public init(model: DividerVM = .init()) { 26 | self.model = model 27 | super.init(frame: .zero) 28 | self.style() 29 | } 30 | 31 | public required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | // MARK: - Style 36 | 37 | private func style() { 38 | self.backgroundColor = self.model.lineColor.uiColor 39 | self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 40 | self.setContentCompressionResistancePriority(.defaultLow, for: .vertical) 41 | } 42 | 43 | // MARK: - Update 44 | 45 | public func update(_ oldModel: DividerVM) { 46 | guard self.model != oldModel else { return } 47 | 48 | self.backgroundColor = self.model.lineColor.uiColor 49 | 50 | if self.model.shouldUpdateLayout(oldModel) { 51 | self.invalidateIntrinsicContentSize() 52 | } 53 | } 54 | 55 | // MARK: - UIView Methods 56 | 57 | open override func sizeThatFits(_ size: CGSize) -> CGSize { 58 | let lineSize = self.model.lineSize 59 | switch self.model.orientation { 60 | case .vertical: 61 | return CGSize(width: lineSize, height: size.height) 62 | case .horizontal: 63 | return CGSize(width: size.width, height: lineSize) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | final class AvatarImageManager: ObservableObject { 5 | @Published var avatarImage: UIImage 6 | 7 | private var model: AvatarVM 8 | private static var remoteImagesCache = NSCache() 9 | 10 | init(model: AvatarVM) { 11 | self.model = model 12 | 13 | let size = model.preferredSize 14 | switch model.imageSrc { 15 | case .remote(let url): 16 | self.avatarImage = model.placeholderImage(for: size) 17 | self.downloadImage(url: url) 18 | case let .local(name, bundle): 19 | self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) 20 | case .none: 21 | self.avatarImage = model.placeholderImage(for: size) 22 | } 23 | } 24 | 25 | func update(model: AvatarVM, size: CGSize) { 26 | self.model = model 27 | 28 | switch model.imageSrc { 29 | case .remote(let url): 30 | if let image = Self.remoteImagesCache.object(forKey: url.absoluteString as NSString) { 31 | self.avatarImage = image 32 | } else { 33 | self.avatarImage = model.placeholderImage(for: size) 34 | self.downloadImage(url: url) 35 | } 36 | case let .local(name, bundle): 37 | self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) 38 | case .none: 39 | self.avatarImage = model.placeholderImage(for: size) 40 | } 41 | } 42 | 43 | private func downloadImage(url: URL) { 44 | Task { @MainActor in 45 | let request = URLRequest(url: url) 46 | guard let (data, _) = try? await URLSession.shared.data(for: request), 47 | let image = UIImage(data: data) 48 | else { return } 49 | 50 | Self.remoteImagesCache.setObject(image, forKey: url.absoluteString as NSString) 51 | 52 | if url == self.model.imageURL { 53 | self.avatarImage = image 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a loading indicator component. 4 | public struct LoadingVM: ComponentVM { 5 | /// The color of the loading indicator. 6 | /// 7 | /// Defaults to `.accent`. 8 | public var color: ComponentColor = .accent 9 | 10 | /// The width of the lines used in the loading indicator. 11 | /// 12 | /// If not provided, the line width is automatically adjusted based on the size. 13 | public var lineWidth: CGFloat? 14 | 15 | /// The predefined size of the loading indicator. 16 | /// 17 | /// Defaults to `.medium`. 18 | public var size: ComponentSize = .medium 19 | 20 | /// The style of the loading indicator (e.g., spinner, bar). 21 | /// 22 | /// Defaults to `.spinner`. 23 | public var style: Style = .spinner 24 | 25 | /// Initializes a new instance of `LoadingVM` with default values. 26 | public init() {} 27 | } 28 | 29 | // MARK: Shared Helpers 30 | 31 | extension LoadingVM { 32 | var loadingLineWidth: CGFloat { 33 | return self.lineWidth ?? max(self.preferredSize.width / 8, 2) 34 | } 35 | var preferredSize: CGSize { 36 | switch self.style { 37 | case .spinner: 38 | switch self.size { 39 | case .small: 40 | return .init(width: 24, height: 24) 41 | case .medium: 42 | return .init(width: 36, height: 36) 43 | case .large: 44 | return .init(width: 48, height: 48) 45 | } 46 | } 47 | } 48 | var radius: CGFloat { 49 | return self.preferredSize.height / 2 - self.loadingLineWidth / 2 50 | } 51 | } 52 | 53 | // MARK: UIKit Helpers 54 | 55 | extension LoadingVM { 56 | func shouldUpdateShapePath(_ oldModel: Self) -> Bool { 57 | return self.size != oldModel.size || self.lineWidth != oldModel.lineWidth 58 | } 59 | } 60 | 61 | // MARK: SwiftUI Helpers 62 | 63 | extension LoadingVM { 64 | var center: CGPoint { 65 | return .init( 66 | x: self.preferredSize.width / 2, 67 | y: self.preferredSize.height / 2 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/SubmitType.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | /// Specifies the text string that displays in the Return key of a keyboard. 5 | public enum SubmitType { 6 | /// Specifies that the title of the Return key is *Done*. 7 | case done 8 | /// Specifies that the title of the Return key is *Go*. 9 | case go 10 | /// Specifies that the title of the Return key is *Send*. 11 | case send 12 | /// Specifies that the title of the Return key is *Join*. 13 | case join 14 | /// Specifies that the title of the Return key is *Route*. 15 | case route 16 | /// Specifies that the title of the Return key is *Search*. 17 | case search 18 | /// Specifies that the title of the Return key is *Return*. 19 | case `return` 20 | /// Specifies that the title of the Return key is *Next*. 21 | case next 22 | /// Specifies that the title of the Return key is *Continue*. 23 | case `continue` 24 | } 25 | 26 | // MARK: - UIKit Helpers 27 | 28 | extension SubmitType { 29 | public var returnKeyType: UIReturnKeyType { 30 | switch self { 31 | case .done: 32 | return .done 33 | case .go: 34 | return .go 35 | case .send: 36 | return .send 37 | case .join: 38 | return .join 39 | case .route: 40 | return .route 41 | case .search: 42 | return .search 43 | case .return: 44 | return .default 45 | case .next: 46 | return .next 47 | case .continue: 48 | return .continue 49 | } 50 | } 51 | } 52 | 53 | // MARK: - SwiftUI Helpers 54 | 55 | extension SubmitType { 56 | public var submitLabel: SubmitLabel { 57 | switch self { 58 | case .done: 59 | return .done 60 | case .go: 61 | return .go 62 | case .send: 63 | return .send 64 | case .join: 65 | return .join 66 | case .route: 67 | return .route 68 | case .search: 69 | return .search 70 | case .return: 71 | return .return 72 | case .next: 73 | return .next 74 | case .continue: 75 | return .continue 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ProgressBarPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct ProgressBarPreview: View { 6 | @State private var model = Self.initialModel 7 | 8 | private let progressBar = UKProgressBar(model: Self.initialModel) 9 | 10 | private let timer = Timer 11 | .publish(every: 0.5, on: .main, in: .common) 12 | .autoconnect() 13 | 14 | var body: some View { 15 | VStack { 16 | PreviewWrapper(title: "UIKit") { 17 | self.progressBar 18 | .preview 19 | .onAppear { 20 | self.progressBar.model = Self.initialModel 21 | } 22 | .onChange(of: self.model) { newValue in 23 | self.progressBar.model = newValue 24 | } 25 | } 26 | PreviewWrapper(title: "SwiftUI") { 27 | SUProgressBar(model: self.model) 28 | } 29 | Form { 30 | ComponentColorPicker(selection: self.$model.color) 31 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 32 | Text("Custom: 2px").tag(ComponentRadius.custom(2)) 33 | } 34 | SizePicker(selection: self.$model.size) 35 | Picker("Style", selection: self.$model.style) { 36 | Text("Light").tag(ProgressBarVM.Style.light) 37 | Text("Filled").tag(ProgressBarVM.Style.filled) 38 | Text("Striped").tag(ProgressBarVM.Style.striped) 39 | } 40 | } 41 | } 42 | .onReceive(self.timer) { _ in 43 | if self.model.currentValue < self.model.maxValue { 44 | let step = (self.model.maxValue - self.model.minValue) / 100 45 | self.model.currentValue = min( 46 | self.model.maxValue, 47 | self.model.currentValue + CGFloat(Int.random(in: 1...20)) * step 48 | ) 49 | } else { 50 | self.model.currentValue = self.model.minValue 51 | } 52 | } 53 | } 54 | 55 | // MARK: - Helpers 56 | 57 | private static var initialModel: ProgressBarVM { 58 | return .init() 59 | } 60 | } 61 | 62 | #Preview { 63 | ProgressBarPreview() 64 | } 65 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct AvatarGroupPreview: View { 6 | @State private var model = AvatarGroupVM { 7 | $0.items = [ 8 | .init { 9 | $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=12")!) 10 | }, 11 | .init { 12 | $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=14")!) 13 | }, 14 | .init { 15 | $0.imageSrc = .remote(URL(string: "https://i.pravatar.cc/150?img=15")!) 16 | }, 17 | .init(), 18 | .init(), 19 | .init { 20 | $0.placeholder = .text("IM") 21 | }, 22 | .init { 23 | $0.placeholder = .sfSymbol("person.circle") 24 | }, 25 | ] 26 | } 27 | 28 | var body: some View { 29 | VStack { 30 | PreviewWrapper(title: "UIKit") { 31 | UKAvatarGroup(model: self.model) 32 | .preview 33 | } 34 | PreviewWrapper(title: "SwiftUI") { 35 | SUAvatarGroup(model: self.model) 36 | } 37 | Form { 38 | Picker("Border Color", selection: self.$model.borderColor) { 39 | Text("Background").tag(UniversalColor.background) 40 | Text("Accent Background").tag(ComponentColor.accent.background) 41 | Text("Success Background").tag(ComponentColor.success.background) 42 | Text("Warning Background").tag(ComponentColor.warning.background) 43 | Text("Danger Background").tag(ComponentColor.danger.background) 44 | } 45 | ComponentOptionalColorPicker(selection: self.$model.color) 46 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 47 | Text("Custom: 4px").tag(ComponentRadius.custom(4)) 48 | } 49 | Picker("Max Visible Avatars", selection: self.$model.maxVisibleAvatars) { 50 | Text("3").tag(3) 51 | Text("5").tag(5) 52 | Text("7").tag(7) 53 | } 54 | SizePicker(selection: self.$model.size) 55 | } 56 | } 57 | } 58 | } 59 | 60 | #Preview { 61 | AvatarGroupPreview() 62 | } 63 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Badge/Models/BadgeVM.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A model that defines the appearance properties for a badge component. 4 | public struct BadgeVM: ComponentVM { 5 | /// The text displayed on the badge. 6 | public var title: String = "" 7 | 8 | /// The color of the badge. 9 | public var color: ComponentColor? 10 | 11 | /// The corner radius of the badge. 12 | /// 13 | /// Defaults to `.medium`. 14 | public var cornerRadius: ComponentRadius = .medium 15 | 16 | /// The font used for the badge's text. 17 | /// 18 | /// Defaults to `.smButton`. 19 | public var font: UniversalFont = .smButton 20 | 21 | /// A Boolean value indicating whether the button is enabled or disabled. 22 | /// 23 | /// Defaults to `true`. 24 | public var isEnabled: Bool = true 25 | 26 | /// Paddings for the badge. 27 | public var paddings: Paddings = .init(horizontal: 10, vertical: 8) 28 | 29 | /// The visual style of the badge. 30 | /// 31 | /// Defaults to `.filled`. 32 | public var style: Style = .filled 33 | 34 | /// Initializes a new instance of `BadgeVM` with default values. 35 | public init() {} 36 | } 37 | 38 | // MARK: Helpers 39 | 40 | extension BadgeVM { 41 | /// Returns the background color of the badge based on its style. 42 | var backgroundColor: UniversalColor { 43 | let color = switch self.style { 44 | case .filled: 45 | self.color?.main ?? .content2 46 | case .light: 47 | self.color?.background ?? .content1 48 | } 49 | return color.enabled(self.isEnabled) 50 | } 51 | 52 | /// Returns the foreground color of the badge based on its style. 53 | var foregroundColor: UniversalColor { 54 | let color = switch self.style { 55 | case .filled: 56 | self.color?.contrast ?? .foreground 57 | case .light: 58 | self.color?.main ?? .foreground 59 | } 60 | return color.enabled(self.isEnabled) 61 | } 62 | } 63 | 64 | // MARK: UIKit Helpers 65 | 66 | extension BadgeVM { 67 | func shouldUpdateLayout(_ oldModel: Self?) -> Bool { 68 | return self.font != oldModel?.font 69 | || self.paddings != oldModel?.paddings 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct CountdownPreview: View { 6 | @State private var model = CountdownVM() 7 | 8 | var body: some View { 9 | VStack { 10 | PreviewWrapper(title: "UIKit") { 11 | UKCountdown(model: self.model) 12 | .preview 13 | } 14 | PreviewWrapper(title: "SwiftUI") { 15 | SUCountdown(model: self.model) 16 | } 17 | Form { 18 | ComponentOptionalColorPicker(selection: self.$model.color) 19 | Picker("Locale", selection: self.$model.locale) { 20 | Text("Current").tag(Locale.current) 21 | Text("EN").tag(Locale(identifier: "en")) 22 | Text("ES").tag(Locale(identifier: "es")) 23 | Text("FR").tag(Locale(identifier: "fr")) 24 | Text("DE").tag(Locale(identifier: "de")) 25 | Text("ZH").tag(Locale(identifier: "zh")) 26 | Text("JA").tag(Locale(identifier: "ja")) 27 | Text("RU").tag(Locale(identifier: "ru")) 28 | Text("AR").tag(Locale(identifier: "ar")) 29 | Text("HI").tag(Locale(identifier: "hi")) 30 | Text("PT").tag(Locale(identifier: "pt")) 31 | } 32 | HeadlineFontPicker(title: "Main Font", selection: self.$model.mainFont) 33 | CaptionFontPicker(title: "Secondary Font", selection: self.$model.secondaryFont) 34 | SizePicker(selection: self.$model.size) 35 | Picker("Style", selection: self.$model.style) { 36 | Text("Plain").tag(CountdownVM.Style.plain) 37 | Text("Light").tag(CountdownVM.Style.light) 38 | } 39 | Picker("Units Style", selection: self.$model.unitsStyle) { 40 | Text("None").tag(CountdownVM.UnitsStyle.hidden) 41 | Text("Bottom").tag(CountdownVM.UnitsStyle.bottom) 42 | Text("Trailing").tag(CountdownVM.UnitsStyle.trailing) 43 | } 44 | DatePicker("Until Date", selection: self.$model.until, in: Date()..., displayedComponents: [.date, .hourAndMinute]) 45 | .datePickerStyle(.compact) 46 | } 47 | } 48 | } 49 | } 50 | 51 | #Preview { 52 | CountdownPreview() 53 | } 54 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModalPresentationModifier: ViewModifier { 4 | @State var isPresented: Bool = false 5 | @Binding var isContentVisible: Bool 6 | 7 | @ViewBuilder var content: () -> Modal 8 | 9 | let transitionDuration: TimeInterval 10 | let onDismiss: (() -> Void)? 11 | 12 | init( 13 | isVisible: Binding, 14 | transitionDuration: TimeInterval, 15 | onDismiss: (() -> Void)?, 16 | @ViewBuilder content: @escaping () -> Modal 17 | ) { 18 | self._isContentVisible = isVisible 19 | self.transitionDuration = transitionDuration 20 | self.onDismiss = onDismiss 21 | self.content = content 22 | } 23 | 24 | func body(content: Content) -> some View { 25 | content 26 | .transaction { 27 | $0.disablesAnimations = false 28 | } 29 | .onAppear { 30 | if self.isContentVisible { 31 | self.isPresented = true 32 | } 33 | } 34 | .onChange(of: self.isContentVisible) { isVisible in 35 | if isVisible { 36 | self.isPresented = true 37 | } else { 38 | DispatchQueue.main.asyncAfter(deadline: .now() + self.transitionDuration) { 39 | self.isPresented = false 40 | } 41 | } 42 | } 43 | .fullScreenCover( 44 | isPresented: .init( 45 | get: { self.isPresented }, 46 | set: { self.isContentVisible = $0 } 47 | ), 48 | onDismiss: self.onDismiss, 49 | content: { 50 | self.content() 51 | .transparentPresentationBackground() 52 | } 53 | ) 54 | .transaction { 55 | $0.disablesAnimations = true 56 | } 57 | } 58 | } 59 | 60 | extension View { 61 | func modal( 62 | isVisible: Binding, 63 | transitionDuration: TimeInterval, 64 | onDismiss: (() -> Void)? = nil, 65 | @ViewBuilder content: @escaping () -> Modal 66 | ) -> some View { 67 | modifier(ModalPresentationModifier( 68 | isVisible: isVisible, 69 | transitionDuration: transitionDuration, 70 | onDismiss: onDismiss, 71 | content: content 72 | )) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/Shadow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | /// Defines shadow options for components. 5 | public enum Shadow: Hashable { 6 | /// No shadow is applied. 7 | case none 8 | /// A small shadow. 9 | case small 10 | /// A medium shadow. 11 | case medium 12 | /// A large shadow. 13 | case large 14 | /// A custom shadow with specific parameters. 15 | /// 16 | /// - Parameters: 17 | /// - radius: The blur radius of the shadow. 18 | /// - offset: The offset of the shadow. 19 | /// - color: The color of the shadow. 20 | case custom(_ radius: CGFloat, _ offset: CGSize, _ color: UniversalColor) 21 | } 22 | 23 | extension Shadow { 24 | public var radius: CGFloat { 25 | return switch self { 26 | case .none: CGFloat(0) 27 | case .small: Theme.current.layout.shadow.small.radius 28 | case .medium: Theme.current.layout.shadow.medium.radius 29 | case .large: Theme.current.layout.shadow.large.radius 30 | case .custom(let radius, _, _): radius 31 | } 32 | } 33 | 34 | public var offset: CGSize { 35 | return switch self { 36 | case .none: .zero 37 | case .small: Theme.current.layout.shadow.small.offset 38 | case .medium: Theme.current.layout.shadow.medium.offset 39 | case .large: Theme.current.layout.shadow.large.offset 40 | case .custom(_, let offset, _): offset 41 | } 42 | } 43 | 44 | public var color: UniversalColor { 45 | return switch self { 46 | case .none: .clear 47 | case .small: Theme.current.layout.shadow.small.color 48 | case .medium: Theme.current.layout.shadow.medium.color 49 | case .large: Theme.current.layout.shadow.large.color 50 | case .custom(_, _, let color): color 51 | } 52 | } 53 | } 54 | 55 | // MARK: - UIKit + Shadow 56 | 57 | extension UIView { 58 | public func shadow(_ shadow: Shadow) { 59 | self.layer.shadowRadius = shadow.radius 60 | self.layer.shadowOffset = shadow.offset 61 | self.layer.shadowColor = shadow.color.cgColor 62 | self.layer.shadowOpacity = 1 63 | } 64 | } 65 | 66 | // MARK: - SwiftUI + Shadow 67 | 68 | extension View { 69 | public func shadow(_ shadow: Shadow) -> some View { 70 | self.shadow( 71 | color: shadow.color.color, 72 | radius: shadow.radius, 73 | x: shadow.offset.width, 74 | y: shadow.offset.height 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComponentsKit 2 | 3 | [![Platforms](https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages/componentskit/ComponentsKit/badge?type%3Dplatforms)](https://swiftpackageindex.com/componentskit/ComponentsKit) 4 | [![Swift Versions](https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages/componentskit/ComponentsKit/badge?type%3Dswift-versions)](https://swiftpackageindex.com/componentskit/ComponentsKit) 5 | [![License](https://img.shields.io/github/license/componentskit/ComponentsKit)](https://github.com/componentskit/ComponentsKit/blob/main/LICENSE) 6 | 7 | A library with UIKit and SwiftUI components to build iOS apps faster. 8 | 9 | ![cover](https://raw.githubusercontent.com/componentskit/ComponentsKit/HEAD/.github/cover.webp) 10 | 11 | ## Available Components 12 | 13 | - [Alert](https://componentskit.io/docs/components/alert) 14 | - [Avatar](https://componentskit.io/docs/components/avatar) 15 | - [Avatar Group](https://componentskit.io/docs/components/avatar-group) 16 | - [Badge](https://componentskit.io/docs/components/badge) 17 | - [Button](https://componentskit.io/docs/components/button) 18 | - [Card](https://componentskit.io/docs/components/card) 19 | - [Checkbox](https://componentskit.io/docs/components/checkbox) 20 | - [Circular Progress](https://componentskit.io/docs/components/circular-progress) 21 | - [Countdown](https://componentskit.io/docs/components/countdown) 22 | - [Divider](https://componentskit.io/docs/components/divider) 23 | - [Input Field](https://componentskit.io/docs/components/input-field) 24 | - [Loading](https://componentskit.io/docs/components/loading) 25 | - [Modal (Bottom)](https://componentskit.io/docs/components/bottom-modal) 26 | - [Modal (Center)](https://componentskit.io/docs/components/center-modal) 27 | - [Progress Bar](https://componentskit.io/docs/components/progress-bar) 28 | - [Radio Group](https://componentskit.io/docs/components/radio-group) 29 | - [Segmented Control](https://componentskit.io/docs/components/segmented-control) 30 | - [Slider](https://componentskit.io/docs/components/slider) 31 | - [Text Input](https://componentskit.io/docs/components/text-input) 32 | 33 | ## Documentation 34 | 35 | Visit https://componentskit.io/docs to view the full documentation. 36 | 37 | ## License 38 | 39 | Licensed under the [MIT license](https://github.com/componentskit/ComponentsKit/blob/main/LICENSE). 40 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/Models/BottomModalVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for a bottom modal component. 4 | public struct BottomModalVM: ModalVM { 5 | /// The background color of the modal. 6 | public var backgroundColor: UniversalColor? 7 | 8 | /// The border thickness of the modal. 9 | /// 10 | /// Defaults to `.small`. 11 | public var borderWidth: BorderWidth = .small 12 | 13 | /// A Boolean value indicating whether the modal should close when tapping on the overlay. 14 | /// 15 | /// Defaults to `true`. 16 | public var closesOnOverlayTap: Bool = true 17 | 18 | /// The padding applied to the modal's content area. 19 | /// 20 | /// Defaults to a padding value of `16` for all sides. 21 | public var contentPaddings: Paddings = .init(padding: 16) 22 | 23 | /// The spacing between header, body and footer. 24 | public var contentSpacing: CGFloat = 16 25 | 26 | /// The corner radius of the modal. 27 | /// 28 | /// Defaults to `.medium`. 29 | public var cornerRadius: ContainerRadius = .medium 30 | 31 | /// A Boolean value indicating whether the modal should hide when it is swiped down. 32 | /// 33 | /// Defaults to `true`. 34 | public var hidesOnSwipe: Bool = true 35 | 36 | /// A Boolean value indicating whether the modal is draggable. 37 | /// 38 | /// If `true`, the modal can be dragged vertically allowing the user to pull the modal up or down 39 | /// to interact or dismiss it. Defaults to `true`. 40 | public var isDraggable: Bool = true 41 | 42 | /// The style of the overlay displayed behind the modal. 43 | /// 44 | /// Defaults to `.dimmed`. 45 | public var overlayStyle: ModalOverlayStyle = .dimmed 46 | 47 | /// The padding applied outside the modal's content area, creating space between the modal and the screen edges. 48 | /// 49 | /// Defaults to a padding value of `20` for all sides. 50 | public var outerPaddings: Paddings = .init(padding: 20) 51 | 52 | /// The predefined maximum size of the modal. 53 | /// 54 | /// Defaults to `.medium`. 55 | public var size: ModalSize = .medium 56 | 57 | /// The transition duration of the modal's appearance and dismissal animations. 58 | /// 59 | /// Defaults to `.fast`. 60 | public var transition: ModalTransition = .fast 61 | 62 | /// Initializes a new instance of `BottomModalVM` with default values. 63 | public init() {} 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SwiftUI/Helpers/ModalPresentationWithItemModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModalPresentationWithItemModifier: ViewModifier { 4 | @State var presentedItem: Item? 5 | @Binding var visibleItem: Item? 6 | 7 | @ViewBuilder var content: (Item) -> Modal 8 | 9 | let transitionDuration: (Item) -> TimeInterval 10 | let onDismiss: (() -> Void)? 11 | 12 | init( 13 | item: Binding, 14 | transitionDuration: @escaping (Item) -> TimeInterval, 15 | onDismiss: (() -> Void)?, 16 | @ViewBuilder content: @escaping (Item) -> Modal 17 | ) { 18 | self._visibleItem = item 19 | self.transitionDuration = transitionDuration 20 | self.onDismiss = onDismiss 21 | self.content = content 22 | } 23 | 24 | func body(content: Content) -> some View { 25 | content 26 | .transaction { 27 | $0.disablesAnimations = false 28 | } 29 | .onAppear { 30 | self.presentedItem = self.visibleItem 31 | } 32 | .onChange(of: self.visibleItem.isNotNil) { isVisible in 33 | if isVisible { 34 | self.presentedItem = self.visibleItem 35 | } else { 36 | let duration = self.presentedItem.map { item in 37 | self.transitionDuration(item) 38 | } ?? 0.3 39 | DispatchQueue.main.asyncAfter(deadline: .now() + duration) { 40 | self.presentedItem = self.visibleItem 41 | } 42 | } 43 | } 44 | .fullScreenCover( 45 | item: .init( 46 | get: { self.presentedItem }, 47 | set: { self.visibleItem = $0 } 48 | ), 49 | onDismiss: self.onDismiss, 50 | content: { item in 51 | self.content(item) 52 | .transparentPresentationBackground() 53 | } 54 | ) 55 | .transaction { 56 | $0.disablesAnimations = true 57 | } 58 | } 59 | } 60 | 61 | extension View { 62 | func modal( 63 | item: Binding, 64 | transitionDuration: @escaping (Item) -> TimeInterval, 65 | onDismiss: (() -> Void)? = nil, 66 | @ViewBuilder content: @escaping (Item) -> Modal 67 | ) -> some View { 68 | modifier(ModalPresentationWithItemModifier( 69 | item: item, 70 | transitionDuration: transitionDuration, 71 | onDismiss: onDismiss, 72 | content: content 73 | )) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Card/SUCard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that serves as a container for provided content. 4 | /// 5 | /// - Example: 6 | /// ```swift 7 | /// SUCard( 8 | /// model: .init(), 9 | /// content: { 10 | /// Text("This is the content of the card.") 11 | /// } 12 | /// ) 13 | /// ``` 14 | public struct SUCard: View { 15 | // MARK: - Properties 16 | 17 | /// A model that defines the appearance properties. 18 | public let model: CardVM 19 | /// A closure that is triggered when the card is tapped. 20 | public var onTap: () -> Void 21 | 22 | /// A current scale effect value. 23 | @State public var scale: CGFloat = 1.0 24 | 25 | @ViewBuilder private let content: () -> Content 26 | @State private var contentSize: CGSize = .zero 27 | 28 | // MARK: - Initialization 29 | 30 | /// Initializer. 31 | /// 32 | /// - Parameters: 33 | /// - model: A model that defines the appearance properties. 34 | /// - content: The content that is displayed in the card. 35 | public init( 36 | model: CardVM = .init(), 37 | content: @escaping () -> Content, 38 | onTap: @escaping () -> Void = {} 39 | ) { 40 | self.model = model 41 | self.content = content 42 | self.onTap = onTap 43 | } 44 | 45 | // MARK: - Body 46 | 47 | public var body: some View { 48 | self.content() 49 | .padding(self.model.contentPaddings.edgeInsets) 50 | .background(self.model.backgroundColor.color) 51 | .cornerRadius(self.model.cornerRadius.value) 52 | .overlay( 53 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value) 54 | .strokeBorder( 55 | self.model.borderColor.color, 56 | lineWidth: self.model.borderWidth.value 57 | ) 58 | ) 59 | .shadow(self.model.shadow) 60 | .observeSize { self.contentSize = $0 } 61 | .contentShape(.rect) 62 | .onTapGesture { 63 | guard self.model.isTappable else { return } 64 | self.onTap() 65 | } 66 | .simultaneousGesture( 67 | DragGesture(minimumDistance: 0.0) 68 | .onChanged { _ in 69 | self.scale = self.model.animationScale.value 70 | } 71 | .onEnded { _ in 72 | self.scale = 1.0 73 | }, 74 | isEnabled: self.model.isTappable 75 | ) 76 | .scaleEffect(self.scale, anchor: .center) 77 | .animation(.easeOut(duration: 0.05), value: self.scale) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CenterModalPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct CenterModalPreview: View { 6 | @State var model = CenterModalVM() 7 | 8 | @State var isModalPresented: Bool = false 9 | @State var isCheckboxSelected: Bool = false 10 | 11 | @State var hasHeader = true 12 | @State var contentBody: ModalPreviewHelpers.ContentBody = .shortText 13 | @State var contentFooter: ModalPreviewHelpers.ContentFooter? = .buttonAndCheckbox 14 | 15 | var body: some View { 16 | VStack { 17 | PreviewWrapper(title: "UIKit") { 18 | UKButton(model: .init { $0.title = "Show Modal" }) { 19 | UIApplication.shared.topViewController?.present( 20 | UKCenterModalController( 21 | model: self.model, 22 | header: ModalPreviewHelpers.ukHeader(hasHeader: self.hasHeader), 23 | body: ModalPreviewHelpers.ukBody(body: self.contentBody), 24 | footer: ModalPreviewHelpers.ukFooter(footer: self.contentFooter) 25 | ), 26 | animated: true 27 | ) 28 | } 29 | .preview 30 | } 31 | PreviewWrapper(title: "SwiftUI") { 32 | SUButton(model: .init { $0.title = "Show Modal" }) { 33 | self.isModalPresented = true 34 | } 35 | .centerModal( 36 | isPresented: self.$isModalPresented, 37 | model: self.model, 38 | header: { 39 | ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) 40 | }, 41 | body: { 42 | ModalPreviewHelpers.suBody(body: self.contentBody) 43 | }, 44 | footer: { 45 | ModalPreviewHelpers.suFooter( 46 | isPresented: self.$isModalPresented, 47 | isCheckboxSelected: self.$isCheckboxSelected, 48 | footer: self.contentFooter 49 | ) 50 | } 51 | ) 52 | } 53 | Form { 54 | ModalPreviewHelpers.ContentSection( 55 | model: self.$model, 56 | hasHeader: self.$hasHeader, 57 | contentBody: self.$contentBody, 58 | contentFooter: self.$contentFooter 59 | ) 60 | ModalPreviewHelpers.PropertiesSection( 61 | model: self.$model, 62 | footer: self.$contentFooter, 63 | additionalPickers: { 64 | EmptyView() 65 | } 66 | ) 67 | } 68 | } 69 | } 70 | } 71 | 72 | #Preview { 73 | CenterModalPreview() 74 | } 75 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Shared/Types/Paddings.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | /// Defines padding values for each edge. 5 | public struct Paddings: Hashable { 6 | /// The padding value for the top edge. 7 | public var top: CGFloat 8 | 9 | /// The padding value for the leading edge. 10 | public var leading: CGFloat 11 | 12 | /// The padding value for the bottom edge. 13 | public var bottom: CGFloat 14 | 15 | /// The padding value for the trailing edge. 16 | public var trailing: CGFloat 17 | 18 | /// Initializes a new `Paddings` instance with specific values for all edges. 19 | /// 20 | /// - Parameters: 21 | /// - top: The padding value for the top edge. 22 | /// - leading: The padding value for the leading edge. 23 | /// - bottom: The padding value for the bottom edge. 24 | /// - trailing: The padding value for the trailing edge. 25 | public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { 26 | self.top = top 27 | self.leading = leading 28 | self.bottom = bottom 29 | self.trailing = trailing 30 | } 31 | 32 | /// Initializes a new `Paddings` instance with uniform horizontal and vertical values. 33 | /// 34 | /// - Parameters: 35 | /// - horizontal: The padding value applied to both the leading and trailing edges. 36 | /// - vertical: The padding value applied to both the top and bottom edges. 37 | public init(horizontal: CGFloat, vertical: CGFloat) { 38 | self.top = vertical 39 | self.leading = horizontal 40 | self.bottom = vertical 41 | self.trailing = horizontal 42 | } 43 | 44 | /// Initializes a new `Paddings` instance with the same padding value applied to all edges. 45 | /// 46 | /// - Parameter padding: The uniform padding value for the top, leading, bottom, and trailing edges. 47 | public init(padding: CGFloat) { 48 | self.top = padding 49 | self.leading = padding 50 | self.bottom = padding 51 | self.trailing = padding 52 | } 53 | } 54 | 55 | // MARK: - SwiftUI Helpers 56 | 57 | extension Paddings { 58 | public var edgeInsets: EdgeInsets { 59 | return EdgeInsets( 60 | top: self.top, 61 | leading: self.leading, 62 | bottom: self.bottom, 63 | trailing: self.trailing 64 | ) 65 | } 66 | } 67 | 68 | // MARK: - UIKit Helpers 69 | 70 | extension Paddings { 71 | public var uiEdgeInsets: UIEdgeInsets { 72 | return UIEdgeInsets( 73 | top: self.top, 74 | left: self.leading, 75 | bottom: self.bottom, 76 | right: self.trailing 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct ButtonPreview: View { 6 | private static let title = "Button" 7 | @State private var model = ButtonVM { 8 | $0.title = Self.title 9 | } 10 | 11 | var body: some View { 12 | VStack { 13 | PreviewWrapper(title: "UIKit") { 14 | UKButton(model: self.model) 15 | .preview 16 | } 17 | PreviewWrapper(title: "SwiftUI") { 18 | SUButton(model: self.model) 19 | } 20 | Form { 21 | AnimationScalePicker(selection: self.$model.animationScale) 22 | ComponentOptionalColorPicker(selection: self.$model.color) 23 | Picker("Content Spacing", selection: self.$model.contentSpacing) { 24 | Text("4").tag(CGFloat(4)) 25 | Text("8").tag(CGFloat(8)) 26 | Text("12").tag(CGFloat(12)) 27 | } 28 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 29 | Text("Custom: 20px").tag(ComponentRadius.custom(20)) 30 | } 31 | Toggle("Enabled", isOn: self.$model.isEnabled) 32 | ButtonFontPicker(selection: self.$model.font) 33 | Toggle("Full Width", isOn: self.$model.isFullWidth) 34 | Picker("Image Location", selection: self.$model.imageLocation) { 35 | Text("Leading").tag(ButtonVM.ImageLocation.leading) 36 | Text("Trailing").tag(ButtonVM.ImageLocation.trailing) 37 | } 38 | Picker("Image Rendering Mode", selection: self.$model.imageRenderingMode) { 39 | Text("Default").tag(Optional.none) 40 | Text("Template").tag(ImageRenderingMode.template) 41 | Text("Original").tag(ImageRenderingMode.original) 42 | } 43 | Picker("Image Source", selection: self.$model.imageSrc) { 44 | Text("SF Symbol").tag(ButtonVM.ImageSource.sfSymbol("camera.fill")) 45 | Text("Local").tag(ButtonVM.ImageSource.local("avatar_placeholder")) 46 | Text("None").tag(Optional.none) 47 | } 48 | Toggle("Loading", isOn: self.$model.isLoading) 49 | Toggle("Show Title", isOn: Binding( 50 | get: { !self.model.title.isEmpty }, 51 | set: { newValue in 52 | self.model.title = newValue ? Self.title : "" 53 | } 54 | )) 55 | SizePicker(selection: self.$model.size) 56 | ButtonStylePicker(selection: self.$model.style) 57 | } 58 | } 59 | } 60 | } 61 | 62 | #Preview { 63 | ButtonPreview() 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/SegmentedControl/SUSegmentedControl.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that allows users to choose between multiple segments or options. 4 | public struct SUSegmentedControl: View { 5 | // MARK: Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: SegmentedControlVM 9 | 10 | /// A Binding value to control the selected segment. 11 | @Binding public var selectedId: ID 12 | 13 | @Namespace private var animationNamespace 14 | 15 | // MARK: Initialization 16 | 17 | /// Initializer. 18 | /// - Parameters: 19 | /// - selectedId: A Binding value to control the selected segment. 20 | /// - model: A model that defines the appearance properties. 21 | public init( 22 | selectedId: Binding, 23 | model: SegmentedControlVM 24 | ) { 25 | self._selectedId = selectedId 26 | self.model = model 27 | } 28 | 29 | // MARK: Body 30 | 31 | public var body: some View { 32 | HStack(spacing: 0) { 33 | ForEach(self.model.items) { itemVM in 34 | Text(itemVM.title) 35 | .lineLimit(1) 36 | .font(self.model.preferredFont(for: itemVM.id).font) 37 | .foregroundStyle( 38 | self.model.foregroundColor(id: itemVM.id, selectedId: self.selectedId).color 39 | ) 40 | .frame(maxWidth: self.model.width, maxHeight: self.model.height) 41 | .padding(.horizontal, self.model.horizontalInnerPaddings) 42 | .contentShape(Rectangle()) 43 | .onTapGesture { 44 | withAnimation(.easeInOut(duration: 0.3)) { 45 | self.selectedId = itemVM.id 46 | } 47 | } 48 | .disabled(!itemVM.isEnabled) 49 | .background( 50 | ZStack { 51 | if itemVM.isEnabled, self.selectedId == itemVM.id { 52 | RoundedRectangle( 53 | cornerRadius: self.model.selectedSegmentCornerRadius() 54 | ) 55 | .fill(self.model.selectedSegmentColor.color) 56 | .matchedGeometryEffect( 57 | id: "segment", 58 | in: self.animationNamespace 59 | ) 60 | } 61 | } 62 | ) 63 | } 64 | } 65 | .padding(.all, self.model.outerPaddings) 66 | .frame(height: self.model.height) 67 | .background(self.model.backgroundColor.color) 68 | .clipShape( 69 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value()) 70 | ) 71 | .disabled(!self.model.isEnabled) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/AvatarGroup/UIKit/AvatarContainer.swift: -------------------------------------------------------------------------------- 1 | import AutoLayout 2 | import UIKit 3 | 4 | final class AvatarContainer: UIView { 5 | // MARK: - Properties 6 | 7 | let avatar: UKAvatar 8 | var groupVM: AvatarGroupVM 9 | var avatarConstraints = LayoutConstraints() 10 | 11 | // MARK: - Initialization 12 | 13 | init(avatarVM: AvatarVM, groupVM: AvatarGroupVM) { 14 | self.avatar = UKAvatar(model: avatarVM) 15 | self.groupVM = groupVM 16 | 17 | super.init(frame: .zero) 18 | 19 | self.setup() 20 | self.style() 21 | self.layout() 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | // MARK: - Setup 29 | 30 | func setup() { 31 | self.addSubview(self.avatar) 32 | } 33 | 34 | // MARK: - Style 35 | 36 | func style() { 37 | Self.Style.mainView(self, model: self.groupVM) 38 | } 39 | 40 | // MARK: - Layout 41 | 42 | func layout() { 43 | self.avatarConstraints = .merged { 44 | self.avatar.allEdges(self.groupVM.padding) 45 | self.avatar.height(self.groupVM.avatarHeight) 46 | self.avatar.width(self.groupVM.avatarWidth) 47 | } 48 | 49 | self.avatarConstraints.height?.priority = .defaultHigh 50 | self.avatarConstraints.width?.priority = .defaultHigh 51 | } 52 | 53 | override func layoutSubviews() { 54 | super.layoutSubviews() 55 | 56 | self.layer.cornerRadius = self.groupVM.cornerRadius.value(for: self.bounds.height) 57 | } 58 | 59 | // MARK: - Update 60 | 61 | func update(avatarVM: AvatarVM, groupVM: AvatarGroupVM) { 62 | let oldModel = self.groupVM 63 | self.groupVM = groupVM 64 | 65 | if self.groupVM.size != oldModel.size { 66 | self.avatarConstraints.top?.constant = groupVM.padding 67 | self.avatarConstraints.leading?.constant = groupVM.padding 68 | self.avatarConstraints.bottom?.constant = -groupVM.padding 69 | self.avatarConstraints.trailing?.constant = -groupVM.padding 70 | self.avatarConstraints.height?.constant = groupVM.avatarHeight 71 | self.avatarConstraints.width?.constant = groupVM.avatarWidth 72 | 73 | self.setNeedsLayout() 74 | } 75 | 76 | self.avatar.model = avatarVM 77 | self.style() 78 | } 79 | } 80 | 81 | // MARK: - Style Helpers 82 | 83 | extension AvatarContainer { 84 | fileprivate enum Style { 85 | static func mainView(_ view: UIView, model: AvatarGroupVM) { 86 | view.backgroundColor = model.borderColor.uiColor 87 | view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct CircularProgressPreview: View { 6 | @State private var model = Self.initialModel 7 | 8 | private let circularProgress = UKCircularProgress(model: Self.initialModel) 9 | 10 | private let timer = Timer 11 | .publish(every: 0.5, on: .main, in: .common) 12 | .autoconnect() 13 | 14 | var body: some View { 15 | VStack { 16 | PreviewWrapper(title: "UIKit") { 17 | self.circularProgress 18 | .preview 19 | .onAppear { 20 | self.circularProgress.model = Self.initialModel 21 | } 22 | .onChange(of: model) { newModel in 23 | self.circularProgress.model = newModel 24 | } 25 | } 26 | PreviewWrapper(title: "SwiftUI") { 27 | SUCircularProgress(model: self.model) 28 | } 29 | Form { 30 | ComponentColorPicker(selection: self.$model.color) 31 | CaptionFontPicker(selection: self.$model.font) 32 | Picker("Line Cap", selection: self.$model.lineCap) { 33 | Text("Rounded").tag(CircularProgressVM.LineCap.rounded) 34 | Text("Square").tag(CircularProgressVM.LineCap.square) 35 | } 36 | Picker("Line Width", selection: self.$model.lineWidth) { 37 | Text("Default").tag(Optional.none) 38 | Text("2").tag(Optional.some(2)) 39 | Text("4").tag(Optional.some(4)) 40 | Text("8").tag(Optional.some(8)) 41 | } 42 | Picker("Shape", selection: self.$model.shape) { 43 | Text("Circle").tag(CircularProgressVM.Shape.circle) 44 | Text("Arc").tag(CircularProgressVM.Shape.arc) 45 | } 46 | SizePicker(selection: self.$model.size) 47 | } 48 | .onReceive(self.timer) { _ in 49 | if self.model.currentValue < self.model.maxValue { 50 | let step = (self.model.maxValue - self.model.minValue) / 100 51 | self.model.currentValue = min( 52 | self.model.maxValue, 53 | self.model.currentValue + CGFloat(Int.random(in: 1...20)) * step 54 | ) 55 | } else { 56 | self.model.currentValue = self.model.minValue 57 | } 58 | self.model.label = "\(Int(self.model.currentValue))%" 59 | } 60 | } 61 | } 62 | 63 | // MARK: - Helpers 64 | 65 | private static var initialModel = CircularProgressVM { 66 | $0.label = "0%" 67 | $0.currentValue = 0.0 68 | } 69 | } 70 | 71 | #Preview { 72 | CircularProgressPreview() 73 | } 74 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/BottomModalPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct BottomModalPreview: View { 6 | @State var model = BottomModalVM() 7 | 8 | @State var isModalPresented: Bool = false 9 | @State var isCheckboxSelected: Bool = false 10 | 11 | @State var hasHeader = true 12 | @State var contentBody: ModalPreviewHelpers.ContentBody = .shortText 13 | @State var contentFooter: ModalPreviewHelpers.ContentFooter? = .buttonAndCheckbox 14 | 15 | var body: some View { 16 | VStack { 17 | PreviewWrapper(title: "UIKit") { 18 | UKButton(model: .init { $0.title = "Show Modal" }) { 19 | UIApplication.shared.topViewController?.present( 20 | UKBottomModalController( 21 | model: self.model, 22 | header: ModalPreviewHelpers.ukHeader(hasHeader: self.hasHeader), 23 | body: ModalPreviewHelpers.ukBody(body: self.contentBody), 24 | footer: ModalPreviewHelpers.ukFooter(footer: self.contentFooter) 25 | ), 26 | animated: true 27 | ) 28 | } 29 | .preview 30 | } 31 | PreviewWrapper(title: "SwiftUI") { 32 | SUButton(model: .init { $0.title = "Show Modal" }) { 33 | self.isModalPresented = true 34 | } 35 | .bottomModal( 36 | isPresented: self.$isModalPresented, 37 | model: self.model, 38 | header: { 39 | ModalPreviewHelpers.suHeader(hasHeader: self.hasHeader) 40 | }, 41 | body: { 42 | ModalPreviewHelpers.suBody(body: self.contentBody) 43 | }, 44 | footer: { 45 | ModalPreviewHelpers.suFooter( 46 | isPresented: self.$isModalPresented, 47 | isCheckboxSelected: self.$isCheckboxSelected, 48 | footer: self.contentFooter 49 | ) 50 | } 51 | ) 52 | } 53 | Form { 54 | ModalPreviewHelpers.ContentSection( 55 | model: self.$model, 56 | hasHeader: self.$hasHeader, 57 | contentBody: self.$contentBody, 58 | contentFooter: self.$contentFooter 59 | ) 60 | ModalPreviewHelpers.PropertiesSection( 61 | model: self.$model, 62 | footer: self.$contentFooter, 63 | additionalPickers: { 64 | Toggle("Draggable", isOn: self.$model.isDraggable) 65 | Toggle("Hides On Swipe", isOn: self.$model.hidesOnSwipe) 66 | } 67 | ) 68 | } 69 | } 70 | } 71 | } 72 | 73 | #Preview { 74 | BottomModalPreview() 75 | } 76 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/Core/App.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | 4 | struct App: View { 5 | var body: some View { 6 | NavigationView { 7 | List { 8 | NavigationLinkWithTitle("Alert") { 9 | AlertPreview() 10 | } 11 | NavigationLinkWithTitle("Avatar") { 12 | AvatarPreview() 13 | } 14 | NavigationLinkWithTitle("Avatar Group") { 15 | AvatarGroupPreview() 16 | } 17 | NavigationLinkWithTitle("Badge") { 18 | BadgePreview() 19 | } 20 | NavigationLinkWithTitle("Button") { 21 | ButtonPreview() 22 | } 23 | NavigationLinkWithTitle("Card") { 24 | CardPreview() 25 | } 26 | NavigationLinkWithTitle("Checkbox") { 27 | CheckboxPreview() 28 | } 29 | NavigationLinkWithTitle("Circular Progress") { 30 | CircularProgressPreview() 31 | } 32 | NavigationLinkWithTitle("Countdown") { 33 | CountdownPreview() 34 | } 35 | NavigationLinkWithTitle("Divider") { 36 | DividerPreview() 37 | } 38 | NavigationLinkWithTitle("Input Field") { 39 | InputFieldPreview() 40 | } 41 | NavigationLinkWithTitle("Loading") { 42 | LoadingPreview() 43 | } 44 | NavigationLinkWithTitle("Modal (Bottom)") { 45 | BottomModalPreview() 46 | } 47 | NavigationLinkWithTitle("Modal (Center)") { 48 | CenterModalPreview() 49 | } 50 | NavigationLinkWithTitle("Progress Bar") { 51 | ProgressBarPreview() 52 | } 53 | NavigationLinkWithTitle("Radio Group") { 54 | RadioGroupPreview() 55 | } 56 | NavigationLinkWithTitle("Segmented Control") { 57 | SegmentedControlPreview() 58 | } 59 | NavigationLinkWithTitle("Slider") { 60 | SliderPreview() 61 | } 62 | NavigationLinkWithTitle("Text Input") { 63 | TextInputPreviewPreview() 64 | } 65 | } 66 | .navigationTitle("Components") 67 | .navigationBarTitleDisplayMode(.inline) 68 | } 69 | } 70 | } 71 | 72 | // MARK: - Helper 73 | 74 | private struct NavigationLinkWithTitle: View { 75 | let title: String 76 | @ViewBuilder let destination: () -> Destination 77 | 78 | init(_ title: String, destination: @escaping () -> Destination) { 79 | self.title = title 80 | self.destination = destination 81 | } 82 | 83 | var body: some View { 84 | NavigationLink(self.title) { 85 | self.destination() 86 | .navigationTitle(self.title) 87 | } 88 | } 89 | } 90 | 91 | // MARK: - Preview 92 | 93 | #Preview { 94 | App() 95 | } 96 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension NSObject { 5 | /// Observes changes to the `.current` theme and updates dependent views. 6 | /// 7 | /// This method allows you to respond to theme changes by updating view properties that depend on the theme. 8 | /// 9 | /// You can invoke the ``observeThemeChange(_:)`` method a single time in the `viewDidLoad` 10 | /// and update all the view elements: 11 | /// 12 | /// ```swift 13 | /// override func viewDidLoad() { 14 | /// super.viewDidLoad() 15 | /// 16 | /// style() 17 | /// 18 | /// observeThemeChanges { [weak self] in 19 | /// guard let self else { return } 20 | /// 21 | /// self.style() 22 | /// } 23 | /// } 24 | /// 25 | /// func style() { 26 | /// view.backgroundColor = UniversalColor.background.uiColor 27 | /// button.model = ButtonVM { 28 | /// $0.title = "Tap me" 29 | /// $0.color = .accent 30 | /// } 31 | /// // ... 32 | /// } 33 | /// ``` 34 | /// 35 | /// ## Cancellation 36 | /// 37 | /// The method returns an ``AnyCancellable`` that can be used to cancel observation. For 38 | /// example, if you only want to observe while a view controller is visible, you can start 39 | /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: 40 | /// 41 | /// ```swift 42 | /// var cancellable: AnyCancellable? 43 | /// 44 | /// func viewWillAppear() { 45 | /// super.viewWillAppear() 46 | /// cancellable = observeThemeChange { [weak self] in 47 | /// // ... 48 | /// } 49 | /// } 50 | /// func viewWillDisappear() { 51 | /// super.viewWillDisappear() 52 | /// cancellable?.cancel() 53 | /// } 54 | /// ``` 55 | /// 56 | /// - Parameter apply: A closure that will be called whenever the `.current` theme changes. 57 | /// This should contain logic to update theme-dependent views. 58 | /// - Returns: An `AnyCancellable` instance that can be used to stop observing the theme changes when needed. 59 | @discardableResult 60 | public func observeThemeChange(_ apply: @escaping () -> Void) -> AnyCancellable { 61 | let cancellable = NotificationCenter.default.publisher( 62 | for: Theme.didChangeThemeNotification 63 | ) 64 | .receive(on: DispatchQueue.main) 65 | .sink { _ in 66 | apply() 67 | } 68 | self.cancellables.append(cancellable) 69 | return cancellable 70 | } 71 | 72 | fileprivate var cancellables: [Any] { 73 | get { 74 | objc_getAssociatedObject(self, Self.cancellablesKey) as? [Any] ?? [] 75 | } 76 | set { 77 | objc_setAssociatedObject(self, Self.cancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 78 | } 79 | } 80 | 81 | private static let cancellablesKey = "themeChangeObserverCancellables" 82 | } 83 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/SwiftUI/ModalContent.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ModalContent: View { 4 | let model: VM 5 | 6 | @ViewBuilder let contentHeader: () -> Header 7 | @ViewBuilder let contentBody: () -> Body 8 | @ViewBuilder let contentFooter: () -> Footer 9 | 10 | @State private var headerSize: CGSize = .zero 11 | @State private var bodySize: CGSize = .zero 12 | @State private var footerSize: CGSize = .zero 13 | 14 | init( 15 | model: VM, 16 | @ViewBuilder header: @escaping () -> Header, 17 | @ViewBuilder body: @escaping () -> Body, 18 | @ViewBuilder footer: @escaping () -> Footer 19 | ) { 20 | self.model = model 21 | self.contentHeader = header 22 | self.contentBody = body 23 | self.contentFooter = footer 24 | } 25 | 26 | var body: some View { 27 | VStack(spacing: self.model.contentSpacing) { 28 | self.contentHeader() 29 | .observeSize { 30 | self.headerSize = $0 31 | } 32 | .padding(.top, self.model.contentPaddings.top) 33 | .padding(.leading, self.model.contentPaddings.leading) 34 | .padding(.trailing, self.model.contentPaddings.trailing) 35 | 36 | ScrollView { 37 | self.contentBody() 38 | .padding(.leading, self.model.contentPaddings.leading) 39 | .padding(.trailing, self.model.contentPaddings.trailing) 40 | .observeSize { 41 | self.bodySize = $0 42 | } 43 | .padding(.top, self.bodyTopPadding) 44 | .padding(.bottom, self.bodyBottomPadding) 45 | } 46 | .frame(maxWidth: .infinity, maxHeight: self.scrollViewMaxHeight) 47 | .disableScrollWhenContentFits() 48 | 49 | self.contentFooter() 50 | .observeSize { 51 | self.footerSize = $0 52 | } 53 | .padding(.leading, self.model.contentPaddings.leading) 54 | .padding(.trailing, self.model.contentPaddings.trailing) 55 | .padding(.bottom, self.model.contentPaddings.bottom) 56 | } 57 | .frame(maxWidth: self.model.size.maxWidth, alignment: .leading) 58 | .background(self.model.preferredBackgroundColor.color) 59 | .clipShape(RoundedRectangle(cornerRadius: self.model.cornerRadius.value)) 60 | .overlay( 61 | RoundedRectangle(cornerRadius: self.model.cornerRadius.value) 62 | .strokeBorder(UniversalColor.divider.color, lineWidth: self.model.borderWidth.value) 63 | ) 64 | .padding(self.model.outerPaddings.edgeInsets) 65 | } 66 | 67 | private var bodyTopPadding: CGFloat { 68 | return self.headerSize.height > 0 ? 0 : self.model.contentPaddings.top 69 | } 70 | private var bodyBottomPadding: CGFloat { 71 | return self.footerSize.height > 0 ? 0 : self.model.contentPaddings.bottom 72 | } 73 | private var scrollViewMaxHeight: CGFloat { 74 | return self.bodySize.height + self.bodyTopPadding + self.bodyBottomPadding 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/RadioGroup/SwiftUI/SURadioGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that displays a group of radio buttons, allowing users to select one option from multiple choices. 4 | public struct SURadioGroup: View { 5 | // MARK: Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: RadioGroupVM 9 | 10 | /// A Binding value to control the selected identifier. 11 | @Binding public var selectedId: ID? 12 | 13 | @State private var viewSizes: [ID: CGSize] = [:] 14 | @State private var tappingId: ID? 15 | 16 | // MARK: Initialization 17 | 18 | /// Initializer. 19 | /// - Parameters: 20 | /// - selectedId: A binding to the selected identifier. 21 | /// - model: A model that defines the appearance properties. 22 | public init( 23 | selectedId: Binding, 24 | model: RadioGroupVM 25 | ) { 26 | self._selectedId = selectedId 27 | self.model = model 28 | } 29 | 30 | // MARK: Body 31 | 32 | public var body: some View { 33 | VStack(alignment: .leading, spacing: self.model.spacing) { 34 | ForEach(self.model.items) { item in 35 | HStack(spacing: 8) { 36 | ZStack { 37 | Circle() 38 | .strokeBorder( 39 | self.model.radioItemColor(for: item, isSelected: self.selectedId == item.id).color, 40 | lineWidth: self.model.lineWidth 41 | ) 42 | .frame(width: self.model.circleSize, height: self.model.circleSize) 43 | if self.selectedId == item.id { 44 | Circle() 45 | .fill( 46 | self.model.radioItemColor(for: item, isSelected: true).color 47 | ) 48 | .frame(width: self.model.innerCircleSize, height: self.model.innerCircleSize) 49 | .transition(.scale) 50 | } 51 | } 52 | .animation(.easeOut(duration: 0.2), value: self.selectedId) 53 | .scaleEffect(self.tappingId == item.id ? self.model.animationScale.value : 1.0) 54 | Text(item.title) 55 | .font(self.model.preferredFont(for: item.id).font) 56 | .foregroundColor(self.model.textColor(for: item).color) 57 | } 58 | .background( 59 | GeometryReader { proxy in 60 | Color.clear 61 | .onAppear { 62 | self.viewSizes[item.id] = proxy.size 63 | } 64 | .onChange(of: proxy.size) { value in 65 | self.viewSizes[item.id] = value 66 | } 67 | } 68 | ) 69 | .simultaneousGesture( 70 | DragGesture(minimumDistance: 0) 71 | .onChanged { _ in 72 | self.tappingId = item.id 73 | } 74 | .onEnded { gesture in 75 | self.tappingId = nil 76 | 77 | if let size = self.viewSizes[item.id], 78 | CGRect(origin: .zero, size: size).contains(gesture.location) { 79 | self.selectedId = item.id 80 | } 81 | } 82 | ) 83 | .disabled(!item.isEnabled || !self.model.isEnabled) 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp.xcodeproj/xcshareddata/xcschemes/DemosApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that displays the progress of a task or operation in a circular form. 4 | public struct SUCircularProgress: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: CircularProgressVM 9 | 10 | /// The current progress value. 11 | public var currentValue: CGFloat? 12 | 13 | private var progress: CGFloat { 14 | self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress 15 | } 16 | 17 | // MARK: - Initializer 18 | 19 | /// Initializer. 20 | /// - Parameters: 21 | /// - currentValue: Current progress. 22 | /// - model: A model that defines the appearance properties. 23 | @available(*, deprecated, message: "Set `currentValue` in the model instead.") 24 | public init( 25 | currentValue: CGFloat = 0, 26 | model: CircularProgressVM = .init() 27 | ) { 28 | self.currentValue = currentValue 29 | self.model = model 30 | } 31 | 32 | /// Initializer. 33 | /// - Parameters: 34 | /// - model: A model that defines the appearance properties. 35 | public init(model: CircularProgressVM) { 36 | self.model = model 37 | } 38 | 39 | // MARK: - Body 40 | 41 | public var body: some View { 42 | ZStack { 43 | // Background part 44 | Path { path in 45 | path.addArc( 46 | center: self.model.center, 47 | radius: self.model.radius, 48 | startAngle: .radians(self.model.startAngle), 49 | endAngle: .radians(self.model.endAngle), 50 | clockwise: false 51 | ) 52 | } 53 | .stroke( 54 | self.model.color.background.color, 55 | style: StrokeStyle( 56 | lineWidth: self.model.circularLineWidth, 57 | lineCap: self.model.lineCap.cgLineCap 58 | ) 59 | ) 60 | .frame( 61 | width: self.model.preferredSize.width, 62 | height: self.model.preferredSize.height 63 | ) 64 | 65 | // Foreground part 66 | Path { path in 67 | path.addArc( 68 | center: self.model.center, 69 | radius: self.model.radius, 70 | startAngle: .radians(self.model.startAngle), 71 | endAngle: .radians(self.model.endAngle), 72 | clockwise: false 73 | ) 74 | } 75 | .trim(from: 0, to: self.progress) 76 | .stroke( 77 | self.model.color.main.color, 78 | style: StrokeStyle( 79 | lineWidth: self.model.circularLineWidth, 80 | lineCap: self.model.lineCap.cgLineCap 81 | ) 82 | ) 83 | .frame( 84 | width: self.model.preferredSize.width, 85 | height: self.model.preferredSize.height 86 | ) 87 | 88 | // Optional label 89 | if let label = self.model.label { 90 | Text(label) 91 | .font(self.model.titleFont.font) 92 | .foregroundColor(self.model.color.main.color) 93 | } 94 | } 95 | .animation( 96 | Animation.linear(duration: self.model.animationDuration), 97 | value: self.progress 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarGroupVM.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A model that defines the appearance properties for an avatar group component. 4 | public struct AvatarGroupVM: ComponentVM { 5 | /// The border color of avatars. 6 | public var borderColor: UniversalColor = .background 7 | 8 | /// The color of the placeholders. 9 | public var color: ComponentColor? 10 | 11 | /// The corner radius of the avatars. 12 | /// 13 | /// Defaults to `.full`. 14 | public var cornerRadius: ComponentRadius = .full 15 | 16 | /// The array of avatars in the group. 17 | public var items: [AvatarItemVM] = [] { 18 | didSet { 19 | self._identifiedItems = self.items.map({ 20 | return .init(id: UUID(), item: $0) 21 | }) 22 | } 23 | } 24 | 25 | /// The maximum number of visible avatars. 26 | /// 27 | /// Defaults to `5`. 28 | public var maxVisibleAvatars: Int = 5 29 | 30 | /// The predefined size of the component. 31 | /// 32 | /// Defaults to `.medium`. 33 | public var size: ComponentSize = .medium 34 | 35 | /// The array of avatar items with an associated id value to properly display content in SwiftUI. 36 | private var _identifiedItems: [IdentifiedAvatarItem] = [] 37 | 38 | /// Initializes a new instance of `AvatarGroupVM` with default values. 39 | public init() {} 40 | } 41 | 42 | // MARK: - Helpers 43 | 44 | fileprivate struct IdentifiedAvatarItem: Equatable { 45 | var id: UUID 46 | var item: AvatarItemVM 47 | } 48 | 49 | extension AvatarGroupVM { 50 | var identifiedAvatarVMs: [(UUID, AvatarVM)] { 51 | var avatars = self._identifiedItems.prefix(self.maxVisibleAvatars).map { data in 52 | return (data.id, AvatarVM { 53 | $0.color = self.color 54 | $0.cornerRadius = self.cornerRadius 55 | $0.imageSrc = data.item.imageSrc 56 | $0.placeholder = data.item.placeholder 57 | $0.size = self.size 58 | }) 59 | } 60 | 61 | if self.numberOfHiddenAvatars > 0 { 62 | avatars.append((UUID(), AvatarVM { 63 | $0.color = self.color 64 | $0.cornerRadius = self.cornerRadius 65 | $0.placeholder = .text("+\(self.numberOfHiddenAvatars)") 66 | $0.size = self.size 67 | })) 68 | } 69 | 70 | return avatars 71 | } 72 | 73 | var itemSize: CGSize { 74 | switch self.size { 75 | case .small: 76 | return .init(width: 36, height: 36) 77 | case .medium: 78 | return .init(width: 48, height: 48) 79 | case .large: 80 | return .init(width: 64, height: 64) 81 | } 82 | } 83 | 84 | var padding: CGFloat { 85 | switch self.size { 86 | case .small: 87 | return 3 88 | case .medium: 89 | return 4 90 | case .large: 91 | return 5 92 | } 93 | } 94 | 95 | var spacing: CGFloat { 96 | return -self.itemSize.width / 3 97 | } 98 | 99 | var numberOfHiddenAvatars: Int { 100 | return max(0, self.items.count - self.maxVisibleAvatars) 101 | } 102 | } 103 | 104 | // MARK: - UIKit Helpers 105 | 106 | extension AvatarGroupVM { 107 | var avatarHeight: CGFloat { 108 | return self.itemSize.height - self.padding * 2 109 | } 110 | var avatarWidth: CGFloat { 111 | return self.itemSize.width - self.padding * 2 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/UIKit/UKAvatar.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | /// A UIKit component that displays a profile picture, initials or fallback icon for a user. 5 | open class UKAvatar: UIImageView, UKComponent { 6 | // MARK: - Properties 7 | 8 | /// A model that defines the appearance properties. 9 | public var model: AvatarVM { 10 | didSet { 11 | self.update(oldValue) 12 | } 13 | } 14 | 15 | private let imageManager: AvatarImageManager 16 | private var cancellable: AnyCancellable? 17 | 18 | // MARK: - UIView Properties 19 | 20 | open override var intrinsicContentSize: CGSize { 21 | return self.model.preferredSize 22 | } 23 | 24 | // MARK: - Initialization 25 | 26 | /// Initializer. 27 | /// - Parameters: 28 | /// - model: A model that defines the appearance properties. 29 | public init(model: AvatarVM) { 30 | self.model = model 31 | self.imageManager = AvatarImageManager(model: model) 32 | 33 | super.init(frame: .zero) 34 | 35 | self.setup() 36 | self.style() 37 | } 38 | 39 | public required init?(coder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | // MARK: - Deinitialization 44 | 45 | deinit { 46 | self.cancellable?.cancel() 47 | self.cancellable = nil 48 | } 49 | 50 | // MARK: - Setup 51 | 52 | private func setup() { 53 | self.cancellable = self.imageManager.$avatarImage 54 | .receive(on: DispatchQueue.main) 55 | .sink { self.image = $0 } 56 | 57 | if #available(iOS 17.0, *) { 58 | self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in 59 | view.handleTraitChanges() 60 | } 61 | } 62 | } 63 | 64 | // MARK: - Style 65 | 66 | private func style() { 67 | self.contentMode = .scaleToFill 68 | self.clipsToBounds = true 69 | } 70 | 71 | // MARK: - Update 72 | 73 | public func update(_ oldModel: AvatarVM) { 74 | guard self.model != oldModel else { return } 75 | 76 | self.imageManager.update(model: self.model, size: self.bounds.size) 77 | 78 | if self.model.cornerRadius != oldModel.cornerRadius { 79 | self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) 80 | } 81 | if self.model.size != oldModel.size { 82 | self.setNeedsLayout() 83 | self.invalidateIntrinsicContentSize() 84 | } 85 | } 86 | 87 | // MARK: - Layout 88 | 89 | open override func layoutSubviews() { 90 | super.layoutSubviews() 91 | 92 | self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) 93 | 94 | self.imageManager.update(model: self.model, size: self.bounds.size) 95 | } 96 | 97 | // MARK: - UIView Methods 98 | 99 | open override func sizeThatFits(_ size: CGSize) -> CGSize { 100 | let minProvidedSide = min(size.width, size.height) 101 | let minPreferredSide = min(self.model.preferredSize.width, self.model.preferredSize.height) 102 | let side = min(minProvidedSide, minPreferredSide) 103 | return CGSize(width: side, height: side) 104 | } 105 | 106 | open override func traitCollectionDidChange( 107 | _ previousTraitCollection: UITraitCollection? 108 | ) { 109 | super.traitCollectionDidChange(previousTraitCollection) 110 | self.handleTraitChanges() 111 | } 112 | 113 | // MARK: Helpers 114 | 115 | @objc private func handleTraitChanges() { 116 | self.imageManager.update(model: self.model, size: self.bounds.size) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Alert/Models/AlertVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A model that defines the appearance properties for an alert. 4 | public struct AlertVM: ComponentVM { 5 | /// The title of the alert. 6 | public var title: String? 7 | 8 | /// The message of the alert. 9 | public var message: String? 10 | 11 | /// The model that defines the appearance properties for a primary button in the alert. 12 | /// 13 | /// If it is `nil`, the primary button will not be displayed. 14 | public var primaryButton: AlertButtonVM? 15 | 16 | /// The model that defines the appearance properties for a secondary button in the alert. 17 | /// 18 | /// If it is `nil`, the secondary button will not be displayed. 19 | public var secondaryButton: AlertButtonVM? 20 | 21 | /// The background color of the alert. 22 | public var backgroundColor: UniversalColor? 23 | 24 | /// The border thickness of the alert. 25 | /// 26 | /// Defaults to `.small`. 27 | public var borderWidth: BorderWidth = .small 28 | 29 | /// A Boolean value indicating whether the alert should close when tapping on the overlay. 30 | /// 31 | /// Defaults to `false`. 32 | public var closesOnOverlayTap: Bool = false 33 | 34 | /// The padding applied to the alert's content area. 35 | /// 36 | /// Defaults to a padding value of `16` for all sides. 37 | public var contentPaddings: Paddings = .init(padding: 16) 38 | 39 | /// The corner radius of the alert. 40 | /// 41 | /// Defaults to `.medium`. 42 | public var cornerRadius: ContainerRadius = .medium 43 | 44 | /// The style of the overlay displayed behind the alert. 45 | /// 46 | /// Defaults to `.dimmed`. 47 | public var overlayStyle: ModalOverlayStyle = .dimmed 48 | 49 | /// The transition duration of the alert's appearance and dismissal animations. 50 | /// 51 | /// Defaults to `.fast`. 52 | public var transition: ModalTransition = .fast 53 | 54 | /// Initializes a new instance of `AlertVM` with default values. 55 | public init() {} 56 | } 57 | 58 | // MARK: - Helpers 59 | 60 | extension AlertVM { 61 | var modalVM: CenterModalVM { 62 | return CenterModalVM { 63 | $0.backgroundColor = self.backgroundColor 64 | $0.borderWidth = self.borderWidth 65 | $0.closesOnOverlayTap = self.closesOnOverlayTap 66 | $0.contentPaddings = self.contentPaddings 67 | $0.cornerRadius = self.cornerRadius 68 | $0.overlayStyle = self.overlayStyle 69 | $0.transition = self.transition 70 | $0.size = .small 71 | } 72 | } 73 | 74 | var primaryButtonVM: ButtonVM? { 75 | let buttonVM = self.primaryButton.map(self.mapAlertButtonVM) 76 | if self.secondaryButton.isNotNil { 77 | return buttonVM 78 | } else { 79 | return buttonVM ?? Self.defaultButtonVM 80 | } 81 | } 82 | 83 | var secondaryButtonVM: ButtonVM? { 84 | return self.secondaryButton.map(self.mapAlertButtonVM) 85 | } 86 | 87 | private func mapAlertButtonVM(_ model: AlertButtonVM) -> ButtonVM { 88 | return ButtonVM { 89 | $0.title = model.title 90 | $0.animationScale = model.animationScale 91 | $0.color = model.color 92 | $0.cornerRadius = model.cornerRadius 93 | $0.style = model.style 94 | $0.isFullWidth = true 95 | } 96 | } 97 | } 98 | 99 | extension AlertVM { 100 | static let buttonsSpacing: CGFloat = 12 101 | 102 | static let defaultButtonVM = ButtonVM { 103 | $0.title = "OK" 104 | $0.color = .primary 105 | $0.style = .filled 106 | $0.isFullWidth = true 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/AvatarGroup/UIKit/UKAvatarGroup.swift: -------------------------------------------------------------------------------- 1 | import AutoLayout 2 | import UIKit 3 | 4 | /// A UIKit component that displays a group of avatars. 5 | open class UKAvatarGroup: UIView, UKComponent { 6 | // MARK: - Properties 7 | 8 | /// A model that defines the appearance properties. 9 | public var model: AvatarGroupVM { 10 | didSet { 11 | self.update(oldValue) 12 | } 13 | } 14 | 15 | // MARK: - Subviews 16 | 17 | /// The stack view that contains avatars. 18 | public var stackView = UIStackView() 19 | 20 | // MARK: - Initializers 21 | 22 | /// Initializer. 23 | /// - Parameters: 24 | /// - model: A model that defines the appearance properties. 25 | public init(model: AvatarGroupVM) { 26 | self.model = model 27 | 28 | super.init(frame: .zero) 29 | 30 | self.setup() 31 | self.style() 32 | self.layout() 33 | } 34 | 35 | public required init?(coder: NSCoder) { 36 | fatalError("init(coder:) has not been implemented") 37 | } 38 | 39 | // MARK: - Setup 40 | 41 | private func setup() { 42 | self.addSubview(self.stackView) 43 | self.model.identifiedAvatarVMs.forEach { _, avatarVM in 44 | self.stackView.addArrangedSubview(AvatarContainer( 45 | avatarVM: avatarVM, 46 | groupVM: self.model 47 | )) 48 | } 49 | } 50 | 51 | // MARK: - Style 52 | 53 | private func style() { 54 | Self.Style.stackView(self.stackView, model: self.model) 55 | } 56 | 57 | // MARK: - Layout 58 | 59 | private func layout() { 60 | self.stackView.centerVertically() 61 | self.stackView.centerHorizontally() 62 | 63 | self.stackView.topAnchor.constraint( 64 | greaterThanOrEqualTo: self.topAnchor 65 | ).isActive = true 66 | self.stackView.bottomAnchor.constraint( 67 | lessThanOrEqualTo: self.bottomAnchor 68 | ).isActive = true 69 | self.stackView.leadingAnchor.constraint( 70 | greaterThanOrEqualTo: self.leadingAnchor 71 | ).isActive = true 72 | self.stackView.trailingAnchor.constraint( 73 | lessThanOrEqualTo: self.trailingAnchor 74 | ).isActive = true 75 | } 76 | 77 | // MARK: - Update 78 | 79 | public func update(_ oldModel: AvatarGroupVM) { 80 | guard self.model != oldModel else { return } 81 | 82 | let avatarVMs = self.model.identifiedAvatarVMs.map(\.1) 83 | self.addOrRemoveArrangedSubviews(newNumber: avatarVMs.count) 84 | 85 | self.stackView.arrangedSubviews.enumerated().forEach { index, view in 86 | (view as? AvatarContainer)?.update( 87 | avatarVM: avatarVMs[index], 88 | groupVM: self.model 89 | ) 90 | } 91 | self.style() 92 | } 93 | 94 | private func addOrRemoveArrangedSubviews(newNumber: Int) { 95 | let diff = newNumber - self.stackView.arrangedSubviews.count 96 | if diff > 0 { 97 | for _ in 0 ..< diff { 98 | self.stackView.addArrangedSubview(AvatarContainer(avatarVM: .init(), groupVM: self.model)) 99 | } 100 | } else if diff < 0 { 101 | for _ in 0 ..< abs(diff) { 102 | if let view = self.stackView.arrangedSubviews.first { 103 | self.stackView.removeArrangedSubview(view) 104 | view.removeFromSuperview() 105 | } 106 | } 107 | } 108 | } 109 | } 110 | 111 | // MARK: - Style Helpers 112 | 113 | extension UKAvatarGroup { 114 | fileprivate enum Style { 115 | static func stackView(_ view: UIStackView, model: Model) { 116 | view.axis = .horizontal 117 | view.spacing = model.spacing 118 | view.distribution = .equalCentering 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that can be selected by a user. 4 | public struct SUCheckbox: View { 5 | // MARK: Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: CheckboxVM 9 | 10 | /// A Binding Boolean value indicating whether the checkbox is selected. 11 | @Binding public var isSelected: Bool 12 | 13 | @State private var checkmarkStroke: CGFloat 14 | @State private var borderOpacity: CGFloat 15 | 16 | // MARK: Initialization 17 | 18 | /// Initializer. 19 | /// - Parameters: 20 | /// - isSelected: A Binding Boolean value indicating whether the checkbox is selected. 21 | /// - model: A model that defines the appearance properties. 22 | public init( 23 | isSelected: Binding, 24 | model: CheckboxVM = .init() 25 | ) { 26 | self._isSelected = isSelected 27 | self.model = model 28 | self.checkmarkStroke = isSelected.wrappedValue ? 1.0 : 0.0 29 | self.borderOpacity = isSelected.wrappedValue ? 0.0 : 1.0 30 | } 31 | 32 | // MARK: Body 33 | 34 | public var body: some View { 35 | HStack(spacing: self.model.spacing) { 36 | ZStack { 37 | self.model.backgroundColor.color 38 | .clipShape( 39 | RoundedRectangle(cornerRadius: self.model.checkboxCornerRadius) 40 | ) 41 | .scaleEffect(self.isSelected ? 1.0 : 0.1) 42 | .opacity(self.isSelected ? 1.0 : 0.0) 43 | .animation( 44 | .easeInOut(duration: CheckboxAnimationDurations.background), 45 | value: self.isSelected 46 | ) 47 | 48 | Path(self.model.checkmarkPath) 49 | .trim(from: 0, to: self.checkmarkStroke) 50 | .stroke(style: StrokeStyle( 51 | lineWidth: self.model.checkmarkLineWidth, 52 | lineCap: .round, 53 | lineJoin: .round 54 | )) 55 | .foregroundStyle(self.model.foregroundColor.color) 56 | } 57 | .overlay { 58 | RoundedRectangle(cornerRadius: self.model.checkboxCornerRadius) 59 | .strokeBorder( 60 | self.model.borderColor.color, 61 | lineWidth: self.model.borderWidth 62 | ) 63 | .opacity(self.borderOpacity) 64 | } 65 | .frame( 66 | width: self.model.checkboxSide, 67 | height: self.model.checkboxSide, 68 | alignment: .center 69 | ) 70 | 71 | if let title = self.model.title { 72 | Text(title) 73 | .foregroundStyle(self.model.titleColor.color) 74 | .font(self.model.titleFont.font) 75 | } 76 | } 77 | .onTapGesture { 78 | self.isSelected.toggle() 79 | } 80 | .disabled(!self.model.isEnabled) 81 | .onChange(of: self.isSelected) { isSelected in 82 | if isSelected { 83 | withAnimation( 84 | .linear(duration: CheckboxAnimationDurations.checkmarkStroke) 85 | .delay(CheckboxAnimationDurations.checkmarkStrokeDelay) 86 | ) { 87 | self.checkmarkStroke = 1.0 88 | } 89 | withAnimation( 90 | .linear(duration: CheckboxAnimationDurations.borderOpacity) 91 | .delay(CheckboxAnimationDurations.selectedBorderDelay) 92 | ) { 93 | self.borderOpacity = 0.0 94 | } 95 | } else { 96 | self.checkmarkStroke = 0.0 97 | withAnimation( 98 | .linear(duration: CheckboxAnimationDurations.borderOpacity) 99 | ) { 100 | self.borderOpacity = 1.0 101 | } 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import Observation 3 | import SwiftUI 4 | import UIKit 5 | 6 | struct TextInputPreviewPreview: View { 7 | @State private var model = Self.initialModel 8 | 9 | @State private var text: String = "" 10 | @FocusState private var isFocused: Bool 11 | 12 | @ObservedObject private var textInput = PreviewTextInput(model: Self.initialModel) 13 | 14 | var body: some View { 15 | VStack { 16 | PreviewWrapper(title: "UIKit") { 17 | self.textInput 18 | .preview 19 | .onAppear { 20 | self.textInput.text = "" 21 | self.textInput.model = Self.initialModel 22 | } 23 | .onChange(of: self.model) { newValue in 24 | self.textInput.model = newValue 25 | } 26 | } 27 | PreviewWrapper(title: "SwiftUI") { 28 | SUTextInput( 29 | text: self.$text, 30 | isFocused: self.$isFocused, 31 | model: self.model 32 | ) 33 | } 34 | Form { 35 | AutocapitalizationPicker(selection: self.$model.autocapitalization) 36 | Toggle("Autocorrection Enabled", isOn: self.$model.isAutocorrectionEnabled) 37 | ComponentOptionalColorPicker(selection: self.$model.color) 38 | ComponentRadiusPicker(selection: self.$model.cornerRadius) { 39 | Text("Custom: 20px").tag(ComponentRadius.custom(20)) 40 | } 41 | Toggle("Enabled", isOn: self.$model.isEnabled) 42 | BodyFontPicker(selection: self.$model.font) 43 | KeyboardTypePicker(selection: self.$model.keyboardType) 44 | Picker("Max Rows", selection: self.$model.maxRows) { 45 | Text("3 Rows").tag(3) 46 | Text("4 Rows").tag(4) 47 | Text("No Limit").tag(Optional.none) 48 | } 49 | Picker("Min Rows", selection: self.$model.minRows) { 50 | Text("1 Row").tag(1) 51 | Text("2 Rows").tag(2) 52 | Text("3 Rows").tag(3) 53 | } 54 | Toggle("Placeholder", isOn: .init( 55 | get: { 56 | return self.model.placeholder != nil 57 | }, 58 | set: { newValue in 59 | self.model.placeholder = newValue ? "Placeholder" : nil 60 | } 61 | )) 62 | SizePicker(selection: self.$model.size) 63 | InputStylePicker(selection: self.$model.style) 64 | SubmitTypePicker(selection: self.$model.submitType) 65 | UniversalColorPicker( 66 | title: "Tint Color", 67 | selection: self.$model.tintColor 68 | ) 69 | } 70 | } 71 | .toolbar { 72 | ToolbarItem(placement: .primaryAction) { 73 | if (self.textInput.isEditing || self.isFocused) && !ProcessInfo.processInfo.isiOSAppOnMac { 74 | Button("Hide Keyboard") { 75 | self.isFocused = false 76 | self.textInput.resignFirstResponder() 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | private static var initialModel: TextInputVM { 84 | return .init { 85 | $0.placeholder = "Placeholder" 86 | $0.minRows = 2 87 | $0.maxRows = nil 88 | } 89 | } 90 | } 91 | 92 | private final class PreviewTextInput: UKTextInput, ObservableObject { 93 | @Published var isEditing: Bool = false 94 | 95 | func textViewDidBeginEditing(_ textView: UITextView) { 96 | self.isEditing = true 97 | } 98 | func textViewDidEndEditing(_ textView: UITextView) { 99 | self.isEditing = false 100 | } 101 | } 102 | 103 | #Preview { 104 | TextInputPreviewPreview() 105 | } 106 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Countdown/SUCountdown.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI timer component that counts down from a specified duration to zero. 4 | public struct SUCountdown: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: CountdownVM 9 | 10 | @State private var timeWidth: CGFloat = 70 11 | 12 | /// The countdown manager handling the countdown logic. 13 | @StateObject private var manager = CountdownManager() 14 | 15 | // MARK: - Initializer 16 | 17 | /// Initializer. 18 | /// - Parameters: 19 | /// - model: A model that defines the appearance properties. 20 | public init(model: CountdownVM) { 21 | self.model = model 22 | } 23 | 24 | // MARK: - Body 25 | 26 | public var body: some View { 27 | HStack(alignment: .top, spacing: self.model.spacing) { 28 | switch (self.model.style, self.model.unitsStyle) { 29 | case (.plain, .bottom): 30 | self.styledTime(value: self.manager.days, unit: .days) 31 | self.colonView 32 | self.styledTime(value: self.manager.hours, unit: .hours) 33 | self.colonView 34 | self.styledTime(value: self.manager.minutes, unit: .minutes) 35 | self.colonView 36 | self.styledTime(value: self.manager.seconds, unit: .seconds) 37 | 38 | case (.plain, .hidden), (.plain, .trailing): 39 | self.styledTime(value: self.manager.days, unit: .days) 40 | self.colonView 41 | self.styledTime(value: self.manager.hours, unit: .hours) 42 | self.colonView 43 | self.styledTime(value: self.manager.minutes, unit: .minutes) 44 | self.colonView 45 | self.styledTime(value: self.manager.seconds, unit: .seconds) 46 | 47 | case (.light, _): 48 | self.lightStyledTime(value: self.manager.days, unit: .days) 49 | self.lightStyledTime(value: self.manager.hours, unit: .hours) 50 | self.lightStyledTime(value: self.manager.minutes, unit: .minutes) 51 | self.lightStyledTime(value: self.manager.seconds, unit: .seconds) 52 | } 53 | } 54 | .onAppear { 55 | self.manager.start(until: self.model.until) 56 | self.timeWidth = self.model.timeWidth(manager: self.manager) 57 | } 58 | .onChange(of: self.model.until) { newDate in 59 | self.manager.stop() 60 | self.manager.start(until: newDate) 61 | } 62 | .onChange(of: self.model) { newValue in 63 | if newValue.shouldRecalculateWidth(self.model) { 64 | self.timeWidth = newValue.timeWidth(manager: self.manager) 65 | } 66 | } 67 | .onDisappear { 68 | self.manager.stop() 69 | } 70 | } 71 | 72 | // MARK: - Subviews 73 | 74 | private func styledTime( 75 | value: Int, 76 | unit: CountdownHelpers.Unit 77 | ) -> some View { 78 | let attributedString = AttributedString(self.model.timeText(value: value, unit: unit)) 79 | return Text(attributedString) 80 | .multilineTextAlignment(.center) 81 | .frame(width: self.timeWidth) 82 | } 83 | 84 | private var colonView: some View { 85 | Text(":") 86 | .font(self.model.preferredMainFont.font) 87 | .foregroundColor(self.model.colonColor.color) 88 | } 89 | 90 | private func lightStyledTime( 91 | value: Int, 92 | unit: CountdownHelpers.Unit 93 | ) -> some View { 94 | return self.styledTime(value: value, unit: unit) 95 | .frame(minHeight: self.model.lightBackgroundMinHight) 96 | .frame(minWidth: self.model.lightBackgroundMinWidth) 97 | .background(RoundedRectangle(cornerRadius: 8) 98 | .fill(self.model.backgroundColor.color) 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift: -------------------------------------------------------------------------------- 1 | import ComponentsKit 2 | import SwiftUI 3 | import UIKit 4 | 5 | struct CardPreview: View { 6 | @State private var model = CardVM() 7 | 8 | var body: some View { 9 | VStack { 10 | PreviewWrapper(title: "UIKit") { 11 | UKCard(model: self.model, content: self.ukCardContent) 12 | .preview 13 | } 14 | PreviewWrapper(title: "SwiftUI") { 15 | SUCard(model: self.model, content: self.suCardContent) 16 | } 17 | Form { 18 | AnimationScalePicker(selection: self.$model.animationScale) 19 | Picker("Background Color", selection: self.$model.backgroundColor) { 20 | Text("Background").tag(UniversalColor.background) 21 | Text("Secondary Background").tag(UniversalColor.secondaryBackground) 22 | Text("Accent Background").tag(UniversalColor.accentBackground) 23 | Text("Success Background").tag(UniversalColor.successBackground) 24 | Text("Warning Background").tag(UniversalColor.warningBackground) 25 | Text("Danger Background").tag(UniversalColor.dangerBackground) 26 | } 27 | Picker("Border Color", selection: self.$model.borderColor) { 28 | Text("Divider").tag(UniversalColor.divider) 29 | Text("Primary").tag(UniversalColor.primary) 30 | Text("Accent").tag(UniversalColor.accent) 31 | Text("Success").tag(UniversalColor.success) 32 | Text("Warning").tag(UniversalColor.warning) 33 | Text("Danger").tag(UniversalColor.danger) 34 | Text("Custom").tag(UniversalColor.universal(.uiColor(.systemPurple))) 35 | } 36 | BorderWidthPicker(selection: self.$model.borderWidth) 37 | Picker("Content Paddings", selection: self.$model.contentPaddings) { 38 | Text("12px").tag(Paddings(padding: 12)) 39 | Text("16px").tag(Paddings(padding: 16)) 40 | Text("20px").tag(Paddings(padding: 20)) 41 | } 42 | ContainerRadiusPicker(selection: self.$model.cornerRadius) { 43 | Text("Custom 4px").tag(ContainerRadius.custom(4)) 44 | } 45 | Picker("Shadow", selection: self.$model.shadow) { 46 | Text("None").tag(Shadow.none) 47 | Text("Small").tag(Shadow.small) 48 | Text("Medium").tag(Shadow.medium) 49 | Text("Large").tag(Shadow.large) 50 | Text("Custom").tag(Shadow.custom(20.0, .zero, UniversalColor.accentBackground)) 51 | } 52 | Toggle("Tappable", isOn: self.$model.isTappable) 53 | } 54 | } 55 | } 56 | 57 | // MARK: - Helpers 58 | 59 | private func ukCardContent() -> UIView { 60 | let titleLabel = UILabel() 61 | titleLabel.text = "Card" 62 | titleLabel.font = UniversalFont.mdHeadline.uiFont 63 | titleLabel.textColor = UniversalColor.foreground.uiColor 64 | titleLabel.numberOfLines = 0 65 | 66 | let subtitleLabel = UILabel() 67 | subtitleLabel.text = "Card is a container for text, images, and other content." 68 | subtitleLabel.font = UniversalFont.mdBody.uiFont 69 | subtitleLabel.textColor = UniversalColor.secondaryForeground.uiColor 70 | subtitleLabel.numberOfLines = 0 71 | 72 | let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) 73 | stackView.axis = .vertical 74 | stackView.spacing = 8 75 | 76 | return stackView 77 | } 78 | 79 | private func suCardContent() -> some View { 80 | VStack(alignment: .leading, spacing: 8) { 81 | Text("Card") 82 | .foregroundStyle(UniversalColor.foreground.color) 83 | .font(UniversalFont.mdHeadline.font) 84 | 85 | Text("Card is a container for text, images, and other content.") 86 | .foregroundStyle(UniversalColor.secondaryForeground.color) 87 | .font(UniversalFont.mdBody.font) 88 | } 89 | } 90 | } 91 | 92 | #Preview { 93 | CardPreview() 94 | } 95 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A center-aligned modal controller. 4 | /// 5 | /// - Example: 6 | /// ```swift 7 | /// let centerModal = UKCenterModalController( 8 | /// model: CenterModalVM(), 9 | /// header: { _ in 10 | /// let headerLabel = UILabel() 11 | /// headerLabel.text = "Header" 12 | /// return headerLabel 13 | /// }, 14 | /// body: { _ in 15 | /// let bodyLabel = UILabel() 16 | /// bodyLabel.text = "This is the body content of the modal." 17 | /// bodyLabel.numberOfLines = 0 18 | /// return bodyLabel 19 | /// }, 20 | /// footer: { dismiss in 21 | /// return UKButton(model: .init { 22 | /// $0.title = "Close" 23 | /// }) { 24 | /// dismiss(true) 25 | /// } 26 | /// } 27 | /// ) 28 | /// 29 | /// vc.present(centerModal, animated: true) 30 | /// ``` 31 | public class UKCenterModalController: UKModalController { 32 | // MARK: - Initialization 33 | 34 | /// Initializer. 35 | /// 36 | /// - Parameters: 37 | /// - model: A model that defines the appearance properties. 38 | /// - header: An optional content block for the modal's header. 39 | /// - body: The main content block for the modal. 40 | /// - footer: An optional content block for the modal's footer. 41 | public init( 42 | model: CenterModalVM = .init(), 43 | header: Content? = nil, 44 | body: Content, 45 | footer: Content? = nil 46 | ) { 47 | super.init(model: model) 48 | 49 | self.header = header?({ [weak self] animated in 50 | self?.dismiss(animated: animated) 51 | }) 52 | self.body = body({ [weak self] animated in 53 | self?.dismiss(animated: animated) 54 | }) 55 | self.footer = footer?({ [weak self] animated in 56 | self?.dismiss(animated: animated) 57 | }) 58 | } 59 | 60 | override init(model: CenterModalVM) { 61 | super.init(model: model) 62 | } 63 | 64 | required public init?(coder: NSCoder) { 65 | fatalError("init(coder:) has not been implemented") 66 | } 67 | 68 | // MARK: - Lifecycle 69 | 70 | public override func viewWillAppear(_ animated: Bool) { 71 | super.viewWillAppear(animated) 72 | 73 | self.overlay.alpha = 0 74 | self.contentView.alpha = 0 75 | } 76 | 77 | public override func viewDidAppear(_ animated: Bool) { 78 | super.viewDidAppear(animated) 79 | 80 | UIView.animate(withDuration: self.model.transition.value) { 81 | self.overlay.alpha = 1 82 | self.contentView.alpha = 1 83 | } 84 | } 85 | 86 | // MARK: - Layout 87 | 88 | public override func layout() { 89 | super.layout() 90 | 91 | self.contentViewBottomConstraint = self.contentView.bottomAnchor.constraint( 92 | lessThanOrEqualTo: self.view.safeAreaLayoutGuide.bottomAnchor, 93 | constant: -self.model.outerPaddings.bottom 94 | ) 95 | self.contentViewBottomConstraint?.isActive = true 96 | 97 | let verticalConstraint = self.contentView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor) 98 | verticalConstraint.isActive = true 99 | verticalConstraint.priority = .defaultLow 100 | } 101 | 102 | // MARK: - UIViewController Methods 103 | 104 | public override func dismiss( 105 | animated flag: Bool, 106 | completion: (() -> Void)? = nil 107 | ) { 108 | UIView.animate(withDuration: self.model.transition.value) { 109 | self.overlay.alpha = 0 110 | self.contentView.alpha = 0 111 | } completion: { _ in 112 | super.dismiss(animated: false) 113 | } 114 | } 115 | } 116 | 117 | // MARK: - UIViewController + Present Center Modal 118 | 119 | extension UIViewController { 120 | public func present( 121 | _ vc: UKCenterModalController, 122 | animated: Bool, 123 | completion: (() -> Void)? = nil 124 | ) { 125 | self.present(vc as UIViewController, animated: false) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Badge/UKBadge.swift: -------------------------------------------------------------------------------- 1 | import AutoLayout 2 | import UIKit 3 | 4 | /// A UIKit component that is used to display status, notification counts, or labels. 5 | open class UKBadge: UIView, UKComponent { 6 | // MARK: - Properties 7 | 8 | /// A model that defines the appearance properties. 9 | public var model: BadgeVM { 10 | didSet { 11 | self.update(oldValue) 12 | } 13 | } 14 | 15 | private var titleLabelConstraints: LayoutConstraints = .init() 16 | 17 | // MARK: - Subviews 18 | 19 | /// A label that displays the title from the model. 20 | public var titleLabel = UILabel() 21 | 22 | // MARK: - UIView Properties 23 | 24 | open override var intrinsicContentSize: CGSize { 25 | return self.sizeThatFits(UIView.layoutFittingExpandedSize) 26 | } 27 | 28 | // MARK: - Initialization 29 | 30 | /// Initializes a new instance of `UKBadge`. 31 | /// - Parameter model: A model that defines the appearance properties for the badge. 32 | public init(model: BadgeVM) { 33 | self.model = model 34 | super.init(frame: .zero) 35 | 36 | self.setup() 37 | self.style() 38 | self.layout() 39 | } 40 | 41 | public required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | // MARK: - Setup 46 | 47 | private func setup() { 48 | self.addSubview(self.titleLabel) 49 | } 50 | 51 | // MARK: - Style 52 | 53 | private func style() { 54 | Self.Style.mainView(self, model: self.model) 55 | Self.Style.titleLabel(self.titleLabel, model: self.model) 56 | } 57 | 58 | // MARK: - Layout 59 | 60 | private func layout() { 61 | self.titleLabelConstraints = .merged { 62 | self.titleLabel.top(self.model.paddings.top) 63 | self.titleLabel.leading(self.model.paddings.leading) 64 | self.titleLabel.bottom(self.model.paddings.bottom) 65 | self.titleLabel.trailing(self.model.paddings.trailing) 66 | } 67 | 68 | self.titleLabelConstraints.allConstraints.forEach { $0?.priority = .defaultHigh } 69 | } 70 | 71 | open override func layoutSubviews() { 72 | super.layoutSubviews() 73 | 74 | self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) 75 | } 76 | 77 | // MARK: - Update 78 | 79 | public func update(_ oldModel: BadgeVM) { 80 | guard self.model != oldModel else { return } 81 | 82 | self.style() 83 | if self.model.shouldUpdateLayout(oldModel) { 84 | self.titleLabelConstraints.leading?.constant = self.model.paddings.leading 85 | self.titleLabelConstraints.top?.constant = self.model.paddings.top 86 | self.titleLabelConstraints.bottom?.constant = -self.model.paddings.bottom 87 | self.titleLabelConstraints.trailing?.constant = -self.model.paddings.trailing 88 | 89 | self.invalidateIntrinsicContentSize() 90 | self.setNeedsLayout() 91 | } 92 | } 93 | 94 | // MARK: - UIView Methods 95 | 96 | open override func sizeThatFits(_ size: CGSize) -> CGSize { 97 | let contentSize = self.titleLabel.sizeThatFits(size) 98 | 99 | let totalWidthPadding = self.model.paddings.leading + self.model.paddings.trailing 100 | let totalHeightPadding = self.model.paddings.top + self.model.paddings.bottom 101 | 102 | let width = contentSize.width + totalWidthPadding 103 | let height = contentSize.height + totalHeightPadding 104 | 105 | return CGSize( 106 | width: min(width, size.width), 107 | height: min(height, size.height) 108 | ) 109 | } 110 | } 111 | 112 | // MARK: - Style Helpers 113 | 114 | extension UKBadge { 115 | fileprivate enum Style { 116 | static func mainView(_ view: UIView, model: BadgeVM) { 117 | view.backgroundColor = model.backgroundColor.uiColor 118 | view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height) 119 | } 120 | static func titleLabel(_ label: UILabel, model: BadgeVM) { 121 | label.textAlignment = .center 122 | label.text = model.title 123 | label.font = model.font.uiFont 124 | label.textColor = model.foregroundColor.uiColor 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A model that defines the appearance properties for a checkbox component. 4 | public struct CheckboxVM: ComponentVM { 5 | /// The label text displayed next to the checkbox. 6 | public var title: String? 7 | 8 | /// The color of the checkbox. 9 | /// 10 | /// Defaults to `.accent`. 11 | public var color: ComponentColor = .accent 12 | 13 | /// The corner radius of the checkbox. 14 | /// 15 | /// Defaults to `.medium`. 16 | public var cornerRadius: ComponentRadius = .medium 17 | 18 | /// The font used for the checkbox label text. 19 | /// 20 | /// If not provided, the font is automatically calculated based on the checkbox's size. 21 | public var font: UniversalFont? 22 | 23 | /// A Boolean value indicating whether the checkbox is enabled or disabled. 24 | /// 25 | /// Defaults to `true`. 26 | public var isEnabled: Bool = true 27 | 28 | /// The predefined size of the checkbox. 29 | /// 30 | /// Defaults to `.medium`. 31 | public var size: ComponentSize = .medium 32 | 33 | /// Initializes a new instance of `CheckboxVM` with default values. 34 | public init() {} 35 | } 36 | 37 | // MARK: Shared Helpers 38 | 39 | extension CheckboxVM { 40 | var backgroundColor: UniversalColor { 41 | return self.color.main.enabled(self.isEnabled) 42 | } 43 | var foregroundColor: UniversalColor { 44 | return self.color.contrast.enabled(self.isEnabled) 45 | } 46 | var titleColor: UniversalColor { 47 | return .foreground.enabled(self.isEnabled) 48 | } 49 | var borderColor: UniversalColor { 50 | return .divider 51 | } 52 | var borderWidth: CGFloat { 53 | return 2.0 54 | } 55 | var spacing: CGFloat { 56 | return self.title.isNil ? 0.0 : 8.0 57 | } 58 | var checkmarkLineWidth: CGFloat { 59 | switch self.size { 60 | case .small: 61 | return 1.5 62 | case .medium: 63 | return 1.75 64 | case .large: 65 | return 2.0 66 | } 67 | } 68 | var checkboxSide: CGFloat { 69 | switch self.size { 70 | case .small: 71 | return 20.0 72 | case .medium: 73 | return 24.0 74 | case .large: 75 | return 28.0 76 | } 77 | } 78 | var checkboxCornerRadius: CGFloat { 79 | switch self.cornerRadius { 80 | case .none: 81 | return 0.0 82 | case .small: 83 | return self.checkboxSide / 3.5 84 | case .medium: 85 | return self.checkboxSide / 3.0 86 | case .large: 87 | return self.checkboxSide / 2.5 88 | case .full: 89 | return self.checkboxSide / 2.0 90 | case .custom(let value): 91 | return min(value, self.checkboxSide / 2) 92 | } 93 | } 94 | var titleFont: UniversalFont { 95 | if let font { 96 | return font 97 | } 98 | 99 | switch self.size { 100 | case .small: 101 | return .smBody 102 | case .medium: 103 | return .mdBody 104 | case .large: 105 | return .lgBody 106 | } 107 | } 108 | var checkmarkPath: CGPath { 109 | let path = UIBezierPath() 110 | path.move(to: .init( 111 | x: 7 / 24 * self.checkboxSide, 112 | y: 12 / 24 * self.checkboxSide 113 | )) 114 | path.addLine(to: .init( 115 | x: 11 / 24 * self.checkboxSide, 116 | y: 16 / 24 * self.checkboxSide 117 | )) 118 | path.addLine(to: .init( 119 | x: 17 / 24 * self.checkboxSide, 120 | y: 8 / 24 * self.checkboxSide 121 | )) 122 | return path.cgPath 123 | } 124 | } 125 | 126 | // MARK: UIKit Helpers 127 | 128 | extension CheckboxVM { 129 | func shouldAddLabel(_ oldModel: Self) -> Bool { 130 | return self.title.isNotNilAndEmpty && oldModel.title.isNilOrEmpty 131 | } 132 | func shouldRemoveLabel(_ oldModel: Self) -> Bool { 133 | return self.title.isNilOrEmpty && oldModel.title.isNotNilAndEmpty 134 | } 135 | func shouldUpdateSize(_ oldModel: Self) -> Bool { 136 | return self.size != oldModel.size 137 | } 138 | func shouldUpdateLayout(_ oldModel: Self) -> Bool { 139 | return self.size != oldModel.size 140 | || self.title.isNotNilAndEmpty && oldModel.title.isNilOrEmpty 141 | || self.title.isNilOrEmpty && oldModel.title.isNotNilAndEmpty 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Slider/SUSlider.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that lets users select a value from a range by dragging a thumb along a track. 4 | public struct SUSlider: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: SliderVM 9 | 10 | /// A binding to control the current value. 11 | @Binding public var currentValue: CGFloat 12 | 13 | private var progress: CGFloat { 14 | self.model.progress(for: self.currentValue) 15 | } 16 | 17 | // MARK: - Initializer 18 | 19 | /// Initializer. 20 | /// - Parameters: 21 | /// - currentValue: A binding to the current value. 22 | /// - model: A model that defines the appearance properties. 23 | public init( 24 | currentValue: Binding, 25 | model: SliderVM = .init() 26 | ) { 27 | self._currentValue = currentValue 28 | self.model = model 29 | } 30 | 31 | // MARK: - Body 32 | 33 | public var body: some View { 34 | GeometryReader { geometry in 35 | let barWidth = self.model.barWidth(for: geometry.size.width, progress: self.progress) 36 | let backgroundWidth = self.model.backgroundWidth(for: geometry.size.width, progress: self.progress) 37 | 38 | HStack(spacing: self.model.trackSpacing) { 39 | // Progress segment 40 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) 41 | .foregroundStyle(self.model.color.main.color) 42 | .frame(width: barWidth, height: self.model.trackHeight) 43 | 44 | // Handle 45 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleSize.width)) 46 | .foregroundStyle(self.model.color.main.color) 47 | .frame(width: self.model.handleSize.width, height: self.model.handleSize.height) 48 | .overlay( 49 | Group { 50 | if self.model.size == .large { 51 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.handleOverlaySide)) 52 | .foregroundStyle(self.model.color.contrast.color) 53 | .frame(width: self.model.handleOverlaySide, height: self.model.handleOverlaySide) 54 | } 55 | } 56 | ) 57 | .gesture( 58 | DragGesture(minimumDistance: 0) 59 | .onChanged { value in 60 | let totalWidth = geometry.size.width 61 | let sliderWidth = max(0, totalWidth - self.model.handleSize.width - 2 * self.model.trackSpacing) 62 | 63 | let currentLeft = barWidth 64 | let newOffset = currentLeft + value.translation.width 65 | 66 | let clampedOffset = min(max(newOffset, 0), sliderWidth) 67 | self.currentValue = self.model.steppedValue(for: clampedOffset, trackWidth: sliderWidth) 68 | } 69 | ) 70 | 71 | // Remaining segment 72 | Group { 73 | switch self.model.style { 74 | case .light: 75 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) 76 | .foregroundStyle(self.model.color.background.color) 77 | .frame(width: backgroundWidth) 78 | case .striped: 79 | ZStack { 80 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.trackHeight)) 81 | .foregroundStyle(.clear) 82 | 83 | StripesShapeSlider(model: self.model) 84 | .foregroundStyle(self.model.color.main.color) 85 | .cornerRadius(self.model.cornerRadius(for: self.model.trackHeight)) 86 | } 87 | .frame(width: backgroundWidth) 88 | } 89 | } 90 | .frame(height: self.model.trackHeight) 91 | } 92 | } 93 | .frame(height: self.model.containerHeight) 94 | .onAppear { 95 | self.model.validateMinMaxValues() 96 | } 97 | } 98 | } 99 | // MARK: - Helpers 100 | 101 | struct StripesShapeSlider: Shape, @unchecked Sendable { 102 | var model: SliderVM 103 | 104 | func path(in rect: CGRect) -> Path { 105 | self.model.stripesPath(in: rect) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/RadioGroup/Models/RadioGroupVM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | /// A model that defines the data and appearance properties for a radio group component. 5 | public struct RadioGroupVM: ComponentVM { 6 | /// The scaling factor for the button's press animation, with a value between 0 and 1. 7 | /// 8 | /// Defaults to `.medium`. 9 | public var animationScale: AnimationScale = .medium 10 | 11 | /// The color of the selected radio button. 12 | public var color: UniversalColor = .accent 13 | 14 | /// The font used for the radio items' titles. 15 | public var font: UniversalFont? 16 | 17 | /// A Boolean value indicating whether the radio group is enabled or disabled. 18 | /// 19 | /// Defaults to `true`. 20 | public var isEnabled: Bool = true 21 | 22 | /// An array of items representing the options in the radio group. 23 | /// 24 | /// Must contain at least one item, and all items must have unique identifiers. 25 | public var items: [RadioItemVM] = [] { 26 | didSet { 27 | guard self.items.isNotEmpty else { 28 | assertionFailure("Array of items must contain at least one item.") 29 | return 30 | } 31 | if let duplicatedId { 32 | assertionFailure("Items must have unique ids! Duplicated id: \(duplicatedId)") 33 | } 34 | } 35 | } 36 | 37 | /// The predefined size of the radio buttons. 38 | /// 39 | /// Defaults to `.medium`. 40 | public var size: ComponentSize = .medium 41 | 42 | /// The spacing between radio items. 43 | /// 44 | /// Defaults to `10`. 45 | public var spacing: CGFloat = 10 46 | 47 | /// Initializes a new instance of `RadioGroupVM` with default values. 48 | public init() {} 49 | } 50 | 51 | // MARK: - Shared Helpers 52 | 53 | extension RadioGroupVM { 54 | var circleSize: CGFloat { 55 | switch self.size { 56 | case .small: 57 | return 16 58 | case .medium: 59 | return 20 60 | case .large: 61 | return 24 62 | } 63 | } 64 | 65 | var innerCircleSize: CGFloat { 66 | switch self.size { 67 | case .small: 68 | return 10 69 | case .medium: 70 | return 12 71 | case .large: 72 | return 14 73 | } 74 | } 75 | 76 | var lineWidth: CGFloat { 77 | switch self.size { 78 | case .small: 79 | return 1.5 80 | case .medium: 81 | return 2.0 82 | case .large: 83 | return 2.0 84 | } 85 | } 86 | 87 | func preferredFont(for id: ID) -> UniversalFont { 88 | if let itemFont = self.item(for: id)?.font { 89 | return itemFont 90 | } else if let font = self.font { 91 | return font 92 | } 93 | 94 | switch self.size { 95 | case .small: 96 | return .smBody 97 | case .medium: 98 | return .mdBody 99 | case .large: 100 | return .lgBody 101 | } 102 | } 103 | 104 | func item(for id: ID) -> RadioItemVM? { 105 | return self.items.first(where: { $0.id == id }) 106 | } 107 | } 108 | 109 | // MARK: - Appearance 110 | 111 | extension RadioGroupVM { 112 | func isItemEnabled(_ item: RadioItemVM) -> Bool { 113 | return item.isEnabled && self.isEnabled 114 | } 115 | 116 | func radioItemColor(for item: RadioItemVM, isSelected: Bool) -> UniversalColor { 117 | if isSelected { 118 | return self.color.enabled(self.isItemEnabled(item)) 119 | } else { 120 | return .divider 121 | } 122 | } 123 | 124 | func textColor(for item: RadioItemVM) -> UniversalColor { 125 | return .foreground.enabled(self.isItemEnabled(item)) 126 | } 127 | } 128 | 129 | // MARK: - UIKit Helpers 130 | 131 | extension RadioGroupVM { 132 | func shouldUpdateLayout(_ oldModel: RadioGroupVM) -> Bool { 133 | return self.items != oldModel.items || self.size != oldModel.size 134 | } 135 | } 136 | 137 | // MARK: - Validation 138 | 139 | extension RadioGroupVM { 140 | /// Checks for duplicated item identifiers in the radio group. 141 | private var duplicatedId: ID? { 142 | var set: Set = [] 143 | for item in self.items { 144 | if set.contains(item.id) { 145 | return item.id 146 | } 147 | set.insert(item.id) 148 | } 149 | return nil 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/ProgressBar/SUProgressBar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A SwiftUI component that visually represents the progress of a task or process using a horizontal bar. 4 | public struct SUProgressBar: View { 5 | // MARK: - Properties 6 | 7 | /// A model that defines the appearance properties. 8 | public var model: ProgressBarVM 9 | /// The current progress value. 10 | public var currentValue: CGFloat? 11 | 12 | private var progress: CGFloat { 13 | self.currentValue.map { self.model.progress(for: $0) } ?? self.model.progress 14 | } 15 | 16 | // MARK: - Initializer 17 | 18 | /// Initializer. 19 | /// - Parameters: 20 | /// - currentValue: The current progress value. 21 | /// - model: A model that defines the appearance properties. 22 | @available(*, deprecated, message: "Set `currentValue` in the model instead.") 23 | public init( 24 | currentValue: CGFloat, 25 | model: ProgressBarVM = .init() 26 | ) { 27 | self.currentValue = currentValue 28 | self.model = model 29 | } 30 | 31 | /// Initializer. 32 | /// - Parameters: 33 | /// - model: A model that defines the appearance properties. 34 | public init(model: ProgressBarVM) { 35 | self.model = model 36 | } 37 | 38 | // MARK: - Body 39 | 40 | public var body: some View { 41 | GeometryReader { geometry in 42 | switch self.model.style { 43 | case .light: 44 | HStack(spacing: self.model.lightBarSpacing) { 45 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) 46 | .foregroundStyle(self.model.barColor.color) 47 | .frame(width: geometry.size.width * self.progress) 48 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) 49 | .foregroundStyle(self.model.backgroundColor.color) 50 | .frame(width: geometry.size.width * (1 - self.progress)) 51 | } 52 | 53 | case .filled: 54 | ZStack(alignment: .leading) { 55 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) 56 | .foregroundStyle(self.model.color.main.color) 57 | .frame(width: geometry.size.width) 58 | 59 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) 60 | .foregroundStyle(self.model.color.contrast.color) 61 | .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) 62 | .padding(.vertical, self.model.progressPadding) 63 | .padding(.horizontal, self.model.progressPadding) 64 | } 65 | 66 | case .striped: 67 | ZStack(alignment: .leading) { 68 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.backgroundHeight)) 69 | .foregroundStyle(self.model.color.main.color) 70 | .frame(width: geometry.size.width) 71 | 72 | RoundedRectangle(cornerRadius: self.model.cornerRadius(for: self.model.progressHeight)) 73 | .foregroundStyle(self.model.color.contrast.color) 74 | .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) 75 | .padding(.vertical, self.model.progressPadding) 76 | .padding(.horizontal, self.model.progressPadding) 77 | 78 | StripesShape(model: self.model) 79 | .foregroundStyle(self.model.color.main.color) 80 | .cornerRadius(self.model.cornerRadius(for: self.model.progressHeight)) 81 | .frame(width: (geometry.size.width - self.model.progressPadding * 2) * self.progress) 82 | .padding(.vertical, self.model.progressPadding) 83 | .padding(.horizontal, self.model.progressPadding) 84 | } 85 | } 86 | } 87 | .animation( 88 | Animation.linear(duration: self.model.animationDuration), 89 | value: self.progress 90 | ) 91 | .frame(height: self.model.backgroundHeight) 92 | .onAppear { 93 | self.model.validateMinMaxValues() 94 | } 95 | } 96 | } 97 | 98 | // MARK: - Helpers 99 | 100 | struct StripesShape: Shape, @unchecked Sendable { 101 | var model: ProgressBarVM 102 | 103 | func path(in rect: CGRect) -> Path { 104 | self.model.stripesPath(in: rect) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A model that defines the appearance properties for a circular progress component. 4 | public struct CircularProgressVM: ComponentVM { 5 | /// The color of the circular progress. 6 | /// 7 | /// Defaults to `.accent`. 8 | public var color: ComponentColor = .accent 9 | 10 | /// The current value of the circular progress. 11 | /// 12 | /// Defaults to `0`. 13 | public var currentValue: CGFloat = 0 14 | 15 | /// The font used for the circular progress label text. 16 | public var font: UniversalFont? 17 | 18 | /// An optional label to display inside the circular progress. 19 | public var label: String? 20 | 21 | /// The style of line endings. 22 | public var lineCap: LineCap = .rounded 23 | 24 | /// The width of the circular progress stroke. 25 | public var lineWidth: CGFloat? 26 | 27 | /// The maximum value of the circular progress. 28 | /// 29 | /// Defaults to `100`. 30 | public var maxValue: CGFloat = 100 31 | 32 | /// The minimum value of the circular progress. 33 | /// 34 | /// Defaults to `0`. 35 | public var minValue: CGFloat = 0 36 | 37 | /// The shape of the circular progress indicator. 38 | /// 39 | /// Defaults to `.circle`. 40 | public var shape: Shape = .circle 41 | 42 | /// The size of the circular progress. 43 | /// 44 | /// Defaults to `.medium`. 45 | public var size: ComponentSize = .medium 46 | 47 | /// Initializes a new instance of `CircularProgressVM` with default values. 48 | public init() {} 49 | } 50 | 51 | // MARK: Shared Helpers 52 | 53 | extension CircularProgressVM { 54 | var animationDuration: TimeInterval { 55 | return 0.2 56 | } 57 | var circularLineWidth: CGFloat { 58 | return self.lineWidth ?? max(self.preferredSize.width / 8, 2) 59 | } 60 | var preferredSize: CGSize { 61 | switch self.size { 62 | case .small: 63 | return CGSize(width: 48, height: 48) 64 | case .medium: 65 | return CGSize(width: 64, height: 64) 66 | case .large: 67 | return CGSize(width: 80, height: 80) 68 | } 69 | } 70 | var radius: CGFloat { 71 | return self.preferredSize.height / 2 - self.circularLineWidth / 2 72 | } 73 | var center: CGPoint { 74 | return .init( 75 | x: self.preferredSize.width / 2, 76 | y: self.preferredSize.height / 2 77 | ) 78 | } 79 | var startAngle: CGFloat { 80 | switch self.shape { 81 | case .circle: 82 | return -0.5 * .pi 83 | case .arc: 84 | return 0.75 * .pi 85 | } 86 | } 87 | var endAngle: CGFloat { 88 | switch self.shape { 89 | case .circle: 90 | return 1.5 * .pi 91 | case .arc: 92 | return 2.25 * .pi 93 | } 94 | } 95 | var titleFont: UniversalFont { 96 | if let font { 97 | return font 98 | } 99 | switch self.size { 100 | case .small: 101 | return .smCaption 102 | case .medium: 103 | return .mdCaption 104 | case .large: 105 | return .lgCaption 106 | } 107 | } 108 | } 109 | 110 | extension CircularProgressVM { 111 | var progress: CGFloat { 112 | let range = self.maxValue - self.minValue 113 | guard range > 0 else { return 0 } 114 | let normalized = (self.currentValue - self.minValue) / range 115 | return max(0, min(1, normalized)) 116 | } 117 | 118 | func progress(for currentValue: CGFloat) -> CGFloat { 119 | let range = self.maxValue - self.minValue 120 | guard range > 0 else { return 0 } 121 | let normalized = (currentValue - self.minValue) / range 122 | return max(0, min(1, normalized)) 123 | } 124 | } 125 | 126 | // MARK: - UIKit Helpers 127 | 128 | extension CircularProgressVM { 129 | func shouldInvalidateIntrinsicContentSize(_ oldModel: Self) -> Bool { 130 | return self.preferredSize != oldModel.preferredSize 131 | } 132 | func shouldUpdateText(_ oldModel: Self) -> Bool { 133 | return self.label != oldModel.label 134 | } 135 | func shouldRecalculateProgress(_ oldModel: Self) -> Bool { 136 | return self.minValue != oldModel.minValue 137 | || self.maxValue != oldModel.maxValue 138 | || self.currentValue != oldModel.currentValue 139 | } 140 | func shouldUpdateShape(_ oldModel: Self) -> Bool { 141 | return self.shape != oldModel.shape 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A model that defines the appearance properties for an avatar component. 4 | public struct AvatarVM: ComponentVM, Hashable { 5 | /// The color of the placeholder. 6 | public var color: ComponentColor? 7 | 8 | /// The corner radius of the avatar. 9 | /// 10 | /// Defaults to `.full`. 11 | public var cornerRadius: ComponentRadius = .full 12 | 13 | /// The source of the image to be displayed. 14 | public var imageSrc: ImageSource? 15 | 16 | /// The placeholder that is displayed if the image is not provided or fails to load. 17 | public var placeholder: Placeholder = .icon("avatar_placeholder", Bundle.module) 18 | 19 | /// The predefined size of the avatar. 20 | /// 21 | /// Defaults to `.medium`. 22 | public var size: ComponentSize = .medium 23 | 24 | /// Initializes a new instance of `AvatarVM` with default values. 25 | public init() {} 26 | } 27 | 28 | // MARK: - Helpers 29 | 30 | extension AvatarVM { 31 | var preferredSize: CGSize { 32 | switch self.size { 33 | case .small: 34 | return .init(width: 36, height: 36) 35 | case .medium: 36 | return .init(width: 48, height: 48) 37 | case .large: 38 | return .init(width: 64, height: 64) 39 | } 40 | } 41 | 42 | var imageURL: URL? { 43 | switch self.imageSrc { 44 | case .remote(let url): 45 | return url 46 | case .local, .none: 47 | return nil 48 | } 49 | } 50 | } 51 | 52 | extension AvatarVM { 53 | func placeholderImage(for size: CGSize) -> UIImage { 54 | switch self.placeholder { 55 | case .text(let value): 56 | return self.drawName(value, size: size) 57 | case .icon(let name, let bundle): 58 | let icon = UIImage(named: name, in: bundle, with: nil) 59 | return self.drawIcon(icon, size: size) 60 | case .sfSymbol(let name): 61 | let systemIcon = UIImage(systemName: name) 62 | return self.drawIcon(systemIcon, size: size) 63 | } 64 | } 65 | 66 | private var placeholderFont: UIFont { 67 | switch self.size { 68 | case .small: 69 | return UniversalFont.smButton.uiFont 70 | case .medium: 71 | return UniversalFont.mdButton.uiFont 72 | case .large: 73 | return UniversalFont.lgButton.uiFont 74 | } 75 | } 76 | 77 | private func iconSize(for avatarSize: CGSize) -> CGSize { 78 | let minSide = min(avatarSize.width, avatarSize.height) 79 | let iconSize = minSide / 3 * 2 80 | return .init(width: iconSize, height: iconSize) 81 | } 82 | 83 | private var placeholderBackgroundColor: UIColor { 84 | return (self.color?.background ?? .content1).uiColor 85 | } 86 | 87 | private var placeholderForegroundColor: UIColor { 88 | return (self.color?.main ?? .foreground).uiColor 89 | } 90 | 91 | private func drawIcon(_ icon: UIImage?, size: CGSize) -> UIImage { 92 | let iconSize = self.iconSize(for: size) 93 | let renderer = UIGraphicsImageRenderer(size: size) 94 | return renderer.image { _ in 95 | self.placeholderBackgroundColor.setFill() 96 | UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill() 97 | 98 | icon?.withTintColor(self.placeholderForegroundColor).draw(in: CGRect( 99 | x: (size.width - iconSize.width) / 2, 100 | y: (size.height - iconSize.height) / 2, 101 | width: iconSize.width, 102 | height: iconSize.height 103 | )) 104 | } 105 | } 106 | 107 | private func drawName(_ name: String, size: CGSize) -> UIImage { 108 | let renderer = UIGraphicsImageRenderer(size: size) 109 | return renderer.image { _ in 110 | self.placeholderBackgroundColor.setFill() 111 | UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill() 112 | 113 | let paragraphStyle = NSMutableParagraphStyle() 114 | paragraphStyle.alignment = .center 115 | 116 | let attributes = [ 117 | NSAttributedString.Key.font: self.placeholderFont, 118 | NSAttributedString.Key.paragraphStyle: paragraphStyle, 119 | NSAttributedString.Key.foregroundColor: self.placeholderForegroundColor 120 | ] 121 | 122 | let yOffset = (size.height - self.placeholderFont.lineHeight) / 2 123 | String(name.prefix(3)).draw( 124 | with: CGRect(x: 0, y: yOffset, width: size.width, height: size.height), 125 | options: .usesLineFragmentOrigin, 126 | attributes: attributes, 127 | context: nil 128 | ) 129 | } 130 | } 131 | } 132 | --------------------------------------------------------------------------------