diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index df58ad2e..7f7715e6 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") { @@ -19,12 +19,33 @@ struct ButtonPreview: View { Form { AnimationScalePicker(selection: self.$model.animationScale) ComponentOptionalColorPicker(selection: self.$model.color) + Picker("Content Spacing", selection: self.$model.contentSpacing) { + Text("4").tag(CGFloat(4)) + Text("8").tag(CGFloat(8)) + Text("12").tag(CGFloat(12)) + } ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) } - ButtonFontPicker(selection: self.$model.font) Toggle("Enabled", isOn: self.$model.isEnabled) + ButtonFontPicker(selection: self.$model.font) Toggle("Full Width", isOn: self.$model.isFullWidth) + Picker("Image Location", selection: self.$model.imageLocation) { + Text("Leading").tag(ButtonVM.ImageLocation.leading) + Text("Trailing").tag(ButtonVM.ImageLocation.trailing) + } + 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) + } + Toggle("Loading", isOn: self.$model.isLoading) + Toggle("Show Title", isOn: Binding( + get: { !self.model.title.isEmpty }, + set: { newValue in + self.model.title = newValue ? "Button" : "" + } + )) SizePicker(selection: self.$model.size) Picker("Style", selection: self.$model.style) { Text("Filled").tag(ButtonStyle.filled) diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonImageLocation.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonImageLocation.swift new file mode 100644 index 00000000..e22a109a --- /dev/null +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonImageLocation.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Specifies the position of the image relative to the button's title. +extension ButtonVM { + public enum ImageLocation { + /// The image is displayed before the title. + case leading + /// The image is displayed after the title. + case trailing + } +} diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonImageSource.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonImageSource.swift new file mode 100644 index 00000000..c9598303 --- /dev/null +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonImageSource.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Defines the image source options for a button. +extension ButtonVM { + public enum ImageSource: Hashable { + /// An image loaded from a system SF Symbol. + /// + /// - Parameter name: The name of the SF Symbol. + case sfSymbol(String) + + /// An image loaded from a local asset. + /// + /// - Parameters: + /// - name: The name of the local image asset. + /// - bundle: The bundle containing the image resource. Defaults to `nil` to use the main bundle. + case local(String, bundle: Bundle? = nil) + } +} diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index 8212febf..90c3bc45 100644 --- a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -2,9 +2,6 @@ import UIKit /// A model that defines the appearance properties for a button component. public struct ButtonVM: ComponentVM { - /// The text displayed on the button. - public var title: String = "" - /// The scaling factor for the button's press animation, with a value between 0 and 1. /// /// Defaults to `.medium`. @@ -13,6 +10,11 @@ public struct ButtonVM: ComponentVM { /// The color of the button. public var color: ComponentColor? + /// The spacing between the button's title and its image or loading indicator. + /// + /// Defaults to `8.0`. + public var contentSpacing: CGFloat = 8.0 + /// The corner radius of the button. /// /// Defaults to `.medium`. @@ -23,6 +25,14 @@ public struct ButtonVM: ComponentVM { /// If not provided, the font is automatically calculated based on the button's size. public var font: UniversalFont? + /// The position of the image relative to the button's title. + /// + /// Defaults to `.leading`. + public var imageLocation: ImageLocation = .leading + + /// The source of the image to be displayed. + public var imageSrc: ImageSource? + /// A Boolean value indicating whether the button is enabled or disabled. /// /// Defaults to `true`. @@ -33,6 +43,16 @@ public struct ButtonVM: ComponentVM { /// Defaults to `false`. public var isFullWidth: Bool = false + /// A Boolean value indicating whether the button is currently in a loading state. + /// + /// Defaults to `false`. + public var isLoading: Bool = false + + /// The loading VM used for the loading indicator. + /// + /// If not provided, a default loading view model is used. + public var loadingVM: LoadingVM? + /// The predefined size of the button. /// /// Defaults to `.medium`. @@ -43,6 +63,9 @@ public struct ButtonVM: ComponentVM { /// Defaults to `.filled`. public var style: ButtonStyle = .filled + /// The text displayed on the button. + public var title: String = "" + /// Initializes a new instance of `ButtonVM` with default values. public init() {} } @@ -50,14 +73,26 @@ public struct ButtonVM: ComponentVM { // MARK: Shared Helpers extension ButtonVM { + var isInteractive: Bool { + self.isEnabled && !self.isLoading + } + 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: let color = self.color?.main ?? .content2 - return color.enabled(self.isEnabled) + return color.enabled(self.isInteractive) case .light: let color = self.color?.background ?? .content1 - return color.enabled(self.isEnabled) + return color.enabled(self.isInteractive) case .plain, .bordered: return nil } @@ -69,7 +104,7 @@ extension ButtonVM { case .plain, .light, .bordered: self.color?.main ?? .foreground } - return color.enabled(self.isEnabled) + return color.enabled(self.isInteractive) } var borderWidth: CGFloat { switch self.style { @@ -85,7 +120,7 @@ extension ButtonVM { return nil case .bordered: if let color { - return color.main.enabled(self.isEnabled) + return color.main.enabled(self.isInteractive) } else { return .divider } @@ -112,6 +147,13 @@ extension ButtonVM { case .large: 52 } } + var imageSide: CGFloat { + switch self.size { + case .small: 20 + case .medium: 24 + case .large: 28 + } + } var horizontalPadding: CGFloat { return switch self.size { case .small: 16 @@ -121,6 +163,21 @@ extension ButtonVM { } } +extension ButtonVM { + var image: UIImage? { + guard let imageSrc else { return nil } + switch imageSrc { + case .sfSymbol(let name): + return UIImage(systemName: name)?.withTintColor( + self.foregroundColor.uiColor, + renderingMode: .alwaysOriginal + ) + case .local(let name, let bundle): + return UIImage(named: name, in: bundle, compatibleWith: nil) + } + } +} + // MARK: UIKit Helpers extension ButtonVM { @@ -141,10 +198,23 @@ extension ButtonVM { return .init(width: width, height: self.height) } - func shouldUpdateSize(_ oldModel: Self?) -> Bool { - return self.size != oldModel?.size - || self.font != oldModel?.font - || self.isFullWidth != oldModel?.isFullWidth + func shouldUpdateImagePosition(_ oldModel: Self?) -> Bool { + guard let oldModel else { return true } + return self.imageLocation != oldModel.imageLocation + } + func shouldUpdateImageSize(_ oldModel: Self?) -> Bool { + guard let oldModel else { return true } + return self.imageSide != oldModel.imageSide + } + func shouldRecalculateSize(_ oldModel: Self?) -> Bool { + guard let oldModel else { return true } + return self.size != oldModel.size + || self.font != oldModel.font + || self.isFullWidth != oldModel.isFullWidth + || self.isLoading != oldModel.isLoading + || self.imageSrc != oldModel.imageSrc + || self.contentSpacing != oldModel.contentSpacing + || self.title != oldModel.title } } diff --git a/Sources/ComponentsKit/Components/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift index d415a8ae..9dcf96d1 100644 --- a/Sources/ComponentsKit/Components/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -29,26 +29,78 @@ 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 + } + } + .buttonStyle(CustomButtonStyle(model: self.model)) + .simultaneousGesture(DragGesture(minimumDistance: 0.0) + .onChanged { _ in + self.isPressed = true + } + .onEnded { _ in + self.isPressed = false + } + ) + .disabled(!self.model.isInteractive) + .scaleEffect( + self.isPressed ? self.model.animationScale.value : 1, + anchor: .center + ) + } + + @ViewBuilder + private var content: some View { + switch (self.model.isLoading, self.model.image, self.model.imageLocation) { + case (true, _, _) where self.model.title.isEmpty: + SULoading(model: self.model.preferredLoadingVM) + case (true, _, _): + SULoading(model: self.model.preferredLoadingVM) + Text(self.model.title) + case (false, let uiImage?, .leading) where self.model.title.isEmpty: + ButtonImageView(image: uiImage) + .frame(width: self.model.imageSide, height: self.model.imageSide) + case (false, let uiImage?, .leading): + ButtonImageView(image: uiImage) + .frame(width: self.model.imageSide, height: self.model.imageSide) + Text(self.model.title) + case (false, let uiImage?, .trailing) where self.model.title.isEmpty: + ButtonImageView(image: uiImage) + .frame(width: self.model.imageSide, height: self.model.imageSide) + case (false, let uiImage?, .trailing): + Text(self.model.title) + ButtonImageView(image: uiImage) + .frame(width: self.model.imageSide, height: self.model.imageSide) + case (false, _, _): + Text(self.model.title) + } } } // MARK: - Helpers +private struct ButtonImageView: UIViewRepresentable { + class InternalImageView: UIImageView { + override var intrinsicContentSize: CGSize { + return .zero + } + } + + let image: UIImage + + func makeUIView(context: Context) -> UIImageView { + let imageView = InternalImageView() + imageView.image = self.image + imageView.contentMode = .scaleAspectFit + return imageView + } + + func updateUIView(_ imageView: UIImageView, context: Context) { + imageView.image = self.image + } +} + private struct CustomButtonStyle: SwiftUI.ButtonStyle { let model: ButtonVM diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index 058c1609..113d2a63 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -18,7 +18,7 @@ open class UKButton: UIView, UKComponent { /// A Boolean value indicating whether the button is pressed. public private(set) var isPressed: Bool = false { didSet { - self.transform = self.isPressed && self.model.isEnabled + self.transform = self.isPressed && self.model.isInteractive ? .init( scaleX: self.model.animationScale.value, y: self.model.animationScale.value @@ -32,6 +32,19 @@ open class UKButton: UIView, UKComponent { /// A label that displays the title from the model. public var titleLabel = UILabel() + /// A loading indicator shown when the button is in a loading state. + public let loaderView = UKLoading() + + /// A stack view that manages the layout of the button’s internal content. + private let stackView = UIStackView() + + /// An optional image displayed alongside the title. + public let imageView = UIImageView() + + // MARK: Private Properties + + private var imageViewConstraints = LayoutConstraints() + // MARK: UIView Properties open override var intrinsicContentSize: CGSize { @@ -64,7 +77,16 @@ open class UKButton: UIView, UKComponent { // MARK: Setup private func setup() { - self.addSubview(self.titleLabel) + self.addSubview(self.stackView) + + self.stackView.addArrangedSubview(self.loaderView) + self.stackView.addArrangedSubview(self.titleLabel) + switch self.model.imageLocation { + case .leading: + self.stackView.insertArrangedSubview(self.imageView, at: 0) + case .trailing: + self.stackView.addArrangedSubview(self.imageView) + } if #available(iOS 17.0, *) { self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in @@ -78,12 +100,20 @@ open class UKButton: UIView, UKComponent { private func style() { Self.Style.mainView(self, model: self.model) Self.Style.titleLabel(self.titleLabel, model: self.model) + Self.Style.configureStackView(self.stackView, model: self.model) + Self.Style.loaderView(self.loaderView, model: self.model) + Self.Style.imageView(self.imageView, model: self.model) } // MARK: Layout private func layout() { - self.titleLabel.center() + self.stackView.center() + + self.imageViewConstraints = self.imageView.size( + width: self.model.imageSide, + height: self.model.imageSide + ) } open override func layoutSubviews() { @@ -99,7 +129,26 @@ open class UKButton: UIView, UKComponent { self.style() - if self.model.shouldUpdateSize(oldModel) { + if self.model.shouldUpdateImagePosition(oldModel) { + self.stackView.removeArrangedSubview(self.imageView) + switch self.model.imageLocation { + case .leading: + self.stackView.insertArrangedSubview(self.imageView, at: 0) + case .trailing: + self.stackView.addArrangedSubview(self.imageView) + } + } + + if self.model.shouldUpdateImageSize(oldModel) { + self.imageViewConstraints.width?.constant = self.model.imageSide + self.imageViewConstraints.height?.constant = self.model.imageSide + + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + } + + if self.model.shouldRecalculateSize(oldModel) { self.invalidateIntrinsicContentSize() } } @@ -107,7 +156,7 @@ open class UKButton: UIView, UKComponent { // MARK: UIView methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let contentSize = self.titleLabel.sizeThatFits(size) + let contentSize = self.stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let preferredSize = self.model.preferredSize( for: contentSize, parentWidth: self.superview?.bounds.width @@ -135,7 +184,7 @@ open class UKButton: UIView, UKComponent { defer { self.isPressed = false } - if self.model.isEnabled, + if self.model.isInteractive, let location = touches.first?.location(in: self), self.bounds.contains(location) { self.action() @@ -182,6 +231,25 @@ extension UKButton { label.text = model.title label.font = model.preferredFont.uiFont label.textColor = model.foregroundColor.uiColor + label.isHidden = model.title.isEmpty + } + static func configureStackView( + _ stackView: UIStackView, + model: Model + ) { + stackView.spacing = model.contentSpacing + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = model.contentSpacing + } + static func loaderView(_ view: UKLoading, model: Model) { + view.model = model.preferredLoadingVM + view.isVisible = model.isLoading + } + static func imageView(_ imageView: UIImageView, model: Model) { + imageView.image = model.image + imageView.contentMode = .scaleAspectFit + imageView.isHidden = model.isLoading || model.imageSrc.isNil } } }