diff --git a/Examples/DemosApp/DemosApp.xcodeproj/project.pbxproj b/Examples/DemosApp/DemosApp.xcodeproj/project.pbxproj index 00497518..81af7444 100644 --- a/Examples/DemosApp/DemosApp.xcodeproj/project.pbxproj +++ b/Examples/DemosApp/DemosApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -80,7 +80,6 @@ 740D221F2CD3BECA006731A5 /* Project object */ = { isa = PBXProject; attributes = { - BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1610; LastUpgradeCheck = 1610; TargetAttributes = { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift index 4645fa94..e97783d9 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift @@ -23,7 +23,7 @@ struct AutocapitalizationPicker: View { @Binding var selection: TextAutocapitalization var body: some View { - Picker("Autocapitalization", selection: $selection) { + Picker("Autocapitalization", selection: self.$selection) { Text("Never").tag(TextAutocapitalization.never) Text("Characters").tag(TextAutocapitalization.characters) Text("Words").tag(TextAutocapitalization.words) @@ -36,7 +36,7 @@ struct AutocapitalizationPicker: View { struct BorderWidthPicker: View { @Binding var selection: BorderWidth - + var body: some View { Picker("Border Width", selection: self.$selection) { Text("None").tag(BorderWidth.none) @@ -47,6 +47,22 @@ struct BorderWidthPicker: View { } } +struct ButtonStylePicker: View { + @Binding var selection: ComponentsKit.ButtonStyle + + var body: some View { + Picker("Style", selection: $selection) { + Text("Filled").tag(ButtonStyle.filled) + Text("Plain").tag(ButtonStyle.plain) + Text("Light").tag(ButtonStyle.light) + Text("Minimal").tag(ButtonStyle.minimal) + Text("Bordered with small border").tag(ButtonStyle.bordered(.small)) + Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) + Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) + } + } +} + // MARK: - ComponentColorPicker struct ComponentColorPicker: View { @@ -203,13 +219,25 @@ struct CaptionFontPicker: View { } } +struct InputStylePicker: View { + @Binding var selection: InputStyle + + var body: some View { + Picker("Style", selection: self.$selection) { + Text("Light").tag(InputStyle.light) + Text("Bordered").tag(InputStyle.bordered) + Text("Faded").tag(InputStyle.faded) + } + } +} + // MARK: - KeyboardTypePicker struct KeyboardTypePicker: View { @Binding var selection: UIKeyboardType var body: some View { - Picker("Keyboard Type", selection: $selection) { + Picker("Keyboard Type", selection: self.$selection) { Text("Default").tag(UIKeyboardType.default) Text("asciiCapable").tag(UIKeyboardType.asciiCapable) Text("numbersAndPunctuation").tag(UIKeyboardType.numbersAndPunctuation) @@ -260,7 +288,7 @@ struct SubmitTypePicker: View { @Binding var selection: SubmitType var body: some View { - Picker("Submit Type", selection: $selection) { + Picker("Submit Type", selection: self.$selection) { Text("done").tag(SubmitType.done) Text("go").tag(SubmitType.go) Text("join").tag(SubmitType.join) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift index 93e3b098..658a73d0 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AlertPreview.swift @@ -115,14 +115,7 @@ struct AlertPreview: View { ComponentRadiusPicker(selection: buttonVM.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) } - Picker("Style", selection: buttonVM.style) { - Text("Filled").tag(ButtonStyle.filled) - Text("Plain").tag(ButtonStyle.plain) - Text("Light").tag(ButtonStyle.light) - Text("Bordered with small border").tag(ButtonStyle.bordered(.small)) - Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) - Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) - } + ButtonStylePicker(selection: buttonVM.style) } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index df58ad2e..19dc015b 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift @@ -3,10 +3,11 @@ import SwiftUI import UIKit struct ButtonPreview: View { + private static let title = "Button" @State private var model = ButtonVM { - $0.title = "Button" + $0.title = Self.title } - + var body: some View { VStack { PreviewWrapper(title: "UIKit") { @@ -19,21 +20,35 @@ 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) - SizePicker(selection: self.$model.size) - Picker("Style", selection: self.$model.style) { - Text("Filled").tag(ButtonStyle.filled) - Text("Plain").tag(ButtonStyle.plain) - Text("Light").tag(ButtonStyle.light) - Text("Bordered with small border").tag(ButtonStyle.bordered(.small)) - Text("Bordered with medium border").tag(ButtonStyle.bordered(.medium)) - Text("Bordered with large border").tag(ButtonStyle.bordered(.large)) + 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 ? Self.title : "" + } + )) + SizePicker(selection: self.$model.size) + ButtonStylePicker(selection: self.$model.style) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift index 1543e844..ce9e600f 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CardPreview.swift @@ -15,14 +15,24 @@ struct CardPreview: View { SUCard(model: self.model, content: self.suCardContent) } Form { + AnimationScalePicker(selection: self.$model.animationScale) Picker("Background Color", selection: self.$model.backgroundColor) { - Text("Default").tag(Optional.none) + Text("Background").tag(UniversalColor.background) Text("Secondary Background").tag(UniversalColor.secondaryBackground) Text("Accent Background").tag(UniversalColor.accentBackground) Text("Success Background").tag(UniversalColor.successBackground) Text("Warning Background").tag(UniversalColor.warningBackground) Text("Danger Background").tag(UniversalColor.dangerBackground) } + Picker("Border Color", selection: self.$model.borderColor) { + Text("Divider").tag(UniversalColor.divider) + Text("Primary").tag(UniversalColor.primary) + Text("Accent").tag(UniversalColor.accent) + Text("Success").tag(UniversalColor.success) + Text("Warning").tag(UniversalColor.warning) + Text("Danger").tag(UniversalColor.danger) + Text("Custom").tag(UniversalColor.universal(.uiColor(.systemPurple))) + } BorderWidthPicker(selection: self.$model.borderWidth) Picker("Content Paddings", selection: self.$model.contentPaddings) { Text("12px").tag(Paddings(padding: 12)) @@ -39,6 +49,7 @@ struct CardPreview: View { Text("Large").tag(Shadow.large) Text("Custom").tag(Shadow.custom(20.0, .zero, UniversalColor.accentBackground)) } + Toggle("Tappable", isOn: self.$model.isTappable) } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift index 3851467d..1b74b659 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift @@ -34,6 +34,15 @@ struct InputFieldPreview: View { Form { AutocapitalizationPicker(selection: self.$model.autocapitalization) Toggle("Autocorrection Enabled", isOn: self.$model.isAutocorrectionEnabled) + Toggle("Caption", isOn: .init( + get: { + return self.model.caption != nil + }, + set: { newValue in + self.model.caption = newValue ? Self.caption : nil + } + )) + CaptionFontPicker(title: "Caption Font", selection: self.$model.captionFont) ComponentOptionalColorPicker(selection: self.$model.color) ComponentRadiusPicker(selection: self.$model.cornerRadius) { Text("Custom: 20px").tag(ComponentRadius.custom(20)) @@ -46,12 +55,13 @@ struct InputFieldPreview: View { return self.model.placeholder != nil }, set: { newValue in - self.model.placeholder = newValue ? "Placeholder" : nil + self.model.placeholder = newValue ? Self.placeholder : nil } )) Toggle("Required", isOn: self.$model.isRequired) Toggle("Secure Input", isOn: self.$model.isSecureInput) SizePicker(selection: self.$model.size) + InputStylePicker(selection: self.$model.style) SubmitTypePicker(selection: self.$model.submitType) UniversalColorPicker( title: "Tint Color", @@ -62,9 +72,14 @@ struct InputFieldPreview: View { return self.model.title != nil }, set: { newValue in - self.model.title = newValue ? "Title" : nil + self.model.title = newValue ? Self.title : nil } )) + BodyFontPicker(title: "Title Font", selection: self.$model.titleFont) + Picker("Title Position", selection: self.$model.titlePosition) { + Text("Inside").tag(InputFieldVM.TitlePosition.inside) + Text("Outside").tag(InputFieldVM.TitlePosition.outside) + } } } .toolbar { @@ -79,9 +94,14 @@ struct InputFieldPreview: View { } } + private static let title = "Email" + private static let placeholder = "Enter your email" + private static let caption = "Your email address will be used to send a verification code" private static var initialModel: InputFieldVM { return .init { - $0.title = "Title" + $0.title = Self.title + $0.placeholder = Self.placeholder + $0.caption = Self.caption } } } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift index 978ee110..40ee622e 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/TextInputPreview.swift @@ -60,6 +60,7 @@ struct TextInputPreviewPreview: View { } )) SizePicker(selection: self.$model.size) + InputStylePicker(selection: self.$model.style) SubmitTypePicker(selection: self.$model.submitType) UniversalColorPicker( title: "Tint Color", diff --git a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift index a1731720..2af9e366 100644 --- a/Sources/ComponentsKit/Components/Alert/UKAlertController.swift +++ b/Sources/ComponentsKit/Components/Alert/UKAlertController.swift @@ -143,11 +143,14 @@ public class UKAlertController: UKCenterModalController { self.buttonsStackView.removeArrangedSubview(self.secondaryButton) self.buttonsStackView.insertArrangedSubview(self.secondaryButton, at: 0) self.buttonsStackView.axis = .horizontal + self.buttonsStackView.distribution = .fillEqually case .vertical: self.buttonsStackView.axis = .vertical + self.buttonsStackView.distribution = .fillProportionally } } else { self.buttonsStackView.axis = .vertical + self.buttonsStackView.distribution = .fillProportionally } } } @@ -173,7 +176,6 @@ extension UKAlertController { } static func buttonsStackView(_ stackView: UIStackView) { - stackView.distribution = .fillEqually stackView.spacing = AlertVM.buttonsSpacing } } 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..bd9242fb 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,15 +73,27 @@ 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) - case .plain, .bordered: + return color.enabled(self.isInteractive) + case .plain, .bordered, .minimal: return nil } } @@ -66,14 +101,14 @@ extension ButtonVM { let color = switch self.style { case .filled: self.color?.contrast ?? .foreground - case .plain, .light, .bordered: + case .plain, .light, .bordered, .minimal: self.color?.main ?? .foreground } - return color.enabled(self.isEnabled) + return color.enabled(self.isInteractive) } var borderWidth: CGFloat { switch self.style { - case .filled, .plain, .light: + case .filled, .plain, .light, .minimal: return 0.0 case .bordered(let borderWidth): return borderWidth.value @@ -81,11 +116,11 @@ extension ButtonVM { } var borderColor: UniversalColor? { switch self.style { - case .filled, .plain, .light: + case .filled, .plain, .light, .minimal: return nil case .bordered: if let color { - return color.main.enabled(self.isEnabled) + return color.main.enabled(self.isInteractive) } else { return .divider } @@ -105,18 +140,50 @@ extension ButtonVM { return .lgButton } } - var height: CGFloat { - return switch self.size { - case .small: 36 - case .medium: 44 - case .large: 52 + var height: CGFloat? { + switch self.style { + case .minimal: + return nil + case .light, .filled, .bordered, .plain: + return switch self.size { + case .small: 36 + case .medium: 44 + 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 - case .medium: 20 - case .large: 24 + switch self.style { + case .minimal: + return 0 + case .light, .filled, .bordered, .plain: + return switch self.size { + case .small: 16 + case .medium: 20 + case .large: 24 + } + } + } +} + +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) } } } @@ -139,12 +206,25 @@ extension ButtonVM { width = contentSize.width + 2 * self.horizontalPadding } - return .init(width: width, height: self.height) + return .init(width: width, height: self.height ?? contentSize.height) + } + 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 shouldUpdateSize(_ oldModel: Self?) -> Bool { - return self.size != oldModel?.size - || self.font != oldModel?.font - || self.isFullWidth != oldModel?.isFullWidth + 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..80cf0e89 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 @@ -56,6 +108,7 @@ private struct CustomButtonStyle: SwiftUI.ButtonStyle { configuration.label .font(self.model.preferredFont.font) .lineLimit(1) + .contentShape(.rect) .padding(.horizontal, self.model.horizontalPadding) .frame(maxWidth: self.model.width) .frame(height: self.model.height) diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index 058c1609..e7b63552 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -2,7 +2,7 @@ import AutoLayout import UIKit /// A UIKit component that performs an action when it is tapped by a user. -open class UKButton: UIView, UKComponent { +open class UKButton: FullWidthComponent, UKComponent { // MARK: Properties /// A closure that is triggered when the button is tapped. @@ -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 } } } diff --git a/Sources/ComponentsKit/Components/Card/Models/CardVM.swift b/Sources/ComponentsKit/Components/Card/Models/CardVM.swift index a6baf586..2103b564 100644 --- a/Sources/ComponentsKit/Components/Card/Models/CardVM.swift +++ b/Sources/ComponentsKit/Components/Card/Models/CardVM.swift @@ -2,8 +2,16 @@ import Foundation /// A model that defines the appearance properties for a card component. public struct CardVM: ComponentVM { + /// The scaling factor for the card's tap animation, with a value between 0 and 1. + /// + /// Defaults to `.medium`. + public var animationScale: AnimationScale = .medium + /// The background color of the card. - public var backgroundColor: UniversalColor? + public var backgroundColor: UniversalColor = .background + + /// The border color of the card. + public var borderColor: UniversalColor = .divider /// The border thickness of the card. /// @@ -20,6 +28,11 @@ public struct CardVM: ComponentVM { /// Defaults to `.medium`. public var cornerRadius: ContainerRadius = .medium + /// A Boolean value indicating whether the card should allow to be tapped. + /// + /// Defaults to `true`. + public var isTappable: Bool = false + /// The shadow of the card. /// /// Defaults to `.medium`. @@ -28,11 +41,3 @@ public struct CardVM: ComponentVM { /// Initializes a new instance of `CardVM` with default values. public init() {} } - -// MARK: - Helpers - -extension CardVM { - var preferredBackgroundColor: UniversalColor { - return self.backgroundColor ?? .background - } -} diff --git a/Sources/ComponentsKit/Components/Card/SUCard.swift b/Sources/ComponentsKit/Components/Card/SUCard.swift index ad6be3dd..368db91a 100644 --- a/Sources/ComponentsKit/Components/Card/SUCard.swift +++ b/Sources/ComponentsKit/Components/Card/SUCard.swift @@ -16,8 +16,14 @@ public struct SUCard: View { /// A model that defines the appearance properties. public let model: CardVM + /// A closure that is triggered when the card is tapped. + public var onTap: () -> Void + + /// A Boolean value indicating whether the card is pressed. + @State public var isPressed: Bool = false @ViewBuilder private let content: () -> Content + @State private var contentSize: CGSize = .zero // MARK: - Initialization @@ -28,10 +34,12 @@ public struct SUCard: View { /// - content: The content that is displayed in the card. public init( model: CardVM = .init(), - content: @escaping () -> Content + content: @escaping () -> Content, + onTap: @escaping () -> Void = {} ) { self.model = model self.content = content + self.onTap = onTap } // MARK: - Body @@ -39,12 +47,35 @@ public struct SUCard: View { public var body: some View { self.content() .padding(self.model.contentPaddings.edgeInsets) - .background(self.model.preferredBackgroundColor.color) + .background(self.model.backgroundColor.color) .cornerRadius(self.model.cornerRadius.value) .overlay( RoundedRectangle(cornerRadius: self.model.cornerRadius.value) - .stroke(UniversalColor.divider.color, lineWidth: self.model.borderWidth.value) + .stroke( + self.model.borderColor.color, + lineWidth: self.model.borderWidth.value + ) ) .shadow(self.model.shadow) + .observeSize { self.contentSize = $0 } + .simultaneousGesture(DragGesture(minimumDistance: 0.0) + .onChanged { _ in + guard self.model.isTappable else { return } + self.isPressed = true + } + .onEnded { value in + guard self.model.isTappable else { return } + + defer { self.isPressed = false } + + if CGRect(origin: .zero, size: self.contentSize).contains(value.location) { + self.onTap() + } + } + ) + .scaleEffect( + self.isPressed ? self.model.animationScale.value : 1, + anchor: .center + ) } } diff --git a/Sources/ComponentsKit/Components/Card/UKCard.swift b/Sources/ComponentsKit/Components/Card/UKCard.swift index bae38bde..aa634187 100644 --- a/Sources/ComponentsKit/Components/Card/UKCard.swift +++ b/Sources/ComponentsKit/Components/Card/UKCard.swift @@ -21,9 +21,22 @@ open class UKCard: UIView, UKComponent { /// The primary content of the card, provided as a custom view. public let content: Content - // MARK: - Properties + // MARK: - Public Properties - private var contentConstraints = LayoutConstraints() + /// A closure that is triggered when the card is tapped. + public var onTap: () -> Void + + /// A Boolean value indicating whether the button is pressed. + public private(set) var isPressed: Bool = false { + didSet { + self.transform = self.isPressed + ? .init( + scaleX: self.model.animationScale.value, + y: self.model.animationScale.value + ) + : .identity + } + } /// A model that defines the appearance properties. public var model: CardVM { @@ -32,6 +45,10 @@ open class UKCard: UIView, UKComponent { } } + // MARK: - Private Properties + + private var contentConstraints = LayoutConstraints() + // MARK: - Initialization /// Initializer. @@ -41,10 +58,12 @@ open class UKCard: UIView, UKComponent { /// - content: The content that is displayed in the card. public init( model: CardVM = .init(), - content: @escaping () -> Content + content: @escaping () -> Content, + onTap: @escaping () -> Void = {} ) { self.model = model self.content = content() + self.onTap = onTap super.init(frame: .zero) @@ -95,6 +114,8 @@ open class UKCard: UIView, UKComponent { self.layer.shadowPath = UIBezierPath(rect: self.bounds).cgPath } + // MARK: - Update + /// Updates appearance when the model changes. open func update(_ oldValue: CardVM) { guard self.model != oldValue else { return } @@ -113,6 +134,43 @@ open class UKCard: UIView, UKComponent { // MARK: - UIView Methods + open override func touchesBegan( + _ touches: Set, + with event: UIEvent? + ) { + super.touchesBegan(touches, with: event) + + guard self.model.isTappable else { return } + + self.isPressed = true + } + + open override func touchesEnded( + _ touches: Set, + with event: UIEvent? + ) { + super.touchesEnded(touches, with: event) + + guard self.model.isTappable else { return } + + defer { self.isPressed = false } + + if self.model.isTappable, + let location = touches.first?.location(in: self), + self.bounds.contains(location) { + self.onTap() + } + } + + open override func touchesCancelled( + _ touches: Set, + with event: UIEvent? + ) { + super.touchesCancelled(touches, with: event) + + self.isPressed = false + } + open override func traitCollectionDidChange( _ previousTraitCollection: UITraitCollection? ) { @@ -130,10 +188,10 @@ open class UKCard: UIView, UKComponent { extension UKCard { fileprivate enum Style { static func mainView(_ view: UIView, model: Model) { - view.backgroundColor = model.preferredBackgroundColor.uiColor + view.backgroundColor = model.backgroundColor.uiColor view.layer.cornerRadius = model.cornerRadius.value view.layer.borderWidth = model.borderWidth.value - view.layer.borderColor = UniversalColor.divider.cgColor + view.layer.borderColor = model.borderColor.cgColor view.shadow(model.shadow) } } diff --git a/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift b/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift index 56b824f4..27f2f2a4 100644 --- a/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift +++ b/Sources/ComponentsKit/Components/Checkbox/Models/CheckboxVM.swift @@ -60,19 +60,19 @@ extension CheckboxVM { case .small: return 1.5 case .medium: - return 2.0 + return 1.75 case .large: - return 2.5 + return 2.0 } } var checkboxSide: CGFloat { switch self.size { case .small: - return 16.0 - case .medium: return 20.0 - case .large: + case .medium: return 24.0 + case .large: + return 28.0 } } var checkboxCornerRadius: CGFloat { @@ -105,6 +105,22 @@ extension CheckboxVM { return .lgBody } } + var checkmarkPath: CGPath { + let path = UIBezierPath() + path.move(to: .init( + x: 7 / 24 * self.checkboxSide, + y: 12 / 24 * self.checkboxSide + )) + path.addLine(to: .init( + x: 11 / 24 * self.checkboxSide, + y: 16 / 24 * self.checkboxSide + )) + path.addLine(to: .init( + x: 17 / 24 * self.checkboxSide, + y: 8 / 24 * self.checkboxSide + )) + return path.cgPath + } } // MARK: UIKit Helpers diff --git a/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift b/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift index 0029acab..56db0274 100644 --- a/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift +++ b/Sources/ComponentsKit/Components/Checkbox/SUCheckbox.swift @@ -45,27 +45,14 @@ public struct SUCheckbox: View { value: self.isSelected ) - Path { path in - path.move(to: .init( - x: self.model.checkboxSide / 4, - y: 11 / 24 * self.model.checkboxSide + Path(self.model.checkmarkPath) + .trim(from: 0, to: self.checkmarkStroke) + .stroke(style: StrokeStyle( + lineWidth: self.model.checkmarkLineWidth, + lineCap: .round, + lineJoin: .round )) - path.addLine(to: .init( - x: 11 / 24 * self.model.checkboxSide, - y: 17 / 24 * self.model.checkboxSide - )) - path.addLine(to: .init( - x: 3 / 4 * self.model.checkboxSide, - y: 7 / 24 * self.model.checkboxSide - )) - } - .trim(from: 0, to: self.checkmarkStroke) - .stroke(style: StrokeStyle( - lineWidth: self.model.checkmarkLineWidth, - lineCap: .round, - lineJoin: .round - )) - .foregroundStyle(self.model.foregroundColor.color) + .foregroundStyle(self.model.foregroundColor.color) } .overlay { RoundedRectangle(cornerRadius: self.model.checkboxCornerRadius) diff --git a/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift b/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift index 4a188c3f..164eeae0 100644 --- a/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift +++ b/Sources/ComponentsKit/Components/Checkbox/UKCheckbox.swift @@ -96,22 +96,7 @@ open class UKCheckbox: UIView, UKComponent { self.checkmarkLayer.lineCap = .round self.checkmarkLayer.lineJoin = .round self.checkmarkLayer.strokeEnd = self.isSelected ? 1.0 : 0.0 - - let checkmarkPath = UIBezierPath() - checkmarkPath.move(to: .init( - x: self.model.checkboxSide / 4, - y: 11 / 24 * self.model.checkboxSide - )) - checkmarkPath.addLine(to: .init( - x: 11 / 24 * self.model.checkboxSide, - y: 17 / 24 * self.model.checkboxSide - )) - checkmarkPath.addLine(to: .init( - x: 3 / 4 * self.model.checkboxSide, - y: 7 / 24 * self.model.checkboxSide - )) - - self.checkmarkLayer.path = checkmarkPath.cgPath + self.checkmarkLayer.path = self.model.checkmarkPath } // MARK: Style diff --git a/Sources/ComponentsKit/Components/InputField/Models/InputFieldTitlePosition.swift b/Sources/ComponentsKit/Components/InputField/Models/InputFieldTitlePosition.swift new file mode 100644 index 00000000..5fc920a7 --- /dev/null +++ b/Sources/ComponentsKit/Components/InputField/Models/InputFieldTitlePosition.swift @@ -0,0 +1,11 @@ +import Foundation + +extension InputFieldVM { + /// Specifies the position of the title relative to the input field. + public enum TitlePosition { + /// The title is displayed inside the input field. + case inside + /// The title is displayed above the input field. + case outside + } +} diff --git a/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift index 7ffd3b73..fba374c2 100644 --- a/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift +++ b/Sources/ComponentsKit/Components/InputField/Models/InputFieldVM.swift @@ -8,6 +8,14 @@ public struct InputFieldVM: ComponentVM { /// Defaults to `.sentences`, which capitalizes the first letter of each sentence. public var autocapitalization: TextAutocapitalization = .sentences + /// The caption displayed below the input field. + public var caption: String? + + /// The font used for the input field's caption. + /// + /// If not provided, the font is automatically calculated based on the input field's size. + public var captionFont: UniversalFont? + /// The color of the input field. public var color: ComponentColor? @@ -54,6 +62,11 @@ public struct InputFieldVM: ComponentVM { /// Defaults to `.medium`. public var size: ComponentSize = .medium + /// The visual style of the input field. + /// + /// Defaults to `.light`. + public var style: InputStyle = .light + /// The type of the submit button on the keyboard. /// /// Defaults to `.return`. @@ -67,6 +80,16 @@ public struct InputFieldVM: ComponentVM { /// The title displayed on the input field. public var title: String? + /// The font used for the input field's title. + /// + /// If not provided, the font is automatically calculated based on the input field's size. + public var titleFont: UniversalFont? + + /// The position of the title relative to the input field. + /// + /// Defaults to `.inside`. + public var titlePosition: TitlePosition = .inside + /// Initializes a new instance of `InputFieldVM` with default values. public init() {} } @@ -88,6 +111,34 @@ extension InputFieldVM { return .lgBody } } + var preferredTitleFont: UniversalFont { + if let titleFont { + return titleFont + } + + switch self.size { + case .small: + return .smBody + case .medium: + return .mdBody + case .large: + return .lgBody + } + } + var preferredCaptionFont: UniversalFont { + if let captionFont { + return captionFont + } + + switch self.size { + case .small: + return .smCaption + case .medium: + return .mdCaption + case .large: + return .lgCaption + } + } var height: CGFloat { return switch self.size { case .small: 40 @@ -104,14 +155,23 @@ extension InputFieldVM { } } var spacing: CGFloat { - return self.title.isNotNilAndEmpty ? 12 : 0 + switch self.titlePosition { + case .inside: + return 12 + case .outside: + return 8 + } } var backgroundColor: UniversalColor { - return self.color?.background ?? .content1 + switch self.style { + case .light, .faded: + return self.color?.background ?? .content1 + case .bordered: + return .background + } } var foregroundColor: UniversalColor { - let color = self.color?.main ?? .foreground - return color.enabled(self.isEnabled) + return (self.color?.main ?? .foreground).enabled(self.isEnabled) } var placeholderColor: UniversalColor { if let color { @@ -120,6 +180,27 @@ extension InputFieldVM { return .secondaryForeground.enabled(self.isEnabled) } } + var captionColor: UniversalColor { + return (self.color?.main ?? .secondaryForeground).enabled(self.isEnabled) + } + var borderWidth: CGFloat { + switch self.style { + case .light: + return 0 + case .bordered, .faded: + switch self.size { + case .small: + return BorderWidth.small.value + case .medium: + return BorderWidth.medium.value + case .large: + return BorderWidth.large.value + } + } + } + var borderColor: UniversalColor { + return (self.color?.main ?? .content3).enabled(self.isEnabled) + } } // MARK: - UIKit Helpers @@ -146,7 +227,7 @@ extension InputFieldVM { attributedString.append(NSAttributedString( string: title, attributes: [ - .font: self.preferredFont.uiFont, + .font: self.preferredTitleFont.uiFont, .foregroundColor: self.foregroundColor.uiColor ] )) @@ -160,21 +241,23 @@ extension InputFieldVM { attributedString.append(NSAttributedString( string: "*", attributes: [ - .font: self.preferredFont.uiFont, - .foregroundColor: UniversalColor.danger.uiColor + .font: self.preferredTitleFont.uiFont, + .foregroundColor: UniversalColor.danger.enabled(self.isEnabled).uiColor ] )) } return attributedString } + func shouldUpdateTitlePosition(_ oldModel: Self) -> Bool { + return self.titlePosition != oldModel.titlePosition + } func shouldUpdateLayout(_ oldModel: Self) -> Bool { return self.size != oldModel.size || self.horizontalPadding != oldModel.horizontalPadding || self.spacing != oldModel.spacing || self.cornerRadius != oldModel.cornerRadius - } - func shouldUpdateCornerRadius(_ oldModel: Self) -> Bool { - return self.cornerRadius != oldModel.cornerRadius + || self.titlePosition != oldModel.titlePosition + || self.title.isNilOrEmpty != oldModel.title.isNilOrEmpty } } diff --git a/Sources/ComponentsKit/Components/InputField/SUInputField.swift b/Sources/ComponentsKit/Components/InputField/SUInputField.swift index a217a258..a2a58b35 100644 --- a/Sources/ComponentsKit/Components/InputField/SUInputField.swift +++ b/Sources/ComponentsKit/Components/InputField/SUInputField.swift @@ -14,7 +14,7 @@ public struct SUInputField: View { /// /// When the `localFocus` value matches `globalFocus`, this input field becomes focused. /// This enables centralized focus management for multiple text inputs and input fields within a single view. - @FocusState.Binding public var globalFocus: FocusValue + public let globalFocus: FocusState.Binding? /// The unique value for this field to match against the global focus state to determine whether this input field is focused. /// @@ -24,7 +24,7 @@ public struct SUInputField: View { /// /// - Warning: The `localFocus` value must be unique to each text input and input field, to ensure that different /// text inputs and input fields within the same view can be independently focused based on the shared `globalFocus`. - public var localFocus: FocusValue + public let localFocus: FocusValue // MARK: Initialization @@ -41,7 +41,7 @@ public struct SUInputField: View { model: InputFieldVM = .init() ) { self._text = text - self._globalFocus = globalFocus + self.globalFocus = globalFocus self.localFocus = localFocus self.model = model } @@ -49,46 +49,84 @@ public struct SUInputField: View { // MARK: Body public var body: some View { - HStack(spacing: self.model.spacing) { - if let title = self.model.attributedTitle { + VStack(alignment: .leading, spacing: self.model.spacing) { + if let title = self.model.attributedTitle, + self.model.titlePosition == .outside { Text(title) - .font(self.model.preferredFont.font) } - Group { - if self.model.isSecureInput { - SecureField(text: self.$text, label: { - Text(self.model.placeholder ?? "") - .foregroundStyle(self.model.placeholderColor.color) - }) - } else { - TextField(text: self.$text, label: { - Text(self.model.placeholder ?? "") - .foregroundStyle(self.model.placeholderColor.color) - }) + HStack(spacing: self.model.spacing) { + if let title = self.model.attributedTitle, + self.model.titlePosition == .inside { + Text(title) } + + Group { + if self.model.isSecureInput { + SecureField(text: self.$text, label: { + Text(self.model.placeholder ?? "") + .foregroundStyle(self.model.placeholderColor.color) + }) + } else { + TextField(text: self.$text, label: { + Text(self.model.placeholder ?? "") + .foregroundStyle(self.model.placeholderColor.color) + }) + } + } + .tint(self.model.tintColor.color) + .font(self.model.preferredFont.font) + .foregroundStyle(self.model.foregroundColor.color) + .applyFocus(globalFocus: self.globalFocus, localFocus: self.localFocus) + .disabled(!self.model.isEnabled) + .keyboardType(self.model.keyboardType) + .submitLabel(self.model.submitType.submitLabel) + .autocorrectionDisabled(!self.model.isAutocorrectionEnabled) + .textInputAutocapitalization(self.model.autocapitalization.textInputAutocapitalization) + } + .padding(.horizontal, self.model.horizontalPadding) + .frame(height: self.model.height) + .background(self.model.backgroundColor.color) + .onTapGesture { + self.globalFocus?.wrappedValue = self.localFocus + } + .clipShape( + RoundedRectangle( + cornerRadius: self.model.cornerRadius.value() + ) + ) + .overlay( + RoundedRectangle( + cornerRadius: self.model.cornerRadius.value() + ) + .stroke( + self.model.borderColor.color, + lineWidth: self.model.borderWidth + ) + ) + + if let caption = self.model.caption, caption.isNotEmpty { + Text(caption) + .font(self.model.preferredCaptionFont.font) + .foregroundStyle(self.model.captionColor.color) } - .tint(self.model.tintColor.color) - .font(self.model.preferredFont.font) - .foregroundStyle(self.model.foregroundColor.color) - .focused(self.$globalFocus, equals: self.localFocus) - .disabled(!self.model.isEnabled) - .keyboardType(self.model.keyboardType) - .submitLabel(self.model.submitType.submitLabel) - .autocorrectionDisabled(!self.model.isAutocorrectionEnabled) - .textInputAutocapitalization(self.model.autocapitalization.textInputAutocapitalization) } - .padding(.horizontal, self.model.horizontalPadding) - .frame(height: self.model.height) - .background(self.model.backgroundColor.color) - .onTapGesture { - self.globalFocus = self.localFocus + } +} + +// MARK: Helpers + +extension View { + @ViewBuilder + fileprivate func applyFocus( + globalFocus: FocusState.Binding?, + localFocus: FocusValue + ) -> some View { + if let globalFocus { + self.focused(globalFocus, equals: localFocus) + } else { + self } - .clipShape( - RoundedRectangle( - cornerRadius: self.model.cornerRadius.value() - ) - ) } } @@ -106,7 +144,25 @@ extension SUInputField where FocusValue == Bool { model: InputFieldVM = .init() ) { self._text = text - self._globalFocus = isFocused + self.globalFocus = isFocused + self.localFocus = true + self.model = model + } +} + +// MARK: - No Focus Value + +extension SUInputField where FocusValue == Bool { + /// Initializer. + /// - Parameters: + /// - text: A Binding value to control the inputted text. + /// - model: A model that defines the appearance properties. + public init( + text: Binding, + model: InputFieldVM = .init() + ) { + self._text = text + self.globalFocus = nil self.localFocus = true self.model = model } diff --git a/Sources/ComponentsKit/Components/InputField/UKInputField.swift b/Sources/ComponentsKit/Components/InputField/UKInputField.swift index 2300b4f7..1c1e4bf1 100644 --- a/Sources/ComponentsKit/Components/InputField/UKInputField.swift +++ b/Sources/ComponentsKit/Components/InputField/UKInputField.swift @@ -2,8 +2,8 @@ import AutoLayout import UIKit /// A UIKit component that displays a field to input a text. -open class UKInputField: UIView, UKComponent { - // MARK: Properties +open class UKInputField: FullWidthComponent, UKComponent { + // MARK: Public Properties /// A closure that is triggered when the text changes. public var onValueChange: (String) -> Void @@ -28,15 +28,25 @@ open class UKInputField: UIView, UKComponent { } } - private var titleLabelConstraints: LayoutConstraints? - private var inputFieldConstraints: LayoutConstraints? - // MARK: Subviews /// A label that displays the title from the model. public var titleLabel = UILabel() /// An underlying text field from the standard library. public var textField = UITextField() + /// A label that displays the caption from the model. + public var captionLabel = UILabel() + /// A view that contains `horizontalStackView` to have paddings. + public var textFieldContainer = UIView() + /// A stack view that contains `textField` and `titleLabel` when it is inside. + public var horizontalStackView = UIStackView() + /// A stack view that contains `textFieldContainer`, `captionLabel` and `titleLabel` when it is outside. + public var verticalStackView = UIStackView() + + // MARK: Private Properties + + private var textFieldContainerConstraints = LayoutConstraints() + private var horizontalStackViewConstraints = LayoutConstraints() // MARK: UIView Properties @@ -78,11 +88,26 @@ open class UKInputField: UIView, UKComponent { // MARK: Setup private func setup() { - self.addSubview(self.titleLabel) - self.addSubview(self.textField) + self.addSubview(self.verticalStackView) + switch self.model.titlePosition { + case .inside: + self.horizontalStackView.addArrangedSubview(self.titleLabel) + case .outside: + self.verticalStackView.addArrangedSubview(self.titleLabel) + } + self.verticalStackView.addArrangedSubview(self.textFieldContainer) + self.verticalStackView.addArrangedSubview(self.captionLabel) + self.horizontalStackView.addArrangedSubview(self.textField) + self.textFieldContainer.addSubview(self.horizontalStackView) - self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap))) + self.textFieldContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap))) self.textField.addTarget(self, action: #selector(self.handleTextChange), for: .editingChanged) + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } } @objc private func handleTap() { @@ -96,35 +121,31 @@ open class UKInputField: UIView, UKComponent { // MARK: Style private func style() { - Self.Style.mainView(self, model: self.model) + Self.Style.textFieldContainer(self.textFieldContainer, model: self.model) + Self.Style.horizontalStackView(self.horizontalStackView, model: self.model) + Self.Style.verticalStackView(self.verticalStackView, model: self.model) Self.Style.textField(self.textField, model: self.model) Self.Style.titleLabel(self.titleLabel, model: self.model) + Self.Style.captionLabel(self.captionLabel, model: self.model) } // MARK: Layout private func layout() { - self.titleLabelConstraints = self.titleLabel.leading(self.model.horizontalPadding) - self.titleLabel.centerVertically() + self.verticalStackView.allEdges() - self.textField.trailing(self.model.horizontalPadding) - self.textField.vertically() + self.textFieldContainerConstraints = self.textFieldContainer.height(self.model.height) + self.textFieldContainer.horizontally() - self.inputFieldConstraints = self.textField.after( - self.titleLabel, - padding: self.model.spacing - ) + self.horizontalStackView.vertically() + self.horizontalStackViewConstraints = self.horizontalStackView.horizontally(self.model.horizontalPadding) + + self.captionLabel.horizontally() self.textField.setContentHuggingPriority(.defaultLow, for: .horizontal) self.titleLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) } - open override func layoutSubviews() { - super.layoutSubviews() - - self.updateCornerRadius() - } - // MARK: Update public func update(_ oldModel: InputFieldVM) { @@ -132,10 +153,19 @@ open class UKInputField: UIView, UKComponent { self.style() - self.inputFieldConstraints?.leading?.constant = self.model.spacing - self.titleLabelConstraints?.leading?.constant = self.model.horizontalPadding - if self.model.shouldUpdateCornerRadius(oldModel) { - self.updateCornerRadius() + self.horizontalStackViewConstraints.leading?.constant = self.model.horizontalPadding + self.horizontalStackViewConstraints.trailing?.constant = -self.model.horizontalPadding + self.textFieldContainerConstraints.height?.constant = self.model.height + + if self.model.shouldUpdateTitlePosition(oldModel) { + switch self.model.titlePosition { + case .inside: + self.verticalStackView.removeArrangedSubview(self.titleLabel) + self.horizontalStackView.insertArrangedSubview(self.titleLabel, at: 0) + case .outside: + self.horizontalStackView.removeArrangedSubview(self.titleLabel) + self.verticalStackView.insertArrangedSubview(self.titleLabel, at: 0) + } } if self.model.shouldUpdateLayout(oldModel) { self.setNeedsLayout() @@ -163,16 +193,26 @@ open class UKInputField: UIView, UKComponent { } else { width = 10_000 } + + let height = self.verticalStackView.sizeThatFits(UIView.layoutFittingCompressedSize).height + return .init( width: min(size.width, width), - height: min(size.height, self.model.height) + height: min(size.height, height) ) } + open override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + // MARK: Helpers - private func updateCornerRadius() { - self.layer.cornerRadius = self.model.cornerRadius.value(for: self.bounds.height) + @objc private func handleTraitChanges() { + Self.Style.textFieldContainer(self.textFieldContainer, model: self.model) } } @@ -180,22 +220,25 @@ open class UKInputField: UIView, UKComponent { extension UKInputField { fileprivate enum Style { - static func mainView( + static func textFieldContainer( _ view: UIView, - model: InputFieldVM + model: Model ) { view.backgroundColor = model.backgroundColor.uiColor - view.layer.cornerRadius = model.cornerRadius.value(for: view.bounds.height) + view.layer.cornerRadius = model.cornerRadius.value(for: model.height) + view.layer.borderWidth = model.borderWidth + view.layer.borderColor = model.borderColor.cgColor } static func titleLabel( _ label: UILabel, - model: InputFieldVM + model: Model ) { label.attributedText = model.nsAttributedTitle + label.isVisible = model.title.isNotNilAndEmpty } static func textField( _ textField: UITextField, - model: InputFieldVM + model: Model ) { textField.font = model.preferredFont.uiFont textField.textColor = model.foregroundColor.uiColor @@ -208,5 +251,31 @@ extension UKInputField { textField.autocorrectionType = model.autocorrectionType textField.autocapitalizationType = model.autocapitalization.textAutocapitalizationType } + static func captionLabel( + _ label: UILabel, + model: Model + ) { + label.text = model.caption + label.isVisible = model.caption.isNotNilAndEmpty + label.textColor = model.captionColor.uiColor + label.font = model.preferredCaptionFont.uiFont + label.numberOfLines = 0 + } + static func horizontalStackView( + _ stackView: UIStackView, + model: Model + ) { + stackView.axis = .horizontal + stackView.spacing = model.spacing + } + static func verticalStackView( + _ stackView: UIStackView, + model: Model + ) { + stackView.axis = .vertical + stackView.spacing = model.spacing + stackView.alignment = .leading + stackView.distribution = .fillProportionally + } } } diff --git a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift index 28943e6b..1583645b 100644 --- a/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift +++ b/Sources/ComponentsKit/Components/ProgressBar/UKProgressBar.swift @@ -2,7 +2,7 @@ import AutoLayout import UIKit /// A UIKit component that visually represents the progress of a task or process using a horizontal bar. -open class UKProgressBar: UIView, UKComponent { +open class UKProgressBar: FullWidthComponent, UKComponent { // MARK: - Public Properties /// A model that defines the appearance properties. diff --git a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift index f759f3da..e642c6cb 100644 --- a/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift +++ b/Sources/ComponentsKit/Components/SegmentedControl/UKSegmentedControl.swift @@ -2,7 +2,7 @@ import AutoLayout import UIKit /// A UIKit component that allows users to choose between multiple segments or options. -open class UKSegmentedControl: UIView, UKComponent { +open class UKSegmentedControl: FullWidthComponent, UKComponent { // MARK: Properties /// A closure that is triggered when a selected segment changes. diff --git a/Sources/ComponentsKit/Components/Slider/UKSlider.swift b/Sources/ComponentsKit/Components/Slider/UKSlider.swift index 1a715eb6..22615b63 100644 --- a/Sources/ComponentsKit/Components/Slider/UKSlider.swift +++ b/Sources/ComponentsKit/Components/Slider/UKSlider.swift @@ -2,7 +2,7 @@ import AutoLayout import UIKit /// A UIKit component that lets users select a value from a range by dragging a thumb along a track. -open class UKSlider: UIView, UKComponent { +open class UKSlider: FullWidthComponent, UKComponent { // MARK: - Properties /// A closure that is triggered when the `currentValue` changes. diff --git a/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift index 274cce4b..84dab608 100644 --- a/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift +++ b/Sources/ComponentsKit/Components/TextInput/Models/TextInputVM.swift @@ -52,6 +52,11 @@ public struct TextInputVM: ComponentVM { /// Defaults to `.medium`. public var size: ComponentSize = .medium + /// The visual style of the text input. + /// + /// Defaults to `.light`. + public var style: InputStyle = .light + /// The type of the submit button on the keyboard. /// /// Defaults to `.return`. @@ -89,7 +94,12 @@ extension TextInputVM { } var backgroundColor: UniversalColor { - return self.color?.background ?? .content1 + switch self.style { + case .light, .faded: + return self.color?.background ?? .content1 + case .bordered: + return .background + } } var foregroundColor: UniversalColor { @@ -105,6 +115,26 @@ extension TextInputVM { } } + var borderWidth: CGFloat { + switch self.style { + case .light: + return 0 + case .bordered, .faded: + switch self.size { + case .small: + return BorderWidth.small.value + case .medium: + return BorderWidth.medium.value + case .large: + return BorderWidth.large.value + } + } + } + + var borderColor: UniversalColor { + return (self.color?.main ?? .content3).enabled(self.isEnabled) + } + var minTextInputHeight: CGFloat { let numberOfRows: Int if let maxRows { @@ -117,7 +147,7 @@ extension TextInputVM { var maxTextInputHeight: CGFloat { if let maxRows { - return self.height(forRows: maxRows) + return self.height(forRows: max(maxRows, self.minRows)) } else { return 10_000 } diff --git a/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift index 47e68014..bd90dcb3 100644 --- a/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift @@ -14,7 +14,7 @@ public struct SUTextInput: View { /// /// When the `localFocus` value matches `globalFocus`, this text input becomes focused. /// This enables centralized focus management for multiple text inputs and input fields within a single view. - @FocusState.Binding public var globalFocus: FocusValue + public let globalFocus: FocusState.Binding? /// The unique value for this field to match against the global focus state to determine whether this text input is focused. /// @@ -24,7 +24,7 @@ public struct SUTextInput: View { /// /// - Warning: The `localFocus` value must be unique to each text input and input field, to ensure that different /// text inputs and input fields within the same view can be independently focused based on the shared `globalFocus`. - public var localFocus: FocusValue + public let localFocus: FocusValue @State private var textEditorPreferredHeight: CGFloat = 0 @@ -43,7 +43,7 @@ public struct SUTextInput: View { model: TextInputVM = .init() ) { self._text = text - self._globalFocus = globalFocus + self.globalFocus = globalFocus self.localFocus = localFocus self.model = model } @@ -55,19 +55,28 @@ public struct SUTextInput: View { TextEditor(text: self.$text) .contentMargins(self.model.contentPadding) .transparentScrollBackground() - .frame(minHeight: self.model.minTextInputHeight) - .frame(height: max( - self.model.minTextInputHeight, - min( - self.model.maxTextInputHeight, - self.textEditorPreferredHeight + .frame( + minHeight: self.model.minTextInputHeight, + idealHeight: max( + self.model.minTextInputHeight, + min( + self.model.maxTextInputHeight, + self.textEditorPreferredHeight + ) + ), + maxHeight: max( + self.model.minTextInputHeight, + min( + self.model.maxTextInputHeight, + self.textEditorPreferredHeight + ) ) - )) + ) .lineSpacing(0) .font(self.model.preferredFont.font) .foregroundStyle(self.model.foregroundColor.color) .tint(self.model.tintColor.color) - .focused(self.$globalFocus, equals: self.localFocus) + .applyFocus(globalFocus: self.globalFocus, localFocus: self.localFocus) .disabled(!self.model.isEnabled) .keyboardType(self.model.keyboardType) .submitLabel(self.model.submitType.submitLabel) @@ -124,6 +133,15 @@ public struct SUTextInput: View { cornerRadius: self.model.adaptedCornerRadius() ) ) + .overlay( + RoundedRectangle( + cornerRadius: self.model.cornerRadius.value() + ) + .stroke( + self.model.borderColor.color, + lineWidth: self.model.borderWidth + ) + ) } } @@ -154,6 +172,18 @@ extension View { UITextView.appearance().textContainer.lineFragmentPadding = 0 } } + + @ViewBuilder + fileprivate func applyFocus( + globalFocus: FocusState.Binding?, + localFocus: FocusValue + ) -> some View { + if let globalFocus { + self.focused(globalFocus, equals: localFocus) + } else { + self + } + } } // MARK: - Boolean Focus Value @@ -170,7 +200,25 @@ extension SUTextInput where FocusValue == Bool { model: TextInputVM = .init() ) { self._text = text - self._globalFocus = isFocused + self.globalFocus = isFocused + self.localFocus = true + self.model = model + } +} + +// MARK: - No Focus Value + +extension SUTextInput where FocusValue == Bool { + /// Initializer. + /// - Parameters: + /// - text: A Binding value to control the inputted text. + /// - model: A model that defines the appearance properties. + public init( + text: Binding, + model: TextInputVM = .init() + ) { + self._text = text + self.globalFocus = nil self.localFocus = true self.model = model } diff --git a/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift b/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift index 8e4b8519..7710feda 100644 --- a/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/UKTextInput.swift @@ -79,6 +79,12 @@ open class UKTextInput: UIView, UKComponent { self.addSubview(self.placeholderLabel) self.textView.delegate = self + + if #available(iOS 17.0, *) { + self.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in + view.handleTraitChanges() + } + } } // MARK: - Style @@ -157,7 +163,18 @@ open class UKTextInput: UIView, UKComponent { return CGSize(width: width, height: height) } - // MARK: - Helpers + open override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + self.handleTraitChanges() + } + + // MARK: Helpers + + @objc private func handleTraitChanges() { + Self.Style.mainView(self, model: self.model) + } private func handleTextChanges() { self.onValueChange(self.text) @@ -187,6 +204,8 @@ extension UKTextInput { static func mainView(_ view: UIView, model: TextInputVM) { view.backgroundColor = model.backgroundColor.uiColor view.layer.cornerRadius = model.adaptedCornerRadius(for: view.bounds.height) + view.layer.borderColor = model.borderColor.cgColor + view.layer.borderWidth = model.borderWidth } static func textView( @@ -205,8 +224,11 @@ extension UKTextInput { } static func textView(_ textView: UITextView, padding: CGFloat) { - textView.textContainerInset = .init(inset: padding) textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset.top = padding + textView.textContainerInset.left = padding + textView.textContainerInset.right = padding + textView.textContainerInset.bottom = padding } static func placeholder( diff --git a/Sources/ComponentsKit/Helpers/UIKit/FullWidthComponent.swift b/Sources/ComponentsKit/Helpers/UIKit/FullWidthComponent.swift new file mode 100644 index 00000000..85dc5c4f --- /dev/null +++ b/Sources/ComponentsKit/Helpers/UIKit/FullWidthComponent.swift @@ -0,0 +1,28 @@ +import UIKit + +/// A base-class for views whose intrinsic content size depends on the +/// width of their super-view (e.g. full width button, input field, etc.). +/// +/// By inheriting from `FullWidthComponent` the component gets automatic +/// `invalidateIntrinsicContentSize()` calls whenever the device rotates, the +/// window is resized (iPad multitasking, Stage Manager) or the view moves +/// into a different container with a new width. +open class FullWidthComponent: UIView { + private var lastKnownParentWidth: CGFloat = .nan + + open override func layoutSubviews() { + super.layoutSubviews() + + guard let parentWidth = self.superview?.bounds.width else { return } + + if parentWidth != self.lastKnownParentWidth { + self.lastKnownParentWidth = parentWidth + + // Defer to the next run-loop tick so the current layout pass + // finishes with the new parent size first. + DispatchQueue.main.async { + self.invalidateIntrinsicContentSize() + } + } + } +} diff --git a/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift b/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift index bc598997..0dc44879 100644 --- a/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift +++ b/Sources/ComponentsKit/Shared/Types/ButtonStyle.swift @@ -10,4 +10,6 @@ public enum ButtonStyle: Hashable { case light /// A button with a transparent background and a border. case bordered(BorderWidth) + /// A button with no background or padding, sized strictly to fit its content. + case minimal } diff --git a/Sources/ComponentsKit/Shared/Types/InputStyle.swift b/Sources/ComponentsKit/Shared/Types/InputStyle.swift new file mode 100644 index 00000000..34da511c --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/InputStyle.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The appearance style of inputs. +public enum InputStyle: Hashable { + /// An input with a partially transparent background. + case light + /// An input with a transparent background and a border. + case bordered + /// An input with a partially transparent background and a border. + case faded +}