├── .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 |
5 |
--------------------------------------------------------------------------------
/Sources/ComponentsKit/Resources/Assets.xcassets/avatar_placeholder.imageset/avatar_placeholder.svg:
--------------------------------------------------------------------------------
1 |
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 | [](https://swiftpackageindex.com/componentskit/ComponentsKit)
4 | [](https://swiftpackageindex.com/componentskit/ComponentsKit)
5 | [](https://github.com/componentskit/ComponentsKit/blob/main/LICENSE)
6 |
7 | A library with UIKit and SwiftUI components to build iOS apps faster.
8 |
9 | 
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 |
--------------------------------------------------------------------------------