diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index df58ad2e..58f83b49 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift @@ -6,7 +6,7 @@ struct ButtonPreview: View { @State private var model = ButtonVM { $0.title = "Button" } - + var body: some View { VStack { PreviewWrapper(title: "UIKit") { @@ -25,6 +25,18 @@ struct ButtonPreview: View { ButtonFontPicker(selection: self.$model.font) Toggle("Enabled", isOn: self.$model.isEnabled) Toggle("Full Width", isOn: self.$model.isFullWidth) + Picker("Image Source", selection: self.$model.imageSrc) { + Text("SF Symbol").tag(ButtonVM.ImageSource.sfSymbol("camera.fill")) + Text("Local").tag(ButtonVM.ImageSource.local("avatar_placeholder")) + Text("None").tag(Optional.none) + } + if self.model.imageSrc != nil { + Picker("Image Location", selection: self.$model.imageLocation) { + Text("Leading").tag(ButtonVM.ImageLocation.leading) + Text("Trailing").tag(ButtonVM.ImageLocation.trailing) + } + } + Toggle("Loading", isOn: self.$model.isLoading) SizePicker(selection: self.$model.size) Picker("Style", selection: self.$model.style) { Text("Filled").tag(ButtonStyle.filled) @@ -34,6 +46,16 @@ struct ButtonPreview: View { Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) } + .onChange(of: self.model.imageLocation) { _ in + if self.model.isLoading { + self.model.isLoading = false + } + } + .onChange(of: self.model.imageSrc) { _ in + if self.model.isLoading { + self.model.isLoading = false + } + } } } } diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index 8212febf..52e4e7a9 100644 --- a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -1,3 +1,4 @@ +import SwiftUI import UIKit /// A model that defines the appearance properties for a button component. @@ -43,6 +44,29 @@ public struct ButtonVM: ComponentVM { /// Defaults to `.filled`. public var style: ButtonStyle = .filled + /// The loading VM used for the loading indicator. + /// + /// If not provided, a default loading view model is used. + public var loadingVM: LoadingVM? + + /// A Boolean value indicating whether the button is currently in a loading state. + /// + /// Defaults to `false`. + public var isLoading: Bool = false + + /// The source of the image to be displayed. + public var imageSrc: ImageSource? + + /// The position of the image relative to the button's title. + /// + /// Defaults to `.leading`. + public var imageLocation: ImageLocation = .leading + + /// The spacing between the button's title and its image or loading indicator. + /// + /// Defaults to `8.0`. + public var contentSpacing: CGFloat = 8.0 + /// Initializes a new instance of `ButtonVM` with default values. public init() {} } @@ -50,6 +74,15 @@ public struct ButtonVM: ComponentVM { // MARK: Shared Helpers extension ButtonVM { + var preferredLoadingVM: LoadingVM { + return self.loadingVM ?? .init { + $0.color = .init( + main: foregroundColor, + contrast: self.color?.main ?? .background + ) + $0.size = .small + } + } var backgroundColor: UniversalColor? { switch self.style { case .filled: @@ -121,6 +154,18 @@ extension ButtonVM { } } +extension ButtonVM { + public enum ImageSource: Hashable { + case sfSymbol(String) + case local(String, bundle: Bundle? = nil) + } + + public enum ImageLocation { + case leading + case trailing + } +} + // MARK: UIKit Helpers extension ButtonVM { @@ -155,3 +200,15 @@ extension ButtonVM { return self.isFullWidth ? 10_000 : nil } } + +extension ButtonVM { + var buttonImage: Image? { + guard let imageSrc = self.imageSrc else { return nil } + switch imageSrc { + case .sfSymbol(let name): + return Image(systemName: name) + case .local(let name, let bundle): + return Image(name, bundle: bundle) + } + } +} diff --git a/Sources/ComponentsKit/Components/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift index d415a8ae..ffb30f7c 100644 --- a/Sources/ComponentsKit/Components/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -29,25 +29,46 @@ public struct SUButton: View { // MARK: Body public var body: some View { - Button(self.model.title, action: self.action) - .buttonStyle(CustomButtonStyle(model: self.model)) - .simultaneousGesture(DragGesture(minimumDistance: 0.0) - .onChanged { _ in - self.isPressed = true - } - .onEnded { _ in - self.isPressed = false - } - ) - .disabled(!self.model.isEnabled) - .scaleEffect( - self.isPressed ? self.model.animationScale.value : 1, - anchor: .center - ) + Button(action: self.action) { + HStack(spacing: self.model.contentSpacing) { + self.content() + } + .frame(maxWidth: self.model.width) + .frame(height: self.model.height) + } + .buttonStyle(CustomButtonStyle(model: self.model)) + .simultaneousGesture(DragGesture(minimumDistance: 0.0) + .onChanged { _ in + self.isPressed = true + } + .onEnded { _ in + self.isPressed = false + } + ) + .disabled(!self.model.isEnabled || self.model.isLoading) + .scaleEffect( + self.isPressed ? self.model.animationScale.value : 1, + anchor: .center + ) } -} -// MARK: - Helpers + @ViewBuilder + private func content() -> some View { + switch (self.model.isLoading, self.model.buttonImage, self.model.imageLocation) { + case (true, _, _): + SULoading(model: self.model.preferredLoadingVM) + Text(self.model.title) + case (false, let image?, .leading): + image + Text(self.model.title) + case (false, let image?, .trailing): + Text(self.model.title) + image + default: + Text(self.model.title) + } + } +} private struct CustomButtonStyle: SwiftUI.ButtonStyle { let model: ButtonVM