diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/InputFieldPreview.swift index 3851467d..f511e2ba 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,17 @@ 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) + Picker("Style", selection: self.$model.style) { + Text("Light").tag(InputFieldVM.Style.light) + Text("Bordered").tag(InputFieldVM.Style.bordered) + Text("Faded").tag(InputFieldVM.Style.faded) + } SubmitTypePicker(selection: self.$model.submitType) UniversalColorPicker( title: "Tint Color", @@ -62,9 +76,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 +98,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/Sources/ComponentsKit/Components/InputField/Models/InputFieldStyle.swift b/Sources/ComponentsKit/Components/InputField/Models/InputFieldStyle.swift new file mode 100644 index 00000000..52741b34 --- /dev/null +++ b/Sources/ComponentsKit/Components/InputField/Models/InputFieldStyle.swift @@ -0,0 +1,13 @@ +import Foundation + +extension InputFieldVM { + /// The input fields appearance style. + public enum Style: Hashable { + /// An input field with a partially transparent background. + case light + /// An input field with a transparent background and a border. + case bordered + /// An input field with a partially transparent background and a border. + case faded + } +} 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..dd9c2f88 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: Style = .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 aa735b3d..a2a58b35 100644 --- a/Sources/ComponentsKit/Components/InputField/SUInputField.swift +++ b/Sources/ComponentsKit/Components/InputField/SUInputField.swift @@ -49,46 +49,68 @@ 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) } - .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() + .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) + } + } } } @@ -98,7 +120,7 @@ extension View { @ViewBuilder fileprivate func applyFocus( globalFocus: FocusState.Binding?, - localFocus: FocusValue, + localFocus: FocusValue ) -> some View { if let globalFocus { self.focused(globalFocus, equals: localFocus) diff --git a/Sources/ComponentsKit/Components/InputField/UKInputField.swift b/Sources/ComponentsKit/Components/InputField/UKInputField.swift index 2300b4f7..1a553155 100644 --- a/Sources/ComponentsKit/Components/InputField/UKInputField.swift +++ b/Sources/ComponentsKit/Components/InputField/UKInputField.swift @@ -3,7 +3,7 @@ import UIKit /// A UIKit component that displays a field to input a text. open class UKInputField: UIView, UKComponent { - // MARK: Properties + // 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,29 @@ 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.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 +151,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 +191,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 +218,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 +249,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/TextInput/SUTextInput.swift b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift index d91b66d9..0ca46401 100644 --- a/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift +++ b/Sources/ComponentsKit/Components/TextInput/SUTextInput.swift @@ -158,7 +158,7 @@ extension View { @ViewBuilder fileprivate func applyFocus( globalFocus: FocusState.Binding?, - localFocus: FocusValue, + localFocus: FocusValue ) -> some View { if let globalFocus { self.focused(globalFocus, equals: localFocus)