From ba7e1ee17706717785bc37bfa876543742e38494 Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Wed, 7 May 2025 18:46:17 +0300 Subject: [PATCH 1/6] add CustomCell --- LICENSE | 21 ++++ Package.swift | 45 ++++++--- Sources/CustomCell/CustomCell.swift | 99 +++++++++++++++++++ .../CustomCellStyle+Environment.swift | 34 +++++++ .../CustomCellStyle/CustomCellStyle.swift | 27 +++++ .../CustomCellStyleConfiguration.swift | 39 ++++++++ .../DefaultCustomCellStyle.swift | 35 +++++++ 7 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 LICENSE create mode 100644 Sources/CustomCell/CustomCell.swift create mode 100644 Sources/CustomCell/CustomCellStyle/CustomCellStyle+Environment.swift create mode 100644 Sources/CustomCell/CustomCellStyle/CustomCellStyle.swift create mode 100644 Sources/CustomCell/CustomCellStyle/CustomCellStyleConfiguration.swift create mode 100644 Sources/CustomCell/CustomCellStyle/DefaultCustomCellStyle.swift diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bee456 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Valeriy Malishevskyi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift index 4150f2f..0cc868b 100644 --- a/Package.swift +++ b/Package.swift @@ -3,22 +3,43 @@ import PackageDescription +struct Component { + static let sharedDependencies: [Target.Dependency] = [ + .product(name: "SwiftHelpers", package: "SwiftHelpers"), + .product(name: "SwiftUIHelpers", package: "SwiftUIHelpers"), + ] + + let name: String + let dependencies: [Target.Dependency] + + init(name: String, dependencies: [Target.Dependency] = sharedDependencies) { + self.name = name + self.dependencies = dependencies + } +} + +let components: [Component] = [ + Component(name: "CustomCell"), + //Component(name: "CustomPicker") +] + let package = Package( name: "CustomComponents", + platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v10), .macCatalyst(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "CustomComponents", - targets: ["CustomComponents"]), + .library(name: "CustomComponents", targets: ["CustomComponents"]), ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "CustomComponents"), - .testTarget( - name: "CustomComponentsTests", - dependencies: ["CustomComponents"] - ), + dependencies: [ + .package(url: "https://github.com/stalkermv/SwiftHelpers.git", from: "1.0.0"), + .package(url: "https://github.com/stalkermv/SwiftUIHelpers.git", from: "1.0.0"), ] ) + +let umbrella: Target = .target(name: "CustomComponents", dependencies: components.map { .target(name: $0.name) }) +let testTarget: Target = .testTarget(name: "CustomComponentsTests", dependencies: ["CustomComponents"]) +let componentsTargets: [Target] = components.map { component in + .target(name: component.name, dependencies: component.dependencies) +} + +package.targets = [umbrella, testTarget] + componentsTargets diff --git a/Sources/CustomCell/CustomCell.swift b/Sources/CustomCell/CustomCell.swift new file mode 100644 index 0000000..fedb7ef --- /dev/null +++ b/Sources/CustomCell/CustomCell.swift @@ -0,0 +1,99 @@ +// +// CustomCell.swift +// UIComponentsLibrary +// +// Created by Valeriy Malishevskyi on 06.08.2024. +// + +import SwiftUI +import SwiftUIHelpers + +/// A configurable cell component that adapts to a custom style and supports image, label, secondary text, and accessory views. +/// +/// `CustomCell` is a generic, reusable building block for list items or rows. It supports the injection +/// of four content types: a main `content`, a `secondary` label, an optional leading `image`, and a trailing `accessory`. +/// The appearance is driven by a `CustomCellStyle` provided via the environment. +/// +/// ```swift +/// CustomCell { +/// Text("Title") +/// } secondary: { +/// Text("Subtitle") +/// } image: { +/// Image(systemName: "star") +/// } accessory: { +/// Image(systemName: "chevron.right") +/// } +/// ``` +/// +/// You can override the visual style using `.customCellStyle(...)`. +public struct CustomCell : View +where Content: View, Image: View, Secondary: View, Accessory: View { + + @Environment(\.customCellStyle) var style + + @ViewBuilder var content: Content + @ViewBuilder var image: Image + @ViewBuilder var secondary: Secondary + @ViewBuilder var accessory: Accessory + + public var body: some View { + let configuration = CustomCellStyleConfiguration( + label: .init(body: AnyView(content)), + secondaryLabel: .init(body: AnyView(secondary)), + image: .init(body: AnyView(image)), + accessory: .init(body: AnyView(accessory)) + ) + + AnyView(style.makeBody(configuration: configuration)) + } +} + +extension CustomCell { + /// Creates a `CustomCell` with optional secondary, image, and accessory views. + /// + /// - Parameters: + /// - content: The primary content view. + /// - secondary: A secondary label view (e.g. subtitle). Defaults to `EmptyView`. + /// - image: A leading image view. Defaults to `EmptyView`. + /// - accessory: A trailing accessory view. Defaults to `EmptyView`. + public init( + @ViewBuilder content: () -> Content, + @ViewBuilder secondary: () -> Secondary = EmptyView.init, + @ViewBuilder image: () -> Image = EmptyView.init, + @ViewBuilder accessory: () -> Accessory = EmptyView.init + ) { + self.init( + content: content, + image: image, + secondary: secondary, + accessory: accessory + ) + } +} + +#Preview { + List { + CustomCell { + Text("Default") + } + + CustomCell { + Text("With image") + } image: { + Image(systemName: "star") + } + + CustomCell { + Text("Subtitle") + } secondary: { + Text("Secondary") + } + + CustomCell { + Text("Accessory") + } accessory: { + Image(systemName: "arrow.right") + } + } +} diff --git a/Sources/CustomCell/CustomCellStyle/CustomCellStyle+Environment.swift b/Sources/CustomCell/CustomCellStyle/CustomCellStyle+Environment.swift new file mode 100644 index 0000000..3e1445b --- /dev/null +++ b/Sources/CustomCell/CustomCellStyle/CustomCellStyle+Environment.swift @@ -0,0 +1,34 @@ +// +// CustomCellStyle+Environment.swift +// UIComponentsLibrary +// +// Created by Valeriy Malishevskyi on 06.08.2024. +// + +import SwiftUI + +extension View { + + /// Sets a custom style to be used for `CustomCell` within the view hierarchy. + /// + /// - Parameter style: The custom style to apply. + /// - Returns: A view that uses the specified `CustomCellStyle`. + public func customCellStyle(_ style: Style) -> some View { + environment(\.customCellStyle, style) + } +} + +extension EnvironmentValues { + + /// The current `CustomCellStyle` from the environment. + var customCellStyle: any CustomCellStyle { + get { self[CustomCellStyleKey.self] } + set { self[CustomCellStyleKey.self] = newValue } + } +} + +struct CustomCellStyleKey: EnvironmentKey { + + /// The default cell style used when no other style is set in the environment. + nonisolated(unsafe) static let defaultValue: any CustomCellStyle = DefaultCustomCellStyle() +} diff --git a/Sources/CustomCell/CustomCellStyle/CustomCellStyle.swift b/Sources/CustomCell/CustomCellStyle/CustomCellStyle.swift new file mode 100644 index 0000000..be4a5b2 --- /dev/null +++ b/Sources/CustomCell/CustomCellStyle/CustomCellStyle.swift @@ -0,0 +1,27 @@ +// +// CustomCellStyle.swift +// UIComponentsLibrary +// +// Created by Valeriy Malishevskyi on 06.08.2024. +// + +import SwiftUI + +/// A protocol that defines the visual appearance of a `CustomCell`. +/// +/// Conform to `CustomCellStyle` to customize the layout and appearance of cells across your app. +/// Apply your style with `.customCellStyle(...)` on any view. +public protocol CustomCellStyle { + + /// The configuration passed into a custom cell style. + typealias Configuration = CustomCellStyleConfiguration + + /// The type of view produced by the style. + associatedtype Body: View + + /// Creates a view representing the body of a custom cell using the given configuration. + /// + /// - Parameter configuration: The current configuration of the custom cell. + /// - Returns: A view representing the styled cell. + @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body +} diff --git a/Sources/CustomCell/CustomCellStyle/CustomCellStyleConfiguration.swift b/Sources/CustomCell/CustomCellStyle/CustomCellStyleConfiguration.swift new file mode 100644 index 0000000..f3c18d9 --- /dev/null +++ b/Sources/CustomCell/CustomCellStyle/CustomCellStyleConfiguration.swift @@ -0,0 +1,39 @@ +// +// CustomCellStyleConfiguration.swift +// UIComponentsLibrary +// +// Created by Valeriy Malishevskyi on 06.08.2024. +// + +import SwiftUI + +/// A configuration object that describes the content provided to a `CustomCellStyle`. +/// +/// Contains views for the main label, secondary label, image, and accessory. +public struct CustomCellStyleConfiguration { + + /// The primary label content. + public struct Label: View { + public let body: AnyView + } + + /// The secondary label content. + public struct SecondaryLabel: View { + public let body: AnyView + } + + /// The image content, typically displayed on the leading edge. + public struct Image: View { + public let body: AnyView + } + + /// The accessory content, typically displayed on the trailing edge. + public struct Accessory: View { + public let body: AnyView + } + + public let label: Label + public let secondaryLabel: SecondaryLabel + public let image: Image + public let accessory: Accessory +} diff --git a/Sources/CustomCell/CustomCellStyle/DefaultCustomCellStyle.swift b/Sources/CustomCell/CustomCellStyle/DefaultCustomCellStyle.swift new file mode 100644 index 0000000..e1fdde4 --- /dev/null +++ b/Sources/CustomCell/CustomCellStyle/DefaultCustomCellStyle.swift @@ -0,0 +1,35 @@ +// +// DefaultCustomCellStyle.swift +// UIComponentsLibrary +// +// Created by Valeriy Malishevskyi on 06.08.2024. +// + +import SwiftUI +import SwiftUIHelpers + +extension CustomCellStyle where Self == DefaultCustomCellStyle { + static var `default`: DefaultCustomCellStyle { + DefaultCustomCellStyle() + } +} + +public struct DefaultCustomCellStyle: CustomCellStyle { + public func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.image + + VStack(alignment: .leading) { + configuration.label + + configuration.secondaryLabel + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + configuration.accessory + .foregroundStyle(.tint) + } + } +} From 810d54e8f118d1d77d6af38226145ae8f676e1aa Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Wed, 7 May 2025 19:04:17 +0300 Subject: [PATCH 2/6] Create README.md --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4612828 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# CustomComponents + +CustomComponents is a Swift package offering a collection of reusable, production-ready SwiftUI components. Designed to accelerate iOS development, this library provides modular UI elements that seamlessly integrate into your SwiftUI projects. + +## Features +- Reusable SwiftUI Components: A variety of customizable UI elements to enhance your app’s interface. +- Modular Design: Each component is self-contained, promoting clean and maintainable code. +- Swift Package Manager Support: Easily integrate into your projects using Swift Package Manager. + +## Installation + +You can add CustomComponents to your project using Swift Package Manager: +1. In Xcode, go to File > Add Packages… +2. Enter the repository URL: `https://github.com/stalkermv/CustomComponents` +3. Select the development branch. + +Alternatively, add it directly to your Package.swift: + +``` +dependencies: [ + .package(url: "https://github.com/stalkermv/CustomComponents.git", branch: "development") +] +``` + +## Usage + +Import CustomComponents in your SwiftUI files: + +```swift +import CustomComponents +``` + +Then, utilize the provided components as needed. For example: + +```swift +struct ContentView: View { + var body: some View { + CustomCell { + Text("Hello, Custom Component!") + } + } +} +``` + +## License + +CustomComponents is released under the MIT License. See LICENSE for details. From 37f478bf7091b88e4526a38e208fdd312768ee5e Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Thu, 8 May 2025 21:04:01 +0300 Subject: [PATCH 3/6] Create tests.yml --- .github/workflows/tests.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..37f769c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-15 + + steps: + - name: Select Xcode 16.3 + run: sudo xcode-select -s /Applications/Xcode_16.3.app + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v From 016d146116d844c73d4cfeb70bfaeb84882944e5 Mon Sep 17 00:00:00 2001 From: Valeriy Malishevskyi Date: Tue, 13 May 2025 02:24:29 +0300 Subject: [PATCH 4/6] add components --- Package.resolved | 24 + Package.swift | 7 +- Sources/AsyncButton/AsyncButton.swift | 79 ++ .../AsyncButton/AsyncButtonExecution.swift | 49 + .../AsyncButtonExecutionModifier.swift | 37 + Sources/AsyncButton/AsyncButtonOptions.swift | 26 + Sources/AsyncButton/EnvironmentValues.swift | 13 + Sources/AsyncButton/Previews.swift | 84 ++ Sources/CustomCell/CustomCell.swift | 42 +- .../CustomCellStyle+Environment.swift | 1 - .../CustomCellStyle.swift | 3 +- .../CustomCellStyleConfiguration.swift | 1 - .../DefaultCustomCellStyle.swift | 1 - Sources/CustomSection/CustomSection.swift | 137 +++ .../CustomSection/CustomSectionStyle.swift | 48 + .../CustomSectionStyleEnvironmentKey.swift | 16 + .../DefaultCustomSectionStyle.swift | 17 + .../CustomStepper+Initializers.swift | 1040 +++++++++++++++++ Sources/CustomStepper/CustomStepper.swift | 281 +++++ .../CustomStepperElementConfiguration.swift | 17 + .../CustomStepperElementStyle.swift | 38 + .../CustomStepper/CustomStepperStyle.swift | 26 + .../CustomStepperStyleConfiguration.swift | 43 + .../DefaultCustomStepperElementStyle.swift | 21 + .../DefaultCustomStepperStyle.swift | 29 + .../View+CustomStepperElementStyle.swift | 29 + .../View+CustomStepperStyle.swift | 31 + .../CustomTextField+Init.swift | 339 ++++++ Sources/CustomTextField/CustomTextField.swift | 226 ++++ .../CustomTextFieldStyle.swift | 49 + .../CustomTextFieldStyleConfiguration.swift | 44 + .../DefaultCustomTextFieldStyle.swift | 13 + .../CustomTextField/EmptyFormatStyle.swift | 23 + .../CustomTextField/EnvironmentValues.swift | 29 + .../CustomTextField/TextFieldStyleProxy.swift | 16 + .../View+CustomTextFieldStyle.swift | 41 + 36 files changed, 2911 insertions(+), 9 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/AsyncButton/AsyncButton.swift create mode 100644 Sources/AsyncButton/AsyncButtonExecution.swift create mode 100644 Sources/AsyncButton/AsyncButtonExecutionModifier.swift create mode 100644 Sources/AsyncButton/AsyncButtonOptions.swift create mode 100644 Sources/AsyncButton/EnvironmentValues.swift create mode 100644 Sources/AsyncButton/Previews.swift rename Sources/CustomCell/{CustomCellStyle => }/CustomCellStyle+Environment.swift (97%) rename Sources/CustomCell/{CustomCellStyle => }/CustomCellStyle.swift (93%) rename Sources/CustomCell/{CustomCellStyle => }/CustomCellStyleConfiguration.swift (97%) rename Sources/CustomCell/{CustomCellStyle => }/DefaultCustomCellStyle.swift (97%) create mode 100644 Sources/CustomSection/CustomSection.swift create mode 100644 Sources/CustomSection/CustomSectionStyle.swift create mode 100644 Sources/CustomSection/CustomSectionStyleEnvironmentKey.swift create mode 100644 Sources/CustomSection/DefaultCustomSectionStyle.swift create mode 100644 Sources/CustomStepper/CustomStepper+Initializers.swift create mode 100644 Sources/CustomStepper/CustomStepper.swift create mode 100644 Sources/CustomStepper/CustomStepperElementConfiguration.swift create mode 100644 Sources/CustomStepper/CustomStepperElementStyle.swift create mode 100644 Sources/CustomStepper/CustomStepperStyle.swift create mode 100644 Sources/CustomStepper/CustomStepperStyleConfiguration.swift create mode 100644 Sources/CustomStepper/DefaultCustomStepperElementStyle.swift create mode 100644 Sources/CustomStepper/DefaultCustomStepperStyle.swift create mode 100644 Sources/CustomStepper/View+CustomStepperElementStyle.swift create mode 100644 Sources/CustomStepper/View+CustomStepperStyle.swift create mode 100644 Sources/CustomTextField/CustomTextField+Init.swift create mode 100644 Sources/CustomTextField/CustomTextField.swift create mode 100644 Sources/CustomTextField/CustomTextFieldStyle.swift create mode 100644 Sources/CustomTextField/CustomTextFieldStyleConfiguration.swift create mode 100644 Sources/CustomTextField/DefaultCustomTextFieldStyle.swift create mode 100644 Sources/CustomTextField/EmptyFormatStyle.swift create mode 100644 Sources/CustomTextField/EnvironmentValues.swift create mode 100644 Sources/CustomTextField/TextFieldStyleProxy.swift create mode 100644 Sources/CustomTextField/View+CustomTextFieldStyle.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..397d276 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f23732d2b879b4a4e2eb81ce177da597f008ed3b62e19a8628e9bbc4796f06d7", + "pins" : [ + { + "identity" : "swifthelpers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stalkermv/SwiftHelpers.git", + "state" : { + "revision" : "3d6eda3fbfe4e8607731c3a9c6e2d30711b8024d", + "version" : "1.0.3" + } + }, + { + "identity" : "swiftuihelpers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stalkermv/SwiftUIHelpers.git", + "state" : { + "revision" : "5a1b7138c7b9db75deff39717138ad833aa4c8b8", + "version" : "1.0.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 0cc868b..8afd3d2 100644 --- a/Package.swift +++ b/Package.swift @@ -19,13 +19,16 @@ struct Component { } let components: [Component] = [ + Component(name: "AsyncButton"), Component(name: "CustomCell"), - //Component(name: "CustomPicker") + Component(name: "CustomSection"), + Component(name: "CustomStepper"), + Component(name: "CustomTextField") ] let package = Package( name: "CustomComponents", - platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v10), .macCatalyst(.v15)], + platforms: [.macOS(.v13), .iOS(.v15), .tvOS(.v15), .watchOS(.v10), .macCatalyst(.v15)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library(name: "CustomComponents", targets: ["CustomComponents"]), diff --git a/Sources/AsyncButton/AsyncButton.swift b/Sources/AsyncButton/AsyncButton.swift new file mode 100644 index 0000000..b5ad7e4 --- /dev/null +++ b/Sources/AsyncButton/AsyncButton.swift @@ -0,0 +1,79 @@ +// +// AsyncButton.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 13.05.2025. +// + +import SwiftUI + +/// A SwiftUI button that supports asynchronous actions with execution control and optional role and styling. +/// +/// `AsyncButton` triggers an `async` action when tapped, while managing execution state +/// such as disabling the button during task execution. It supports roles (e.g. `.destructive`) and +/// can be customized via `AsyncButtonOptions`. +/// +/// ```swift +/// AsyncButton { +/// try await Task.sleep(nanoseconds: 1_000_000_000) +/// print("Action complete!") +/// } label: { +/// Text("Submit") +/// } +/// ``` +/// +/// - Note: Internally, this button uses a unique `executionID` to trigger side effects via a custom modifier. +/// +/// - Parameters: +/// - role: The semantic role of the button (e.g., `.destructive`). Default is `nil`. +/// - options: Behavior options that affect execution (e.g. disables during execution). Default is empty. +/// - action: An async closure to execute on tap. +/// - label: A `ViewBuilder` closure that generates the button’s label. +public struct AsyncButton: View { + + private let action: () async -> Void + private let label: () -> Label + private let role: ButtonRole? + private let options: AsyncButtonOptions + + var executionModifier: AsyncButtonExecutionModifier { + AsyncButtonExecutionModifier( + executionID: executionID, + options: options, + isDisabled: $isDisabled, + action: action + ) + } + + @State private var isDisabled = false + @State private var executionID: UUID? + + /// Creates an `AsyncButton` with the given role, options, async action, and label. + /// + /// - Parameters: + /// - role: The button's semantic role, such as `.cancel` or `.destructive`. Default is `nil`. + /// - options: A set of flags controlling how the async action behaves (e.g., auto-disabling). Default is `[]`. + /// - action: An `async` closure to execute when the button is tapped. + /// - label: A closure that returns the button’s label view. + public init( + role: ButtonRole? = nil, + options: AsyncButtonOptions = [], + action: @escaping () async -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.role = role + self.options = options + self.action = action + self.label = label + } + + public var body: some View { + Button(role: role, action: performAction, label: label) + .disabled(isDisabled) + .modifier(executionModifier) + } + + private func performAction() { + executionID = UUID() + } +} diff --git a/Sources/AsyncButton/AsyncButtonExecution.swift b/Sources/AsyncButton/AsyncButtonExecution.swift new file mode 100644 index 0000000..f30c116 --- /dev/null +++ b/Sources/AsyncButton/AsyncButtonExecution.swift @@ -0,0 +1,49 @@ +// +// AsyncButtonExecution.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 13.05.2025. +// + +import SwiftUI + +@MainActor final class AsyncButtonExecution: ObservableObject { + @Published private(set) var task: Task? + @Published private(set) var isLoading = false + private var options: AsyncButtonOptions = [] + + func start(options: AsyncButtonOptions, _ action: @escaping () async -> Void) { + self.options = options + self.task?.cancel() + self.task = Task { [weak self] in + let loadingIndicatorTask = Task { + try await Task.sleep(for: .seconds(1)) + try Task.checkCancellation() + + if !options.contains(.loadingIndicatorHidden) { + self?.isLoading = true + } + } + + await action() + + try Task.checkCancellation() + + // Cancel loading indicator task + loadingIndicatorTask.cancel() + + if !options.contains(.loadingIndicatorHidden) { + self?.isLoading = false + } + + self?.task = nil + } + } + + deinit { + MainActor.assumeIsolated { + guard !options.contains(.detachesTask) else { return } + task?.cancel() + } + } +} diff --git a/Sources/AsyncButton/AsyncButtonExecutionModifier.swift b/Sources/AsyncButton/AsyncButtonExecutionModifier.swift new file mode 100644 index 0000000..6146a97 --- /dev/null +++ b/Sources/AsyncButton/AsyncButtonExecutionModifier.swift @@ -0,0 +1,37 @@ +// +// AsyncButtonExecutionModifier.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 13.05.2025. +// + +import SwiftUI + +struct AsyncButtonExecutionModifier: ViewModifier { + let executionID: UUID? + let options: AsyncButtonOptions + @Binding var isDisabled: Bool + let action: () async -> Void + + @StateObject private var execution = AsyncButtonExecution() + + func body(content: Content) -> some View { + content.onChange(of: executionID) { newValue in + if newValue != nil { + performAction() + } + } + .onChange(of: execution.task) { newTask in + if newTask != nil, !options.contains(.enabledDuringExecution) { + isDisabled = true + } else { + isDisabled = false + } + } + .environment(\.isLoading, execution.isLoading) + } + + private func performAction() { + execution.start(options: options, action) + } +} diff --git a/Sources/AsyncButton/AsyncButtonOptions.swift b/Sources/AsyncButton/AsyncButtonOptions.swift new file mode 100644 index 0000000..17f2f3d --- /dev/null +++ b/Sources/AsyncButton/AsyncButtonOptions.swift @@ -0,0 +1,26 @@ +// +// AsyncButtonOptions.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 13.05.2025. +// + +import SwiftUI + +/// Options to configure the behavior of `AsyncButton`. +/// Use these options to **disable** or **hide** features. +/// The default (empty set) enables all features except keeping the button enabled during execution. +public struct AsyncButtonOptions: OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Hide the loading indicator during the async operation. + public static let loadingIndicatorHidden = AsyncButtonOptions(rawValue: 1 << 0) + /// Keep the button enabled while the async operation is running (default is disabled). + public static let enabledDuringExecution = AsyncButtonOptions(rawValue: 1 << 1) + /// Detach the task from the button's lifecycle (won't be cancelled on deinit). + public static let detachesTask = AsyncButtonOptions(rawValue: 1 << 2) +} diff --git a/Sources/AsyncButton/EnvironmentValues.swift b/Sources/AsyncButton/EnvironmentValues.swift new file mode 100644 index 0000000..4542633 --- /dev/null +++ b/Sources/AsyncButton/EnvironmentValues.swift @@ -0,0 +1,13 @@ +// +// EnvironmentValues.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 13.05.2025. +// + +import SwiftUI + +extension EnvironmentValues { + /// A Boolean value indicating whether the button is currently loading. + @Entry public var isLoading: Bool = false +} diff --git a/Sources/AsyncButton/Previews.swift b/Sources/AsyncButton/Previews.swift new file mode 100644 index 0000000..a334fb2 --- /dev/null +++ b/Sources/AsyncButton/Previews.swift @@ -0,0 +1,84 @@ +// +// AsyncButton.swift +// CustomComponents +// +// Created by Valeriy Malishevskyi on 12.05.2025. +// + +import SwiftUI + +private struct PreviewButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.isLoading) private var isLoading + + func makeBody(configuration: Configuration) -> some View { + ZStack { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + configuration.label + } + } + .padding() + .background(configuration.isPressed ? Color.gray : Color.blue) + .opacity(isEnabled ? 1 : 0.5) + .foregroundColor(.white) + .cornerRadius(8) + } +} + +private struct PreviewView: View { + var body: some View { + NavigationStack { + NavigationLink(destination: Text("Destination")) { + Text("Go to Destination") + } + + NavigationLink { + VStack { + AsyncButton(role: .destructive) { + do { + try await Task.sleep(for: .seconds(3)) + print("Destructive action completed") + } catch { + print("Error: \(error)") + } + } label: { + Text("Destructive") + } + + AsyncButton(options: .detachesTask, action: someAction) { + Text("Detached") + } + } + } label: { + Text("Destructive Navigation") + } + + AsyncButton(options: .loadingIndicatorHidden, action: someAction) { + Text("No Loading Indicator") + } + + AsyncButton(options: .enabledDuringExecution, action: someAction) { + Text("Enabled") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .buttonStyle(PreviewButtonStyle()) + } + + private func someAction() async { + do { + try await Task.sleep(for: .seconds(2)) + print("Action completed") + } catch { + print("Error: \(error)") + } + } +} + +@available(iOS 16.0, macOS 14.0, tvOS 16.0, watchOS 10.0, *) +#Preview(traits: .fixedLayout(width: 400, height: 400)) { + PreviewView() +} diff --git a/Sources/CustomCell/CustomCell.swift b/Sources/CustomCell/CustomCell.swift index fedb7ef..97d7f19 100644 --- a/Sources/CustomCell/CustomCell.swift +++ b/Sources/CustomCell/CustomCell.swift @@ -1,6 +1,5 @@ // // CustomCell.swift -// UIComponentsLibrary // // Created by Valeriy Malishevskyi on 06.08.2024. // @@ -45,7 +44,23 @@ where Content: View, Image: View, Secondary: View, Accessory: View { accessory: .init(body: AnyView(accessory)) ) - AnyView(style.makeBody(configuration: configuration)) + AnyView(style.resolve(configuration)) + } +} + +private struct CustomCellResolver