From 17d31bc49da4bd6ad217685d723c8f6c78099442 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 31 Jan 2024 16:20:29 +0100 Subject: [PATCH 001/117] [FormField#782] Add formfield --- .../FormFieldAccessibilityIdentifier.swift | 16 ++ .../FormField/Enum/FormFieldIntent.swift | 18 ++ .../FormField/Model/FormFieldColors.swift | 14 + .../FormField/Model/FormFieldViewModel.swift | 106 +++++++ .../UseCase/FormFieldColorsUseCase.swift | 47 +++ .../View/UIKit/FormFieldUIView.swift | 267 ++++++++++++++++++ spark/Demo/Classes/Enum/UIComponent.swift | 2 + .../Components/ComponentsViewController.swift | 2 + .../FormField/FormFieldComponentUIView.swift | 115 ++++++++ .../FormFieldComponentUIViewController.swift | 127 +++++++++ .../FormFieldComponentUIViewModel.swift | 165 +++++++++++ 11 files changed, 879 insertions(+) create mode 100644 core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift create mode 100644 core/Sources/Components/FormField/Enum/FormFieldIntent.swift create mode 100644 core/Sources/Components/FormField/Model/FormFieldColors.swift create mode 100644 core/Sources/Components/FormField/Model/FormFieldViewModel.swift create mode 100644 core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift create mode 100644 core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift create mode 100644 spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift create mode 100644 spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift create mode 100644 spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift diff --git a/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift b/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift new file mode 100644 index 000000000..48db89456 --- /dev/null +++ b/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift @@ -0,0 +1,16 @@ +// +// FormFieldAccessibilityIdentifier.swift +// SparkCore +// +// Created by alican.aycil on 30.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +public enum FormFieldAccessibilityIdentifier { + + // MARK: - Properties + + public static let formField = "spark-formfield" +} diff --git a/core/Sources/Components/FormField/Enum/FormFieldIntent.swift b/core/Sources/Components/FormField/Enum/FormFieldIntent.swift new file mode 100644 index 000000000..cc92acd0e --- /dev/null +++ b/core/Sources/Components/FormField/Enum/FormFieldIntent.swift @@ -0,0 +1,18 @@ +// +// FormFieldIntent.swift +// SparkCore +// +// Created by alican.aycil on 31.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +/// The various intent color a formfield may have. +public enum FormFieldIntent: CaseIterable { + case base + case support + case error + case success + case alert +} diff --git a/core/Sources/Components/FormField/Model/FormFieldColors.swift b/core/Sources/Components/FormField/Model/FormFieldColors.swift new file mode 100644 index 000000000..ea56f9a3a --- /dev/null +++ b/core/Sources/Components/FormField/Model/FormFieldColors.swift @@ -0,0 +1,14 @@ +// +// FormFieldColors.swift +// SparkCore +// +// Created by alican.aycil on 31.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +struct FormFieldColors { + let titleColor: any ColorToken + let descriptionColor: any ColorToken +} diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift new file mode 100644 index 000000000..b58d7cdae --- /dev/null +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -0,0 +1,106 @@ +// +// FormFieldViewModel.swift +// SparkCore +// +// Created by alican.aycil on 30.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Combine +import SwiftUI +import UIKit + +final class FormFieldViewModel: ObservableObject { + + // MARK: - Internal properties + @Published var title: Either? + @Published var titleFont: Either? + @Published var titleColor: Either? + @Published var titleOpacity: CGFloat? + @Published var description: Either? + @Published var descriptionFont: Either? + @Published var descriptionColor: Either? + @Published var descriptionOpacity: CGFloat? + @Published var spacing: CGFloat + + var theme: Theme { + didSet { + self.updateColors() + self.updateFonts() + self.updateSpacing() + self.updateOpacity() + } + } + + var intent: FormFieldIntent { + didSet { + self.updateColors() + } + } + + var isEnabled: Bool { + didSet { + self.updateOpacity() + } + } + + var colorUseCase: FormFieldColorsUseCase + + // MARK: - Init + + init( + theme: Theme, + intent: FormFieldIntent, + isEnabled: Bool, + title: Either?, + description: Either?, + colorUseCase: FormFieldColorsUseCase = .init() + ) { + self.theme = theme + self.intent = intent + self.isEnabled = isEnabled + self.title = title + self.description = description + self.colorUseCase = colorUseCase + self.spacing = self.theme.layout.spacing.small + } + + private func updateColors() { + let colors = colorUseCase.execute(from: self.theme.colors, intent: self.intent) + + if self.title?.leftValue != nil { + self.titleColor = .left(colors.titleColor.uiColor) + } else { + self.titleColor = .right(colors.titleColor.color) + } + + if self.description?.leftValue != nil { + self.descriptionColor = .left(colors.descriptionColor.uiColor) + } else { + self.descriptionColor = .right(colors.descriptionColor.color) + } + } + + private func updateFonts() { + if self.title?.leftValue != nil { + self.titleFont = .left(self.theme.typography.subhead.uiFont) + } else { + self.titleFont = .right(self.theme.typography.subhead.font) + } + + if self.description?.leftValue != nil { + self.descriptionFont = .left(self.theme.typography.caption.uiFont) + } else { + self.descriptionFont = .right(self.theme.typography.caption.font) + } + } + + private func updateSpacing() { + self.spacing = self.theme.layout.spacing.small + } + + private func updateOpacity() { + self.titleOpacity = isEnabled ? self.theme.dims.none : self.theme.dims.dim3 + self.descriptionOpacity = isEnabled ? self.theme.dims.none : self.theme.dims.dim1 + } +} diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift new file mode 100644 index 000000000..7c71d48b8 --- /dev/null +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift @@ -0,0 +1,47 @@ +// +// FormFieldColorsUseCase.swift +// SparkCore +// +// Created by alican.aycil on 31.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +// sourcery: AutoMockable +protocol FormFieldColorsUseCaseable { + func execute(from colors: Colors, intent: FormFieldIntent) -> FormFieldColors +} + +struct FormFieldColorsUseCase: FormFieldColorsUseCaseable { + + func execute(from colors: Colors, intent: FormFieldIntent) -> FormFieldColors { + switch intent { + case .error: + return FormFieldColors( + titleColor: colors.base.onSurface, + descriptionColor: colors.feedback.error + ) + case .success: + return FormFieldColors( + titleColor: colors.base.onSurface, + descriptionColor: colors.feedback.success + ) + case .alert: + return FormFieldColors( + titleColor: colors.base.onSurface, + descriptionColor: colors.feedback.alert + ) + case .support: + return FormFieldColors( + titleColor: colors.base.onSurface, + descriptionColor: colors.support.support + ) + case .base: + return FormFieldColors( + titleColor: colors.base.onSurface, + descriptionColor: colors.base.onSurface + ) + } + } +} diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift new file mode 100644 index 000000000..40e908258 --- /dev/null +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -0,0 +1,267 @@ +// +// FormFieldUIView.swift +// SparkCore +// +// Created by alican.aycil on 30.01.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Combine +import SwiftUI +import UIKit + +/// The `FormFieldUIView`renders a component with title and subtitle using UIKit. +public final class FormFieldUIView: UIControl { + + // MARK: - Private Properties. + + private let titleLabel: UILabel = { + let label = UILabel() + label.backgroundColor = .clear + label.numberOfLines = 0 + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.backgroundColor = .clear + label.numberOfLines = 0 + return label + }() + + private lazy var componentContainerStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.component]) + return stackView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, componentContainerStackView, descriptionLabel]) + stackView.axis = .vertical + stackView.spacing = self.spacing + stackView.alignment = .leading + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private var cancellables = Set() + @ScaledUIMetric private var spacing: CGFloat + + // MARK: - Public properties. + + /// The title of formfield. + public var title: String? { + get { + return self.titleLabel.text + } + set { + self.viewModel.title = .left(newValue.map(NSAttributedString.init)) + } + } + + /// The attributedTitle of formfield. + public var attributedTitle: NSAttributedString? { + get { + return self.titleLabel.attributedText + } + set { + self.viewModel.title = .left(newValue) + } + } + + /// The description of formfield. + public var descriptionString: String? { + get { + return self.descriptionLabel.text + } + set { + self.viewModel.description = .left(newValue.map(NSAttributedString.init)) + } + } + + /// The attributedDescription of formfield. + public var attributedDescription: NSAttributedString? { + get { + return self.descriptionLabel.attributedText + } + set { + self.viewModel.description = .left(newValue) + } + } + + /// Returns the theme of the formfield. + public var theme: Theme { + get { + return self.viewModel.theme + } + set { + self.viewModel.theme = newValue + } + } + + /// Returns the theme of the formfield. + public var intent: FormFieldIntent { + get { + return self.viewModel.intent + } + set { + self.viewModel.intent = newValue + } + } + + /// The current state of the component. + public override var isEnabled: Bool { + get { + return self.viewModel.isEnabled + } + set { + self.viewModel.isEnabled = newValue + self.component.isEnabled = newValue + } + } + + public override var isHighlighted: Bool { + get { + return self.component.isHighlighted + } + set { + self.component.isHighlighted = newValue + } + } + + /// The current selection state of the component. + public override var isSelected: Bool { + get { + return self.component.isSelected + } + set { + self.component.isSelected = newValue + } + } + + /// The component of formfield. + public var component: UIControl { + didSet { + self.componentContainerStackView.removeArrangedSubviews() + self.componentContainerStackView.addArrangedSubview(self.component) + } + } + + var viewModel: FormFieldViewModel + + // MARK: - Initialization + + /// Not implemented. Please use another init. + /// - Parameter coder: the coder. + public required init?(coder: NSCoder) { + fatalError("not implemented") + } + + /// Initialize a new checkbox UIKit-view. + /// - Parameters: + /// - theme: The current Spark-Theme. + /// - title: The formfield title. + /// - attributedTitle: The formfield attributedTitle. + /// - description: The formfield description. + /// - attributedDescription: The formfield attributedDescription. + /// - component: The formfield component. + public init( + theme: Theme, + intent: FormFieldIntent, + isEnabled: Bool = true, + title: String? = nil, + attributedTitle: NSAttributedString? = nil, + description: String? = nil, + attributedDescription: NSAttributedString? = nil, + component: UIControl + ) { + let titleValue: NSAttributedString? + let descriptionValue: NSAttributedString? + + if let title = title { + titleValue = NSAttributedString(string: title) + } else { + titleValue = attributedTitle + } + + if let description = description { + descriptionValue = NSAttributedString(string: description) + } else { + descriptionValue = attributedDescription + } + + let viewModel = FormFieldViewModel( + theme: theme, + intent: intent, + isEnabled: isEnabled, + title: .left(titleValue), + description: .left(descriptionValue) + ) + + self.viewModel = viewModel + self.spacing = viewModel.spacing + self.component = component + + super.init(frame: .zero) + self.commonInit() + } + + private func commonInit() { + self.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formField + self.setupViews() + self.subscribe() + } + + private func setupViews() { + self.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.stackView) + NSLayoutConstraint.stickEdges(from: self.stackView, to: self) + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory else { return } + + self._spacing.update(traitCollection: traitCollection) + self.stackView.spacing = self.spacing + } + + private func subscribe() { + + Publishers.CombineLatest4( + self.viewModel.$title, + self.viewModel.$titleFont, + self.viewModel.$titleColor, + self.viewModel.$titleOpacity + ).subscribe(in: &self.cancellables) { [weak self] title, font, color, opacity in + guard let self else { return } + let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty + self.titleLabel.isHidden = labelHidden + self.titleLabel.font = font?.leftValue + self.titleLabel.textColor = color?.leftValue + self.titleLabel.attributedText = title?.leftValue + self.titleLabel.layer.opacity = Float(opacity ?? 1.0) + } + + Publishers.CombineLatest4( + self.viewModel.$description, + self.viewModel.$descriptionFont, + self.viewModel.$descriptionColor, + self.viewModel.$descriptionOpacity + ).subscribe(in: &self.cancellables) { [weak self] title, font, color, opacity in + guard let self else { return } + let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty + self.descriptionLabel.isHidden = labelHidden + self.descriptionLabel.font = font?.leftValue + self.descriptionLabel.textColor = color?.leftValue + self.descriptionLabel.attributedText = title?.leftValue + self.descriptionLabel.layer.opacity = Float(opacity ?? 1.0) + } + + self.viewModel.$spacing.subscribe(in: &self.cancellables) { [weak self] spacing in + guard let self = self else { return } + self._spacing.wrappedValue = spacing + self.stackView.spacing = self.spacing + } + } +} diff --git a/spark/Demo/Classes/Enum/UIComponent.swift b/spark/Demo/Classes/Enum/UIComponent.swift index b54c611ed..30da74e1a 100644 --- a/spark/Demo/Classes/Enum/UIComponent.swift +++ b/spark/Demo/Classes/Enum/UIComponent.swift @@ -15,6 +15,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { .button, .checkbox, .chip, + .formField, .icon, .progressBarIndeterminate, .progressBarSingle, @@ -37,6 +38,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { static let button = UIComponent(rawValue: "Button") static let checkbox = UIComponent(rawValue: "Checkbox") static let chip = UIComponent(rawValue: "Chip") + static let formField = UIComponent(rawValue: "FormField") static let icon = UIComponent(rawValue: "Icon") static let progressBarIndeterminate = UIComponent(rawValue: "Progress Bar Indeterminate") static let progressBarSingle = UIComponent(rawValue: "Progress Bar Single") diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index 44e981ff5..17f59f7cd 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -80,6 +80,8 @@ extension ComponentsViewController { ) case .chip: viewController = ChipComponentViewController.build() + case .formField: + viewController = FormFieldComponentUIViewController.build() case .icon: viewController = IconComponentUIViewController.build() case .progressBarIndeterminate: diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift new file mode 100644 index 000000000..48ca2fc68 --- /dev/null +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -0,0 +1,115 @@ +// +// FormFieldComponentUIView.swift +// Spark +// +// Created by alican.aycil on 30.01.24. +// Copyright (c) 2024 Adevinta. All rights reserved. +// + +import Combine +import SparkCore +import Spark +import UIKit + +final class FormFieldComponentUIView: ComponentUIView { + + // MARK: - Components + private let componentView: FormFieldUIView + + // MARK: - Properties + + private let viewModel: FormFieldComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Initializer + init(viewModel: FormFieldComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = Self.makeFormField(viewModel) + + super.init( + viewModel: viewModel, + componentView: self.componentView + ) + + // Setup + self.setupSubscriptions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Subscribe + private func setupSubscriptions() { + self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in + guard let self = self else { return } + let themes = self.viewModel.themes + let themeTitle: String? = theme is SparkTheme ? themes.first?.title : themes.last?.title + + self.viewModel.themeConfigurationItemViewModel.buttonTitle = themeTitle + self.componentView.theme = theme + } + + self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + guard let self = self else { return } + self.viewModel.intentConfigurationItemViewModel.buttonTitle = intent.name + self.componentView.intent = intent + } + + self.viewModel.$titleStyle.subscribe(in: &self.cancellables) { [weak self] textStyle in + guard let self = self else { return } + self.viewModel.titleStyleConfigurationItemViewModel.buttonTitle = textStyle.name + switch textStyle { + case .text: + self.componentView.title = self.viewModel.text + case .multilineText: + self.componentView.title = self.viewModel.multilineText + case .attributeText: + self.componentView.attributedTitle = self.viewModel.attributeText + case .none: + self.componentView.title = nil + } + } + + self.viewModel.$descriptionStyle.subscribe(in: &self.cancellables) { [weak self] textStyle in + guard let self = self else { return } + self.viewModel.descriptionStyleConfigurationItemViewModel.buttonTitle = textStyle.name + switch textStyle { + case .text: + self.componentView.descriptionString = self.viewModel.descriptionText + case .multilineText: + self.componentView.descriptionString = self.viewModel.multilineText + case .attributeText: + self.componentView.attributedDescription = self.viewModel.attributeText + case .none: + self.componentView.descriptionString = nil + } + } + + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in + guard let self = self else { return } + self.componentView.isEnabled = isEnabled + } + } + + // MARK: - Create View + static func makeFormField(_ viewModel: FormFieldComponentUIViewModel) -> FormFieldUIView { + return .init( + theme: viewModel.theme, + intent: .support, + title: "Agreement", + description: "Your agreement is important to us.", + component: Self.makeBasicView() + ) + } + + static func makeBasicView() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.lightGray + view.heightAnchor.constraint(equalToConstant: 100).isActive = true + view.widthAnchor.constraint(equalToConstant: 200).isActive = true + return view + } + + +} diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift new file mode 100644 index 000000000..9630b5444 --- /dev/null +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift @@ -0,0 +1,127 @@ +// +// FormFieldComponentUIViewController.swift +// Spark +// +// Created by alican.aycil on 30.01.24. +// Copyright (c) 2024 Adevinta. All rights reserved. +// + +import Combine +import Spark +import SparkCore +import SwiftUI +import UIKit + +final class FormFieldComponentUIViewController: UIViewController { + + // MARK: - Published Properties + @ObservedObject private var themePublisher = SparkThemePublisher.shared + + // MARK: - Properties + let componentView: FormFieldComponentUIView + let viewModel: FormFieldComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Initializer + init(viewModel: FormFieldComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = FormFieldComponentUIView(viewModel: viewModel) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + super.loadView() + view = componentView + } + + // MARK: - ViewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + self.navigationItem.title = "FormField" + addPublisher() + } + + // MARK: - Add Publishers + private func addPublisher() { + + self.themePublisher + .$theme + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.theme = theme + self.navigationController?.navigationBar.tintColor = theme.colors.main.main.uiColor + } + .store(in: &self.cancellables) + + self.viewModel.showThemeSheet.subscribe(in: &self.cancellables) { intents in + self.presentThemeActionSheet(intents) + } + + self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in + self.presentIntentActionSheet(intents) + } + + self.viewModel.showTitleSheet.subscribe(in: &self.cancellables) { titles in + self.presentTitleStyleActionSheet(titles) + } + + self.viewModel.showDescriptionSheet.subscribe(in: &self.cancellables) { descriptions in + self.presentDescriptionStyleActionSheet(descriptions) + } + } +} + +// MARK: - Builder +extension FormFieldComponentUIViewController { + + static func build() -> FormFieldComponentUIViewController { + let viewModel = FormFieldComponentUIViewModel(theme: SparkThemePublisher.shared.theme) + let viewController = FormFieldComponentUIViewController(viewModel: viewModel) + return viewController + } +} + +// MARK: - Navigation +extension FormFieldComponentUIViewController { + + private func presentThemeActionSheet(_ themes: [ThemeCellModel]) { + let actionSheet = SparkActionSheet.init( + values: themes.map { $0.theme }, + texts: themes.map { $0.title }) { theme in + self.themePublisher.theme = theme + } + self.present(actionSheet, animated: true) + } + + private func presentIntentActionSheet(_ intents: [FormFieldIntent]) { + let actionSheet = SparkActionSheet.init( + values: intents, + texts: intents.map { $0.name }) { intent in + self.viewModel.intent = intent + } + self.present(actionSheet, isAnimated: true) + } + + private func presentTitleStyleActionSheet(_ textStyles: [FormFieldTextStyle]) { + let actionSheet = SparkActionSheet.init( + values: textStyles, + texts: textStyles.map { $0.name }) { textStyle in + self.viewModel.titleStyle = textStyle + } + self.present(actionSheet, isAnimated: true) + } + + private func presentDescriptionStyleActionSheet(_ textStyles: [FormFieldTextStyle]) { + let actionSheet = SparkActionSheet.init( + values: textStyles, + texts: textStyles.map { $0.name }) { textStyle in + self.viewModel.descriptionStyle = textStyle + } + self.present(actionSheet, isAnimated: true) + } +} diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift new file mode 100644 index 000000000..84499d8e7 --- /dev/null +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift @@ -0,0 +1,165 @@ +// +// FormFieldComponentUIViewModel.swift +// Spark +// +// Created by alican.aycil on 30.01.24. +// Copyright (c) 2024 Adevinta. All rights reserved. +// + +import Combine +import Spark +import SparkCore +import UIKit + +final class FormFieldComponentUIViewModel: ComponentUIViewModel { + + // MARK: - Published Properties + var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { + showThemeSheetSubject + .eraseToAnyPublisher() + } + + var showIntentSheet: AnyPublisher<[FormFieldIntent], Never> { + showIntentSheetSubject + .eraseToAnyPublisher() + } + + var showTitleSheet: AnyPublisher<[FormFieldTextStyle], Never> { + showTitleStyleSheetSubject + .eraseToAnyPublisher() + } + + var showDescriptionSheet: AnyPublisher<[FormFieldTextStyle], Never> { + showDescriptionStyleSheetSubject + .eraseToAnyPublisher() + } + + // MARK: - Private Properties + private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() + private var showIntentSheetSubject: PassthroughSubject<[FormFieldIntent], Never> = .init() + private var showTitleStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() + private var showDescriptionStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() + + // MARK: - Items Properties + lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Theme", + type: .button, + target: (source: self, action: #selector(self.presentThemeSheet)) + ) + }() + + lazy var intentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Intent", + type: .button, + target: (source: self, action: #selector(self.presentIntentSheet)) + ) + }() + + lazy var titleStyleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Title Style", + type: .button, + target: (source: self, action: #selector(self.presentTextStyleSheet)) + ) + }() + + lazy var descriptionStyleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Description Style", + type: .button, + target: (source: self, action: #selector(self.presentDescriptionStyleSheet)) + ) + }() + + lazy var disableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Enable", + type: .checkbox(title: "", isOn: self.isEnabled), + target: (source: self, action: #selector(self.enabledChanged(_:)))) + }() + + // MARK: - Default Properties + var themes = ThemeCellModel.themes + let text: String = "Agreement" + let descriptionText = "Your agreement is important to us." + let multilineText: String = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." + var attributeText: NSAttributedString { + let attributeString = NSMutableAttributedString( + string: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + attributes: [.font: UIFont.italicSystemFont(ofSize: 18)] + ) + let attributes: [NSMutableAttributedString.Key: Any] = [ + .font: UIFont( + descriptor: UIFontDescriptor().withSymbolicTraits([.traitBold, .traitItalic]) ?? UIFontDescriptor(), + size: 18 + ), + .foregroundColor: UIColor.red + ] + attributeString.setAttributes(attributes, range: NSRange(location: 0, length: 11)) + return attributeString + } + + // MARK: - Initialization + @Published var theme: Theme + @Published var intent: FormFieldIntent + @Published var titleStyle: FormFieldTextStyle + @Published var descriptionStyle: FormFieldTextStyle + @Published var isEnabled: Bool + + init( + theme: Theme, + intent: FormFieldIntent = .support, + titleStyle: FormFieldTextStyle = .text, + descriptionStyle: FormFieldTextStyle = .text, + isEnabled: Bool = true + ) { + self.theme = theme + self.intent = intent + self.titleStyle = titleStyle + self.descriptionStyle = descriptionStyle + self.isEnabled = isEnabled + super.init(identifier: "FormField") + + self.configurationViewModel = .init(itemsViewModel: [ + self.themeConfigurationItemViewModel, + self.intentConfigurationItemViewModel, + self.titleStyleConfigurationItemViewModel, + self.descriptionStyleConfigurationItemViewModel, + self.disableConfigurationItemViewModel + ]) + } +} + +// MARK: - Navigation +extension FormFieldComponentUIViewModel { + + @objc func presentThemeSheet() { + self.showThemeSheetSubject.send(themes) + } + + @objc func presentIntentSheet() { + self.showIntentSheetSubject.send(FormFieldIntent.allCases) + } + + @objc func presentTextStyleSheet() { + self.showTitleStyleSheetSubject.send(FormFieldTextStyle.allCases) + } + + @objc func presentDescriptionStyleSheet() { + self.showDescriptionStyleSheetSubject.send(FormFieldTextStyle.allCases) + } + + @objc func enabledChanged(_ isSelected: Any?) { + self.isEnabled = isTrue(isSelected) + } +} + +// MARK: - Enum +enum FormFieldTextStyle: CaseIterable { + case text + case multilineText + case attributeText + case none +} From 29dddd0dfdd18ac536fffe02f652086ce705bf67 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 1 Feb 2024 13:48:00 +0100 Subject: [PATCH 002/117] [FormField#782] Add component configuration on demo project --- .../FormField/FormFieldComponentUIView.swift | 143 +++++++++++++++++- .../FormFieldComponentUIViewController.swift | 13 ++ .../FormFieldComponentUIViewModel.swift | 36 +++++ 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 48ca2fc68..1ba57e92c 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -86,6 +86,40 @@ final class FormFieldComponentUIView: ComponentUIView { } } + self.viewModel.$componentStyle.subscribe(in: &self.cancellables) { [weak self] componentStyle in + guard let self = self else { return } + self.viewModel.componentStyleConfigurationItemViewModel.buttonTitle = componentStyle.name + + let component: UIControl! + + switch componentStyle { + case .basic: + component = Self.makeBasicView() + case .singleCheckbox: + component = Self.makeSingleCheckbox() + case .verticalCheckbox: + component = Self.makeVerticalCheckbox() + case .horizontalCheckbox: + component = Self.makeHorizontalCheckbox() + case .horizontalScrollableCheckbox: + component = Self.makeHorizontalScrollableCheckbox() + case .singleRadioButton: + component = Self.makeSingleRadioButton() + case .verticalRadioButton: + component = Self.makeVerticalRadioButton() + case .horizontalRadioButton: + component = Self.makeHorizontalRadioButton() + case .textField: + component = Self.makeTextField() + case .addOnTextField: + component = Self.makeAddOnTextField() + case .ratingInput: + component = Self.makeRatingInput() + } + + self.componentView.component = component + } + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in guard let self = self else { return } self.componentView.isEnabled = isEnabled @@ -94,12 +128,39 @@ final class FormFieldComponentUIView: ComponentUIView { // MARK: - Create View static func makeFormField(_ viewModel: FormFieldComponentUIViewModel) -> FormFieldUIView { + let component: UIControl! + + switch viewModel.componentStyle { + case .basic: + component = Self.makeBasicView() + case .singleCheckbox: + component = Self.makeSingleCheckbox() + case .verticalCheckbox: + component = Self.makeVerticalCheckbox() + case .horizontalCheckbox: + component = Self.makeHorizontalCheckbox() + case .horizontalScrollableCheckbox: + component = Self.makeHorizontalScrollableCheckbox() + case .singleRadioButton: + component = Self.makeSingleRadioButton() + case .verticalRadioButton: + component = Self.makeVerticalRadioButton() + case .horizontalRadioButton: + component = Self.makeHorizontalRadioButton() + case .textField: + component = Self.makeTextField() + case .addOnTextField: + component = Self.makeAddOnTextField() + case .ratingInput: + component = Self.makeRatingInput() + } + return .init( theme: viewModel.theme, intent: .support, title: "Agreement", description: "Your agreement is important to us.", - component: Self.makeBasicView() + component: component ) } @@ -107,9 +168,87 @@ final class FormFieldComponentUIView: ComponentUIView { let view = UIControl() view.backgroundColor = UIColor.lightGray view.heightAnchor.constraint(equalToConstant: 100).isActive = true - view.widthAnchor.constraint(equalToConstant: 200).isActive = true + view.widthAnchor.constraint(equalToConstant: 50).isActive = true + return view + } + + static func makeSingleCheckbox() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.red + view.heightAnchor.constraint(equalToConstant: 150).isActive = true + view.widthAnchor.constraint(equalToConstant: 150).isActive = true return view } + static func makeVerticalCheckbox() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.blue + view.heightAnchor.constraint(equalToConstant: 200).isActive = true + view.widthAnchor.constraint(equalToConstant: 100).isActive = true + return view + } + + static func makeHorizontalCheckbox() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.black + view.heightAnchor.constraint(equalToConstant: 20).isActive = true + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + return view + } + + static func makeHorizontalScrollableCheckbox() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.brown + view.heightAnchor.constraint(equalToConstant: 70).isActive = true + view.widthAnchor.constraint(equalToConstant: 140).isActive = true + return view + } + static func makeSingleRadioButton() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.systemPink + view.heightAnchor.constraint(equalToConstant: 60).isActive = true + view.widthAnchor.constraint(equalToConstant: 250).isActive = true + return view + } + + static func makeVerticalRadioButton() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.green + view.heightAnchor.constraint(equalToConstant: 150).isActive = true + view.widthAnchor.constraint(equalToConstant: 250).isActive = true + return view + } + + static func makeHorizontalRadioButton() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.cyan + view.heightAnchor.constraint(equalToConstant: 110).isActive = true + view.widthAnchor.constraint(equalToConstant: 310).isActive = true + return view + } + + static func makeTextField() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.orange + view.heightAnchor.constraint(equalToConstant: 50).isActive = true + view.widthAnchor.constraint(equalToConstant: 100).isActive = true + return view + } + + static func makeAddOnTextField() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.magenta + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + view.widthAnchor.constraint(equalToConstant: 30).isActive = true + return view + } + + static func makeRatingInput() -> UIControl { + let view = UIControl() + view.backgroundColor = UIColor.purple + view.heightAnchor.constraint(equalToConstant: 500).isActive = true + view.widthAnchor.constraint(equalToConstant: 200).isActive = true + return view + } } diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift index 9630b5444..52e6334c9 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift @@ -73,6 +73,10 @@ final class FormFieldComponentUIViewController: UIViewController { self.viewModel.showDescriptionSheet.subscribe(in: &self.cancellables) { descriptions in self.presentDescriptionStyleActionSheet(descriptions) } + + self.viewModel.showComponentSheet.subscribe(in: &self.cancellables) { components in + self.presentComponentStyleActionSheet(components) + } } } @@ -124,4 +128,13 @@ extension FormFieldComponentUIViewController { } self.present(actionSheet, isAnimated: true) } + + private func presentComponentStyleActionSheet(_ componentStyles: [FormFieldComponentStyle]) { + let actionSheet = SparkActionSheet.init( + values: componentStyles, + texts: componentStyles.map { $0.name }) { componentStyle in + self.viewModel.componentStyle = componentStyle + } + self.present(actionSheet, isAnimated: true) + } } diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift index 84499d8e7..937abf44d 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift @@ -34,11 +34,17 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { .eraseToAnyPublisher() } + var showComponentSheet: AnyPublisher<[FormFieldComponentStyle], Never> { + showComponentStyleSheetSubject + .eraseToAnyPublisher() + } + // MARK: - Private Properties private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() private var showIntentSheetSubject: PassthroughSubject<[FormFieldIntent], Never> = .init() private var showTitleStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() private var showDescriptionStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() + private var showComponentStyleSheetSubject: PassthroughSubject<[FormFieldComponentStyle], Never> = .init() // MARK: - Items Properties lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { @@ -73,6 +79,14 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { ) }() + lazy var componentStyleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Description Style", + type: .button, + target: (source: self, action: #selector(self.presentComponentStyleSheet)) + ) + }() + lazy var disableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Enable", @@ -106,6 +120,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { @Published var intent: FormFieldIntent @Published var titleStyle: FormFieldTextStyle @Published var descriptionStyle: FormFieldTextStyle + @Published var componentStyle: FormFieldComponentStyle @Published var isEnabled: Bool init( @@ -113,12 +128,14 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { intent: FormFieldIntent = .support, titleStyle: FormFieldTextStyle = .text, descriptionStyle: FormFieldTextStyle = .text, + componentStyle: FormFieldComponentStyle = .singleCheckbox, isEnabled: Bool = true ) { self.theme = theme self.intent = intent self.titleStyle = titleStyle self.descriptionStyle = descriptionStyle + self.componentStyle = componentStyle self.isEnabled = isEnabled super.init(identifier: "FormField") @@ -127,6 +144,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { self.intentConfigurationItemViewModel, self.titleStyleConfigurationItemViewModel, self.descriptionStyleConfigurationItemViewModel, + self.componentStyleConfigurationItemViewModel, self.disableConfigurationItemViewModel ]) } @@ -151,6 +169,10 @@ extension FormFieldComponentUIViewModel { self.showDescriptionStyleSheetSubject.send(FormFieldTextStyle.allCases) } + @objc func presentComponentStyleSheet() { + self.showComponentStyleSheetSubject.send(FormFieldComponentStyle.allCases) + } + @objc func enabledChanged(_ isSelected: Any?) { self.isEnabled = isTrue(isSelected) } @@ -163,3 +185,17 @@ enum FormFieldTextStyle: CaseIterable { case attributeText case none } + +enum FormFieldComponentStyle: CaseIterable { + case basic + case singleCheckbox + case verticalCheckbox + case horizontalCheckbox + case horizontalScrollableCheckbox + case singleRadioButton + case verticalRadioButton + case horizontalRadioButton + case textField + case addOnTextField + case ratingInput +} From b1ba01ba1f30665cb2019473c45f51f99b45f59c Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 1 Feb 2024 16:11:11 +0100 Subject: [PATCH 003/117] [FormField#782] Add opacity and asterix text to formfield on demo project --- .../FormField/FormFieldComponentUIView.swift | 129 ++++++++++++------ .../FormFieldComponentUIViewModel.swift | 26 +++- 2 files changed, 114 insertions(+), 41 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 1ba57e92c..9ca8aa303 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -62,6 +62,10 @@ final class FormFieldComponentUIView: ComponentUIView { switch textStyle { case .text: self.componentView.title = self.viewModel.text + case .asterixText: + self.componentView.attributedTitle = self.viewModel.textWithAsterix + case .opacityText: + self.componentView.attributedTitle = self.viewModel.textWithOpacity case .multilineText: self.componentView.title = self.viewModel.multilineText case .attributeText: @@ -77,6 +81,10 @@ final class FormFieldComponentUIView: ComponentUIView { switch textStyle { case .text: self.componentView.descriptionString = self.viewModel.descriptionText + case .asterixText: + self.componentView.attributedDescription = self.viewModel.textWithAsterix + case .opacityText: + self.componentView.attributedDescription = self.viewModel.textWithOpacity case .multilineText: self.componentView.descriptionString = self.viewModel.multilineText case .attributeText: @@ -173,82 +181,123 @@ final class FormFieldComponentUIView: ComponentUIView { } static func makeSingleCheckbox() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.red - view.heightAnchor.constraint(equalToConstant: 150).isActive = true - view.widthAnchor.constraint(equalToConstant: 150).isActive = true + let view = CheckboxUIView( + theme: SparkTheme.shared, + text: "Hello World", + checkedImage: DemoIconography.shared.checkmark, + selectionState: .unselected, + alignment: .left + ) return view } static func makeVerticalCheckbox() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.blue - view.heightAnchor.constraint(equalToConstant: 200).isActive = true - view.widthAnchor.constraint(equalToConstant: 100).isActive = true + let view = CheckboxGroupUIView( + checkedImage: DemoIconography.shared.checkmark, + items: [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ], + theme: SparkTheme.shared, + intent: .success, + accessibilityIdentifierPrefix: "checkbox" + ) + view.layout = .vertical return view } static func makeHorizontalCheckbox() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.black - view.heightAnchor.constraint(equalToConstant: 20).isActive = true - view.widthAnchor.constraint(equalToConstant: 300).isActive = true + let view = CheckboxGroupUIView( + checkedImage: DemoIconography.shared.checkmark, + items: [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ], + theme: SparkTheme.shared, + intent: .alert, + accessibilityIdentifierPrefix: "checkbox" + ) + view.layout = .horizontal return view } static func makeHorizontalScrollableCheckbox() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.brown - view.heightAnchor.constraint(equalToConstant: 70).isActive = true - view.widthAnchor.constraint(equalToConstant: 140).isActive = true + let view = CheckboxGroupUIView( + checkedImage: DemoIconography.shared.checkmark, + items: [ + CheckboxGroupItemDefault(title: "Hello World", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", id: "2", selectionState: .selected, isEnabled: true), + ], + theme: SparkTheme.shared, + accessibilityIdentifierPrefix: "checkbox" + ) + view.layout = .horizontal return view } static func makeSingleRadioButton() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.systemPink - view.heightAnchor.constraint(equalToConstant: 60).isActive = true - view.widthAnchor.constraint(equalToConstant: 250).isActive = true + let view = RadioButtonUIView( + theme: SparkTheme.shared, + intent: .info, + id: "radiobutton", + label: NSAttributedString(string: "Hello World"), + isSelected: true + ) return view } static func makeVerticalRadioButton() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.green - view.heightAnchor.constraint(equalToConstant: 150).isActive = true - view.widthAnchor.constraint(equalToConstant: 250).isActive = true + let view = RadioButtonUIGroupView( + theme: SparkTheme.shared, + intent: .danger, + selectedID: "radiobutton", + items: [ + RadioButtonUIItem(id: "1", label: "Radio Button 1"), + RadioButtonUIItem(id: "2", label: "Radio Button 2"), + ], + groupLayout: .vertical + ) return view } static func makeHorizontalRadioButton() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.cyan - view.heightAnchor.constraint(equalToConstant: 110).isActive = true - view.widthAnchor.constraint(equalToConstant: 310).isActive = true + let view = RadioButtonUIGroupView( + theme: SparkTheme.shared, + intent: .support, + selectedID: "radiobutton", + items: [ + RadioButtonUIItem(id: "1", label: "Radio Button 1"), + RadioButtonUIItem(id: "2", label: "Radio Button 2"), + ], + groupLayout: .horizontal + ) return view } static func makeTextField() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.orange - view.heightAnchor.constraint(equalToConstant: 50).isActive = true - view.widthAnchor.constraint(equalToConstant: 100).isActive = true + let view = TextFieldUIView( + theme: SparkTheme.shared, + intent: .alert + ) + view.text = "TextField" return view } static func makeAddOnTextField() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.magenta - view.heightAnchor.constraint(equalToConstant: 300).isActive = true - view.widthAnchor.constraint(equalToConstant: 30).isActive = true + let view = TextFieldUIView( + theme: SparkTheme.shared, + intent: .alert + ) + view.text = "I couldn't add addOnTextField. It is not UIControl for now" return view } static func makeRatingInput() -> UIControl { - let view = UIControl() - view.backgroundColor = UIColor.purple - view.heightAnchor.constraint(equalToConstant: 500).isActive = true - view.widthAnchor.constraint(equalToConstant: 200).isActive = true + let view = RatingInputUIView( + theme: SparkTheme.shared, + intent: .main, + rating: 2.0 + ) return view } } diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift index 937abf44d..0fc38b2f0 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift @@ -81,7 +81,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { lazy var componentStyleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( - name: "Description Style", + name: "Component Style", type: .button, target: (source: self, action: #selector(self.presentComponentStyleSheet)) ) @@ -114,6 +114,28 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { attributeString.setAttributes(attributes, range: NSRange(location: 0, length: 11)) return attributeString } + var textWithOpacity: NSAttributedString { + let attributeString = NSMutableAttributedString( + string: "Your agreement is important to us.", + attributes: [ + .font: self.theme.typography.caption.uiFont, + .foregroundColor: self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1).uiColor + ] + ) + return attributeString + } + var textWithAsterix: NSAttributedString { + let attributeString = NSMutableAttributedString( + string: "Label *", + attributes: [.font: self.theme.typography.body2.uiFont] + ) + let attributes: [NSMutableAttributedString.Key: Any] = [ + .font: self.theme.typography.caption.uiFont, + .foregroundColor: self.theme.colors.base.onSurface.opacity(self.theme.dims.dim3).uiColor + ] + attributeString.setAttributes(attributes, range: NSRange(location: 6, length: 1)) + return attributeString + } // MARK: - Initialization @Published var theme: Theme @@ -181,6 +203,8 @@ extension FormFieldComponentUIViewModel { // MARK: - Enum enum FormFieldTextStyle: CaseIterable { case text + case asterixText + case opacityText case multilineText case attributeText case none From 27681c491b1a3ce0c5ff9d0320e25ebef4cbab6c Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 4 Mar 2024 17:30:43 +0100 Subject: [PATCH 004/117] [Formfield#782] Fix checkmark icon on demo --- .../Components/FormField/FormFieldComponentUIView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 9ca8aa303..dda66de81 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -184,7 +184,7 @@ final class FormFieldComponentUIView: ComponentUIView { let view = CheckboxUIView( theme: SparkTheme.shared, text: "Hello World", - checkedImage: DemoIconography.shared.checkmark, + checkedImage: DemoIconography.shared.checkmark.uiImage, selectionState: .unselected, alignment: .left ) @@ -193,7 +193,7 @@ final class FormFieldComponentUIView: ComponentUIView { static func makeVerticalCheckbox() -> UIControl { let view = CheckboxGroupUIView( - checkedImage: DemoIconography.shared.checkmark, + checkedImage: DemoIconography.shared.checkmark.uiImage, items: [ CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), @@ -208,7 +208,7 @@ final class FormFieldComponentUIView: ComponentUIView { static func makeHorizontalCheckbox() -> UIControl { let view = CheckboxGroupUIView( - checkedImage: DemoIconography.shared.checkmark, + checkedImage: DemoIconography.shared.checkmark.uiImage, items: [ CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), @@ -223,7 +223,7 @@ final class FormFieldComponentUIView: ComponentUIView { static func makeHorizontalScrollableCheckbox() -> UIControl { let view = CheckboxGroupUIView( - checkedImage: DemoIconography.shared.checkmark, + checkedImage: DemoIconography.shared.checkmark.uiImage, items: [ CheckboxGroupItemDefault(title: "Hello World", id: "1", selectionState: .unselected, isEnabled: true), CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", id: "2", selectionState: .selected, isEnabled: true), From 6782aa80b965b1fcb09ad25526ddf088af238467 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 6 Mar 2024 14:40:07 +0100 Subject: [PATCH 005/117] [Formfield#782] Make formfield generic to prevent type cast --- .../FormField/Model/FormFieldViewModel.swift | 17 +++++++++++------ .../FormField/View/UIKit/FormFieldUIView.swift | 10 +++++----- .../FormField/FormFieldComponentUIView.swift | 4 ++-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index b58d7cdae..1d50b7c24 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -16,11 +16,11 @@ final class FormFieldViewModel: ObservableObject { @Published var title: Either? @Published var titleFont: Either? @Published var titleColor: Either? - @Published var titleOpacity: CGFloat? + @Published var titleOpacity: CGFloat @Published var description: Either? @Published var descriptionFont: Either? @Published var descriptionColor: Either? - @Published var descriptionOpacity: CGFloat? + @Published var descriptionOpacity: CGFloat @Published var spacing: CGFloat var theme: Theme { @@ -34,17 +34,19 @@ final class FormFieldViewModel: ObservableObject { var intent: FormFieldIntent { didSet { + guard intent != oldValue else { return } self.updateColors() } } var isEnabled: Bool { didSet { + guard isEnabled != oldValue else { return } self.updateOpacity() } } - var colorUseCase: FormFieldColorsUseCase + var colorUseCase: FormFieldColorsUseCaseable // MARK: - Init @@ -54,7 +56,7 @@ final class FormFieldViewModel: ObservableObject { isEnabled: Bool, title: Either?, description: Either?, - colorUseCase: FormFieldColorsUseCase = .init() + colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase() ) { self.theme = theme self.intent = intent @@ -63,6 +65,8 @@ final class FormFieldViewModel: ObservableObject { self.description = description self.colorUseCase = colorUseCase self.spacing = self.theme.layout.spacing.small + self.titleOpacity = self.theme.dims.none + self.descriptionOpacity = self.theme.dims.none } private func updateColors() { @@ -82,6 +86,7 @@ final class FormFieldViewModel: ObservableObject { } private func updateFonts() { + if self.title?.leftValue != nil { self.titleFont = .left(self.theme.typography.subhead.uiFont) } else { @@ -100,7 +105,7 @@ final class FormFieldViewModel: ObservableObject { } private func updateOpacity() { - self.titleOpacity = isEnabled ? self.theme.dims.none : self.theme.dims.dim3 - self.descriptionOpacity = isEnabled ? self.theme.dims.none : self.theme.dims.dim1 + self.titleOpacity = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 + self.descriptionOpacity = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim1 } } diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 40e908258..d6fd070a9 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -11,7 +11,7 @@ import SwiftUI import UIKit /// The `FormFieldUIView`renders a component with title and subtitle using UIKit. -public final class FormFieldUIView: UIControl { +public final class FormFieldUIView: UIControl { // MARK: - Private Properties. @@ -139,7 +139,7 @@ public final class FormFieldUIView: UIControl { } /// The component of formfield. - public var component: UIControl { + public var component: Component { didSet { self.componentContainerStackView.removeArrangedSubviews() self.componentContainerStackView.addArrangedSubview(self.component) @@ -172,7 +172,7 @@ public final class FormFieldUIView: UIControl { attributedTitle: NSAttributedString? = nil, description: String? = nil, attributedDescription: NSAttributedString? = nil, - component: UIControl + component: Component ) { let titleValue: NSAttributedString? let descriptionValue: NSAttributedString? @@ -240,7 +240,7 @@ public final class FormFieldUIView: UIControl { self.titleLabel.font = font?.leftValue self.titleLabel.textColor = color?.leftValue self.titleLabel.attributedText = title?.leftValue - self.titleLabel.layer.opacity = Float(opacity ?? 1.0) + self.titleLabel.layer.opacity = Float(opacity) } Publishers.CombineLatest4( @@ -255,7 +255,7 @@ public final class FormFieldUIView: UIControl { self.descriptionLabel.font = font?.leftValue self.descriptionLabel.textColor = color?.leftValue self.descriptionLabel.attributedText = title?.leftValue - self.descriptionLabel.layer.opacity = Float(opacity ?? 1.0) + self.descriptionLabel.layer.opacity = Float(opacity) } self.viewModel.$spacing.subscribe(in: &self.cancellables) { [weak self] spacing in diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index dda66de81..11ca13283 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -14,7 +14,7 @@ import UIKit final class FormFieldComponentUIView: ComponentUIView { // MARK: - Components - private let componentView: FormFieldUIView + private let componentView: FormFieldUIView // MARK: - Properties @@ -135,7 +135,7 @@ final class FormFieldComponentUIView: ComponentUIView { } // MARK: - Create View - static func makeFormField(_ viewModel: FormFieldComponentUIViewModel) -> FormFieldUIView { + static func makeFormField(_ viewModel: FormFieldComponentUIViewModel) -> FormFieldUIView { let component: UIControl! switch viewModel.componentStyle { From ec41d60bec1714c9525f16227ff4dcbf06bec5fe Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 7 Mar 2024 09:35:38 +0100 Subject: [PATCH 006/117] [Formfield#782] Fix disable state on demo project --- .../View/Components/FormField/FormFieldComponentUIView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 11ca13283..f683d19d4 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -124,6 +124,7 @@ final class FormFieldComponentUIView: ComponentUIView { case .ratingInput: component = Self.makeRatingInput() } + component.isEnabled = viewModel.isEnabled self.componentView.component = component } From 26e30e5c41d33b4d886d2ee4d96922a73c880fda Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:43:16 +0100 Subject: [PATCH 007/117] [Formfield#782] Add optinal either --- core/Sources/Common/Enum/Either/Either.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/Sources/Common/Enum/Either/Either.swift b/core/Sources/Common/Enum/Either/Either.swift index 5d6fc438c..dc4ca0c09 100644 --- a/core/Sources/Common/Enum/Either/Either.swift +++ b/core/Sources/Common/Enum/Either/Either.swift @@ -34,6 +34,20 @@ extension Either { case .right: fatalError("No value for left part") } } + + var optinalLeftValue: Left? { + switch self { + case let .left(value): return value + case .right: return nil + } + } + + var optinalRightValue: Right? { + switch self { + case let .right(value): return value + case .left: return nil + } + } } extension Either { From 9108ba32069026efeeba62593f5eb66e389ccbae Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:47:10 +0100 Subject: [PATCH 008/117] [Formfield#782] Change intent with feedback state enum --- ...ent.swift => FormFieldFeedbackState.swift} | 9 ++--- .../FormField/Model/FormFieldViewModel.swift | 5 ++- .../UseCase/FormFieldColorsUseCase.swift | 35 ++++++------------- .../View/UIKit/FormFieldUIView.swift | 6 ++-- 4 files changed, 19 insertions(+), 36 deletions(-) rename core/Sources/Components/FormField/Enum/{FormFieldIntent.swift => FormFieldFeedbackState.swift} (60%) diff --git a/core/Sources/Components/FormField/Enum/FormFieldIntent.swift b/core/Sources/Components/FormField/Enum/FormFieldFeedbackState.swift similarity index 60% rename from core/Sources/Components/FormField/Enum/FormFieldIntent.swift rename to core/Sources/Components/FormField/Enum/FormFieldFeedbackState.swift index cc92acd0e..bdecf3b3f 100644 --- a/core/Sources/Components/FormField/Enum/FormFieldIntent.swift +++ b/core/Sources/Components/FormField/Enum/FormFieldFeedbackState.swift @@ -1,5 +1,5 @@ // -// FormFieldIntent.swift +// FormFieldFeedbackState.swift // SparkCore // // Created by alican.aycil on 31.01.24. @@ -9,10 +9,7 @@ import Foundation /// The various intent color a formfield may have. -public enum FormFieldIntent: CaseIterable { - case base - case support +public enum FormFieldFeedbackState: CaseIterable { + case `default` case error - case success - case alert } diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 1d50b7c24..94f4e4bf7 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -28,13 +28,12 @@ final class FormFieldViewModel: ObservableObject { self.updateColors() self.updateFonts() self.updateSpacing() - self.updateOpacity() } } - var intent: FormFieldIntent { + var feedbackState: FormFieldFeedbackState { didSet { - guard intent != oldValue else { return } + guard feedbackState != oldValue else { return } self.updateColors() } } diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift index 7c71d48b8..82a4a74fa 100644 --- a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift @@ -10,37 +10,24 @@ import Foundation // sourcery: AutoMockable protocol FormFieldColorsUseCaseable { - func execute(from colors: Colors, intent: FormFieldIntent) -> FormFieldColors + func execute(from theme: Theme, feedback state: FormFieldFeedbackState) -> FormFieldColors } struct FormFieldColorsUseCase: FormFieldColorsUseCaseable { - func execute(from colors: Colors, intent: FormFieldIntent) -> FormFieldColors { - switch intent { - case .error: - return FormFieldColors( - titleColor: colors.base.onSurface, - descriptionColor: colors.feedback.error - ) - case .success: - return FormFieldColors( - titleColor: colors.base.onSurface, - descriptionColor: colors.feedback.success - ) - case .alert: - return FormFieldColors( - titleColor: colors.base.onSurface, - descriptionColor: colors.feedback.alert - ) - case .support: + func execute(from theme: Theme, feedback state: FormFieldFeedbackState) -> FormFieldColors { + switch state { + case .default: return FormFieldColors( - titleColor: colors.base.onSurface, - descriptionColor: colors.support.support + titleColor: theme.colors.base.onSurface, + descriptionColor: theme.colors.base.onSurface.opacity(theme.dims.dim1), + asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) - case .base: + case .error: return FormFieldColors( - titleColor: colors.base.onSurface, - descriptionColor: colors.base.onSurface + titleColor: theme.colors.base.onSurface, + descriptionColor: theme.colors.feedback.error, + asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) } } diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index d6fd070a9..e4c5d7f01 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -99,12 +99,12 @@ public final class FormFieldUIView: UIControl { } /// Returns the theme of the formfield. - public var intent: FormFieldIntent { + public var feedbackState: FormFieldFeedbackState { get { - return self.viewModel.intent + return self.viewModel.feedbackState } set { - self.viewModel.intent = newValue + self.viewModel.feedbackState = newValue } } From 1cdc0e17316b85f9f5c816e7227e7dfa227bcd1b Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:47:51 +0100 Subject: [PATCH 009/117] [Formfield#782] Add asterisk symbol --- .../FormField/Model/FormFieldColors.swift | 1 + .../FormField/Model/FormFieldViewModel.swift | 85 ++++++++++++------- .../View/UIKit/FormFieldUIView.swift | 43 ++++++---- 3 files changed, 85 insertions(+), 44 deletions(-) diff --git a/core/Sources/Components/FormField/Model/FormFieldColors.swift b/core/Sources/Components/FormField/Model/FormFieldColors.swift index ea56f9a3a..ab10b8820 100644 --- a/core/Sources/Components/FormField/Model/FormFieldColors.swift +++ b/core/Sources/Components/FormField/Model/FormFieldColors.swift @@ -11,4 +11,5 @@ import Foundation struct FormFieldColors { let titleColor: any ColorToken let descriptionColor: any ColorToken + let asteriskColor: any ColorToken } diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 94f4e4bf7..64049167b 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -13,14 +13,19 @@ import UIKit final class FormFieldViewModel: ObservableObject { // MARK: - Internal properties - @Published var title: Either? + @Published var title: Either? { + didSet { + if isRequiredTitle { + self.updateAsterix() + } + } + } @Published var titleFont: Either? @Published var titleColor: Either? - @Published var titleOpacity: CGFloat @Published var description: Either? @Published var descriptionFont: Either? @Published var descriptionColor: Either? - @Published var descriptionOpacity: CGFloat + @Published var asteriskText: Either? @Published var spacing: CGFloat var theme: Theme { @@ -38,63 +43,90 @@ final class FormFieldViewModel: ObservableObject { } } - var isEnabled: Bool { + var isRequiredTitle: Bool { didSet { - guard isEnabled != oldValue else { return } - self.updateOpacity() + guard isRequiredTitle != oldValue else { return } + self.updateAsterix() } } var colorUseCase: FormFieldColorsUseCaseable // MARK: - Init - init( theme: Theme, - intent: FormFieldIntent, - isEnabled: Bool, + feedbackState: FormFieldFeedbackState, title: Either?, description: Either?, + isRequiredTitle: Bool = false, colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase() ) { self.theme = theme - self.intent = intent - self.isEnabled = isEnabled + self.feedbackState = feedbackState self.title = title self.description = description + self.isRequiredTitle = isRequiredTitle self.colorUseCase = colorUseCase self.spacing = self.theme.layout.spacing.small - self.titleOpacity = self.theme.dims.none - self.descriptionOpacity = self.theme.dims.none + } + + private func updateAsterix() { + let colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) + let asterix = NSAttributedString( + string: " *", + attributes: [ + NSAttributedString.Key.foregroundColor: colors.asteriskColor.uiColor, + NSAttributedString.Key.font : self.theme.typography.caption.uiFont + ] + ) + + if let attributedString = self.title?.optinalLeftValue as? NSAttributedString { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + mutableAttributedString.append(asterix) + self.asteriskText = self.isRequiredTitle ? .left(NSAttributedString(attributedString: mutableAttributedString)) : nil + } + + if var attributedString = self.title?.optinalRightValue as? AttributedString { + attributedString.append(AttributedString(asterix)) + self.asteriskText = self.isRequiredTitle ? .right(attributedString) : nil + } } private func updateColors() { - let colors = colorUseCase.execute(from: self.theme.colors, intent: self.intent) + let colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) - if self.title?.leftValue != nil { + if self.title?.optinalLeftValue != nil { self.titleColor = .left(colors.titleColor.uiColor) - } else { + } + + if self.title?.optinalRightValue != nil { self.titleColor = .right(colors.titleColor.color) } - if self.description?.leftValue != nil { + if self.description?.optinalLeftValue != nil { self.descriptionColor = .left(colors.descriptionColor.uiColor) - } else { + } + + if self.description?.optinalRightValue != nil { self.descriptionColor = .right(colors.descriptionColor.color) } } private func updateFonts() { - if self.title?.leftValue != nil { - self.titleFont = .left(self.theme.typography.subhead.uiFont) - } else { - self.titleFont = .right(self.theme.typography.subhead.font) + if self.title?.optinalLeftValue != nil { + self.titleFont = .left(self.theme.typography.body2.uiFont) + } + + if self.title?.optinalRightValue != nil { + self.titleFont = .right(self.theme.typography.body2.font) } - if self.description?.leftValue != nil { + if self.description?.optinalLeftValue != nil { self.descriptionFont = .left(self.theme.typography.caption.uiFont) - } else { + } + + if self.description?.optinalRightValue != nil { self.descriptionFont = .right(self.theme.typography.caption.font) } } @@ -102,9 +134,4 @@ final class FormFieldViewModel: ObservableObject { private func updateSpacing() { self.spacing = self.theme.layout.spacing.small } - - private func updateOpacity() { - self.titleOpacity = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 - self.descriptionOpacity = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim1 - } } diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index e4c5d7f01..21d4c16eb 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -19,6 +19,7 @@ public final class FormFieldUIView: UIControl { let label = UILabel() label.backgroundColor = .clear label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true return label }() @@ -26,6 +27,7 @@ public final class FormFieldUIView: UIControl { let label = UILabel() label.backgroundColor = .clear label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true return label }() @@ -68,6 +70,16 @@ public final class FormFieldUIView: UIControl { } } + public var isRequiredTitle: Bool { + get { + return self.viewModel.isRequiredTitle + } + set { + self.viewModel.isRequiredTitle = newValue + } + } + + /// The description of formfield. public var descriptionString: String? { get { @@ -111,10 +123,9 @@ public final class FormFieldUIView: UIControl { /// The current state of the component. public override var isEnabled: Bool { get { - return self.viewModel.isEnabled + return self.component.isEnabled } set { - self.viewModel.isEnabled = newValue self.component.isEnabled = newValue } } @@ -166,8 +177,10 @@ public final class FormFieldUIView: UIControl { /// - component: The formfield component. public init( theme: Theme, - intent: FormFieldIntent, + feedbackState: FormFieldFeedbackState, + isRequiredTitle: Bool = false, isEnabled: Bool = true, + isSelected: Bool = false, title: String? = nil, attributedTitle: NSAttributedString? = nil, description: String? = nil, @@ -191,10 +204,10 @@ public final class FormFieldUIView: UIControl { let viewModel = FormFieldViewModel( theme: theme, - intent: intent, - isEnabled: isEnabled, + feedbackState: feedbackState, title: .left(titleValue), - description: .left(descriptionValue) + description: .left(descriptionValue), + isRequiredTitle: isRequiredTitle ) self.viewModel = viewModel @@ -202,6 +215,9 @@ public final class FormFieldUIView: UIControl { self.component = component super.init(frame: .zero) + + self.isEnabled = isEnabled + self.isSelected = isSelected self.commonInit() } @@ -232,30 +248,27 @@ public final class FormFieldUIView: UIControl { self.viewModel.$title, self.viewModel.$titleFont, self.viewModel.$titleColor, - self.viewModel.$titleOpacity - ).subscribe(in: &self.cancellables) { [weak self] title, font, color, opacity in + self.viewModel.$asteriskText + ).subscribe(in: &self.cancellables) { [weak self] title, font, color, asteriskText in guard let self else { return } let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty self.titleLabel.isHidden = labelHidden self.titleLabel.font = font?.leftValue self.titleLabel.textColor = color?.leftValue - self.titleLabel.attributedText = title?.leftValue - self.titleLabel.layer.opacity = Float(opacity) + self.titleLabel.attributedText = asteriskText?.leftValue != nil ? asteriskText?.leftValue : title?.leftValue } - Publishers.CombineLatest4( + Publishers.CombineLatest3( self.viewModel.$description, self.viewModel.$descriptionFont, - self.viewModel.$descriptionColor, - self.viewModel.$descriptionOpacity - ).subscribe(in: &self.cancellables) { [weak self] title, font, color, opacity in + self.viewModel.$descriptionColor + ).subscribe(in: &self.cancellables) { [weak self] title, font, color in guard let self else { return } let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty self.descriptionLabel.isHidden = labelHidden self.descriptionLabel.font = font?.leftValue self.descriptionLabel.textColor = color?.leftValue self.descriptionLabel.attributedText = title?.leftValue - self.descriptionLabel.layer.opacity = Float(opacity) } self.viewModel.$spacing.subscribe(in: &self.cancellables) { [weak self] spacing in From 128e575cfdaef4bd88200fe0d0c6172bbd0b737b Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:49:00 +0100 Subject: [PATCH 010/117] [Formfield#782] Add isEnabled parameter to checkbox group component --- .../View/UIKit/CheckboxGroupUIView.swift | 13 +++++++++++++ .../CheckboxGroupComponentUIView.swift | 5 +++++ .../CheckboxGroupComponentUIViewModel.swift | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 77003e11a..0920c0c3a 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -124,6 +124,19 @@ public final class CheckboxGroupUIView: UIControl { self.itemsStackView.arrangedSubviews.compactMap { $0 as? CheckboxUIView } } + public override var isEnabled: Bool { + didSet{ + guard isEnabled != oldValue else { return } + if isEnabled { + self.checkboxes.enumerated().forEach { index, item in + item.isEnabled = self.items.indices.contains(index) ? self.items[index].isEnabled : true + } + } else { + self.checkboxes.forEach { $0.isEnabled = false } + } + } + } + // MARK: - Initialization /// Not implemented. Please use another init. diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index a64c580e9..9ee6960d8 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -81,6 +81,11 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.title = showGroupTitle ? viewModel.title : "" } + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in + guard let self = self else { return } + self.componentView.isEnabled = isEnabled + } + self.viewModel.$groupType.subscribe(in: &self.cancellables) { [weak self] type in guard let self = self else { return } self.viewModel.groupTypeConfigurationItemViewModel.buttonTitle = type.name diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift index b7b65ec5d..af4e86103 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift @@ -81,6 +81,14 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { ) }() + lazy var isEnableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Is Enable", + type: .toggle(isOn: self.isEnabled), + target: (source: self, action: #selector(self.toggleIsEnable)) + ) + }() + lazy var iconConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Icons", @@ -142,6 +150,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { @Published var isAlignmentLeft: Bool @Published var isLayoutVertical: Bool @Published var showGroupTitle: Bool + @Published var isEnabled: Bool @Published var icon: [String: UIImage] @Published var groupType: CheckboxGroupType @@ -151,6 +160,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { isAlignmentLeft: Bool = true, isLayoutVertical: Bool = false, showGroupTitle: Bool = false, + isEnabled: Bool = true, icon: [String: UIImage] = ["Checkmark": DemoIconography.shared.checkmark.uiImage], groupType: CheckboxGroupType = .doubleMix ) { @@ -159,6 +169,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.isAlignmentLeft = isAlignmentLeft self.isLayoutVertical = isLayoutVertical self.showGroupTitle = showGroupTitle + self.isEnabled = isEnabled self.icon = icon self.groupType = groupType super.init(identifier: "Checkbox Group") @@ -169,6 +180,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.alignmentConfigurationItemViewModel, self.layoutConfigurationItemViewModel, self.titleConfigurationItemViewModel, + self.isEnableConfigurationItemViewModel, self.iconConfigurationItemViewModel, self.groupTypeConfigurationItemViewModel, self.itemsSelectionStateConfigurationItemViewModel @@ -199,6 +211,10 @@ extension CheckboxGroupComponentUIViewModel { self.showGroupTitle.toggle() } + @objc func toggleIsEnable() { + self.isEnabled.toggle() + } + @objc func presentIconSheet() { self.showIconSheetSubject.send(icons) } From 029b55f7029c3a950cac0b0259879025188586d8 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:49:39 +0100 Subject: [PATCH 011/117] [Formfield#782] Deprecate checkbox group title --- .../View/UIKit/CheckboxGroupUIView.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 0920c0c3a..9d2eec3b1 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -79,6 +79,7 @@ public final class CheckboxGroupUIView: UIControl { } /// The title of the checkbox group displayed on top of the group. + @available(*, deprecated, message: "Formfield will be used to show title of component") public var title: String? { didSet { self.updateTitle() @@ -155,6 +156,7 @@ public final class CheckboxGroupUIView: UIControl { /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. + @available(*, deprecated, message: "Formfield will be used to show title of component. Please use init without title.") public init( title: String? = nil, checkedImage: UIImage, @@ -180,6 +182,39 @@ public final class CheckboxGroupUIView: UIControl { self.commonInit() } + /// Initialize a group of one or multiple checkboxes. + /// - Parameters: + /// - checkedImage: The tick-checkbox image for checked-state. + /// - items: An array containing of multiple `CheckboxGroupItemProtocol`. Each array item is used to render a single checkbox. + /// - layout: The layout of the group can be horizontal or vertical. + /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. + /// - theme: The Spark-Theme. + /// - intent: Current intent of checkbox group + /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. + @available(*, deprecated, message: "Formfield will be used to show title of component. Please use init without title.") + public init( + checkedImage: UIImage, + items: [any CheckboxGroupItemProtocol], + layout: CheckboxGroupLayout = .vertical, + alignment: CheckboxAlignment = .left, + theme: Theme, + intent: CheckboxIntent = .main, + accessibilityIdentifierPrefix: String + ) { + self.checkedImage = checkedImage + self.items = items + self.layout = layout + self.alignment = alignment + self.checkboxAlignment = alignment + self.theme = theme + self.intent = intent + self.accessibilityIdentifierPrefix = accessibilityIdentifierPrefix + self.spacingLarge = theme.layout.spacing.large + self.spacingSmall = theme.layout.spacing.small + super.init(frame: .zero) + self.commonInit() + } + private func commonInit() { self.setupItemsStackView() self.setupView() From 838f23ddfad29218e3e5c8e49960a773fba9df94 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 13 Mar 2024 16:50:00 +0100 Subject: [PATCH 012/117] [Formfield#782] Fix demo project --- .../FormField/FormFieldComponentUIView.swift | 21 +++---- .../FormFieldComponentUIViewController.swift | 12 ++-- .../FormFieldComponentUIViewModel.swift | 63 ++++++++----------- 3 files changed, 42 insertions(+), 54 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index f683d19d4..49c8934f0 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -50,10 +50,10 @@ final class FormFieldComponentUIView: ComponentUIView { self.componentView.theme = theme } - self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + self.viewModel.$feedbackState.subscribe(in: &self.cancellables) { [weak self] feedbackState in guard let self = self else { return } - self.viewModel.intentConfigurationItemViewModel.buttonTitle = intent.name - self.componentView.intent = intent + self.viewModel.feedbackStateConfigurationItemViewModel.buttonTitle = feedbackState.name + self.componentView.feedbackState = feedbackState } self.viewModel.$titleStyle.subscribe(in: &self.cancellables) { [weak self] textStyle in @@ -62,10 +62,6 @@ final class FormFieldComponentUIView: ComponentUIView { switch textStyle { case .text: self.componentView.title = self.viewModel.text - case .asterixText: - self.componentView.attributedTitle = self.viewModel.textWithAsterix - case .opacityText: - self.componentView.attributedTitle = self.viewModel.textWithOpacity case .multilineText: self.componentView.title = self.viewModel.multilineText case .attributeText: @@ -81,10 +77,6 @@ final class FormFieldComponentUIView: ComponentUIView { switch textStyle { case .text: self.componentView.descriptionString = self.viewModel.descriptionText - case .asterixText: - self.componentView.attributedDescription = self.viewModel.textWithAsterix - case .opacityText: - self.componentView.attributedDescription = self.viewModel.textWithOpacity case .multilineText: self.componentView.descriptionString = self.viewModel.multilineText case .attributeText: @@ -133,6 +125,11 @@ final class FormFieldComponentUIView: ComponentUIView { guard let self = self else { return } self.componentView.isEnabled = isEnabled } + + self.viewModel.$isRequiredTitle.subscribe(in: &self.cancellables) { [weak self] isRequiredTitle in + guard let self = self else { return } + self.componentView.isRequiredTitle = isRequiredTitle + } } // MARK: - Create View @@ -166,7 +163,7 @@ final class FormFieldComponentUIView: ComponentUIView { return .init( theme: viewModel.theme, - intent: .support, + feedbackState: .default, title: "Agreement", description: "Your agreement is important to us.", component: component diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift index 52e6334c9..53be5a745 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift @@ -62,8 +62,8 @@ final class FormFieldComponentUIViewController: UIViewController { self.presentThemeActionSheet(intents) } - self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in - self.presentIntentActionSheet(intents) + self.viewModel.showFeedbackStateSheet.subscribe(in: &self.cancellables) { feedbackStates in + self.presentIntentActionSheet(feedbackStates) } self.viewModel.showTitleSheet.subscribe(in: &self.cancellables) { titles in @@ -102,11 +102,11 @@ extension FormFieldComponentUIViewController { self.present(actionSheet, animated: true) } - private func presentIntentActionSheet(_ intents: [FormFieldIntent]) { - let actionSheet = SparkActionSheet.init( + private func presentIntentActionSheet(_ intents: [FormFieldFeedbackState]) { + let actionSheet = SparkActionSheet.init( values: intents, - texts: intents.map { $0.name }) { intent in - self.viewModel.intent = intent + texts: intents.map { $0.name }) { feedbackState in + self.viewModel.feedbackState = feedbackState } self.present(actionSheet, isAnimated: true) } diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift index 0fc38b2f0..fb5a39886 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift @@ -19,8 +19,8 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { .eraseToAnyPublisher() } - var showIntentSheet: AnyPublisher<[FormFieldIntent], Never> { - showIntentSheetSubject + var showFeedbackStateSheet: AnyPublisher<[FormFieldFeedbackState], Never> { + showFeedbackStateSheetSubject .eraseToAnyPublisher() } @@ -41,7 +41,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { // MARK: - Private Properties private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() - private var showIntentSheetSubject: PassthroughSubject<[FormFieldIntent], Never> = .init() + private var showFeedbackStateSheetSubject: PassthroughSubject<[FormFieldFeedbackState], Never> = .init() private var showTitleStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() private var showDescriptionStyleSheetSubject: PassthroughSubject<[FormFieldTextStyle], Never> = .init() private var showComponentStyleSheetSubject: PassthroughSubject<[FormFieldComponentStyle], Never> = .init() @@ -55,9 +55,9 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { ) }() - lazy var intentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + lazy var feedbackStateConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( - name: "Intent", + name: "Feedback State", type: .button, target: (source: self, action: #selector(self.presentIntentSheet)) ) @@ -94,6 +94,13 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { target: (source: self, action: #selector(self.enabledChanged(_:)))) }() + lazy var isRequiredConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Is Required Title", + type: .checkbox(title: "", isOn: self.isRequiredTitle), + target: (source: self, action: #selector(self.isRequiredChanged(_:)))) + }() + // MARK: - Default Properties var themes = ThemeCellModel.themes let text: String = "Agreement" @@ -114,60 +121,42 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { attributeString.setAttributes(attributes, range: NSRange(location: 0, length: 11)) return attributeString } - var textWithOpacity: NSAttributedString { - let attributeString = NSMutableAttributedString( - string: "Your agreement is important to us.", - attributes: [ - .font: self.theme.typography.caption.uiFont, - .foregroundColor: self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1).uiColor - ] - ) - return attributeString - } - var textWithAsterix: NSAttributedString { - let attributeString = NSMutableAttributedString( - string: "Label *", - attributes: [.font: self.theme.typography.body2.uiFont] - ) - let attributes: [NSMutableAttributedString.Key: Any] = [ - .font: self.theme.typography.caption.uiFont, - .foregroundColor: self.theme.colors.base.onSurface.opacity(self.theme.dims.dim3).uiColor - ] - attributeString.setAttributes(attributes, range: NSRange(location: 6, length: 1)) - return attributeString - } // MARK: - Initialization @Published var theme: Theme - @Published var intent: FormFieldIntent + @Published var feedbackState: FormFieldFeedbackState @Published var titleStyle: FormFieldTextStyle @Published var descriptionStyle: FormFieldTextStyle @Published var componentStyle: FormFieldComponentStyle @Published var isEnabled: Bool + @Published var isRequiredTitle: Bool init( theme: Theme, - intent: FormFieldIntent = .support, + feedbackState: FormFieldFeedbackState = .default, titleStyle: FormFieldTextStyle = .text, descriptionStyle: FormFieldTextStyle = .text, componentStyle: FormFieldComponentStyle = .singleCheckbox, - isEnabled: Bool = true + isEnabled: Bool = true, + isRequiredTitle: Bool = false ) { self.theme = theme - self.intent = intent + self.feedbackState = feedbackState self.titleStyle = titleStyle self.descriptionStyle = descriptionStyle self.componentStyle = componentStyle self.isEnabled = isEnabled + self.isRequiredTitle = isRequiredTitle super.init(identifier: "FormField") self.configurationViewModel = .init(itemsViewModel: [ self.themeConfigurationItemViewModel, - self.intentConfigurationItemViewModel, + self.feedbackStateConfigurationItemViewModel, self.titleStyleConfigurationItemViewModel, self.descriptionStyleConfigurationItemViewModel, self.componentStyleConfigurationItemViewModel, - self.disableConfigurationItemViewModel + self.disableConfigurationItemViewModel, + self.isRequiredConfigurationItemViewModel ]) } } @@ -180,7 +169,7 @@ extension FormFieldComponentUIViewModel { } @objc func presentIntentSheet() { - self.showIntentSheetSubject.send(FormFieldIntent.allCases) + self.showFeedbackStateSheetSubject.send(FormFieldFeedbackState.allCases) } @objc func presentTextStyleSheet() { @@ -198,13 +187,15 @@ extension FormFieldComponentUIViewModel { @objc func enabledChanged(_ isSelected: Any?) { self.isEnabled = isTrue(isSelected) } + + @objc func isRequiredChanged(_ isSelected: Any?) { + self.isRequiredTitle = isTrue(isSelected) + } } // MARK: - Enum enum FormFieldTextStyle: CaseIterable { case text - case asterixText - case opacityText case multilineText case attributeText case none From 46de7cd03b5f0d617f78f28de1bb71dfff9a3fb4 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 13:28:26 +0100 Subject: [PATCH 013/117] [Formfield#782] Fix comments --- .../FormField/Model/FormFieldViewModel.swift | 92 ++++++++----------- .../View/UIKit/FormFieldUIView.swift | 30 +++--- .../FormField/FormFieldComponentUIView.swift | 4 +- 3 files changed, 52 insertions(+), 74 deletions(-) diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 64049167b..2b61ee3e3 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -15,17 +15,17 @@ final class FormFieldViewModel: ObservableObject { // MARK: - Internal properties @Published var title: Either? { didSet { - if isRequiredTitle { + if isTitleRequired { self.updateAsterix() } } } - @Published var titleFont: Either? - @Published var titleColor: Either? @Published var description: Either? - @Published var descriptionFont: Either? - @Published var descriptionColor: Either? @Published var asteriskText: Either? + @Published var titleFont: any TypographyFontToken + @Published var descriptionFont: any TypographyFontToken + @Published var titleColor: any ColorToken + @Published var descriptionColor: any ColorToken @Published var spacing: CGFloat var theme: Theme { @@ -43,14 +43,16 @@ final class FormFieldViewModel: ObservableObject { } } - var isRequiredTitle: Bool { + var isTitleRequired: Bool { didSet { - guard isRequiredTitle != oldValue else { return } + guard isTitleRequired != oldValue else { return } self.updateAsterix() } } - var colorUseCase: FormFieldColorsUseCaseable + private var colorUseCase: FormFieldColorsUseCaseable + + private var colors: FormFieldColors // MARK: - Init init( @@ -58,77 +60,59 @@ final class FormFieldViewModel: ObservableObject { feedbackState: FormFieldFeedbackState, title: Either?, description: Either?, - isRequiredTitle: Bool = false, + isTitleRequired: Bool = false, colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase() ) { self.theme = theme self.feedbackState = feedbackState self.title = title self.description = description - self.isRequiredTitle = isRequiredTitle + self.isTitleRequired = isTitleRequired self.colorUseCase = colorUseCase + self.colors = colorUseCase.execute(from: theme, feedback: feedbackState) self.spacing = self.theme.layout.spacing.small + self.titleFont = self.theme.typography.body2 + self.descriptionFont = self.theme.typography.caption + self.titleColor = self.colors.titleColor + self.descriptionColor = self.colors.descriptionColor } private func updateAsterix() { - let colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) - let asterix = NSAttributedString( + let asterisk = NSAttributedString( string: " *", attributes: [ - NSAttributedString.Key.foregroundColor: colors.asteriskColor.uiColor, + NSAttributedString.Key.foregroundColor: self.colors.asteriskColor.uiColor, NSAttributedString.Key.font : self.theme.typography.caption.uiFont ] ) - if let attributedString = self.title?.optinalLeftValue as? NSAttributedString { - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) - mutableAttributedString.append(asterix) - self.asteriskText = self.isRequiredTitle ? .left(NSAttributedString(attributedString: mutableAttributedString)) : nil - } + switch self.title { + case let .left(text): + if let text = text { + let mutableAttributedString = NSMutableAttributedString(attributedString: text) + mutableAttributedString.append(asterisk) + self.asteriskText = self.isTitleRequired ? .left(NSAttributedString(attributedString: mutableAttributedString)) : nil + } - if var attributedString = self.title?.optinalRightValue as? AttributedString { - attributedString.append(AttributedString(asterix)) - self.asteriskText = self.isRequiredTitle ? .right(attributedString) : nil + case let .right(text): + if var text = text { + text.append(AttributedString(asterisk)) + self.asteriskText = self.isTitleRequired ? .right(text) : nil + } + case .none: + self.asteriskText = nil } } private func updateColors() { - let colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) - - if self.title?.optinalLeftValue != nil { - self.titleColor = .left(colors.titleColor.uiColor) - } - - if self.title?.optinalRightValue != nil { - self.titleColor = .right(colors.titleColor.color) - } - - if self.description?.optinalLeftValue != nil { - self.descriptionColor = .left(colors.descriptionColor.uiColor) - } - - if self.description?.optinalRightValue != nil { - self.descriptionColor = .right(colors.descriptionColor.color) - } + self.colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) + self.titleColor = self.colors.titleColor + self.descriptionColor = self.colors.descriptionColor } private func updateFonts() { - - if self.title?.optinalLeftValue != nil { - self.titleFont = .left(self.theme.typography.body2.uiFont) - } - - if self.title?.optinalRightValue != nil { - self.titleFont = .right(self.theme.typography.body2.font) - } - - if self.description?.optinalLeftValue != nil { - self.descriptionFont = .left(self.theme.typography.caption.uiFont) - } - - if self.description?.optinalRightValue != nil { - self.descriptionFont = .right(self.theme.typography.caption.font) - } + self.titleFont = self.theme.typography.body2 + self.descriptionFont = self.theme.typography.caption } private func updateSpacing() { diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 21d4c16eb..e1f8be222 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -31,13 +31,8 @@ public final class FormFieldUIView: UIControl { return label }() - private lazy var componentContainerStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [self.component]) - return stackView - }() - private lazy var stackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [titleLabel, componentContainerStackView, descriptionLabel]) + let stackView = UIStackView(arrangedSubviews: [self.titleLabel, self.descriptionLabel]) stackView.axis = .vertical stackView.spacing = self.spacing stackView.alignment = .leading @@ -70,12 +65,12 @@ public final class FormFieldUIView: UIControl { } } - public var isRequiredTitle: Bool { + public var isTitleRequired: Bool { get { - return self.viewModel.isRequiredTitle + return self.viewModel.isTitleRequired } set { - self.viewModel.isRequiredTitle = newValue + self.viewModel.isTitleRequired = newValue } } @@ -152,8 +147,8 @@ public final class FormFieldUIView: UIControl { /// The component of formfield. public var component: Component { didSet { - self.componentContainerStackView.removeArrangedSubviews() - self.componentContainerStackView.addArrangedSubview(self.component) + oldValue.removeFromSuperview() + self.stackView.insertArrangedSubview(self.component, at: 1) } } @@ -178,7 +173,7 @@ public final class FormFieldUIView: UIControl { public init( theme: Theme, feedbackState: FormFieldFeedbackState, - isRequiredTitle: Bool = false, + isTitleRequired: Bool = false, isEnabled: Bool = true, isSelected: Bool = false, title: String? = nil, @@ -207,7 +202,7 @@ public final class FormFieldUIView: UIControl { feedbackState: feedbackState, title: .left(titleValue), description: .left(descriptionValue), - isRequiredTitle: isRequiredTitle + isTitleRequired: isTitleRequired ) self.viewModel = viewModel @@ -228,7 +223,6 @@ public final class FormFieldUIView: UIControl { } private func setupViews() { - self.translatesAutoresizingMaskIntoConstraints = false self.addSubview(self.stackView) NSLayoutConstraint.stickEdges(from: self.stackView, to: self) } @@ -253,8 +247,8 @@ public final class FormFieldUIView: UIControl { guard let self else { return } let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty self.titleLabel.isHidden = labelHidden - self.titleLabel.font = font?.leftValue - self.titleLabel.textColor = color?.leftValue + self.titleLabel.font = font.uiFont + self.titleLabel.textColor = color.uiColor self.titleLabel.attributedText = asteriskText?.leftValue != nil ? asteriskText?.leftValue : title?.leftValue } @@ -266,8 +260,8 @@ public final class FormFieldUIView: UIControl { guard let self else { return } let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty self.descriptionLabel.isHidden = labelHidden - self.descriptionLabel.font = font?.leftValue - self.descriptionLabel.textColor = color?.leftValue + self.descriptionLabel.font = font.uiFont + self.descriptionLabel.textColor = color.uiColor self.descriptionLabel.attributedText = title?.leftValue } diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 49c8934f0..1c2043867 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -126,9 +126,9 @@ final class FormFieldComponentUIView: ComponentUIView { self.componentView.isEnabled = isEnabled } - self.viewModel.$isRequiredTitle.subscribe(in: &self.cancellables) { [weak self] isRequiredTitle in + self.viewModel.$isTitleRequired.subscribe(in: &self.cancellables) { [weak self] isTitleRequired in guard let self = self else { return } - self.componentView.isRequiredTitle = isRequiredTitle + self.componentView.isTitleRequired = isTitleRequired } } From 487164d2807f9e81103a6b75f6ed97bff2dac104 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 13:28:35 +0100 Subject: [PATCH 014/117] [Formfield#782] Fix comments --- .../FormField/FormFieldComponentUIViewModel.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift index fb5a39886..5d5fd8093 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift @@ -97,7 +97,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { lazy var isRequiredConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Is Required Title", - type: .checkbox(title: "", isOn: self.isRequiredTitle), + type: .checkbox(title: "", isOn: self.isTitleRequired), target: (source: self, action: #selector(self.isRequiredChanged(_:)))) }() @@ -129,7 +129,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { @Published var descriptionStyle: FormFieldTextStyle @Published var componentStyle: FormFieldComponentStyle @Published var isEnabled: Bool - @Published var isRequiredTitle: Bool + @Published var isTitleRequired: Bool init( theme: Theme, @@ -138,7 +138,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { descriptionStyle: FormFieldTextStyle = .text, componentStyle: FormFieldComponentStyle = .singleCheckbox, isEnabled: Bool = true, - isRequiredTitle: Bool = false + isTitleRequired: Bool = false ) { self.theme = theme self.feedbackState = feedbackState @@ -146,7 +146,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { self.descriptionStyle = descriptionStyle self.componentStyle = componentStyle self.isEnabled = isEnabled - self.isRequiredTitle = isRequiredTitle + self.isTitleRequired = isTitleRequired super.init(identifier: "FormField") self.configurationViewModel = .init(itemsViewModel: [ @@ -189,7 +189,7 @@ extension FormFieldComponentUIViewModel { } @objc func isRequiredChanged(_ isSelected: Any?) { - self.isRequiredTitle = isTrue(isSelected) + self.isTitleRequired = isTrue(isSelected) } } From 734517e8e7c2a318ad82a70c0127420b1a46651b Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 14:13:47 +0100 Subject: [PATCH 015/117] [Formfield#782] Duplicate init for attributed text --- .../View/UIKit/FormFieldUIView.swift | 73 ++++++++++++------- .../FormField/FormFieldComponentUIView.swift | 4 +- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index e1f8be222..7aab82378 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -165,43 +165,62 @@ public final class FormFieldUIView: UIControl { /// Initialize a new checkbox UIKit-view. /// - Parameters: /// - theme: The current Spark-Theme. + /// - component: The component is covered by formfield. + /// - feedbackState: The formfield feedback state. 'Default' or 'Error'. /// - title: The formfield title. - /// - attributedTitle: The formfield attributedTitle. - /// - description: The formfield description. - /// - attributedDescription: The formfield attributedDescription. - /// - component: The formfield component. - public init( + /// - description: The formfield helper message. + /// - isTitleRequired: The asterisk symbol at the end of title. + /// - isEnabled: The formfield's component isEnabled value. + /// - isSelected: The formfield's component isSelected state. + public convenience init( theme: Theme, - feedbackState: FormFieldFeedbackState, + component: Component, + feedbackState: FormFieldFeedbackState = .default, + title: String? = nil, + description: String? = nil, isTitleRequired: Bool = false, isEnabled: Bool = true, - isSelected: Bool = false, - title: String? = nil, + isSelected: Bool = false + ) { + let attributedTitle: NSAttributedString? = title.map(NSAttributedString.init) + let attributedDescription: NSAttributedString? = description.map(NSAttributedString.init) + self.init( + theme: theme, + component: component, + feedbackState: feedbackState, + attributedTitle: attributedTitle, + attributedDescription: attributedDescription, + isTitleRequired: isTitleRequired, + isEnabled: isEnabled, + isSelected: isSelected + ) + } + + /// Initialize a new checkbox UIKit-view. + /// - Parameters: + /// - theme: The current Spark-Theme. + /// - component: The component is covered by formfield. + /// - feedbackState: The formfield feedback state. 'Default' or 'Error'. + /// - attributedTitle: The formfield attributedTitle. + /// - attributedDescription: The formfield attributed helper message. + /// - isTitleRequired: The asterisk symbol at the end of title. + /// - isEnabled: The formfield's component isEnabled value. + /// - isSelected: The formfield's component isSelected state. + public init( + theme: Theme, + component: Component, + feedbackState: FormFieldFeedbackState = .default, attributedTitle: NSAttributedString? = nil, - description: String? = nil, attributedDescription: NSAttributedString? = nil, - component: Component + isTitleRequired: Bool = false, + isEnabled: Bool = true, + isSelected: Bool = false ) { - let titleValue: NSAttributedString? - let descriptionValue: NSAttributedString? - - if let title = title { - titleValue = NSAttributedString(string: title) - } else { - titleValue = attributedTitle - } - - if let description = description { - descriptionValue = NSAttributedString(string: description) - } else { - descriptionValue = attributedDescription - } - let viewModel = FormFieldViewModel( theme: theme, feedbackState: feedbackState, - title: .left(titleValue), - description: .left(descriptionValue), + title: .left(attributedTitle), + description: .left(attributedDescription), isTitleRequired: isTitleRequired ) diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift index 1c2043867..f7ddac28a 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift @@ -163,10 +163,10 @@ final class FormFieldComponentUIView: ComponentUIView { return .init( theme: viewModel.theme, + component: component, feedbackState: .default, title: "Agreement", - description: "Your agreement is important to us.", - component: component + description: "Your agreement is important to us." ) } From 6578bf2c23612b982af82e7b341c1474c4cba84a Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 14:22:49 +0100 Subject: [PATCH 016/117] [Formfield#782] Fix warnings on demo project --- .../CheckboxGroupComponentUIView.swift | 5 ----- .../CheckboxGroupComponentUIViewModel.swift | 13 ------------- .../UIKit/SliderComponentUIViewController.swift | 2 +- .../Cells/CheckboxGroupCell/CheckboxGroupCell.swift | 6 ------ .../CheckboxGroupConfiguration.swift | 1 - .../Classes/View/ListView/ListViewDatasource.swift | 6 +++--- .../Demo/Classes/View/SettingsViewController.swift | 3 --- 7 files changed, 4 insertions(+), 32 deletions(-) diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index 9ee6960d8..c386bd8ef 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -76,11 +76,6 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.layout = isLayoutVertical ? .vertical : .horizontal } - self.viewModel.$showGroupTitle.subscribe(in: &self.cancellables) { [weak self] showGroupTitle in - guard let self = self else { return } - self.componentView.title = showGroupTitle ? viewModel.title : "" - } - self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in guard let self = self else { return } self.componentView.isEnabled = isEnabled diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift index af4e86103..43a4760ed 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift @@ -73,14 +73,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { ) }() - lazy var titleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { - return .init( - name: "Show Group Title", - type: .toggle(isOn: self.showGroupTitle), - target: (source: self, action: #selector(self.toggleShowGroupTitle)) - ) - }() - lazy var isEnableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Is Enable", @@ -179,7 +171,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.intentConfigurationItemViewModel, self.alignmentConfigurationItemViewModel, self.layoutConfigurationItemViewModel, - self.titleConfigurationItemViewModel, self.isEnableConfigurationItemViewModel, self.iconConfigurationItemViewModel, self.groupTypeConfigurationItemViewModel, @@ -207,10 +198,6 @@ extension CheckboxGroupComponentUIViewModel { self.isLayoutVertical.toggle() } - @objc func toggleShowGroupTitle() { - self.showGroupTitle.toggle() - } - @objc func toggleIsEnable() { self.isEnabled.toggle() } diff --git a/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift index 288e5580f..6fdf70613 100644 --- a/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift +++ b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift @@ -48,7 +48,7 @@ final class SliderComponentUIViewController: UIViewController { self.addPublisher() self.componentView.slider.addAction(UIAction(handler: { [weak self] _ in - guard let self else { return } + guard self != nil else { return } }), for: .valueChanged) } diff --git a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift index e1d00cea2..129c92744 100644 --- a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift +++ b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift @@ -49,11 +49,5 @@ final class CheckboxGroupCell: UITableViewCell, Configurable { self.component.intent = configuration.intent self.component.alignment = configuration.alignment self.component.layout = configuration.layout - - if configuration.showGroupTitle { - self.component.title = "Checkbox group title" - } else { - self.component.title = nil - } } } diff --git a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift index 62ad286e7..05369cd75 100644 --- a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift +++ b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift @@ -14,6 +14,5 @@ struct CheckboxGroupConfiguration: ComponentConfiguration { var intent: CheckboxIntent var alignment: CheckboxAlignment var layout: CheckboxGroupLayout - var showGroupTitle: Bool var items: [any CheckboxGroupItemProtocol] } diff --git a/spark/Demo/Classes/View/ListView/ListViewDatasource.swift b/spark/Demo/Classes/View/ListView/ListViewDatasource.swift index 08150c2c3..327dfcb2b 100644 --- a/spark/Demo/Classes/View/ListView/ListViewDatasource.swift +++ b/spark/Demo/Classes/View/ListView/ListViewDatasource.swift @@ -283,9 +283,9 @@ extension ListViewDataSource { /// Checkbox Group func createCheckboxGroupConfigurations() -> [CheckboxGroupConfiguration] { - [CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .main, alignment: .left, layout: .vertical, showGroupTitle: false, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)]), - CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .support, alignment: .left, layout: .horizontal, showGroupTitle: false, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", id: "2", selectionState: .indeterminate, isEnabled: true), CheckboxGroupItemDefault(title: "Hello World", id: "3", selectionState: .unselected, isEnabled: true)]), - CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .info, alignment: .right, layout: .vertical, showGroupTitle: true, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: false), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)])] + [CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .main, alignment: .left, layout: .vertical, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)]), + CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .support, alignment: .left, layout: .horizontal, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", id: "2", selectionState: .indeterminate, isEnabled: true), CheckboxGroupItemDefault(title: "Hello World", id: "3", selectionState: .unselected, isEnabled: true)]), + CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .info, alignment: .right, layout: .vertical, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: false), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)])] } /// Chip diff --git a/spark/Demo/Classes/View/SettingsViewController.swift b/spark/Demo/Classes/View/SettingsViewController.swift index 8fe26614b..2f3a103c7 100644 --- a/spark/Demo/Classes/View/SettingsViewController.swift +++ b/spark/Demo/Classes/View/SettingsViewController.swift @@ -66,13 +66,10 @@ extension SettingsViewController { override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let section = Row.allCases[indexPath.row] - var viewController: UIViewController! switch section { case .appearance: self.presentAppearanceSheet() } - guard viewController != nil else { return } - self.navigationController?.pushViewController(viewController, animated: true) } } From 730b490f4dd0f2d182a2badd99c9e5ad3308d3bf Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 14:34:44 +0100 Subject: [PATCH 017/117] [Formfield#782] Fix wrong deprecated init --- .../Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 9d2eec3b1..977f781a6 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -191,7 +191,6 @@ public final class CheckboxGroupUIView: UIControl { /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - @available(*, deprecated, message: "Formfield will be used to show title of component. Please use init without title.") public init( checkedImage: UIImage, items: [any CheckboxGroupItemProtocol], From 9ea055d2284824078327197a316ac4a0378a6dcd Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 15:17:58 +0100 Subject: [PATCH 018/117] [Formfield#782] Remove optinal either --- core/Sources/Common/Enum/Either/Either.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/core/Sources/Common/Enum/Either/Either.swift b/core/Sources/Common/Enum/Either/Either.swift index dc4ca0c09..5d6fc438c 100644 --- a/core/Sources/Common/Enum/Either/Either.swift +++ b/core/Sources/Common/Enum/Either/Either.swift @@ -34,20 +34,6 @@ extension Either { case .right: fatalError("No value for left part") } } - - var optinalLeftValue: Left? { - switch self { - case let .left(value): return value - case .right: return nil - } - } - - var optinalRightValue: Right? { - switch self { - case let .right(value): return value - case .left: return nil - } - } } extension Either { From 41d31a2c7fd619bb1aff00f4f9478a05343859f5 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 15 Mar 2024 16:40:29 +0100 Subject: [PATCH 019/117] [Formfield#782] Fix asterisk text logic --- .../FormField/Model/FormFieldViewModel.swift | 79 ++++++++++--------- .../View/UIKit/FormFieldUIView.swift | 14 ++-- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 2b61ee3e3..7453446b7 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -13,13 +13,7 @@ import UIKit final class FormFieldViewModel: ObservableObject { // MARK: - Internal properties - @Published var title: Either? { - didSet { - if isTitleRequired { - self.updateAsterix() - } - } - } + @Published private(set) var title: Either? @Published var description: Either? @Published var asteriskText: Either? @Published var titleFont: any TypographyFontToken @@ -33,6 +27,7 @@ final class FormFieldViewModel: ObservableObject { self.updateColors() self.updateFonts() self.updateSpacing() + self.updateAsterisk() } } @@ -46,13 +41,14 @@ final class FormFieldViewModel: ObservableObject { var isTitleRequired: Bool { didSet { guard isTitleRequired != oldValue else { return } - self.updateAsterix() + self.title = self.getTitleWithAsteriskIfNeeded() } } private var colorUseCase: FormFieldColorsUseCaseable - private var colors: FormFieldColors + private var userDefinedTitle: Either? + private var asterisk: NSAttributedString = NSAttributedString() // MARK: - Init init( @@ -77,33 +73,6 @@ final class FormFieldViewModel: ObservableObject { self.descriptionColor = self.colors.descriptionColor } - private func updateAsterix() { - let asterisk = NSAttributedString( - string: " *", - attributes: [ - NSAttributedString.Key.foregroundColor: self.colors.asteriskColor.uiColor, - NSAttributedString.Key.font : self.theme.typography.caption.uiFont - ] - ) - - switch self.title { - case let .left(text): - if let text = text { - let mutableAttributedString = NSMutableAttributedString(attributedString: text) - mutableAttributedString.append(asterisk) - self.asteriskText = self.isTitleRequired ? .left(NSAttributedString(attributedString: mutableAttributedString)) : nil - } - - case let .right(text): - if var text = text { - text.append(AttributedString(asterisk)) - self.asteriskText = self.isTitleRequired ? .right(text) : nil - } - case .none: - self.asteriskText = nil - } - } - private func updateColors() { self.colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) self.titleColor = self.colors.titleColor @@ -118,4 +87,42 @@ final class FormFieldViewModel: ObservableObject { private func updateSpacing() { self.spacing = self.theme.layout.spacing.small } + + private func updateAsterisk() { + self.asterisk = NSAttributedString( + string: " *", + attributes: [ + NSAttributedString.Key.foregroundColor: self.colors.asteriskColor.uiColor, + NSAttributedString.Key.font : self.theme.typography.caption.uiFont + ] + ) + } + + func setTitle(_ title: Either?) { + self.userDefinedTitle = title + self.title = self.getTitleWithAsteriskIfNeeded() + } + + private func getTitleWithAsteriskIfNeeded() -> Either? { + switch self.userDefinedTitle { + case .left(let attributedString): + guard let attributedString else { return nil } + + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + if self.isTitleRequired { + mutableAttributedString.append(self.asterisk) + } + return .left(mutableAttributedString) + + case .right(let attributedString): + guard var attributedString else { return nil } + + if self.isTitleRequired { + attributedString.append(AttributedString(self.asterisk)) + } + return .right(attributedString) + + case .none: return nil + } + } } diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 7aab82378..91f1c3a3b 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -51,7 +51,7 @@ public final class FormFieldUIView: UIControl { return self.titleLabel.text } set { - self.viewModel.title = .left(newValue.map(NSAttributedString.init)) + self.viewModel.setTitle(.left(newValue.map(NSAttributedString.init))) } } @@ -61,7 +61,7 @@ public final class FormFieldUIView: UIControl { return self.titleLabel.attributedText } set { - self.viewModel.title = .left(newValue) + self.viewModel.setTitle(.left(newValue)) } } @@ -74,7 +74,6 @@ public final class FormFieldUIView: UIControl { } } - /// The description of formfield. public var descriptionString: String? { get { @@ -257,18 +256,17 @@ public final class FormFieldUIView: UIControl { private func subscribe() { - Publishers.CombineLatest4( + Publishers.CombineLatest3( self.viewModel.$title, self.viewModel.$titleFont, - self.viewModel.$titleColor, - self.viewModel.$asteriskText - ).subscribe(in: &self.cancellables) { [weak self] title, font, color, asteriskText in + self.viewModel.$titleColor + ).subscribe(in: &self.cancellables) { [weak self] title, font, color in guard let self else { return } let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty self.titleLabel.isHidden = labelHidden self.titleLabel.font = font.uiFont self.titleLabel.textColor = color.uiColor - self.titleLabel.attributedText = asteriskText?.leftValue != nil ? asteriskText?.leftValue : title?.leftValue + self.titleLabel.attributedText = title?.leftValue } Publishers.CombineLatest3( From 19bb39288d7375ccc3105fc98d0bb08a3741319a Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 18 Mar 2024 13:52:11 +0100 Subject: [PATCH 020/117] [Formfield#858] Add formfield swiftui view --- .../View/SwiftUI/FormFieldView.swift | 108 +++++++++++++ .../View/Components/ComponentsView.swift | 4 + .../SwiftUI/FormFieldComponentView.swift | 152 ++++++++++++++++++ .../FormFieldComponentUIView.swift | 0 .../FormFieldComponentUIViewController.swift | 0 .../FormFieldComponentUIViewModel.swift | 2 +- 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift create mode 100644 spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift rename spark/Demo/Classes/View/Components/FormField/{ => UIKit}/FormFieldComponentUIView.swift (100%) rename spark/Demo/Classes/View/Components/FormField/{ => UIKit}/FormFieldComponentUIViewController.swift (100%) rename spark/Demo/Classes/View/Components/FormField/{ => UIKit}/FormFieldComponentUIViewModel.swift (99%) diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift new file mode 100644 index 000000000..d40acb1cc --- /dev/null +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift @@ -0,0 +1,108 @@ +// +// FormFieldView.swift +// SparkCore +// +// Created by alican.aycil on 18.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +public struct FormFieldView: View { + + private let component: Component + @ObservedObject private var viewModel: FormFieldViewModel + @ScaledMetric private var spacing: CGFloat + + /// Initialize a new checkbox UIKit-view. + /// - Parameters: + /// - theme: The current Spark-Theme. + /// - component: The component is covered by formfield. + /// - feedbackState: The formfield feedback state. 'Default' or 'Error'. + /// - title: The formfield title. + /// - description: The formfield helper message. + /// - isTitleRequired: The asterisk symbol at the end of title. + /// - isEnabled: The formfield's component isEnabled value. + /// - isSelected: The formfield's component isSelected state. + public init( + theme: Theme, + @ViewBuilder component: @escaping () -> Component, + feedbackState: FormFieldFeedbackState = .default, + title: String? = nil, + description: String? = nil, + isTitleRequired: Bool = false, + isEnabled: Bool = true, + isSelected: Bool = false + ) { + let attributedTitle: AttributedString? = title.map(AttributedString.init) + let attributedDescription: AttributedString? = description.map(AttributedString.init) + self.init( + theme: theme, + component: component, + feedbackState: feedbackState, + attributedTitle: attributedTitle, + attributedDescription: attributedDescription, + isTitleRequired: isTitleRequired, + isEnabled: isEnabled, + isSelected: isSelected + ) + } + + /// Initialize a new checkbox UIKit-view. + /// - Parameters: + /// - theme: The current Spark-Theme. + /// - component: The component is covered by formfield. + /// - feedbackState: The formfield feedback state. 'Default' or 'Error'. + /// - attributedTitle: The formfield attributedTitle. + /// - attributedDescription: The formfield attributed helper message. + /// - isTitleRequired: The asterisk symbol at the end of title. + /// - isEnabled: The formfield's component isEnabled value. + /// - isSelected: The formfield's component isSelected state. + public init( + theme: Theme, + @ViewBuilder component: @escaping () -> Component, + feedbackState: FormFieldFeedbackState = .default, + attributedTitle: AttributedString? = nil, + attributedDescription: AttributedString? = nil, + isTitleRequired: Bool = false, + isEnabled: Bool = true, + isSelected: Bool = false + ) { + let viewModel = FormFieldViewModel( + theme: theme, + feedbackState: feedbackState, + title: .right(attributedTitle), + description: .right(attributedDescription), + isTitleRequired: isTitleRequired + ) + + self.viewModel = viewModel + self._spacing = ScaledMetric(wrappedValue: viewModel.spacing) + self.component = component() + + +// self.isEnabled = isEnabled +// self.isSelected = isSelected + } + + public var body: some View { + VStack(alignment: .leading, spacing: self.spacing) { + + if let title = self.viewModel.title?.rightValue { + Text(title) + .font(self.viewModel.titleFont.font) + .foregroundStyle(self.viewModel.titleColor.color) + } + + self.component + + if let description = self.viewModel.description?.rightValue { + Text(description) + .font(self.viewModel.descriptionFont.font) + .foregroundStyle(self.viewModel.descriptionColor.color) + } + } + } + + +} diff --git a/spark/Demo/Classes/View/Components/ComponentsView.swift b/spark/Demo/Classes/View/Components/ComponentsView.swift index e26cd0a4f..ecddb9943 100644 --- a/spark/Demo/Classes/View/Components/ComponentsView.swift +++ b/spark/Demo/Classes/View/Components/ComponentsView.swift @@ -48,6 +48,10 @@ struct ComponentsView: View { } } + Button("FormField") { + self.navigateToView(FormFieldComponentView()) + } + Button("Icon") { self.navigateToView(IconComponentView()) } diff --git a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift new file mode 100644 index 000000000..78fd50eb4 --- /dev/null +++ b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift @@ -0,0 +1,152 @@ +// +// FormFieldView.swift +// SparkDemo +// +// Created by alican.aycil on 18.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Spark +import SparkCore +import SwiftUI + +struct FormFieldComponentView: View { + + // MARK: - Properties + + @State private var theme: Theme = SparkThemePublisher.shared.theme + @State private var feedbackState: FormFieldFeedbackState = .default + @State private var componentStyle: FormFieldComponentStyle = .singleCheckbox + @State private var titleStyle: FormFieldTextStyle = .text + @State private var descriptionStyle: FormFieldTextStyle = .text + @State private var isEnabled = CheckboxSelectionState.selected + @State private var isTitleRequired = CheckboxSelectionState.unselected + + // MARK: - View + + var body: some View { + + Component( + name: "FormField", + configuration: { + ThemeSelector(theme: self.$theme) + + EnumSelector( + title: "Feedback State", + dialogTitle: "Select an Feedback State", + values: FormFieldFeedbackState.allCases, + value: self.$feedbackState) + + EnumSelector( + title: "Title Style", + dialogTitle: "Select a Title Style", + values: FormFieldTextStyle.allCases, + value: self.$titleStyle) + + EnumSelector( + title: "Description Style", + dialogTitle: "Select an Description Style", + values: FormFieldTextStyle.allCases, + value: self.$descriptionStyle) + + EnumSelector( + title: "Component Style", + dialogTitle: "Select an Description Style", + values: FormFieldComponentStyle.allCases, + value: self.$componentStyle) + + CheckboxView( + text: "Is Enable", + checkedImage: DemoIconography.shared.checkmark.image, + theme: theme, + isEnabled: true, + selectionState: self.$isEnabled + ) + + CheckboxView( + text: "Is Title Require", + checkedImage: DemoIconography.shared.checkmark.image, + theme: theme, + isEnabled: true, + selectionState: self.$isTitleRequired + ) + }, + integration: { + FormFieldView( + theme: self.theme, + component: { + self.setComponent(style: self.componentStyle) + }, + feedbackState: self.feedbackState, + attributedTitle: self.setText(isTitle: true, textStyle: self.titleStyle), + attributedDescription: self.setText(isTitle: false, textStyle: self.descriptionStyle), +// isTitleRequired: self.isTitleRequired == .selected ? true : false + isTitleRequired: true + ) + } + ) + } + + private func setText(isTitle: Bool, textStyle: FormFieldTextStyle) -> AttributedString? { + switch textStyle { + case .text: + return AttributedString(stringLiteral: isTitle ? "Agreement" : "Your agreement is important to us.") + case .multilineText: + return AttributedString(stringLiteral: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.") + case .attributeText: + return AttributedString(self.attributeText) + case .none: + return nil + } + } + + private var attributeText: NSAttributedString { + let attributeString = NSMutableAttributedString( + string: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + attributes: [.font: UIFont.italicSystemFont(ofSize: 18)] + ) + let attributes: [NSMutableAttributedString.Key: Any] = [ + .font: UIFont( + descriptor: UIFontDescriptor().withSymbolicTraits([.traitBold, .traitItalic]) ?? UIFontDescriptor(), + size: 18 + ), + .foregroundColor: UIColor.red + ] + attributeString.setAttributes(attributes, range: NSRange(location: 0, length: 11)) + return attributeString + } + + private func setComponent(style: FormFieldComponentStyle) -> some View { + + Rectangle() + .foregroundColor(Color.gray) + .frame(width: 50, height: 100) + +// switch style { +// case .basic: +// return Rectangle() +// .background(Color.gray) +// .frame(width: 50, height: 100) +// case .singleCheckbox: +// break +// case .verticalCheckbox: +// break +// case .horizontalCheckbox: +// break +// case .horizontalScrollableCheckbox: +// break +// case .singleRadioButton: +// break +// case .verticalRadioButton: +// break +// case .horizontalRadioButton: +// break +// case .textField: +// break +// case .addOnTextField: +// break +// case .ratingInput: +// break +// } + } +} diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift similarity index 100% rename from spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIView.swift rename to spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewController.swift similarity index 100% rename from spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewController.swift rename to spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewController.swift diff --git a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift similarity index 99% rename from spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift rename to spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift index 5d5fd8093..9d02d2e3a 100644 --- a/spark/Demo/Classes/View/Components/FormField/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift @@ -96,7 +96,7 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { lazy var isRequiredConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( - name: "Is Required Title", + name: "Is Title Required", type: .checkbox(title: "", isOn: self.isTitleRequired), target: (source: self, action: #selector(self.isRequiredChanged(_:)))) }() From 2341e7be665fd9bf92d31169ca280c9e36c0a5db Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 20 Mar 2024 16:24:38 +0100 Subject: [PATCH 021/117] [Formfield#858] Add formfield swiftui view --- .../FormField/Model/FormFieldViewModel.swift | 6 +- .../View/SwiftUI/FormFieldView.swift | 21 +-- .../SwiftUI/FormFieldComponentView.swift | 146 +++++++++++++----- 3 files changed, 119 insertions(+), 54 deletions(-) diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 7453446b7..5daac4ba8 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -15,7 +15,6 @@ final class FormFieldViewModel: ObservableObject { // MARK: - Internal properties @Published private(set) var title: Either? @Published var description: Either? - @Published var asteriskText: Either? @Published var titleFont: any TypographyFontToken @Published var descriptionFont: any TypographyFontToken @Published var titleColor: any ColorToken @@ -61,7 +60,7 @@ final class FormFieldViewModel: ObservableObject { ) { self.theme = theme self.feedbackState = feedbackState - self.title = title + self.userDefinedTitle = title self.description = description self.isTitleRequired = isTitleRequired self.colorUseCase = colorUseCase @@ -71,6 +70,9 @@ final class FormFieldViewModel: ObservableObject { self.descriptionFont = self.theme.typography.caption self.titleColor = self.colors.titleColor self.descriptionColor = self.colors.descriptionColor + + self.updateAsterisk() + self.setTitle(title) } private func updateColors() { diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift index d40acb1cc..14f994a42 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift @@ -10,9 +10,9 @@ import SwiftUI public struct FormFieldView: View { - private let component: Component @ObservedObject private var viewModel: FormFieldViewModel @ScaledMetric private var spacing: CGFloat + private let component: Component /// Initialize a new checkbox UIKit-view. /// - Parameters: @@ -23,7 +23,6 @@ public struct FormFieldView: View { /// - description: The formfield helper message. /// - isTitleRequired: The asterisk symbol at the end of title. /// - isEnabled: The formfield's component isEnabled value. - /// - isSelected: The formfield's component isSelected state. public init( theme: Theme, @ViewBuilder component: @escaping () -> Component, @@ -42,9 +41,7 @@ public struct FormFieldView: View { feedbackState: feedbackState, attributedTitle: attributedTitle, attributedDescription: attributedDescription, - isTitleRequired: isTitleRequired, - isEnabled: isEnabled, - isSelected: isSelected + isTitleRequired: isTitleRequired ) } @@ -56,17 +53,13 @@ public struct FormFieldView: View { /// - attributedTitle: The formfield attributedTitle. /// - attributedDescription: The formfield attributed helper message. /// - isTitleRequired: The asterisk symbol at the end of title. - /// - isEnabled: The formfield's component isEnabled value. - /// - isSelected: The formfield's component isSelected state. public init( theme: Theme, @ViewBuilder component: @escaping () -> Component, feedbackState: FormFieldFeedbackState = .default, attributedTitle: AttributedString? = nil, attributedDescription: AttributedString? = nil, - isTitleRequired: Bool = false, - isEnabled: Bool = true, - isSelected: Bool = false + isTitleRequired: Bool = false ) { let viewModel = FormFieldViewModel( theme: theme, @@ -79,10 +72,6 @@ public struct FormFieldView: View { self.viewModel = viewModel self._spacing = ScaledMetric(wrappedValue: viewModel.spacing) self.component = component() - - -// self.isEnabled = isEnabled -// self.isSelected = isSelected } public var body: some View { @@ -93,7 +82,6 @@ public struct FormFieldView: View { .font(self.viewModel.titleFont.font) .foregroundStyle(self.viewModel.titleColor.color) } - self.component if let description = self.viewModel.description?.rightValue { @@ -102,7 +90,6 @@ public struct FormFieldView: View { .foregroundStyle(self.viewModel.descriptionColor.color) } } + .accessibilityIdentifier(FormFieldAccessibilityIdentifier.formField) } - - } diff --git a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift index 78fd50eb4..fd88cbef5 100644 --- a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift +++ b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift @@ -22,6 +22,18 @@ struct FormFieldComponentView: View { @State private var isEnabled = CheckboxSelectionState.selected @State private var isTitleRequired = CheckboxSelectionState.unselected + @State private var checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ] + @State private var scrollableCheckboxGroupItems: [any CheckboxGroupItemProtocol] = [ + CheckboxGroupItemDefault(title: "Hello World", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", id: "2", selectionState: .selected, isEnabled: true), + ] + @State private var texfieldText: String = "" + @State private var selectedID: Int? = 0 + @State private var rating: CGFloat = 2 + // MARK: - View var body: some View { @@ -75,14 +87,14 @@ struct FormFieldComponentView: View { FormFieldView( theme: self.theme, component: { - self.setComponent(style: self.componentStyle) + self.component }, feedbackState: self.feedbackState, attributedTitle: self.setText(isTitle: true, textStyle: self.titleStyle), attributedDescription: self.setText(isTitle: false, textStyle: self.descriptionStyle), -// isTitleRequired: self.isTitleRequired == .selected ? true : false - isTitleRequired: true + isTitleRequired: self.isTitleRequired == .selected ? true : false ) + .disabled(self.isEnabled == .selected ? false : true) } ) } @@ -116,37 +128,101 @@ struct FormFieldComponentView: View { return attributeString } - private func setComponent(style: FormFieldComponentStyle) -> some View { - - Rectangle() - .foregroundColor(Color.gray) - .frame(width: 50, height: 100) - -// switch style { -// case .basic: -// return Rectangle() -// .background(Color.gray) -// .frame(width: 50, height: 100) -// case .singleCheckbox: -// break -// case .verticalCheckbox: -// break -// case .horizontalCheckbox: -// break -// case .horizontalScrollableCheckbox: -// break -// case .singleRadioButton: -// break -// case .verticalRadioButton: -// break -// case .horizontalRadioButton: -// break -// case .textField: -// break -// case .addOnTextField: -// break -// case .ratingInput: -// break -// } + @ViewBuilder + var component: some View { + + switch self.componentStyle { + case .basic: + Rectangle() + .foregroundColor(Color.gray) + .frame(width: 50, height: 100) + + case .singleCheckbox: + // Single checkbox might be fixed + CheckboxView( + text: "Hello World", + checkedImage: DemoIconography.shared.checkmark.image, + theme: self.theme, + intent: .success, + selectionState: .constant(.selected) + ) + .fixedSize(horizontal: false, vertical: true) + + case .verticalCheckbox: + CheckboxGroupView( + checkedImage: DemoIconography.shared.checkmark.image, + items: self.$checkboxGroupItems, + alignment: .left, + theme: self.theme, + accessibilityIdentifierPrefix: "checkbox-group" + ) + + case .horizontalCheckbox: + CheckboxGroupView( + checkedImage: DemoIconography.shared.checkmark.image, + items: self.$checkboxGroupItems, + layout: .horizontal, + alignment: .left, + theme: self.theme, + intent: .support, + accessibilityIdentifierPrefix: "checkbox-group" + ) + + case .horizontalScrollableCheckbox: + CheckboxGroupView( + checkedImage: DemoIconography.shared.checkmark.image, + items: self.$scrollableCheckboxGroupItems, + layout: .horizontal, + alignment: .left, + theme: self.theme, + intent: .support, + accessibilityIdentifierPrefix: "checkbox-group" + ) + case .singleRadioButton: + RadioButtonGroupView( + theme: self.theme, + intent: .accent, + selectedID: self.$selectedID, + items: [ + RadioButtonItem(id: 0, label: "Radio Button 1") + ], + labelAlignment: .trailing + ) + case .verticalRadioButton: + RadioButtonGroupView( + theme: self.theme, + intent: .danger, + selectedID: self.$selectedID, + items: [ + RadioButtonItem(id: 0, label: "Radio Button 1"), + RadioButtonItem(id: 1, label: "Radio Button 2"), + ], + labelAlignment: .leading + ) + case .horizontalRadioButton: + RadioButtonGroupView( + theme: self.theme, + intent: .danger, + selectedID: self.$selectedID, + items: [ + RadioButtonItem(id: 0, label: "Radio Button 1"), + RadioButtonItem(id: 1, label: "Radio Button 2"), + ], + labelAlignment: .trailing, + groupLayout: .horizontal + ) + case .textField: + TextField("Component is not ready yet", text: self.$texfieldText) + .textFieldStyle(.roundedBorder) + case .addOnTextField: + TextField("Component is not ready yet", text: self.$texfieldText) + .textFieldStyle(.roundedBorder) + case .ratingInput: + RatingInputView( + theme: self.theme, + intent: .main, + rating: self.$rating + ) + } } } From 0abd0609d46120435739706d013355e4911cfbde Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 25 Mar 2024 17:45:56 +0100 Subject: [PATCH 022/117] [CheckboxGroup#864] Add isDisabled parameter to uikit checkbox group --- .../View/UIKit/CheckboxGroupUIView.swift | 14 ++++++++++++++ .../CheckboxGroupComponentUIView.swift | 5 +++++ .../CheckboxGroupComponentUIViewModel.swift | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 77003e11a..113ca5c7c 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -124,6 +124,20 @@ public final class CheckboxGroupUIView: UIControl { self.itemsStackView.arrangedSubviews.compactMap { $0 as? CheckboxUIView } } + /// A Boolean value indicating whether the component is in the enabled state. + public override var isEnabled: Bool { + didSet{ + guard isEnabled != oldValue else { return } + if isEnabled { + self.checkboxes.enumerated().forEach { index, item in + item.isEnabled = self.items.indices.contains(index) ? self.items[index].isEnabled : true + } + } else { + self.checkboxes.forEach { $0.isEnabled = false } + } + } + } + // MARK: - Initialization /// Not implemented. Please use another init. diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index a64c580e9..9ee6960d8 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -81,6 +81,11 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.title = showGroupTitle ? viewModel.title : "" } + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in + guard let self = self else { return } + self.componentView.isEnabled = isEnabled + } + self.viewModel.$groupType.subscribe(in: &self.cancellables) { [weak self] type in guard let self = self else { return } self.viewModel.groupTypeConfigurationItemViewModel.buttonTitle = type.name diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift index b7b65ec5d..d6f0d5d09 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift @@ -81,6 +81,14 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { ) }() + lazy var isEnableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Is Enable", + type: .toggle(isOn: self.isEnabled), + target: (source: self, action: #selector(self.toggleIsEnable)) + ) + }() + lazy var iconConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Icons", @@ -142,6 +150,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { @Published var isAlignmentLeft: Bool @Published var isLayoutVertical: Bool @Published var showGroupTitle: Bool + @Published var isEnabled: Bool @Published var icon: [String: UIImage] @Published var groupType: CheckboxGroupType @@ -151,6 +160,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { isAlignmentLeft: Bool = true, isLayoutVertical: Bool = false, showGroupTitle: Bool = false, + isEnabled: Bool = true, icon: [String: UIImage] = ["Checkmark": DemoIconography.shared.checkmark.uiImage], groupType: CheckboxGroupType = .doubleMix ) { @@ -159,6 +169,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.isAlignmentLeft = isAlignmentLeft self.isLayoutVertical = isLayoutVertical self.showGroupTitle = showGroupTitle + self.isEnabled = isEnabled self.icon = icon self.groupType = groupType super.init(identifier: "Checkbox Group") @@ -169,6 +180,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.alignmentConfigurationItemViewModel, self.layoutConfigurationItemViewModel, self.titleConfigurationItemViewModel, + self.isEnableConfigurationItemViewModel, self.iconConfigurationItemViewModel, self.groupTypeConfigurationItemViewModel, self.itemsSelectionStateConfigurationItemViewModel @@ -199,6 +211,10 @@ extension CheckboxGroupComponentUIViewModel { self.showGroupTitle.toggle() } + @objc func toggleIsEnable() { + self.isEnabled.toggle() + } + @objc func presentIconSheet() { self.showIconSheetSubject.send(icons) } From 2e18c12bce01a02374b62660c2cfe3b361e187e7 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 25 Mar 2024 17:47:01 +0100 Subject: [PATCH 023/117] [CheckboxGroup#864] Fix partially disable issue on swiftui checkbox group --- .../Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift index 41fc056df..b25a8a143 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift @@ -166,6 +166,7 @@ public struct CheckboxGroupView: View { isEnabled: item.isEnabled.wrappedValue, selectionState: item.selectionState ) + .disabled(!item.isEnabled.wrappedValue) .accessibilityIdentifier(identifier) } } From dbc7e34fff6e84480d9409122056029542e52656 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 25 Mar 2024 18:19:48 +0100 Subject: [PATCH 024/117] [Checkbox#866] Fix extanding content issue in stack for single checkbox --- core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift index 68a6d3d2c..01c0aa18a 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift @@ -108,6 +108,7 @@ public struct CheckboxView: View { .isEnabledChanged { isEnabled in self.viewModel.isEnabled = isEnabled } + .fixedSize(horizontal: false, vertical: true) } @ViewBuilder From 7ca90f8f68a4e7f31cd8d3ba710e7a8d49b92c7d Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 26 Mar 2024 12:44:42 +0100 Subject: [PATCH 025/117] [Formfield#782] Revert checkbox group changes --- .../View/UIKit/CheckboxGroupUIView.swift | 47 ------------------- .../CheckboxGroupComponentUIView.swift | 5 -- .../CheckboxGroupComponentUIViewModel.swift | 16 ------- .../CheckboxGroupCell/CheckboxGroupCell.swift | 6 +++ .../CheckboxGroupConfiguration.swift | 1 + .../View/ListView/ListViewDatasource.swift | 6 +-- 6 files changed, 10 insertions(+), 71 deletions(-) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 977f781a6..77003e11a 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -79,7 +79,6 @@ public final class CheckboxGroupUIView: UIControl { } /// The title of the checkbox group displayed on top of the group. - @available(*, deprecated, message: "Formfield will be used to show title of component") public var title: String? { didSet { self.updateTitle() @@ -125,19 +124,6 @@ public final class CheckboxGroupUIView: UIControl { self.itemsStackView.arrangedSubviews.compactMap { $0 as? CheckboxUIView } } - public override var isEnabled: Bool { - didSet{ - guard isEnabled != oldValue else { return } - if isEnabled { - self.checkboxes.enumerated().forEach { index, item in - item.isEnabled = self.items.indices.contains(index) ? self.items[index].isEnabled : true - } - } else { - self.checkboxes.forEach { $0.isEnabled = false } - } - } - } - // MARK: - Initialization /// Not implemented. Please use another init. @@ -156,7 +142,6 @@ public final class CheckboxGroupUIView: UIControl { /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - @available(*, deprecated, message: "Formfield will be used to show title of component. Please use init without title.") public init( title: String? = nil, checkedImage: UIImage, @@ -182,38 +167,6 @@ public final class CheckboxGroupUIView: UIControl { self.commonInit() } - /// Initialize a group of one or multiple checkboxes. - /// - Parameters: - /// - checkedImage: The tick-checkbox image for checked-state. - /// - items: An array containing of multiple `CheckboxGroupItemProtocol`. Each array item is used to render a single checkbox. - /// - layout: The layout of the group can be horizontal or vertical. - /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. - /// - theme: The Spark-Theme. - /// - intent: Current intent of checkbox group - /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - public init( - checkedImage: UIImage, - items: [any CheckboxGroupItemProtocol], - layout: CheckboxGroupLayout = .vertical, - alignment: CheckboxAlignment = .left, - theme: Theme, - intent: CheckboxIntent = .main, - accessibilityIdentifierPrefix: String - ) { - self.checkedImage = checkedImage - self.items = items - self.layout = layout - self.alignment = alignment - self.checkboxAlignment = alignment - self.theme = theme - self.intent = intent - self.accessibilityIdentifierPrefix = accessibilityIdentifierPrefix - self.spacingLarge = theme.layout.spacing.large - self.spacingSmall = theme.layout.spacing.small - super.init(frame: .zero) - self.commonInit() - } - private func commonInit() { self.setupItemsStackView() self.setupView() diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index c386bd8ef..444e90136 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -76,11 +76,6 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.layout = isLayoutVertical ? .vertical : .horizontal } - self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in - guard let self = self else { return } - self.componentView.isEnabled = isEnabled - } - self.viewModel.$groupType.subscribe(in: &self.cancellables) { [weak self] type in guard let self = self else { return } self.viewModel.groupTypeConfigurationItemViewModel.buttonTitle = type.name diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift index 43a4760ed..b3bd1ee9b 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift @@ -73,14 +73,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { ) }() - lazy var isEnableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { - return .init( - name: "Is Enable", - type: .toggle(isOn: self.isEnabled), - target: (source: self, action: #selector(self.toggleIsEnable)) - ) - }() - lazy var iconConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Icons", @@ -142,7 +134,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { @Published var isAlignmentLeft: Bool @Published var isLayoutVertical: Bool @Published var showGroupTitle: Bool - @Published var isEnabled: Bool @Published var icon: [String: UIImage] @Published var groupType: CheckboxGroupType @@ -152,7 +143,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { isAlignmentLeft: Bool = true, isLayoutVertical: Bool = false, showGroupTitle: Bool = false, - isEnabled: Bool = true, icon: [String: UIImage] = ["Checkmark": DemoIconography.shared.checkmark.uiImage], groupType: CheckboxGroupType = .doubleMix ) { @@ -161,7 +151,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.isAlignmentLeft = isAlignmentLeft self.isLayoutVertical = isLayoutVertical self.showGroupTitle = showGroupTitle - self.isEnabled = isEnabled self.icon = icon self.groupType = groupType super.init(identifier: "Checkbox Group") @@ -171,7 +160,6 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.intentConfigurationItemViewModel, self.alignmentConfigurationItemViewModel, self.layoutConfigurationItemViewModel, - self.isEnableConfigurationItemViewModel, self.iconConfigurationItemViewModel, self.groupTypeConfigurationItemViewModel, self.itemsSelectionStateConfigurationItemViewModel @@ -198,10 +186,6 @@ extension CheckboxGroupComponentUIViewModel { self.isLayoutVertical.toggle() } - @objc func toggleIsEnable() { - self.isEnabled.toggle() - } - @objc func presentIconSheet() { self.showIconSheetSubject.send(icons) } diff --git a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift index 129c92744..e1d00cea2 100644 --- a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift +++ b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupCell.swift @@ -49,5 +49,11 @@ final class CheckboxGroupCell: UITableViewCell, Configurable { self.component.intent = configuration.intent self.component.alignment = configuration.alignment self.component.layout = configuration.layout + + if configuration.showGroupTitle { + self.component.title = "Checkbox group title" + } else { + self.component.title = nil + } } } diff --git a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift index 05369cd75..62ad286e7 100644 --- a/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift +++ b/spark/Demo/Classes/View/ListView/Cells/CheckboxGroupCell/CheckboxGroupConfiguration.swift @@ -14,5 +14,6 @@ struct CheckboxGroupConfiguration: ComponentConfiguration { var intent: CheckboxIntent var alignment: CheckboxAlignment var layout: CheckboxGroupLayout + var showGroupTitle: Bool var items: [any CheckboxGroupItemProtocol] } diff --git a/spark/Demo/Classes/View/ListView/ListViewDatasource.swift b/spark/Demo/Classes/View/ListView/ListViewDatasource.swift index 69d1035e5..bd28d11d9 100644 --- a/spark/Demo/Classes/View/ListView/ListViewDatasource.swift +++ b/spark/Demo/Classes/View/ListView/ListViewDatasource.swift @@ -283,9 +283,9 @@ extension ListViewDataSource { /// Checkbox Group func createCheckboxGroupConfigurations() -> [CheckboxGroupConfiguration] { - [CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .main, alignment: .left, layout: .vertical, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)]), - CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .support, alignment: .left, layout: .horizontal, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", id: "2", selectionState: .indeterminate, isEnabled: true), CheckboxGroupItemDefault(title: "Hello World", id: "3", selectionState: .unselected, isEnabled: true)]), - CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .info, alignment: .right, layout: .vertical, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: false), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)])] + [CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .main, alignment: .left, layout: .vertical, showGroupTitle: false, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)]), + CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .support, alignment: .left, layout: .horizontal, showGroupTitle: false, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: true), CheckboxGroupItemDefault(title: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", id: "2", selectionState: .indeterminate, isEnabled: true), CheckboxGroupItemDefault(title: "Hello World", id: "3", selectionState: .unselected, isEnabled: true)]), + CheckboxGroupConfiguration(theme: SparkTheme.shared, intent: .info, alignment: .right, layout: .vertical, showGroupTitle: true, items: [CheckboxGroupItemDefault(title: "Text", id: "1", selectionState: .selected, isEnabled: false), CheckboxGroupItemDefault(title: "Text 2", id: "2", selectionState: .unselected, isEnabled: true)])] } /// Chip From ad7a24e72f7f239009a4db872e0a8bf48145a227 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 26 Mar 2024 12:49:26 +0100 Subject: [PATCH 026/117] [Formfield#782] Revert checkbox group changes --- .../CheckboxGroupComponentUIView.swift | 5 +++++ .../CheckboxGroupComponentUIViewModel.swift | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index 444e90136..a64c580e9 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -76,6 +76,11 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.layout = isLayoutVertical ? .vertical : .horizontal } + self.viewModel.$showGroupTitle.subscribe(in: &self.cancellables) { [weak self] showGroupTitle in + guard let self = self else { return } + self.componentView.title = showGroupTitle ? viewModel.title : "" + } + self.viewModel.$groupType.subscribe(in: &self.cancellables) { [weak self] type in guard let self = self else { return } self.viewModel.groupTypeConfigurationItemViewModel.buttonTitle = type.name diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift index b3bd1ee9b..b7b65ec5d 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIViewModel.swift @@ -73,6 +73,14 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { ) }() + lazy var titleConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Show Group Title", + type: .toggle(isOn: self.showGroupTitle), + target: (source: self, action: #selector(self.toggleShowGroupTitle)) + ) + }() + lazy var iconConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( name: "Icons", @@ -160,6 +168,7 @@ final class CheckboxGroupComponentUIViewModel: ComponentUIViewModel { self.intentConfigurationItemViewModel, self.alignmentConfigurationItemViewModel, self.layoutConfigurationItemViewModel, + self.titleConfigurationItemViewModel, self.iconConfigurationItemViewModel, self.groupTypeConfigurationItemViewModel, self.itemsSelectionStateConfigurationItemViewModel @@ -186,6 +195,10 @@ extension CheckboxGroupComponentUIViewModel { self.isLayoutVertical.toggle() } + @objc func toggleShowGroupTitle() { + self.showGroupTitle.toggle() + } + @objc func presentIconSheet() { self.showIconSheetSubject.send(icons) } From b1f24a5589110c4b84e4e1a2708c9686a5a1910f Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 26 Mar 2024 17:13:43 +0100 Subject: [PATCH 027/117] [TextField] Added UseCases & ViewModel --- core/Sources/Common/DataType/Updateable.swift | 15 +- .../Model/AddOnTextFieldViewModel.swift | 70 -- .../Model/AddOnTextFieldViewModelTests.swift | 188 --- .../UIKit/AddOnTextFieldUIView.swift | 224 ---- ...TextFieldBorderLayout+ExtensionTests.swift | 16 + ...ders.swift => TextFieldBorderLayout.swift} | 5 +- .../TextFieldColors+ExtensionTests.swift | 28 + .../TextField/Model/TextFieldColors.swift | 21 +- .../TextFieldSpacings+ExtensionTests.swift | 16 + .../Model/TextFieldUIViewModel.swift | 117 -- .../Model/TextFieldUIViewModelTests.swift | 261 ----- .../UIKit/TextFieldUIView.swift | 223 ---- ...eCasableGeneratedMock+ExtensionTests.swift | 19 + .../TextFieldGetBorderLayoutUseCase.swift | 35 + ...TextFieldGetBorderLayoutUseCaseTests.swift | 87 ++ ...eCasableGeneratedMock+ExtensionTests.swift | 18 + .../GetColors/TextFieldGetColorsUseCase.swift | 58 + .../TextFieldGetColorsUseCaseTests.swift | 179 +++ .../TextFieldGetSpacingsUseCase.swift | 4 +- ...sUseCaseGeneratedMock+ExtensionTests.swift | 18 + .../TextFieldGetSpacingsUseCaseTests.swift | 4 +- .../UseCase/TextFieldGetBordersUseCase.swift | 35 - .../TextFieldGetBordersUseCaseTests.swift | 62 - .../UseCase/TextFieldGetColorsUseCase.swift | 40 - .../TextFieldGetColorsUseCaseTests.swift | 72 -- .../ViewModel/TextFieldViewModel.swift | 212 ++++ .../ViewModel/TextFieldViewModelTests.swift | 1003 +++++++++++++++++ ...lorTokenGeneratedMock+ExtensionTests.swift | 44 +- spark/Demo/Classes/Enum/UIComponent.swift | 2 - .../Components/ComponentsViewController.swift | 2 - .../SwitftUI/TextFieldComponentView.swift | 156 --- .../UIKit/TextFieldComponentUIView.swift | 466 -------- .../TextFieldComponentUIViewController.swift | 182 --- .../UIKit/TextFieldComponentUIViewModel.swift | 153 --- .../AddOnTextFieldCell.swift | 98 -- .../AddOnTextFieldConfiguration.swift | 22 - .../Cells/TextFieldCell/TextFieldCell.swift | 45 - .../TextFieldConfiguration.swift | 19 - .../ListComponentsViewController.swift | 6 - .../View/ListView/ListViewController.swift | 6 - .../View/ListView/ListViewDatasource.swift | 35 - 41 files changed, 1762 insertions(+), 2504 deletions(-) delete mode 100644 core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModel.swift delete mode 100644 core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModelTests.swift delete mode 100644 core/Sources/Components/TextField/AddOnTextField/UIKit/AddOnTextFieldUIView.swift create mode 100644 core/Sources/Components/TextField/Model/TextFieldBorderLayout+ExtensionTests.swift rename core/Sources/Components/TextField/Model/{TextFieldBorders.swift => TextFieldBorderLayout.swift} (65%) create mode 100644 core/Sources/Components/TextField/Model/TextFieldColors+ExtensionTests.swift create mode 100644 core/Sources/Components/TextField/Model/TextFieldSpacings+ExtensionTests.swift delete mode 100644 core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModel.swift delete mode 100644 core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModelTests.swift delete mode 100644 core/Sources/Components/TextField/StandaloneTextField/UIKit/TextFieldUIView.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCasableGeneratedMock+ExtensionTests.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCase.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCaseTests.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCasableGeneratedMock+ExtensionTests.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift create mode 100644 core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift rename core/Sources/Components/TextField/UseCase/{ => GetSpacings}/TextFieldGetSpacingsUseCase.swift (89%) create mode 100644 core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseGeneratedMock+ExtensionTests.swift rename core/Sources/Components/TextField/UseCase/{ => GetSpacings}/TextFieldGetSpacingsUseCaseTests.swift (93%) delete mode 100644 core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCase.swift delete mode 100644 core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCaseTests.swift delete mode 100644 core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCase.swift delete mode 100644 core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCaseTests.swift create mode 100644 core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift create mode 100644 core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift delete mode 100644 spark/Demo/Classes/View/Components/TextField/SwitftUI/TextFieldComponentView.swift delete mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift delete mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift delete mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift delete mode 100644 spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldCell.swift delete mode 100644 spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldConfiguration.swift delete mode 100644 spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldCell.swift delete mode 100644 spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldConfiguration.swift diff --git a/core/Sources/Common/DataType/Updateable.swift b/core/Sources/Common/DataType/Updateable.swift index 0f5ce310a..79edb65e1 100644 --- a/core/Sources/Common/DataType/Updateable.swift +++ b/core/Sources/Common/DataType/Updateable.swift @@ -9,7 +9,10 @@ import Foundation protocol Updateable { - func update(_ keyPath: WritableKeyPath, value: Value) -> Self + associatedtype T + func update(_ keyPath: WritableKeyPath, value: Value) -> T + func updateIfNeeded(keyPath: ReferenceWritableKeyPath, newValue: Value) + func updateIfNeeded(keyPath: ReferenceWritableKeyPath, newValue: any ColorToken) } extension Updateable { @@ -19,4 +22,14 @@ extension Updateable { copy[keyPath: keyPath] = value return copy } + + func updateIfNeeded(keyPath: ReferenceWritableKeyPath, newValue: Value) { + guard self[keyPath: keyPath] != newValue else { return } + self[keyPath: keyPath] = newValue + } + + func updateIfNeeded(keyPath: ReferenceWritableKeyPath, newValue: any ColorToken) { + guard self[keyPath: keyPath].equals(newValue) == false else { return } + self[keyPath: keyPath] = newValue + } } diff --git a/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModel.swift b/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModel.swift deleted file mode 100644 index c240e777c..000000000 --- a/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModel.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// AddOnTextFieldViewModel.swift -// SparkCore -// -// Created by Jacklyn Situmorang on 28.09.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine - -final class AddOnTextFieldViewModel: ObservableObject { - - // MARK: - Published properties - - @Published var textFieldColors: TextFieldColors - - // MARK: - Private properties - - private(set) var theme: Theme { - didSet { - self.updateColor() - } - } - - private(set) var intent: TextFieldIntent { - didSet { - self.updateColor() - } - } - - private let getColorUseCase: TextFieldGetColorsUseCasable - - // MARK: - Initialization - - init( - theme: Theme, - intent: TextFieldIntent, - getColorUseCase: TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase() - ) { - self.theme = theme - self.intent = intent - self.getColorUseCase = getColorUseCase - - self.textFieldColors = getColorUseCase.execute( - theme: theme, - intent: intent - ) - } - - // MARK: - Private methods - - private func updateColor() { - let newTextFieldColors = self.getColorUseCase.execute( - theme: self.theme, - intent: self.intent - ) - guard newTextFieldColors != self.textFieldColors else { return } - self.textFieldColors = newTextFieldColors - } - - // MARK: - Public methods - - public func setTheme(_ theme: Theme) { - self.theme = theme - } - - public func setIntent(_ intent: TextFieldIntent) { - self.intent = intent - } -} diff --git a/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModelTests.swift b/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModelTests.swift deleted file mode 100644 index ecb3eb6b7..000000000 --- a/core/Sources/Components/TextField/AddOnTextField/Model/AddOnTextFieldViewModelTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// AddOnTextFieldViewModelTests.swift -// SparkCoreUnitTests -// -// Created by Jacklyn Situmorang on 18.10.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import XCTest - -@testable import SparkCore - -final class AddOnTextFieldViewModelTests: XCTestCase { - - // MARK: - Properties - var theme: ThemeGeneratedMock! - var getColorUseCase: TextFieldGetColorsUseCasableGeneratedMock! - var borderColorToken: ColorTokenGeneratedMock! - var cancellables: Set! - var sut: AddOnTextFieldViewModel! - - // MARK: - Setup - - override func setUpWithError() throws { - try super.setUpWithError() - - self.theme = ThemeGeneratedMock.mocked() - self.getColorUseCase = TextFieldGetColorsUseCasableGeneratedMock() - - self.borderColorToken = ColorTokenGeneratedMock.random() - self.getColorUseCase.executeWithThemeAndIntentReturnValue = TextFieldColors(border: borderColorToken) - - self.cancellables = .init() - self.sut = .init( - theme: self.theme, - intent: .alert, - getColorUseCase: self.getColorUseCase - ) - } - - // MARK: - Tests - - func test_init() { - for intent in TextFieldIntent.allCases { - // GIVEN - self.getColorUseCase.executeWithThemeAndIntentCallsCount = 0 - self.sut = AddOnTextFieldViewModel( - theme: self.theme, - intent: intent, - getColorUseCase: self.getColorUseCase - ) - - // THEN - XCTAssertIdentical( - self.sut.theme as? ThemeGeneratedMock, - self.theme, - "Add-on text field theme doesn't match expected theme" - ) - - XCTAssertEqual( - self.sut.intent, - intent, - "Add-on text field intent doesn't match expected intent" - ) - - XCTAssertIdentical( - self.sut.textFieldColors.border as? ColorTokenGeneratedMock, - self.borderColorToken, - "Add-on text field border color doesn't match given color" - ) - - self.testGetColorUseCaseExecute( - givenIntent: intent, - expectedCallCount: 1 - ) - } - } - - func test_set_theme() throws { - // GIVEN - self.getColorUseCase.executeWithThemeAndIntentCallsCount = 0 - let newTheme = ThemeGeneratedMock.mocked() - - self.sut.setTheme(newTheme) - self.theme = newTheme - - // THEN - XCTAssertIdentical( - self.sut.theme as? ThemeGeneratedMock, - newTheme, - "Theme is not updated" - ) - - self.testGetColorUseCaseExecute(givenIntent: .alert, expectedCallCount: 1) - } - - func test_set_intent() throws { - for intent in TextFieldIntent.allCases { - // GIVEN - self.sut = AddOnTextFieldViewModel( - theme: self.theme, - intent: intent, - getColorUseCase: self.getColorUseCase - ) - - self.getColorUseCase.executeWithThemeAndIntentCallsCount = 0 - - // THEN - - XCTAssertEqual( - self.sut.intent, - intent, - "Add-on text field intent doesn't match given intent" - ) - self.testGetColorUseCaseExecute(expectedCallCount: 0) - - let newIntent = self.randomizeIntentAndRemoveCurrent(intent) - self.sut.setIntent(newIntent) - - XCTAssertEqual( - self.sut.intent, - newIntent, - "Add-on text field intent doesn't match the initial given intent" - ) - self.testGetColorUseCaseExecute( - givenIntent: newIntent, - expectedCallCount: 1) - } - - } - - func test_color_subscription_on_intent_change() throws { - // GIVEN - let expectation = expectation(description: "Color updated on intent change") - expectation.expectedFulfillmentCount = 1 - self.sut = AddOnTextFieldViewModel( - theme: self.theme, - intent: .alert, - getColorUseCase: self.getColorUseCase - ) - - self.sut.$textFieldColors.sink { _ in - expectation.fulfill() - }.store(in: &self.cancellables) - - // WHEN - self.sut.setIntent(.error) - - // THEN - wait(for: [expectation], timeout: 0.1) - } - - private func testGetColorUseCaseExecute( - givenIntent: TextFieldIntent? = nil, - expectedCallCount: Int - ) { - XCTAssertEqual( - self.getColorUseCase.executeWithThemeAndIntentCallsCount, - expectedCallCount, - "Wrong call count on getColorUseCase execute" - ) - - if expectedCallCount > 0 { - let args = self.getColorUseCase.executeWithThemeAndIntentReceivedArguments - - XCTAssertEqual( - args?.intent, - givenIntent, - "Wrong intent parameter on execute on getColorUseCase" - ) - - XCTAssertIdentical( - args?.theme as? ThemeGeneratedMock, - self.theme, - "Wrong theme parameter on execute on getColorUseCase" - ) - } - } - - private func randomizeIntentAndRemoveCurrent(_ currentIntent: TextFieldIntent) -> TextFieldIntent { - let filteredIntents = TextFieldIntent.allCases.filter({ $0 != currentIntent }) - let randomIndex = Int.random(in: 0...filteredIntents.count - 1) - - return filteredIntents[randomIndex] - } - -} diff --git a/core/Sources/Components/TextField/AddOnTextField/UIKit/AddOnTextFieldUIView.swift b/core/Sources/Components/TextField/AddOnTextField/UIKit/AddOnTextFieldUIView.swift deleted file mode 100644 index 07b597c10..000000000 --- a/core/Sources/Components/TextField/AddOnTextField/UIKit/AddOnTextFieldUIView.swift +++ /dev/null @@ -1,224 +0,0 @@ -// -// AddOnUIView.swift -// Spark -// -// Created by Jacklyn Situmorang on 19.09.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import UIKit - -public final class AddOnTextFieldUIView: UIView { - - // MARK: - Public properties - - public var theme: Theme { - get { - return self.viewModel.theme - } - set { - self.viewModel.setTheme(newValue) - self.textField.theme = newValue - } - } - - public var intent: TextFieldIntent { - get { - return self.viewModel.intent - } - set { - self.viewModel.setIntent(newValue) - self.textFieldViewModel.setIntent(newValue) - } - } - - public var leadingAddOn: UIView? { - didSet { - if let addOn = leadingAddOn { - self.addLeadingAddOn(addOn) - } else { - self.removeLeadingAddOn() - } - } - } - - public var trailingAddOn: UIView? { - didSet { - if let addOn = trailingAddOn { - self.addTrailingAddOn(addOn) - } else { - self.removeTrailingAddOn() - } - } - } - - public let textField: TextFieldUIView - - // MARK: - Private properties - - private let viewModel: AddOnTextFieldViewModel - private let textFieldViewModel: TextFieldUIViewModel - private var cancellable = Set() - - private lazy var hStack: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillProportionally - return stackView - }() - - private lazy var leadingAddOnStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillProportionally - return stackView - }() - - private lazy var trailingAddOnStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillProportionally - return stackView - }() - - // MARK: - Initializers - - public convenience init( - theme: Theme, - intent: TextFieldIntent = .neutral, - leadingAddOn: UIView? = nil, - trailingAddOn: UIView? = nil - ) { - self.init( - theme: theme, - intent: intent, - leadingAddOn: leadingAddOn, - trailingAddOn: trailingAddOn, - getColorsUseCase: TextFieldGetColorsUseCase() - ) - } - - internal init( - theme: Theme, - intent: TextFieldIntent = .neutral, - leadingAddOn: UIView? = nil, - trailingAddOn: UIView? = nil, - getColorsUseCase: TextFieldGetColorsUseCasable - ) { - self.leadingAddOn = leadingAddOn - self.trailingAddOn = trailingAddOn - - self.textFieldViewModel = TextFieldUIViewModel( - theme: theme, - borderStyle: .none, - getColorsUseCase: getColorsUseCase - ) - self.textField = TextFieldUIView(viewModel: textFieldViewModel) - - self.viewModel = AddOnTextFieldViewModel( - theme: theme, - intent: intent, - getColorUseCase: getColorsUseCase - ) - - super.init(frame: .zero) - self.setupView() - self.setupSubscriptions() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - self.setBorderColor(from: self.viewModel.textFieldColors.border) - } - - // MARK: - Private methods - - private func setupView() { - self.translatesAutoresizingMaskIntoConstraints = false - self.hStack.translatesAutoresizingMaskIntoConstraints = false - self.textField.translatesAutoresizingMaskIntoConstraints = false - - self.hStack.setBorderColor(from: self.textFieldViewModel.colors.border) - self.hStack.setBorderWidth(self.theme.border.width.small) - self.hStack.setCornerRadius(self.theme.border.radius.large) - - self.addSubviewSizedEqually(hStack) - self.hStack.addArrangedSubviews([ - self.leadingAddOnStackView, - self.textField, - self.trailingAddOnStackView - ]) - - if let leadingAddOn { - self.addLeadingAddOn(leadingAddOn) - } - - if let trailingAddOn { - self.addTrailingAddOn(trailingAddOn) - } - } - - private func setupSubscriptions() { - self.viewModel.$textFieldColors.subscribe(in: &self.cancellable) { [weak self] textFieldColors in - guard let self else { return } - UIView.animate(withDuration: 0.1) { - self.setBorderColor(from: textFieldColors.border) - self.setCornerRadius(self.theme.border.radius.large) - } - } - - self.textFieldViewModel.$textFieldIsActive.subscribe(in: &self.cancellable) { [weak self] isActive in - guard let self else { return } - let isActive = isActive ?? false - - self.setBorderWidth(isActive ? self.theme.border.width.medium : self.theme.border.width.small) - self.setCornerRadius(self.theme.border.radius.large) - } - } - - private func separatorView() -> UIView { - let separator = UIView() - let separatorWidth = self.theme.border.width.small - separator.backgroundColor = self.viewModel.theme.colors.base.outline.uiColor - separator.widthAnchor.constraint(equalToConstant: separatorWidth).isActive = true - separator.translatesAutoresizingMaskIntoConstraints = false - - return separator - } - - private func addLeadingAddOn(_ addOn: UIView) { - self.removeLeadingAddOn() - addOn.translatesAutoresizingMaskIntoConstraints = false - self.leadingAddOnStackView.isHidden = false - self.leadingAddOnStackView.addArrangedSubviews([ - addOn, - separatorView() - ]) - } - - private func removeLeadingAddOn() { - self.leadingAddOnStackView.isHidden = true - self.leadingAddOnStackView.removeArrangedSubviews() - } - - private func addTrailingAddOn(_ addOn: UIView) { - self.removeTrailingAddOn() - addOn.translatesAutoresizingMaskIntoConstraints = false - self.trailingAddOnStackView.isHidden = false - self.trailingAddOnStackView.addArrangedSubviews([ - separatorView(), - addOn - ]) - } - - private func removeTrailingAddOn() { - self.trailingAddOnStackView.isHidden = true - self.trailingAddOnStackView.removeArrangedSubviews() - } - -} diff --git a/core/Sources/Components/TextField/Model/TextFieldBorderLayout+ExtensionTests.swift b/core/Sources/Components/TextField/Model/TextFieldBorderLayout+ExtensionTests.swift new file mode 100644 index 000000000..83ac021bf --- /dev/null +++ b/core/Sources/Components/TextField/Model/TextFieldBorderLayout+ExtensionTests.swift @@ -0,0 +1,16 @@ +// +// TextFieldBorderLayout+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldBorderLayout { + static func mocked(radius: CGFloat, width: CGFloat) -> TextFieldBorderLayout { + return .init(radius: radius, width: width) + } +} diff --git a/core/Sources/Components/TextField/Model/TextFieldBorders.swift b/core/Sources/Components/TextField/Model/TextFieldBorderLayout.swift similarity index 65% rename from core/Sources/Components/TextField/Model/TextFieldBorders.swift rename to core/Sources/Components/TextField/Model/TextFieldBorderLayout.swift index 3a7a95259..742306b1a 100644 --- a/core/Sources/Components/TextField/Model/TextFieldBorders.swift +++ b/core/Sources/Components/TextField/Model/TextFieldBorderLayout.swift @@ -1,5 +1,5 @@ // -// TextFieldBorders.swift +// TextFieldBorderLayout.swift // SparkCore // // Created by louis.borlee on 25/09/2023. @@ -8,8 +8,7 @@ import Foundation -struct TextFieldBorders: Equatable { +struct TextFieldBorderLayout: Equatable { let radius: CGFloat let width: CGFloat - let widthWhenActive: CGFloat } diff --git a/core/Sources/Components/TextField/Model/TextFieldColors+ExtensionTests.swift b/core/Sources/Components/TextField/Model/TextFieldColors+ExtensionTests.swift new file mode 100644 index 000000000..804d6c499 --- /dev/null +++ b/core/Sources/Components/TextField/Model/TextFieldColors+ExtensionTests.swift @@ -0,0 +1,28 @@ +// +// TextFieldColors+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldColors { + static func mocked( + text: ColorTokenGeneratedMock, + placeholder: ColorTokenGeneratedMock, + border: ColorTokenGeneratedMock, + statusIcon: ColorTokenGeneratedMock, + background: ColorTokenGeneratedMock + ) -> TextFieldColors { + return .init( + text: text, + placeholder: placeholder, + border: border, + statusIcon: statusIcon, + background: background + ) + } +} diff --git a/core/Sources/Components/TextField/Model/TextFieldColors.swift b/core/Sources/Components/TextField/Model/TextFieldColors.swift index e365bb162..35b90f15a 100644 --- a/core/Sources/Components/TextField/Model/TextFieldColors.swift +++ b/core/Sources/Components/TextField/Model/TextFieldColors.swift @@ -8,19 +8,18 @@ import Foundation -struct TextFieldColors { - - // MARK: - Properties - +struct TextFieldColors: Equatable { + let text: any ColorToken + let placeholder: any ColorToken let border: any ColorToken - //TODO: let statusColor: any ColorToken -} - -// MARK: Equatable - -extension TextFieldColors: Equatable { + let statusIcon: any ColorToken + let background: any ColorToken static func == (lhs: TextFieldColors, rhs: TextFieldColors) -> Bool { - lhs.border.equals(rhs.border) + return lhs.text.equals(rhs.text) && + lhs.placeholder.equals(rhs.placeholder) && + lhs.border.equals(rhs.border) && + lhs.statusIcon.equals(rhs.statusIcon) && + lhs.background.equals(rhs.background) } } diff --git a/core/Sources/Components/TextField/Model/TextFieldSpacings+ExtensionTests.swift b/core/Sources/Components/TextField/Model/TextFieldSpacings+ExtensionTests.swift new file mode 100644 index 000000000..d82c28f77 --- /dev/null +++ b/core/Sources/Components/TextField/Model/TextFieldSpacings+ExtensionTests.swift @@ -0,0 +1,16 @@ +// +// TextFieldSpacings+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldSpacings { + static func mocked(left: CGFloat, content: CGFloat, right: CGFloat) -> TextFieldSpacings { + return .init(left: left, content: content, right: right) + } +} diff --git a/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModel.swift b/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModel.swift deleted file mode 100644 index 13033bc1c..000000000 --- a/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModel.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// TextFieldUIViewModel.swift -// Spark -// -// Created by Quentin.richard on 21/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import UIKit - -final class TextFieldUIViewModel: ObservableObject { - // MARK: - Public properties - - @Published var colors: TextFieldColors - @Published var borders: TextFieldBorders - @Published var spacings: TextFieldSpacings - @Published var textFieldIsActive: Bool? - - // MARK: - Private properties - - private(set) var theme: Theme { - didSet { - self.reloadTheme() - } - } - private(set) var intent: TextFieldIntent { - didSet { - self.reloadColors() - } - } - private(set) var borderStyle: TextFieldBorderStyle { - didSet { - self.reloadSpacings() - self.reloadBorders() - } - } - - private let getColorsUseCase: any TextFieldGetColorsUseCasable - private let getBordersUseCase: any TextFieldGetBordersUseCasable - private let getSpacingsUseCase: any TextFieldGetSpacingsUseCasable - - // MARK: - Initialization - - init( - theme: Theme, - intent: TextFieldIntent = .neutral, - borderStyle: TextFieldBorderStyle, - getColorsUseCase: any TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase(), - getBordersUseCase: any TextFieldGetBordersUseCasable = TextFieldGetBordersUseCase(), - getSpacingsUseCase: any TextFieldGetSpacingsUseCasable = TextFieldGetSpacingsUseCase() - ) { - // Properties - self.theme = theme - self.intent = intent - self.borderStyle = borderStyle - - // Use cases - self.getColorsUseCase = getColorsUseCase - self.getBordersUseCase = getBordersUseCase - self.getSpacingsUseCase = getSpacingsUseCase - - // Published vars - self.colors = getColorsUseCase.execute(theme: theme, intent: intent) - self.borders = getBordersUseCase.execute(theme: theme, borderStyle: borderStyle) - self.spacings = getSpacingsUseCase.execute(theme: theme, borderStyle: borderStyle) - } - - // MARK: - Update - private func reloadColors() { - let newColors = self.getColorsUseCase.execute( - theme: self.theme, - intent: self.intent - ) - guard newColors != self.colors else { return } - self.colors = newColors - } - - private func reloadSpacings() { - let newSpacings = self.getSpacingsUseCase.execute( - theme: self.theme, - borderStyle: self.borderStyle - ) - guard newSpacings != self.spacings else { return } - self.spacings = newSpacings - } - - private func reloadBorders() { - let newBorders = self.getBordersUseCase.execute( - theme: self.theme, - borderStyle: self.borderStyle - ) - guard newBorders != self.borders else { return } - self.borders = newBorders - } - - private func reloadTheme() { - self.reloadColors() - self.reloadSpacings() - self.reloadBorders() - - } - - // MARK: - Public Setter - - func setTheme(_ theme: Theme) { - self.theme = theme - } - - func setIntent(_ intent: TextFieldIntent) { - self.intent = intent - } - - func setBorderStyle(_ borderStyle: TextFieldBorderStyle) { - self.borderStyle = borderStyle - } -} diff --git a/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModelTests.swift b/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModelTests.swift deleted file mode 100644 index 9b9f9df28..000000000 --- a/core/Sources/Components/TextField/StandaloneTextField/Model/TextFieldUIViewModelTests.swift +++ /dev/null @@ -1,261 +0,0 @@ -// -// TextFieldUIViewModelTests.swift -// SparkCoreUnitTests -// -// Created by Jacklyn Situmorang on 18.10.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import XCTest - -@testable import SparkCore - -final class TextFieldUIViewModelTests: XCTestCase { - - // MARK: - Properties - - var theme: ThemeGeneratedMock! - var getBordersUseCase: TextFieldGetBordersUseCasableGeneratedMock! - var getColorsUseCase: TextFieldGetColorsUseCasableGeneratedMock! - var getSpacingsUseCase: TextFieldGetSpacingsUseCasableGeneratedMock! - var textFieldColors: TextFieldColors! - var textFieldBorders: TextFieldBorders! - var textFieldSpacings: TextFieldSpacings! - var cancellables: Set! - var sut: TextFieldUIViewModel! - - // MARK: - Setup - - override func setUpWithError() throws { - try super.setUpWithError() - - self.theme = ThemeGeneratedMock.mocked() - self.getBordersUseCase = TextFieldGetBordersUseCasableGeneratedMock() - self.getColorsUseCase = TextFieldGetColorsUseCasableGeneratedMock() - self.getSpacingsUseCase = TextFieldGetSpacingsUseCasableGeneratedMock() - self.textFieldColors = TextFieldColors(border: .mock(ColorTokenGeneratedMock.random().color)) - self.textFieldBorders = TextFieldBorders(radius: .zero, width: .zero, widthWhenActive: .zero) - self.textFieldSpacings = TextFieldSpacings(left: .zero, content: .zero, right: .zero) - - self.getColorsUseCase.executeWithThemeAndIntentReturnValue = self.textFieldColors - self.getBordersUseCase.executeWithThemeAndBorderStyleReturnValue = self.textFieldBorders - self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = self.textFieldSpacings - - self.cancellables = .init() - self.sut = .init( - theme: self.theme, - borderStyle: .none, - getColorsUseCase: self.getColorsUseCase, - getBordersUseCase: self.getBordersUseCase, - getSpacingsUseCase: self.getSpacingsUseCase - ) - } - - // MARK: - Tests - - func test_init() { - for intent in TextFieldIntent.allCases { - for borderStyle in TextFieldBorderStyle.allCases { - // GIVEN - self.getColorsUseCase.executeWithThemeAndIntentCallsCount = 0 - self.sut = TextFieldUIViewModel( - theme: self.theme, - intent: intent, - borderStyle: borderStyle, - getColorsUseCase: self.getColorsUseCase, - getBordersUseCase: self.getBordersUseCase, - getSpacingsUseCase: self.getSpacingsUseCase - ) - - // THEN - XCTAssertIdentical( - self.sut.theme as? ThemeGeneratedMock, - self.theme, - "Textfield theme doesn't match expected theme" - ) - - XCTAssertEqual( - self.sut.intent, - intent, - "Textfield intent doesn't match expected intent" - ) - - XCTAssertEqual( - self.sut.borderStyle, - borderStyle, - "Textfield border style doesn't match expected border style" - ) - - self.testGetColorUseCaseExecute( - givenIntent: intent, - expectedCallCount: 1 - ) - } - } - } - - func test_set_theme() throws { - // GIVEN - self.getColorsUseCase.executeWithThemeAndIntentCallsCount = 0 - let newTheme = ThemeGeneratedMock.mocked() - - self.sut.setTheme(newTheme) - self.theme = newTheme - - // THEN - XCTAssertIdentical( - self.sut.theme as? ThemeGeneratedMock, - newTheme, - "Theme is not updated" - ) - - self.testGetColorUseCaseExecute(givenIntent: .neutral, expectedCallCount: 1) - } - - func test_set_intent() throws { - for intent in TextFieldIntent.allCases { - // GIVEN - self.sut = TextFieldUIViewModel( - theme: self.theme, - intent: intent, - borderStyle: .roundedRect, - getColorsUseCase: self.getColorsUseCase - ) - - self.getColorsUseCase.executeWithThemeAndIntentCallsCount = 0 - - // THEN - - XCTAssertEqual( - self.sut.intent, - intent, - "Textfield intent doesn't match given intent" - ) - self.testGetColorUseCaseExecute(expectedCallCount: 0) - - let newIntent = self.randomizeIntentAndRemoveCurrent(intent) - self.sut.setIntent(newIntent) - - XCTAssertEqual( - self.sut.intent, - newIntent, - "Textfield intent doesn't match updated intent" - ) - self.testGetColorUseCaseExecute(givenIntent: newIntent, expectedCallCount: 1) - } - } - - func test_set_borderStyle() throws { - // GIVEN - self.getBordersUseCase.executeWithThemeAndBorderStyleCallsCount = 0 - let newBorderStyle = TextFieldBorderStyle.roundedRect - - self.sut.setBorderStyle(newBorderStyle) - - // THEN - XCTAssertEqual( - self.sut.borderStyle, - newBorderStyle, - "Border style is not updated" - ) - } - - func test_colors_subscription_on_intent_change() throws { - // GIVEN - let expectation = expectation(description: "Colors updated on intent change") - expectation.expectedFulfillmentCount = 1 - self.sut = TextFieldUIViewModel( - theme: self.theme, - intent: .neutral, - borderStyle: .none, - getColorsUseCase: self.getColorsUseCase - ) - - self.sut.$colors.sink { _ in - expectation.fulfill() - }.store(in: &self.cancellables) - - // WHEN - self.sut.setIntent(.error) - - // THEN - wait(for: [expectation], timeout: 0.1) - } - - func test_borders_subscription_on_borderStyle_change() { - // GIVEN - let expectation = expectation(description: "Colors updated on intent change") - expectation.expectedFulfillmentCount = 1 - self.sut = TextFieldUIViewModel( - theme: self.theme, - borderStyle: .none, - getBordersUseCase: self.getBordersUseCase - ) - - self.sut.$borders.sink { _ in - expectation.fulfill() - }.store(in: &self.cancellables) - - // WHEN - self.sut.setBorderStyle(.roundedRect) - - // THEN - wait(for: [expectation], timeout: 0.1) - } - - func test_spacings_subscription_on_borderSytle_change() { - // GIVEN - let expectation = expectation(description: "Colors updated on intent change") - expectation.expectedFulfillmentCount = 1 - self.sut = TextFieldUIViewModel( - theme: self.theme, - borderStyle: .none, - getSpacingsUseCase: self.getSpacingsUseCase - ) - - self.sut.$spacings.sink { _ in - expectation.fulfill() - }.store(in: &self.cancellables) - - // WHEN - self.sut.setBorderStyle(.roundedRect) - - // THEN - wait(for: [expectation], timeout: 0.1) - } - - private func testGetColorUseCaseExecute( - givenIntent: TextFieldIntent? = nil, - expectedCallCount: Int - ) { - XCTAssertEqual( - self.getColorsUseCase.executeWithThemeAndIntentCallsCount, - expectedCallCount, - "Wrong call count on getColorUseCase execute" - ) - - if expectedCallCount > 0 { - let args = self.getColorsUseCase.executeWithThemeAndIntentReceivedArguments - - XCTAssertEqual( - args?.intent, - givenIntent, - "Wrong intent parameter on execute on getColorUseCase" - ) - - XCTAssertIdentical( - args?.theme as? ThemeGeneratedMock, - self.theme, - "Wrong theme parameter on execute on getColorUseCase" - ) - } - } - - private func randomizeIntentAndRemoveCurrent(_ currentIntent: TextFieldIntent) -> TextFieldIntent { - let filteredIntents = TextFieldIntent.allCases.filter({ $0 != currentIntent }) - let randomIndex = Int.random(in: 0...filteredIntents.count - 1) - - return filteredIntents[randomIndex] - } -} diff --git a/core/Sources/Components/TextField/StandaloneTextField/UIKit/TextFieldUIView.swift b/core/Sources/Components/TextField/StandaloneTextField/UIKit/TextFieldUIView.swift deleted file mode 100644 index 149e61fa7..000000000 --- a/core/Sources/Components/TextField/StandaloneTextField/UIKit/TextFieldUIView.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// TextFieldUIView.swift -// SparkCore -// -// Created by louis.borlee on 11/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import Combine - -public final class TextFieldUIView: UITextField { - - // MARK: - Private properties - - private let viewModel: TextFieldUIViewModel - private var cancellable = Set() - private var heightConstraint: NSLayoutConstraint? - private var leftViewColor: UIColor? - private var rightViewColor: UIColor? - - // MARK: - Public properties - - public var theme: Theme { - get { - return self.viewModel.theme - } - set { - self.viewModel.setTheme(newValue) - } - } - - public var intent: TextFieldIntent { - get { - return self.viewModel.intent - } - set { - self.viewModel.setIntent(newValue) - } - } - - public override var borderStyle: UITextField.BorderStyle { - @available(*, unavailable) - set {} - get { return .init(self.viewModel.borderStyle) } - } - - public override var leftView: UIView? { - get { - super.leftView - } - set { - self.leftViewColor = newValue?.tintColor - super.leftView = newValue - } - } - - public override var rightView: UIView? { - get { - super.rightView - } - set { - self.rightViewColor = newValue?.tintColor - super.rightView = newValue - } - } - - @ScaledUIMetric private var height: CGFloat = 44 - @ScaledUIMetric private var leftViewSize: CGFloat = .zero - @ScaledUIMetric private var rightViewSize: CGFloat = .zero - - // MARK: - Initializers - - internal init(viewModel: TextFieldUIViewModel) { - self.viewModel = viewModel - super.init(frame: .zero) - self.setupView() - self.setupSubscriptions() - } - - public convenience init(theme: Theme, - intent: TextFieldIntent = .neutral) { - let viewModel = TextFieldUIViewModel(theme: theme, - intent: intent, - borderStyle: .roundedRect) - self.init(viewModel: viewModel) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Private methods - - private func setupView() { - self.adjustsFontForContentSizeCategory = true - self.font = .preferredFont(forTextStyle: .body) - self.tintColor = self.theme.colors.base.outlineHigh.uiColor - self.updateHeight() - } - - private func updateHeight() { - self.heightConstraint?.isActive = false - self.heightConstraint = self.heightAnchor.constraint(equalToConstant: self.height) - self.heightConstraint?.isActive = true - } - - private func setupSubscriptions() { - self.viewModel.$colors.subscribe(in: &self.cancellable) { [weak self] colors in - guard let self else { return } - UIView.animate(withDuration: 0.1, animations: { - self.setupColors(colors) - self.setupBorders(self.viewModel.borders) - }) - } - self.viewModel.$borders.subscribe(in: &self.cancellable) { [weak self] borders in - UIView.animate(withDuration: 0.1, animations: { self?.setupBorders(borders) }) - } - self.viewModel.$spacings.subscribe(in: &self.cancellable) { [weak self] spacings in - UIView.animate(withDuration: 0.1, animations: { self?.setNeedsLayout() }) - } - } - - private func setupColors(_ colors: TextFieldColors) { - self.setBorderColor(from: colors.border) - } - - private func setupBorders(_ borders: TextFieldBorders) { - let borderWidth = self.isFirstResponder ? borders.widthWhenActive : borders.width - self.setBorderWidth(borderWidth) - self.setCornerRadius(borders.radius) - } - - private func setInsets(forBounds bounds: CGRect) -> CGRect { - var totalInsets = UIEdgeInsets( - top: .zero, - left: self.viewModel.spacings.left, - bottom: .zero, - right: self.viewModel.spacings.right - ) - let contentSpacing = self.viewModel.spacings.content - if let leftView = self.leftView, leftView.frame.origin.x > 0 { totalInsets.left += leftView.bounds.size.width + (0.5 * contentSpacing) } - if let rightView = self.rightView, rightView.frame.origin.x > 0 { totalInsets.right += rightView.bounds.size.width + (0.75 * contentSpacing) } - if let button = self.value(forKeyPath: "_clearButton") as? UIButton, - button.frame.origin.x > 0 && !((rightView?.frame.origin.x) ?? 0 > 0) { - totalInsets.right += button.bounds.size.width + (0.75 * contentSpacing) - } - return bounds.inset(by: totalInsets) - } - - // MARK: - Rects - - public override func textRect(forBounds bounds: CGRect) -> CGRect { - return self.setInsets(forBounds: bounds) - } - - public override func placeholderRect(forBounds bounds: CGRect) -> CGRect { - return self.setInsets(forBounds: bounds) - } - public override func editingRect(forBounds bounds: CGRect) -> CGRect { - return self.setInsets(forBounds: bounds) - } - - public override func clearButtonRect(forBounds bounds: CGRect) -> CGRect { - var rect = super.clearButtonRect(forBounds: bounds) - rect.origin.x -= self.viewModel.spacings.right - return rect - } - - public override func rightViewRect(forBounds bounds: CGRect) -> CGRect { - var rect = super.rightViewRect(forBounds: bounds) - self.rightViewSize = rect.width - rect.origin.x = (rect.maxX - self.viewModel.spacings.right - self.rightViewSize) - rect.origin.y = bounds.size.height / 2 - self.rightViewSize / 2 - rect.size.width = self.rightViewSize - rect.size.height = self.rightViewSize - return rect - } - - public override func leftViewRect(forBounds bounds: CGRect) -> CGRect { - var rect = super.leftViewRect(forBounds: bounds) - self.leftViewSize = rect.width - rect.origin.x += self.viewModel.spacings.left - rect.origin.y = bounds.size.height / 2 - self.leftViewSize / 2 - rect.size.width = self.leftViewSize - rect.size.height = self.leftViewSize - return rect - } - - // MARK: - Trait collection - - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - self.invalidateIntrinsicContentSize() - self._height.update(traitCollection: traitCollection) - self.updateHeight() - self.setBorderColor(from: self.viewModel.colors.border) - } - - // MARK: - Instance methods - - public override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - self.setupBorders(self.viewModel.borders) - self.viewModel.textFieldIsActive = true - return result - } - - public override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - self.setupBorders(self.viewModel.borders) - self.viewModel.textFieldIsActive = false - return result - } - - // This is a workaround to make sure that leftView and rightView retain their original color - public override func tintColorDidChange() { - super.tintColorDidChange() - self.leftView?.tintColor = self.leftViewColor - self.rightView?.tintColor = self.rightViewColor - } -} diff --git a/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCasableGeneratedMock+ExtensionTests.swift b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCasableGeneratedMock+ExtensionTests.swift new file mode 100644 index 000000000..9a67e0e45 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCasableGeneratedMock+ExtensionTests.swift @@ -0,0 +1,19 @@ +// +// TextFieldGetBorderLayoutUseCasableGeneratedMock+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldGetBorderLayoutUseCasableGeneratedMock { + + static func mocked(returnedBorderLayout: TextFieldBorderLayout) -> TextFieldGetBorderLayoutUseCasableGeneratedMock { + let mock = TextFieldGetBorderLayoutUseCasableGeneratedMock() + mock.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = returnedBorderLayout + return mock + } +} diff --git a/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCase.swift b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCase.swift new file mode 100644 index 000000000..0bb5bb2f7 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCase.swift @@ -0,0 +1,35 @@ +// +// TextFieldGetBorderLayoutUseCase.swift +// SparkCore +// +// Created by louis.borlee on 25/09/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +// sourcery: AutoMockable +protocol TextFieldGetBorderLayoutUseCasable { + func execute(theme: Theme, + borderStyle: TextFieldBorderStyle, + isFocused: Bool) -> TextFieldBorderLayout +} + +final class TextFieldGetBorderLayoutUseCase: TextFieldGetBorderLayoutUseCasable { + func execute(theme: Theme, + borderStyle: TextFieldBorderStyle, + isFocused: Bool) -> TextFieldBorderLayout { + switch borderStyle { + case .none: + return .init( + radius: theme.border.radius.none, + width: theme.border.width.none + ) + case .roundedRect: + return .init( + radius: theme.border.radius.large, + width: isFocused ? theme.border.width.medium : theme.border.width.small + ) + } + } +} diff --git a/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCaseTests.swift new file mode 100644 index 000000000..aef94c537 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetBorderLayout/TextFieldGetBorderLayoutUseCaseTests.swift @@ -0,0 +1,87 @@ +// +// TextFieldGetBorderLayoutUseCaseTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class TextFieldGetBorderLayoutUseCaseTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + + func test_roundedRect_isFocused() { + // GIVEN + let useCase = TextFieldGetBorderLayoutUseCase() + let borderStyle = TextFieldBorderStyle.roundedRect + let isFocused = true + + // WHEN + let borderLayout = useCase.execute( + theme: self.theme, + borderStyle: borderStyle, + isFocused: isFocused + ) + + // THEN + XCTAssertEqual(borderLayout.radius, self.theme.border.radius.large, "Wrong radius") + XCTAssertEqual(borderLayout.width, self.theme.border.width.medium, "Wrong width") + } + + func test_roundedRect_isNotFocused() { + // GIVEN + let useCase = TextFieldGetBorderLayoutUseCase() + let borderStyle = TextFieldBorderStyle.roundedRect + let isFocused = false + + // WHEN + let borderLayout = useCase.execute( + theme: self.theme, + borderStyle: borderStyle, + isFocused: isFocused + ) + + // THEN + XCTAssertEqual(borderLayout.radius, self.theme.border.radius.large, "Wrong radius") + XCTAssertEqual(borderLayout.width, self.theme.border.width.small, "Wrong width") + } + + func test_none_isFocused() { + // GIVEN + let useCase = TextFieldGetBorderLayoutUseCase() + let borderStyle = TextFieldBorderStyle.none + let isFocused = true + + // WHEN + let borderLayout = useCase.execute( + theme: self.theme, + borderStyle: borderStyle, + isFocused: isFocused + ) + + // THEN + XCTAssertEqual(borderLayout.radius, self.theme.border.radius.none, "Wrong radius") + XCTAssertEqual(borderLayout.width, self.theme.border.width.none, "Wrong width") + } + + func test_none_isNotFocused() { + // GIVEN + let useCase = TextFieldGetBorderLayoutUseCase() + let borderStyle = TextFieldBorderStyle.none + let isFocused = false + + // WHEN + let borderLayout = useCase.execute( + theme: self.theme, + borderStyle: borderStyle, + isFocused: isFocused + ) + + // THEN + XCTAssertEqual(borderLayout.radius, self.theme.border.radius.none, "Wrong radius") + XCTAssertEqual(borderLayout.width, self.theme.border.width.none, "Wrong width") + } +} diff --git a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCasableGeneratedMock+ExtensionTests.swift b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCasableGeneratedMock+ExtensionTests.swift new file mode 100644 index 000000000..03066fefc --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCasableGeneratedMock+ExtensionTests.swift @@ -0,0 +1,18 @@ +// +// TextFieldGetColorsUseCasableGeneratedMock+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldGetColorsUseCasableGeneratedMock { + static func mocked(returnedColors: TextFieldColors) -> TextFieldGetColorsUseCasableGeneratedMock { + let mock = TextFieldGetColorsUseCasableGeneratedMock() + mock.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = returnedColors + return mock + } +} diff --git a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift new file mode 100644 index 000000000..2406360d8 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift @@ -0,0 +1,58 @@ +// +// TextFieldGetColorsUseCase.swift +// SparkCore +// +// Created by Quentin.richard on 21/09/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +// sourcery: AutoMockable +protocol TextFieldGetColorsUseCasable { + func execute(theme: Theme, + intent: TextFieldIntent, + isFocused: Bool, + isEnabled: Bool, + isUserInteractionEnabled: Bool) -> TextFieldColors +} + +struct TextFieldGetColorsUseCase: TextFieldGetColorsUseCasable { + func execute(theme: Theme, + intent: TextFieldIntent, + isFocused: Bool, + isEnabled: Bool, + isUserInteractionEnabled: Bool) -> TextFieldColors { + let text = theme.colors.base.onSurface + let placeholder = theme.colors.base.onSurface.opacity(theme.dims.dim1) + + let border: any ColorToken + let background: any ColorToken + if isEnabled, isUserInteractionEnabled { + switch intent { + case .error: + border = theme.colors.feedback.error + case .alert: + border = theme.colors.feedback.alert + case .neutral: + border = isFocused ? theme.colors.base.outlineHigh : theme.colors.base.outline + case .success: + border = theme.colors.feedback.success + } + background = theme.colors.base.surface + } else { + border = theme.colors.base.outline + background = theme.colors.base.onSurface.opacity(theme.dims.dim5) + } + + let statusIcon = theme.colors.feedback.neutral + + return .init( + text: text, + placeholder: placeholder, + border: border, + statusIcon: statusIcon, + background: background + ) + } +} diff --git a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift new file mode 100644 index 000000000..37f013b22 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift @@ -0,0 +1,179 @@ +// +// TextFieldGetColorsUseCaseTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class TextFieldGetColorsUseCaseTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + + func test_isFocused_isEnabled_isUserInteractionEnabled() { + let intentAndExpectedBorderColorArray: [(intent: TextFieldIntent, expectedBorderColor: any ColorToken)] = [ + (intent: .success, self.theme.colors.feedback.success), + (intent: .error, self.theme.colors.feedback.error), + (intent: .alert, self.theme.colors.feedback.alert), + (intent: .neutral, self.theme.colors.base.outlineHigh), + ] + XCTAssertEqual(intentAndExpectedBorderColorArray.count, TextFieldIntent.allCases.count, "Wrong intentAndExpectedBorderColorArray count") + + intentAndExpectedBorderColorArray.forEach { + self._test_isFocused_isEnabled_isUserInteractionEnabled(with: $0.intent, expectedBorderColor: $0.expectedBorderColor) + } + } + + private func _test_isFocused_isEnabled_isUserInteractionEnabled( + with intent: TextFieldIntent, + expectedBorderColor: any ColorToken + ) { + // GIVEN + let isFocused = true + let isEnabled = true + let isUserInteractionEnabled = true + let useCase = TextFieldGetColorsUseCase() + + // WHEN + let colors = useCase.execute( + theme: self.theme, + intent: intent, + isFocused: isFocused, + isEnabled: isEnabled, + isUserInteractionEnabled: isUserInteractionEnabled + ) + + // THEN + XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") + XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") + XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") + XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") + XCTAssertTrue(colors.background.equals(self.theme.colors.base.surface), "Wrong background color for intent: \(intent)") + } + + func test_isNotFocused_isEnabled_isUserInteractionEnabled() { + let intentAndExpectedBorderColorArray: [(intent: TextFieldIntent, expectedBorderColor: any ColorToken)] = [ + (intent: .success, self.theme.colors.feedback.success), + (intent: .error, self.theme.colors.feedback.error), + (intent: .alert, self.theme.colors.feedback.alert), + (intent: .neutral, self.theme.colors.base.outline), + ] + XCTAssertEqual(intentAndExpectedBorderColorArray.count, TextFieldIntent.allCases.count, "Wrong intentAndExpectedBorderColorArray count") + + intentAndExpectedBorderColorArray.forEach { + self._test_isNotFocused_isEnabled_isUserInteractionEnabled(with: $0.intent, expectedBorderColor: $0.expectedBorderColor) + } + } + + private func _test_isNotFocused_isEnabled_isUserInteractionEnabled( + with intent: TextFieldIntent, + expectedBorderColor: any ColorToken + ) { + // GIVEN + let isFocused = false + let isEnabled = true + let isUserInteractionEnabled = true + let useCase = TextFieldGetColorsUseCase() + + // WHEN + let colors = useCase.execute( + theme: self.theme, + intent: intent, + isFocused: isFocused, + isEnabled: isEnabled, + isUserInteractionEnabled: isUserInteractionEnabled + ) + + // THEN + XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") + XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") + XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") + XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") + XCTAssertTrue(colors.background.equals(self.theme.colors.base.surface), "Wrong background color for intent: \(intent)") + } + + func test_isNotFocused_isEnabled_isUserInteractionNotEnabled() { + let intentAndExpectedBorderColorArray: [(intent: TextFieldIntent, expectedBorderColor: any ColorToken)] = [ + (intent: .success, self.theme.colors.base.outline), + (intent: .error, self.theme.colors.base.outline), + (intent: .alert, self.theme.colors.base.outline), + (intent: .neutral, self.theme.colors.base.outline), + ] + XCTAssertEqual(intentAndExpectedBorderColorArray.count, TextFieldIntent.allCases.count, "Wrong intentAndExpectedBorderColorArray count") + + intentAndExpectedBorderColorArray.forEach { + self._test_isNotFocused_isEnabled_isUserInteractionNotEnabled(with: $0.intent, expectedBorderColor: $0.expectedBorderColor) + } + } + + private func _test_isNotFocused_isEnabled_isUserInteractionNotEnabled( + with intent: TextFieldIntent, + expectedBorderColor: any ColorToken + ) { + // GIVEN + let isFocused = false + let isEnabled = true + let isUserInteractionEnabled = false + let useCase = TextFieldGetColorsUseCase() + + // WHEN + let colors = useCase.execute( + theme: self.theme, + intent: intent, + isFocused: isFocused, + isEnabled: isEnabled, + isUserInteractionEnabled: isUserInteractionEnabled + ) + + // THEN + XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") + XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") + XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") + XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") + XCTAssertTrue(colors.background.equals(self.theme.colors.base.onSurface.opacity(theme.dims.dim5)), "Wrong background color for intent: \(intent)") + } + + func test_isFocused_isNotEnabled_isUserInteractionEnabled() { + let intentAndExpectedBorderColorArray: [(intent: TextFieldIntent, expectedBorderColor: any ColorToken)] = [ + (intent: .success, self.theme.colors.base.outline), + (intent: .error, self.theme.colors.base.outline), + (intent: .alert, self.theme.colors.base.outline), + (intent: .neutral, self.theme.colors.base.outline), + ] + XCTAssertEqual(intentAndExpectedBorderColorArray.count, TextFieldIntent.allCases.count, "Wrong intentAndExpectedBorderColorArray count") + + intentAndExpectedBorderColorArray.forEach { + self._test_isFocused_isNotEnabled_isUserInteractionEnabled(with: $0.intent, expectedBorderColor: $0.expectedBorderColor) + } + } + + private func _test_isFocused_isNotEnabled_isUserInteractionEnabled( + with intent: TextFieldIntent, + expectedBorderColor: any ColorToken + ) { + // GIVEN + let isFocused = true + let isEnabled = false + let isUserInteractionEnabled = true + let useCase = TextFieldGetColorsUseCase() + + // WHEN + let colors = useCase.execute( + theme: self.theme, + intent: intent, + isFocused: isFocused, + isEnabled: isEnabled, + isUserInteractionEnabled: isUserInteractionEnabled + ) + + // THEN + XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") + XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") + XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") + XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") + XCTAssertTrue(colors.background.equals(self.theme.colors.base.onSurface.opacity(theme.dims.dim5)), "Wrong background color for intent: \(intent)") + } +} diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCase.swift b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCase.swift similarity index 89% rename from core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCase.swift rename to core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCase.swift index 7030c35d5..21a2a55d3 100644 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCase.swift +++ b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCase.swift @@ -19,9 +19,9 @@ final class TextFieldGetSpacingsUseCase: TextFieldGetSpacingsUseCasable { switch borderStyle { case .none: return .init( - left: theme.layout.spacing.large, + left: theme.layout.spacing.none, content: theme.layout.spacing.medium, - right: theme.layout.spacing.large + right: theme.layout.spacing.none ) case .roundedRect: return .init( diff --git a/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseGeneratedMock+ExtensionTests.swift b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseGeneratedMock+ExtensionTests.swift new file mode 100644 index 000000000..9908d9dd9 --- /dev/null +++ b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseGeneratedMock+ExtensionTests.swift @@ -0,0 +1,18 @@ +// +// TextFieldGetSpacingsUseCaseGeneratedMock+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension TextFieldGetSpacingsUseCasableGeneratedMock { + static func mocked(returnedSpacings: TextFieldSpacings) -> TextFieldGetSpacingsUseCasableGeneratedMock { + let mock = TextFieldGetSpacingsUseCasableGeneratedMock() + mock.executeWithThemeAndBorderStyleReturnValue = returnedSpacings + return mock + } +} diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseTests.swift similarity index 93% rename from core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCaseTests.swift rename to core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseTests.swift index 48235a803..dde56f2a6 100644 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetSpacingsUseCaseTests.swift +++ b/core/Sources/Components/TextField/UseCase/GetSpacings/TextFieldGetSpacingsUseCaseTests.swift @@ -22,9 +22,9 @@ final class TextFieldGetSpacingsUseCaseTests: XCTestCase { self.testExecute( givenBorderStyle: .none, expectedSpacings: .init( - left: self.themeMock.layout.spacing.large, + left: self.themeMock.layout.spacing.none, content: self.themeMock.layout.spacing.medium, - right: self.themeMock.layout.spacing.large + right: self.themeMock.layout.spacing.none ) ) } diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCase.swift b/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCase.swift deleted file mode 100644 index fd8e03460..000000000 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCase.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TextFieldGetBordersUseCase.swift -// SparkCore -// -// Created by louis.borlee on 25/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Foundation - -// sourcery: AutoMockable -protocol TextFieldGetBordersUseCasable { - func execute(theme: Theme, - borderStyle: TextFieldBorderStyle) -> TextFieldBorders -} - -final class TextFieldGetBordersUseCase: TextFieldGetBordersUseCasable { - func execute(theme: Theme, - borderStyle: TextFieldBorderStyle) -> TextFieldBorders { - switch borderStyle { - case .none: - return .init( - radius: .zero, - width: .zero, - widthWhenActive: .zero - ) - case .roundedRect: - return .init( - radius: theme.border.radius.large, - width: theme.border.width.small, - widthWhenActive: theme.border.width.medium - ) - } - } -} diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCaseTests.swift deleted file mode 100644 index d3a411019..000000000 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetBordersUseCaseTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// TextFieldGetBordersUseCaseTests.swift -// SparkCoreUnitTests -// -// Created by Jacklyn Situmorang on 17.10.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import XCTest - -@testable import SparkCore - -final class TextFieldGetBordersUseCaseTests: XCTestCase { - - // MARK: - Properties - - private let themeMock = ThemeGeneratedMock.mocked() - - // MARK: - Tests - - func test_execute_for_none() { - self.testExecute( - givenBorderStyle: .none, - expectedBorder: .init( - radius: .zero, - width: .zero, - widthWhenActive: .zero - )) - } - - func test_execute_for_roundedRect() { - self.testExecute( - givenBorderStyle: .roundedRect, - expectedBorder: .init( - radius: themeMock.border.radius.large, - width: themeMock.border.width.small, - widthWhenActive: themeMock.border.width.medium - )) - } -} - -// MARK: - Extension - -private extension TextFieldGetBordersUseCaseTests { - func testExecute( - givenBorderStyle: TextFieldBorderStyle, - expectedBorder: TextFieldBorders - ) { - // GIVEN - let useCase = TextFieldGetBordersUseCase() - - // WHEN - let textFieldBorders = useCase.execute( - theme: self.themeMock, - borderStyle: givenBorderStyle - ) - - // THEN - XCTAssertEqual(textFieldBorders.radius, expectedBorder.radius) - XCTAssertEqual(textFieldBorders.width, expectedBorder.width) - } -} diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCase.swift b/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCase.swift deleted file mode 100644 index 38c34e2d6..000000000 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCase.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// TextFieldGetColorsUseCase.swift -// SparkCore -// -// Created by Quentin.richard on 21/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Foundation - -// sourcery: AutoMockable -protocol TextFieldGetColorsUseCasable { - func execute(theme: Theme, - intent: TextFieldIntent) -> TextFieldColors -} - -struct TextFieldGetColorsUseCase: TextFieldGetColorsUseCasable { - func execute(theme: Theme, - intent: TextFieldIntent) -> TextFieldColors { - - switch intent { - case .error: - return .init( - border: theme.colors.feedback.error - ) - case .alert: - return .init( - border: theme.colors.feedback.alert - ) - case .neutral: - return .init( - border: theme.colors.base.outline - ) - case .success: - return .init( - border: theme.colors.feedback.success - ) - } - } -} diff --git a/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCaseTests.swift deleted file mode 100644 index 9f3e1cbf4..000000000 --- a/core/Sources/Components/TextField/UseCase/TextFieldGetColorsUseCaseTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// TextFieldGetColorsUseCaseTests.swift -// SparkCoreUnitTests -// -// Created by Jacklyn Situmorang on 17.10.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import XCTest - -@testable import SparkCore - -final class TextFieldGetColorsUseCaseTests: XCTestCase { - - // MARK: - Properties - - private let themeMock = ThemeGeneratedMock.mocked() - - // MARK: - Tests - - func test_execute_for_alert_case() { - self.testExecute( - givenIntent: .alert, - expectedTextFieldColors: .init(border: self.themeMock.colors.feedback.alert) - ) - } - - func test_execute_for_error_case() { - self.testExecute( - givenIntent: .error, - expectedTextFieldColors: .init(border: self.themeMock.colors.feedback.error) - ) - } - - func test_execute_for_neutral_case() { - self.testExecute( - givenIntent: .neutral, - expectedTextFieldColors: .init(border: self.themeMock.colors.base.outline) - ) - } - - func test_execute_for_success_case() { - self.testExecute( - givenIntent: .success, - expectedTextFieldColors: .init(border: self.themeMock.colors.feedback.success) - ) - } - -} - -private extension TextFieldGetColorsUseCaseTests { - func testExecute( - givenIntent: TextFieldIntent, - expectedTextFieldColors: TextFieldColors - ) { - // GIVEN - let useCase = TextFieldGetColorsUseCase() - - // WHEN - let textFieldColors = useCase.execute( - theme: self.themeMock, - intent: givenIntent - ) - - // THEN - XCTAssertIdentical( - textFieldColors.border as? ColorTokenGeneratedMock, - expectedTextFieldColors.border as? ColorTokenGeneratedMock, - "Wrong color for .\(givenIntent) case" - ) - } -} diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift new file mode 100644 index 000000000..5a1fb24ef --- /dev/null +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift @@ -0,0 +1,212 @@ +// +// TextFieldViewModel.swift +// Spark +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import SwiftUI +import Combine + +class TextFieldViewModel: ObservableObject, Updateable { + + // Colors + @Published private(set) var textColor: any ColorToken + @Published private(set) var placeholderColor: any ColorToken + @Published var borderColor: any ColorToken + @Published private(set) var statusIconColor: any ColorToken + @Published var backgroundColor: any ColorToken + + // BorderLayout + @Published private(set) var borderRadius: CGFloat + @Published private(set) var borderWidth: CGFloat + + // Spacings + @Published private(set) var leftSpacing: CGFloat + @Published private(set) var contentSpacing: CGFloat + @Published private(set) var rightSpacing: CGFloat + + @Published var dim: CGFloat + + @Published private(set) var font: any TypographyFontToken + + @Published private(set) var statusImage: Either? + + var successImage: ImageEither //TODO: Add get/set in views + var alertImage: ImageEither //TODO: Add get/set in views + var errorImage: ImageEither //TODO: Add get/set in views + + let getColorsUseCase: any TextFieldGetColorsUseCasable + let getBorderLayoutUseCase: any TextFieldGetBorderLayoutUseCasable + let getSpacingsUseCase: any TextFieldGetSpacingsUseCasable + + var theme: Theme { + didSet { + self.setColors() + self.setBorderLayout() + self.setSpacings() + self.setDim() + self.setFont() + } + } + var intent: TextFieldIntent { + didSet { + guard oldValue != self.intent else { return } + self.setColors() + self.setStatusImage() + } + } + var borderStyle: TextFieldBorderStyle { + didSet { + guard oldValue != self.borderStyle else { return } + self.setBorderLayout() + self.setSpacings() + } + } + + var isFocused: Bool = false { + didSet { + guard oldValue != self.isFocused else { return } + self.setColors() + self.setBorderLayout() + } + } + + var isEnabled: Bool = true { + didSet { + guard oldValue != self.isEnabled else { return } + self.setColors() + self.setDim() + self.setStatusImage() + } + } + + var isUserInteractionEnabled: Bool = true { + didSet { + guard oldValue != self.isUserInteractionEnabled else { return } + self.setColors() + } + } + + init(theme: Theme, + intent: TextFieldIntent, + borderStyle: TextFieldBorderStyle, + successImage: ImageEither, + alertImage: ImageEither, + errorImage: ImageEither, + getColorsUseCase: any TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase(), + getBorderLayoutUseCase: any TextFieldGetBorderLayoutUseCasable = TextFieldGetBorderLayoutUseCase(), + getSpacingsUseCase: any TextFieldGetSpacingsUseCasable = TextFieldGetSpacingsUseCase()) { + self.theme = theme + self.intent = intent + self.borderStyle = borderStyle + + self.successImage = successImage + self.alertImage = alertImage + self.errorImage = errorImage + + self.getColorsUseCase = getColorsUseCase + self.getBorderLayoutUseCase = getBorderLayoutUseCase + self.getSpacingsUseCase = getSpacingsUseCase + + // Colors + let colors = getColorsUseCase.execute( + theme: theme, + intent: intent, + isFocused: self.isFocused, + isEnabled: self.isEnabled, + isUserInteractionEnabled: self.isUserInteractionEnabled + ) + self.textColor = colors.text + self.placeholderColor = colors.placeholder + self.borderColor = colors.border + self.statusIconColor = colors.statusIcon + self.backgroundColor = colors.background + + // BorderLayout + let borderLayout = getBorderLayoutUseCase.execute( + theme: theme, + borderStyle: + borderStyle, + isFocused: self.isFocused) + self.borderWidth = borderLayout.width + self.borderRadius = borderLayout.radius + + // Spacings + let spacings = getSpacingsUseCase.execute(theme: theme, borderStyle: borderStyle) + self.leftSpacing = spacings.left + self.contentSpacing = spacings.content + self.rightSpacing = spacings.right + + self.dim = theme.dims.none + + self.font = theme.typography.body1 + + self.statusImage = nil + self.setStatusImage() + } + + func setColors() { + // Colors + let colors = self.getColorsUseCase.execute( + theme: self.theme, + intent: self.intent, + isFocused: self.isFocused, + isEnabled: self.isEnabled, + isUserInteractionEnabled: self.isUserInteractionEnabled + ) + self.updateIfNeeded(keyPath: \.textColor, newValue: colors.text) + self.updateIfNeeded(keyPath: \.placeholderColor, newValue: colors.placeholder) + self.updateIfNeeded(keyPath: \.borderColor, newValue: colors.border) + self.updateIfNeeded(keyPath: \.statusIconColor, newValue: colors.statusIcon) + self.updateIfNeeded(keyPath: \.backgroundColor, newValue: colors.background) + } + + func setBorderLayout() { + let borderLayout = self.getBorderLayoutUseCase.execute( + theme: self.theme, + borderStyle: self.borderStyle, //.none + isFocused: self.isFocused + ) + self.updateIfNeeded(keyPath: \.borderWidth, newValue: borderLayout.width) + self.updateIfNeeded(keyPath: \.borderRadius, newValue: borderLayout.radius) + } + + func setSpacings() { + let spacings = self.getSpacingsUseCase.execute(theme: self.theme, borderStyle: self.borderStyle) + self.updateIfNeeded(keyPath: \.leftSpacing, newValue: spacings.left) + self.updateIfNeeded(keyPath: \.contentSpacing, newValue: spacings.content) + self.updateIfNeeded(keyPath: \.rightSpacing, newValue: spacings.right) + } + + func setDim() { + let dim = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 + self.updateIfNeeded(keyPath: \.dim, newValue: dim) + } + + private func setFont() { + self.font = self.theme.typography.body1 + } + + private func setStatusImage() { + let image: ImageEither? + if self.isEnabled { + switch self.intent { + case .alert: + image = self.alertImage + case .error: + image = self.errorImage + case .success: + image = self.successImage + default: + image = nil + } + } else { + image = nil + } + guard self.statusImage != image else { return } + self.statusImage = image + } +} diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift new file mode 100644 index 000000000..c87ec7b31 --- /dev/null +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift @@ -0,0 +1,1003 @@ +// +// TextFieldViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import Combine +import UIKit +import SwiftUI +@testable import SparkCore + +final class TextFieldViewModelTests: XCTestCase { + + var theme: ThemeGeneratedMock! + var publishers: TextFieldPublishers! + var getColorsUseCase: TextFieldGetColorsUseCasableGeneratedMock! + var getBorderLayoutUseCase: TextFieldGetBorderLayoutUseCasableGeneratedMock! + var getSpacingsUseCase: TextFieldGetSpacingsUseCasableGeneratedMock! + private var viewModel: TextFieldViewModel! + + let intent = TextFieldIntent.success + let borderStyle = TextFieldBorderStyle.roundedRect + + var expectedColors: TextFieldColors! + var expectedBorderLayout: TextFieldBorderLayout! + var expectedSpacings: TextFieldSpacings! + + let successImage: ImageEither = .left(UIImage(systemName: "square.and.arrow.up.fill")!) + let alertImage: ImageEither = .right(Image(systemName: "rectangle.portrait.and.arrow.right.fill")) + let errorImage: ImageEither = .left(UIImage(systemName: "eraser.fill")!) + + override func setUp() { + super.setUp() + self.theme = ThemeGeneratedMock.mocked() + + self.expectedColors = .mocked( + text: .blue(), + placeholder: .green(), + border: .yellow(), + statusIcon: .red(), + background: .purple() + ) + self.expectedBorderLayout = .mocked(radius: 1, width: 2) + self.expectedSpacings = .mocked(left: 1, content: 2, right: 3) + + self.getColorsUseCase = .mocked(returnedColors: self.expectedColors) + self.getBorderLayoutUseCase = .mocked(returnedBorderLayout: self.expectedBorderLayout) + self.getSpacingsUseCase = .mocked(returnedSpacings: self.expectedSpacings) + self.viewModel = .init( + theme: self.theme, + intent: self.intent, + borderStyle: self.borderStyle, + successImage: self.successImage, + alertImage: self.alertImage, + errorImage: self.errorImage, + getColorsUseCase: self.getColorsUseCase, + getBorderLayoutUseCase: self.getBorderLayoutUseCase, + getSpacingsUseCase: self.getSpacingsUseCase + ) + + self.setupPublishers() + } + + // MARK: - init + func test_init() throws { + // GIVEN / WHEN - Inits from setUp() + // THEN - Simple variables + XCTAssertIdentical(self.viewModel.theme as? ThemeGeneratedMock, self.theme, "Wrong theme") + XCTAssertEqual(self.viewModel.intent, self.intent, "Wrong intent") + XCTAssertEqual(self.viewModel.borderStyle, self.borderStyle, "Wrong borderStyle") + XCTAssertTrue(self.viewModel.isEnabled, "Wrong isEnabled") + XCTAssertTrue(self.viewModel.isUserInteractionEnabled, "Wrong isUserInteractionEnabled") + XCTAssertFalse(self.viewModel.isFocused, "Wrong isFocused") + XCTAssertEqual(self.viewModel.successImage, self.successImage, "Wrong successImage") + XCTAssertEqual(self.viewModel.alertImage, self.alertImage, "Wrong alertImage") + XCTAssertEqual(self.viewModel.errorImage, self.errorImage, "Wrong errorImage") + XCTAssertEqual(self.viewModel.dim, self.theme.dims.none, "Wrong dim") + XCTAssertEqual(self.viewModel.statusImage, self.successImage, "Wrong statusImage") + XCTAssertIdentical(self.viewModel.font as? TypographyFontTokenGeneratedMock, self.theme.typography.body1 as? TypographyFontTokenGeneratedMock, "Wrong font") + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertFalse(getColorsReceivedArguments.isFocused, "Wrong getColorsReceivedArguments.isFocused") + XCTAssertTrue(getColorsReceivedArguments.isEnabled, "Wrong getColorsReceivedArguments.isEnabled") + XCTAssertTrue(getColorsReceivedArguments.isUserInteractionEnabled, "Wrong getColorsReceivedArguments.isUserInteractionEnabled") + XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertIdentical(getBorderLayoutReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getBorderLayoutReceivedArguments.theme") + XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .roundedRect, "Wrong getBorderLayoutReceivedArguments.borderStyle") + XCTAssertFalse(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") + XCTAssertEqual(self.viewModel.borderWidth, self.expectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, self.expectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertIdentical(getSpacingsUseCaseReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getSpacingsUseCaseReceivedArguments.theme") + XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .roundedRect, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") + XCTAssertEqual(self.viewModel.leftSpacing, self.expectedSpacings.left, "Wrong leftSpacing") + XCTAssertEqual(self.viewModel.contentSpacing, self.expectedSpacings.content, "Wrong contentSpacing") + XCTAssertEqual(self.viewModel.rightSpacing, self.expectedSpacings.right, "Wrong rightSpacing") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") + + XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "$leftSpacing should have been called once") + XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "$contentSpacing should have been called once") + XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "$rightSpacing should have been called once") + + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + XCTAssertEqual(self.publishers.font.sinkCount, 1, "$font should have been called once") + XCTAssertEqual(self.publishers.statusImage.sinkCount, 1, "$statusImage should have been called once") + } + + // MARK: Theme + func test_theme_didSet() throws { + // GIVEN - Inits from setUp() + let newTheme = ThemeGeneratedMock() + newTheme.typography = TypographyGeneratedMock.mocked() + newTheme.dims = DimsGeneratedMock.mocked() + + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedColors = TextFieldColors.mocked( + text: .red(), + placeholder: .blue(), + border: .green(), + statusIcon: .purple(), + background: .red() + ) + self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors + + let newExpectedBorderLayout = TextFieldBorderLayout.mocked(radius: 20.0, width: 100.0) + self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newExpectedBorderLayout + + let newExpectedSpacings = TextFieldSpacings.mocked(left: 10, content: 20, right: 30) + self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newExpectedSpacings + + // WHEN + self.viewModel.theme = newTheme + + // THEN - Theme + XCTAssertIdentical(self.viewModel.theme as? ThemeGeneratedMock, newTheme, "Wrong theme") + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, newTheme, "Wrong getColorsReceivedArguments.theme") + XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertIdentical(getBorderLayoutReceivedArguments.theme as? ThemeGeneratedMock, newTheme, "Wrong getBorderLayoutReceivedArguments.theme") + XCTAssertEqual(self.viewModel.borderWidth, newExpectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, newExpectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertIdentical(getSpacingsUseCaseReceivedArguments.theme as? ThemeGeneratedMock, newTheme, "Wrong getSpacingsUseCaseReceivedArguments.theme") + XCTAssertEqual(self.viewModel.leftSpacing, newExpectedSpacings.left, "Wrong leftSpacing") + XCTAssertEqual(self.viewModel.contentSpacing, newExpectedSpacings.content, "Wrong contentSpacing") + XCTAssertEqual(self.viewModel.rightSpacing, newExpectedSpacings.right, "Wrong rightSpacing") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") + + XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "$leftSpacing should have been called once") + XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "$contentSpacing should have been called once") + XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "$rightSpacing should have been called once") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertEqual(self.publishers.font.sinkCount, 1, "$font should have been called once") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + // MARK: - Intent + func test_intent_didSet_equal() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.intent = self.intent + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_intent_didSet_notEqual_samePublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.intent = .error + + // THEN + XCTAssertEqual(self.viewModel.statusImage, self.errorImage, "Wrong statusImage") + + // Then - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertEqual(getColorsReceivedArguments.intent, .error, "Wrong getColorsReceivedArguments.intent") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertEqual(self.publishers.statusImage.sinkCount, 1, "$statusImage should have been called once") + } + + func test_intent_didSet_notEqual_differentPublishedValues() throws { + // GIVEN - Inits from setUp() + self.viewModel.intent = .alert + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedColors = TextFieldColors.mocked( + text: .red(), + placeholder: .blue(), + border: .green(), + statusIcon: .purple(), + background: .red() + ) + self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors + + // WHEN + self.viewModel.intent = .neutral + + // THEN + XCTAssertNil(self.viewModel.statusImage, "Wrong statusImage") + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertEqual(getColorsReceivedArguments.intent , .neutral, "Wrong getColorsReceivedArguments.intent") + XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertEqual(self.publishers.statusImage.sinkCount, 1,"$statusImage should have been called once") + } + + // MARK: - Border Style + func test_borderStyle_didSet_equal() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.borderStyle = self.borderStyle + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_borderStyle_didSet_notEqual_samePublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.borderStyle = .none + + // Then - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .none, "Wrong getBorderLayoutReceivedArguments.borderStyle") + + // THEN - Spacings + XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .none, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_borderStyle_didSet_notEqual_differentPublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedBorderLayout = TextFieldBorderLayout.mocked(radius: 20.0, width: 100.0) + self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newExpectedBorderLayout + + let newExpectedSpacings = TextFieldSpacings.mocked(left: 10, content: 20, right: 30) + self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newExpectedSpacings + + // WHEN + self.viewModel.borderStyle = .none + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .none, "Wrong getBorderLayoutReceivedArguments.borderStyle") + XCTAssertEqual(self.viewModel.borderWidth, newExpectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, newExpectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .none, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") + XCTAssertEqual(self.viewModel.leftSpacing, newExpectedSpacings.left, "Wrong leftSpacing") + XCTAssertEqual(self.viewModel.contentSpacing, newExpectedSpacings.content, "Wrong contentSpacing") + XCTAssertEqual(self.viewModel.rightSpacing, newExpectedSpacings.right, "Wrong rightSpacing") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") + + XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "$leftSpacing should have been called once") + XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "$contentSpacing should have been called once") + XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "$rightSpacing should have been called once") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") + } + + // MARK: - Is Focused + func test_isFocused_didSet_equal() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isFocused = false + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_isFocused_didSet_notEqual_samePublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isFocused = true + + // Then - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertTrue(getColorsReceivedArguments.isFocused, "Wrong getColorsReceivedArguments.isFocused") + XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertTrue(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") + XCTAssertEqual(self.viewModel.borderWidth, self.expectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, self.expectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_isFocused_didSet_notEqual_differentPublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedColors = TextFieldColors.mocked( + text: .red(), + placeholder: .blue(), + border: .green(), + statusIcon: .purple(), + background: .red() + ) + self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors + + let newExpectedBorderLayout = TextFieldBorderLayout.mocked(radius: 20.0, width: 100.0) + self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newExpectedBorderLayout + + // WHEN + self.viewModel.isFocused = true + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertTrue(getColorsReceivedArguments.isFocused, "Wrong getColorsReceivedArguments.isFocused") + XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertTrue(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") + XCTAssertEqual(self.viewModel.borderWidth, newExpectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, newExpectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should have not been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should have not been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should have not been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") + } + + // MARK: - Is Enabled + func test_isEnabled_didSet_equal() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isEnabled = true + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_isEnabled_didSet_notEqual_samePublishedValues() throws { + // GIVEN - Inits from setUp() + self.viewModel.intent = .neutral + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isEnabled = false + + XCTAssertNil(self.viewModel.statusImage, "statusImage should be nil when isEnabled is false") + // Then - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, .neutral, "Wrong getColorsReceivedArguments.intent") + XCTAssertFalse(getColorsReceivedArguments.isEnabled, "Wrong getColorsReceivedArguments.isEnabled") + XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should not have been called") + } + + func test_isEnabled_didSet_notEqual_differentPublishedValues() throws { + // GIVEN - Inits from setUp() + self.viewModel.isEnabled = false + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedColors = TextFieldColors.mocked( + text: .red(), + placeholder: .blue(), + border: .green(), + statusIcon: .purple(), + background: .red() + ) + self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors + + // WHEN + self.viewModel.isEnabled = true + + XCTAssertEqual(self.viewModel.statusImage, self.successImage, "Wrong statusImage") + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertTrue(getColorsReceivedArguments.isEnabled, "Wrong getColorsReceivedArguments.isEnabledd") + XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should have not been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should have not been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should have not been called") + + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertEqual(self.publishers.statusImage.sinkCount, 1,"$statusImage should have been called once") + } + + // MARK: - Is User Interaction Enabled + func test_isUserInteractionEnabled_didSet_equal() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isUserInteractionEnabled = true + + // THEN - Colors + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_isUserInteractionEnabled_didSet_notEqual_samePublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isUserInteractionEnabled = false + + // Then - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertFalse(getColorsReceivedArguments.isUserInteractionEnabled, "Wrong getColorsReceivedArguments.isUserInteractionEnabled") + XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") + XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") + XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") + XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") + } + + func test_isUserInteractionEnabled_didSet_notEqual_differentPublishedValues() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + let newExpectedColors = TextFieldColors.mocked( + text: .red(), + placeholder: .blue(), + border: .green(), + statusIcon: .purple(), + background: .red() + ) + self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors + + // WHEN + self.viewModel.isUserInteractionEnabled = false + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertFalse(getColorsReceivedArguments.isUserInteractionEnabled, "Wrong getColorsReceivedArguments.isUserInteractionEnabled") + XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") + XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") + XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") + XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") + + // THEN - Spacings + XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.textColor.sinkCount, 1, "$textColor should have been called once") + XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") + XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") + + XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should have not been called") + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should have not been called") + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should have not been called") + + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") + XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") + } + + // MARK: - Utils + func setupPublishers() { + self.publishers = .init( + textColor: PublisherMock(publisher: self.viewModel.$textColor), + placeholderColor: PublisherMock(publisher: self.viewModel.$placeholderColor), + borderColor: PublisherMock(publisher: self.viewModel.$borderColor), + statusIconColor: PublisherMock(publisher: self.viewModel.$statusIconColor), + backgroundColor: PublisherMock(publisher: self.viewModel.$backgroundColor), + borderRadius: PublisherMock(publisher: self.viewModel.$borderRadius), + borderWidth: PublisherMock(publisher: self.viewModel.$borderWidth), + leftSpacing: PublisherMock(publisher: self.viewModel.$leftSpacing), + contentSpacing: PublisherMock(publisher: self.viewModel.$contentSpacing), + rightSpacing: PublisherMock(publisher: self.viewModel.$rightSpacing), + dim: PublisherMock(publisher: self.viewModel.$dim), + font: PublisherMock(publisher: self.viewModel.$font), + statusImage: PublisherMock(publisher: self.viewModel.$statusImage) + ) + self.publishers.load() + } + + func resetUseCases() { + self.getColorsUseCase.reset() + self.getBorderLayoutUseCase.reset() + self.getSpacingsUseCase.reset() + } +} + +final class TextFieldPublishers { + var cancellables = Set() + + var textColor: PublisherMock.Publisher> + var placeholderColor: PublisherMock.Publisher> + var borderColor: PublisherMock.Publisher> + var statusIconColor: PublisherMock.Publisher> + var backgroundColor: PublisherMock.Publisher> + + var borderRadius: PublisherMock.Publisher> + var borderWidth: PublisherMock.Publisher> + + var leftSpacing: PublisherMock.Publisher> + var contentSpacing: PublisherMock.Publisher> + var rightSpacing: PublisherMock.Publisher> + + var dim: PublisherMock.Publisher> + + var font: PublisherMock.Publisher> + + var statusImage: PublisherMock.Publisher> + + init( + textColor: PublisherMock.Publisher>, + placeholderColor: PublisherMock.Publisher>, + borderColor: PublisherMock.Publisher>, + statusIconColor: PublisherMock.Publisher>, + backgroundColor: PublisherMock.Publisher>, + borderRadius: PublisherMock.Publisher>, + borderWidth: PublisherMock.Publisher>, + leftSpacing: PublisherMock.Publisher>, + contentSpacing: PublisherMock.Publisher>, + rightSpacing: PublisherMock.Publisher>, + dim: PublisherMock.Publisher>, + font: PublisherMock.Publisher>, + statusImage: PublisherMock.Publisher> + ) { + self.textColor = textColor + self.placeholderColor = placeholderColor + self.borderColor = borderColor + self.statusIconColor = statusIconColor + self.backgroundColor = backgroundColor + self.borderRadius = borderRadius + self.borderWidth = borderWidth + self.leftSpacing = leftSpacing + self.contentSpacing = contentSpacing + self.rightSpacing = rightSpacing + self.dim = dim + self.font = font + self.statusImage = statusImage + } + + func load() { + self.cancellables = Set() + + [self.textColor, self.placeholderColor, self.borderColor, self.statusIconColor, self.backgroundColor].forEach { + $0.loadTesting(on: &self.cancellables) + } + + [self.borderWidth, self.borderRadius, self.leftSpacing, self.contentSpacing, self.rightSpacing, self.dim].forEach { + $0.loadTesting(on: &self.cancellables) + } + + self.font.loadTesting(on: &self.cancellables) + + self.statusImage.loadTesting(on: &self.cancellables) + } + + func reset() { + [self.textColor, self.placeholderColor, self.borderColor, self.statusIconColor, self.backgroundColor].forEach { + $0.reset() + } + + [self.borderWidth, self.borderRadius, self.leftSpacing, self.contentSpacing, self.rightSpacing, self.dim].forEach { + $0.reset() + } + + self.font.reset() + + self.statusImage.reset() + } +} diff --git a/core/Sources/Theming/Content/Colors/ColorTokenGeneratedMock+ExtensionTests.swift b/core/Sources/Theming/Content/Colors/ColorTokenGeneratedMock+ExtensionTests.swift index 70a33dd9d..979eda375 100644 --- a/core/Sources/Theming/Content/Colors/ColorTokenGeneratedMock+ExtensionTests.swift +++ b/core/Sources/Theming/Content/Colors/ColorTokenGeneratedMock+ExtensionTests.swift @@ -12,7 +12,7 @@ import UIKit extension ColorTokenGeneratedMock { // MARK: - Methods - + static func random() -> ColorTokenGeneratedMock { let color = ColorTokenGeneratedMock() let random = UIColor.random() @@ -20,6 +20,48 @@ extension ColorTokenGeneratedMock { color.underlyingColor = Color(random) return color } + + static func red() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .red + color.underlyingUiColor = .red + return color + } + + static func blue() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .blue + color.underlyingUiColor = .blue + return color + } + + static func green() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .green + color.underlyingUiColor = .green + return color + } + + static func orange() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .orange + color.underlyingUiColor = .orange + return color + } + + static func yellow() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .yellow + color.underlyingUiColor = .yellow + return color + } + + static func purple() -> ColorTokenGeneratedMock { + let color = ColorTokenGeneratedMock() + color.underlyingColor = .purple + color.underlyingUiColor = .purple + return color + } } // MARK: - Private extension diff --git a/spark/Demo/Classes/Enum/UIComponent.swift b/spark/Demo/Classes/Enum/UIComponent.swift index 7c6691194..7c8a7037c 100644 --- a/spark/Demo/Classes/Enum/UIComponent.swift +++ b/spark/Demo/Classes/Enum/UIComponent.swift @@ -29,7 +29,6 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { .switchButton, .tab, .tag, - .textField, .textLink ] @@ -53,6 +52,5 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { static let switchButton = UIComponent(rawValue: "Switch Button") static let tab = UIComponent(rawValue: "Tab") static let tag = UIComponent(rawValue: "Tag") - static let textField = UIComponent(rawValue: "TextField") static let textLink = UIComponent(rawValue: "TextLink") } diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index f923439ec..371881dfb 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -108,8 +108,6 @@ extension ComponentsViewController { viewController = TabComponentUIViewController.build() case .tag: viewController = TagComponentUIViewController.build() - case .textField: - viewController = TextFieldComponentUIViewController.build() case .textLink: viewController = TextLinkComponentUIViewController.build() default: diff --git a/spark/Demo/Classes/View/Components/TextField/SwitftUI/TextFieldComponentView.swift b/spark/Demo/Classes/View/Components/TextField/SwitftUI/TextFieldComponentView.swift deleted file mode 100644 index 91125db8b..000000000 --- a/spark/Demo/Classes/View/Components/TextField/SwitftUI/TextFieldComponentView.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// TextFieldComponentView.swift -// Spark -// -// Created by Quentin.richard on 11/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Spark -import SparkCore -import SwiftUI - -private struct TextFieldPreviewFormatter: BadgeFormatting { - func formatText(for value: Int?) -> String { - guard let value else { - return "_" - } - return "Test \(value)" - } -} - -struct TextFieldComponentView: View { - - @ObservedObject private var themePublisher = SparkThemePublisher.shared - - var theme: Theme { - self.themePublisher.theme - } - @State var isThemePresented = false - - let themes = ThemeCellModel.themes - - @State var intent: BadgeIntentType = .danger - @State var isIntentPresented = false - - @State var size: BadgeSize = .medium - @State var isSizePresented = false - - @State var value: Int? = 99 - - @State var format: BadgeFormat = .default - @State var isFormatPresented = false - - @State var isBorderVisible: CheckboxSelectionState = .unselected - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Configuration") - .font(.title2) - .bold() - .padding(.bottom, 6) - - VStack(alignment: .leading, spacing: 8) { - HStack() { - Text("Theme: ").bold() - let selectedTheme = self.theme is SparkTheme ? themes.first : themes.last - Button(selectedTheme?.title ?? "") { - self.isThemePresented = true - } - .confirmationDialog("Select a theme", - isPresented: self.$isThemePresented) { - ForEach(themes, id: \.self) { theme in - Button(theme.title) { - themePublisher.theme = theme.theme - } - } - } - Spacer() - } - HStack() { - Text("Intent: ").bold() - Button(self.intent.name) { - self.isIntentPresented = true - } - .confirmationDialog("Select an intent", isPresented: self.$isIntentPresented) { - ForEach(BadgeIntentType.allCases, id: \.self) { intent in - Button(intent.name) { - self.intent = intent - } - } - } - } - HStack() { - Text("Badge Size: ").bold() - Button(self.size.name) { - self.isSizePresented = true - } - .confirmationDialog("Select a size", isPresented: self.$isSizePresented) { - ForEach(BadgeSize.allCases, id: \.self) { size in - Button(size.name) { - self.size = size - } - } - } - } - HStack() { - Text("Format ").bold() - Button(self.format.name) { - self.isFormatPresented = true - } - .confirmationDialog("Select a format", isPresented: self.$isFormatPresented) { - ForEach(BadgeFormat.allNames, id: \.self) { name in - Button(name) { - self.format = BadgeFormat.from(name: name) - } - } - } - } - HStack() { - Text("Value ").bold() - TextField("Value", value: self.$value, formatter: NumberFormatter()) - } - - CheckboxView( - text: "With Border", - checkedImage: DemoIconography.shared.checkmark.image, - theme: theme, - selectionState: self.$isBorderVisible - ) - } - - Divider() - - Text("Integration") - .font(.title2) - .bold() - - BadgeView(theme: self.theme, intent: self.intent, value: self.value) - .size(self.size) - .format(self.format) - .borderVisible(self.isBorderVisible == .selected) - - Spacer() - } - .padding(.horizontal, 16) - .navigationBarTitle(Text("Badge")) - } -} - -struct TextFieldComponent_Previews: PreviewProvider { - static var previews: some View { - BadgeComponentView() - } -} - -private extension BadgeFormat { - static var allNames: [String] = [Names.default, Names.custom, Names.overflowCounter] - - static func from(name: String) -> BadgeFormat { - switch name { - case Names.custom: return .custom(formatter: BadgePreviewFormatter()) - case Names.overflowCounter: return .overflowCounter(maxValue: 99) - default: return .default - } - } - } diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift deleted file mode 100644 index bf83a39c5..000000000 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift +++ /dev/null @@ -1,466 +0,0 @@ -// -// TextFieldComponentUIView.swift -// Spark -// -// Created by Quentin.richard on 11/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import Combine -import SparkCore -import Spark - -final class TextFieldComponentUIView: UIView { - private var addOnTextField: AddOnTextFieldUIView - private let textField: TextFieldUIView - private let viewModel: TextFieldComponentUIViewModel - private var cancellables: Set = [] - - private lazy var vStack: UIStackView = { - let stackView = UIStackView() - stackView.axis = .vertical - stackView.distribution = .fill - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = 12 - return stackView - }() - - private lazy var configurationLabel: UILabel = { - let label = UILabel() - label.text = "Configuration" - label.font = UIFont.systemFont(ofSize: 22, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var themeLabel: UILabel = { - let label = UILabel() - label.text = "Theme:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - return label - }() - - private lazy var themeButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentThemeSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var themeStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [themeLabel, themeButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var intentLabel: UILabel = { - let label = UILabel() - label.text = "Intent:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - return label - }() - - private lazy var intentButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentIntentSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var intentStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [intentLabel, intentButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var withRightViewCheckBox: CheckboxUIView = { - let view = CheckboxUIView( - theme: viewModel.theme, - text: "Display right view", - checkedImage: DemoIconography.shared.checkmark.uiImage, - selectionState: .selected, - alignment: .left - ) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private lazy var withLeftViewCheckBox: CheckboxUIView = { - let view = CheckboxUIView( - theme: viewModel.theme, - text: "Display left view", - checkedImage: DemoIconography.shared.checkmark.uiImage, - selectionState: .selected, - alignment: .left - ) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private lazy var showLeadingAddOnLabel: UILabel = { - let label = UILabel() - label.text = "Leading add-on:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var leadingAddOnButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentLeadingAddOnSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var leadingAddOnStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [showLeadingAddOnLabel, leadingAddOnButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var showTrailingAddOnLabel: UILabel = { - let label = UILabel() - label.text = "Trailing add-on:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var trailingAddOnButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentTrailingAddOnSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var trailingAddOnStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [showTrailingAddOnLabel, trailingAddOnButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var rightViewModeLabel: UILabel = { - let label = UILabel() - label.text = "Mode for rightView:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var rightViewModeButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentRightViewModeSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var rightViewModeStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [rightViewModeLabel, rightViewModeButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var clearButtonModeLabel: UILabel = { - let label = UILabel() - label.text = "Clear button mode:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var clearButtonModeButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presetClearButtonModeSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var clearButtonModeStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [clearButtonModeLabel, clearButtonModeButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var configurationStackView: UIStackView = { - let stackView = UIStackView( - arrangedSubviews: [ - themeStackView, - intentStackView, - ]) - stackView.axis = .vertical - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var leftViewModeLabel: UILabel = { - let label = UILabel() - label.text = "Mode for leftView:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var leftViewModeButton: UIButton = { - let button = UIButton() - button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) - button.addTarget(self.viewModel, action: #selector(viewModel.presentLeftViewModeSheet), for: .touchUpInside) - button.titleLabel?.font = UIFont.systemFont(ofSize: 17) - return button - }() - - private lazy var leftViewModeStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [leftViewModeLabel, leftViewModeButton, UIView()]) - stackView.axis = .horizontal - stackView.spacing = 10 - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - }() - - private lazy var standaloneTextFieldLabel: UILabel = { - let label = UILabel() - label.text = "Standalone text field:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - private lazy var addOnTextFieldLabel: UILabel = { - let label = UILabel() - label.text = "Add-on text field:" - label.font = UIFont.systemFont(ofSize: 17, weight: .bold) - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - init(viewModel: TextFieldComponentUIViewModel) { - self.viewModel = viewModel - self.textField = TextFieldUIView(theme: viewModel.theme) - self.addOnTextField = AddOnTextFieldUIView( - theme: viewModel.theme, - intent: .neutral, - leadingAddOn: nil, - trailingAddOn: nil - ) - super.init(frame: .zero) - self.setupView() - self.addPublishers() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - self.textField.translatesAutoresizingMaskIntoConstraints = false - self.addOnTextField.translatesAutoresizingMaskIntoConstraints = false - self.textField.addDoneButtonOnKeyboard() - self.addOnTextField.textField.addDoneButtonOnKeyboard() - - self.addSubview(self.vStack) - NSLayoutConstraint.activate([ - self.vStack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 14), - self.vStack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -14), - self.vStack.topAnchor.constraint(equalTo: self.topAnchor) - ]) - - self.vStack.addArrangedSubview(self.configurationLabel) - self.vStack.addArrangedSubview(self.configurationStackView) - self.vStack.addArrangedSubview(self.withRightViewCheckBox) - self.vStack.addArrangedSubview(self.withLeftViewCheckBox) - self.vStack.addArrangedSubview(self.leadingAddOnStackView) - self.vStack.addArrangedSubview(self.trailingAddOnStackView) - self.vStack.addArrangedSubview(self.rightViewModeStackView) - self.vStack.addArrangedSubview(self.leftViewModeStackView) - self.vStack.addArrangedSubview(self.clearButtonModeStackView) - self.vStack.addArrangedSubview(self.standaloneTextFieldLabel) - self.vStack.addArrangedSubview(self.textField) - self.vStack.addArrangedSubview(self.addOnTextFieldLabel) - self.vStack.addArrangedSubview(self.addOnTextField) - - self.textField.rightView = self.createRightView() - self.textField.leftView = self.createLeftView() - - self.addOnTextField.textField.rightView = self.createRightView() - self.addOnTextField.textField.leftView = self.createLeftView() - } - - private func createRightView() -> UIImageView { - let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 32, height: 32)) - imageView.image = UIImage(systemName: "square.and.pencil.circle.fill") - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.heightAnchor.constraint(equalToConstant: 20).isActive = true - imageView.widthAnchor.constraint(equalToConstant: 20).isActive = true - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - } - - private func createLeftView() -> UIImageView { - let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 32, height: 32)) - imageView.image = UIImage(systemName: "square.and.pencil.circle.fill") - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.heightAnchor.constraint(equalToConstant: 30).isActive = true - imageView.widthAnchor.constraint(equalToConstant: 30).isActive = true - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false - return imageView - } - - func createButtonAddOn() -> UIButton { - let button = UIButton() - button.translatesAutoresizingMaskIntoConstraints = false - var buttonConfig = UIButton.Configuration.plain() - buttonConfig.image = UIImage( - systemName: "square.and.arrow.up", - withConfiguration: UIImage.SymbolConfiguration(scale: .small) - ) - button.configuration = buttonConfig - button.addTarget(self.viewModel, action: #selector(self.viewModel.buttonTapped), for: .touchUpInside) - button.widthAnchor.constraint(equalToConstant: button.intrinsicContentSize.width).isActive = true - return button - } - - private func createShortTextAddOn() -> UIView { - let container = UIView() - let label = UILabel() - container.translatesAutoresizingMaskIntoConstraints = false - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "short" - container.addSubview(label) - NSLayoutConstraint.activate([ - container.widthAnchor.constraint(equalToConstant: label.intrinsicContentSize.width + 32), - label.centerXAnchor.constraint(equalTo: container.centerXAnchor), - label.centerYAnchor.constraint(equalTo: container.centerYAnchor) - ]) - label.textColor = .black - return container - } - - private func createLongTextAddOn() -> UIView { - let container = UIView() - let label = UILabel() - container.translatesAutoresizingMaskIntoConstraints = false - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "a very long text" - container.addSubview(label) - NSLayoutConstraint.activate([ - container.widthAnchor.constraint(equalToConstant: label.intrinsicContentSize.width + 32), - label.centerXAnchor.constraint(equalTo: container.centerXAnchor), - label.centerYAnchor.constraint(equalTo: container.centerYAnchor) - ]) - label.textColor = .black - return container - } - - private func addPublishers() { - self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in - guard let self = self else { return } - let color = self.viewModel.theme.colors.main.main.uiColor - let themeTitle: String? = theme is SparkTheme ? viewModel.themes.first?.title : viewModel.themes.last?.title - self.themeButton.setTitle(themeTitle, for: .normal) - self.themeButton.setTitleColor(color, for: .normal) - self.intentButton.setTitleColor(color, for: .normal) - self.rightViewModeButton.setTitleColor(color, for: .normal) - } - - self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in - guard let self = self else { return } - self.intentButton.setTitle(intent.name, for: .normal) - self.textField.intent = intent - self.addOnTextField.intent = intent - } - - self.viewModel.$text.subscribe(in: &self.cancellables) { [weak self] label in - guard let self = self else { return } - self.textField.placeholder = label - self.addOnTextField.textField.placeholder = label - } - - self.withRightViewCheckBox.publisher.subscribe(in: &self.cancellables) { [weak self] state in - guard let self = self else { return } - if state != .unselected { - self.textField.rightView = self.createRightView() - self.addOnTextField.textField.rightView = self.createRightView() - } - } - - self.withLeftViewCheckBox.publisher.subscribe(in: &self.cancellables) { [weak self] state in - guard let self = self else { return } - self.textField.leftView = state == .unselected ? nil : self.createLeftView() - if state != .unselected { - self.textField.leftView = self.createLeftView() - self.addOnTextField.textField.leftView = self.createLeftView() - } - } - - self.viewModel.$leadingAddOnOption.subscribe(in: &self.cancellables) { [weak self] addOnOption in - guard let self else { return } - self.leadingAddOnButton.setTitle(addOnOption.name, for: .normal) - switch addOnOption { - case .none: - self.addOnTextField.leadingAddOn = nil - case .button: - self.addOnTextField.leadingAddOn = self.createButtonAddOn() - case .shortText: - self.addOnTextField.leadingAddOn = self.createShortTextAddOn() - case .longText: - self.addOnTextField.leadingAddOn = self.createLongTextAddOn() - } - } - - self.viewModel.$trailingAddOnOption.subscribe(in: &self.cancellables) { [weak self] addOnOption in - guard let self else { return } - self.trailingAddOnButton.setTitle(addOnOption.name, for: .normal) - switch addOnOption { - case .none: - self.addOnTextField.trailingAddOn = nil - case .button: - self.addOnTextField.trailingAddOn = self.createButtonAddOn() - case .shortText: - self.addOnTextField.trailingAddOn = self.createShortTextAddOn() - case .longText: - self.addOnTextField.trailingAddOn = self.createLongTextAddOn() - } - } - - self.viewModel.$rightViewMode.subscribe(in: &self.cancellables) { [weak self] viewMode in - guard let self = self else { return } - self.rightViewModeButton.setTitle(viewMode.name, for: .normal) - self.textField.rightViewMode = .init(rawValue: viewMode.rawValue) ?? .never - self.addOnTextField.textField.rightViewMode = .init(rawValue: viewMode.rawValue) ?? .never - } - - self.viewModel.$leftViewMode.subscribe(in: &self.cancellables) { [weak self] viewMode in - guard let self = self else { return } - self.leftViewModeButton.setTitle(viewMode.name, for: .normal) - self.textField.leftViewMode = .init(rawValue: viewMode.rawValue) ?? .never - self.addOnTextField.textField.leftViewMode = .init(rawValue: viewMode.rawValue) ?? .never - } - - self.viewModel.$clearButtonMode.subscribe(in: &self.cancellables) { [weak self] viewMode in - guard let self else { return } - self.clearButtonModeButton.setTitle(viewMode.name, for: .normal) - self.textField.clearButtonMode = .init(rawValue: viewMode.rawValue) ?? .never - self.addOnTextField.textField.clearButtonMode = .init(rawValue: viewMode.rawValue) ?? .never - } - } -} diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift deleted file mode 100644 index 9531d8f15..000000000 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// TextFieldComponentUIViewController.swift -// SparkCore -// -// Created by louis.borlee on 11/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import Spark -import SwiftUI -import UIKit -import SparkCore - -class TextFieldComponentUIViewController: UIViewController { - - let textFieldComponentUIView: TextFieldComponentUIView - let viewModel: TextFieldComponentUIViewModel - private var cancellables: Set = [] - - // MARK: - Published Properties - @ObservedObject private var themePublisher = SparkThemePublisher.shared - - // MARK: - Initializer - init(viewModel: TextFieldComponentUIViewModel) { - self.viewModel = viewModel - self.textFieldComponentUIView = TextFieldComponentUIView(viewModel: viewModel) - super.init(nibName: nil, bundle: nil) - } - - // MARK: - Add Publishers - private func addPublisher() { - - self.themePublisher - .$theme - .sink { [weak self] theme in - guard let self = self else { return } - self.viewModel.theme = theme - self.navigationController?.navigationBar.tintColor = theme.colors.main.main.uiColor - } - .store(in: &self.cancellables) - - self.viewModel.showThemeSheet.subscribe(in: &self.cancellables) { intents in - self.presentThemeActionSheet(intents) - } - - self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in - self.presentIntentActionSheet(intents) - } - - self.viewModel.showRightViewModeSheet.subscribe(in: &self.cancellables) { viewMode in - self.presentRightViewModeActionSheet(viewMode) - } - - self.viewModel.showLeftViewModeSheet.subscribe(in: &self.cancellables) { viewMode in - self.presentLeftViewModeActionSheet(viewMode) - } - - self.viewModel.showLeadingAddOnSheet.subscribe(in: &self.cancellables) { addOnOption in - self.presentLeadingAddOnOptionSheet(addOnOption) - } - - self.viewModel.showTrailingAddOnSheet.subscribe(in: &self.cancellables) { addOnOption in - self.presentTrailingAddOnOptionSheet(addOnOption) - } - - self.viewModel.showClearButtonModeSheet.subscribe(in: &self.cancellables) { viewMode in - self.presentClearButtonModeActionSheet(viewMode) - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - ViewDidLoad - override func viewDidLoad() { - super.viewDidLoad() - self.setupView() - self.view.backgroundColor = .systemBackground - self.navigationItem.title = "TextField" - self.addPublisher() - } - - private func setupView() { - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - self.view.addSubview(scrollView) - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) - ]) - - textFieldComponentUIView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(textFieldComponentUIView) - NSLayoutConstraint.activate([ - textFieldComponentUIView.topAnchor.constraint(equalTo: scrollView.topAnchor), - textFieldComponentUIView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - textFieldComponentUIView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - textFieldComponentUIView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - textFieldComponentUIView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), - textFieldComponentUIView.heightAnchor.constraint(equalTo: scrollView.heightAnchor, constant: 1000) - ]) - } - -} - -extension TextFieldComponentUIViewController { - - static func build() -> TextFieldComponentUIViewController { - let viewModel = TextFieldComponentUIViewModel(theme: SparkThemePublisher.shared.theme) - let viewController = TextFieldComponentUIViewController(viewModel: viewModel) - return viewController - } -} - -// MARK: - Navigation -extension TextFieldComponentUIViewController { - - private func presentThemeActionSheet(_ themes: [ThemeCellModel]) { - let actionSheet = SparkActionSheet.init( - values: themes.map { $0.theme }, - texts: themes.map { $0.title }) { theme in - self.themePublisher.theme = theme - } - self.present(actionSheet, isAnimated: true) - } - - private func presentIntentActionSheet(_ intents: [TextFieldIntent]) { - let actionSheet = SparkActionSheet.init( - values: intents, - texts: intents.map { $0.name }) { intent in - self.viewModel.intent = intent - } - self.present(actionSheet, isAnimated: true) - } - - private func presentLeftViewModeActionSheet(_ viewModes: [ViewMode]) { - let actionSheet = SparkActionSheet.init(values: viewModes, - texts: viewModes.map{ $0.name }) { viewMode in - self.viewModel.leftViewMode = viewMode - } - self.present(actionSheet, isAnimated: true) - } - - private func presentRightViewModeActionSheet(_ viewModes: [ViewMode]) { - let actionSheet = SparkActionSheet.init(values: viewModes, - texts: viewModes.map{ $0.name }) { viewMode in - self.viewModel.rightViewMode = viewMode - } - self.present(actionSheet, isAnimated: true) - } - - private func presentLeadingAddOnOptionSheet(_ addOnOptions: [AddOnOption]) { - let actionSheet = SparkActionSheet.init( - values: addOnOptions, - texts: addOnOptions.map { $0.name }) { addOnOption in - self.viewModel.leadingAddOnOption = addOnOption - } - self.present(actionSheet, isAnimated: true) - } - - private func presentTrailingAddOnOptionSheet(_ addOnOptions: [AddOnOption]) { - let actionSheet = SparkActionSheet.init( - values: addOnOptions, - texts: addOnOptions.map { $0.name }) { addOnOption in - self.viewModel.trailingAddOnOption = addOnOption - } - self.present(actionSheet, isAnimated: true) - } - - private func presentClearButtonModeActionSheet(_ viewModes: [ViewMode]) { - let actionSheet = SparkActionSheet.init(values: viewModes, - texts: viewModes.map{ $0.name }) { viewMode in - self.viewModel.clearButtonMode = viewMode - } - self.present(actionSheet, isAnimated: true) - } - -} diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift deleted file mode 100644 index 16ad446aa..000000000 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// TextFieldComponentUIViewModel.swift -// SparkDemo -// -// Created by Quentin.richard on 14/09/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Combine -import Spark -import SparkCore -import UIKit - -final class TextFieldComponentUIViewModel: ObservableObject { - // MARK: - Published Properties - var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { - showThemeSheetSubject - .eraseToAnyPublisher() - } - - var showIntentSheet: AnyPublisher<[TextFieldIntent], Never> { - showIntentSheetSubject - .eraseToAnyPublisher() - } - - var showRightViewModeSheet: AnyPublisher<[ViewMode], Never> { - showRightViewModeSheetSubject - .eraseToAnyPublisher() - } - - var showLeftViewModeSheet: AnyPublisher<[ViewMode], Never> { - showLeftViewModeSheetSubject - .eraseToAnyPublisher() - } - - var showLeadingAddOnSheet: AnyPublisher<[AddOnOption], Never> { - showLeadingAddOnSheetSubject - .eraseToAnyPublisher() - } - - var showTrailingAddOnSheet: AnyPublisher<[AddOnOption], Never> { - showTrailingAddOnSheetSubject - .eraseToAnyPublisher() - } - - var showClearButtonModeSheet: AnyPublisher<[ViewMode], Never> { - showClearButtonModeSheetSubject - .eraseToAnyPublisher() - } - - let themes = ThemeCellModel.themes - - // MARK: - Private Properties - private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() - private var showIntentSheetSubject: PassthroughSubject<[TextFieldIntent], Never> = .init() - private var showLeftViewModeSheetSubject: PassthroughSubject<[ViewMode], Never> = .init() - private var showRightViewModeSheetSubject: PassthroughSubject<[ViewMode], Never> = .init() - private var showLeadingAddOnSheetSubject: PassthroughSubject<[AddOnOption], Never> = .init() - private var showTrailingAddOnSheetSubject: PassthroughSubject<[AddOnOption], Never> = .init() - private var showClearButtonModeSheetSubject: PassthroughSubject<[ViewMode], Never> = .init() - - // MARK: - Initialization - @Published var theme: Theme - @Published var intent: TextFieldIntent - @Published var leftViewMode: ViewMode - @Published var rightViewMode: ViewMode - @Published var leadingAddOnOption: AddOnOption - @Published var trailingAddOnOption: AddOnOption - @Published var clearButtonMode: ViewMode - @Published var text: String? - @Published var icon: UIImage? - @Published var component: UIView? - @Published var action: (()->Void)? - - init( - theme: Theme, - intent: TextFieldIntent = .neutral, - leftViewMode: ViewMode = .never, - rigthViewMode: ViewMode = .never, - leadingAddOnOption: AddOnOption = .none, - trailingAddOnOption: AddOnOption = .none, - clearButtonMode: ViewMode = .never, - text: String? = "Label", - icon: UIImage? = UIImage(imageLiteralResourceName: "alert"), - component: UIView? = nil, - action: (()->Void)? = nil - ) { - self.theme = theme - self.intent = intent - self.text = text - self.icon = icon - self.component = component - self.action = action - self.leftViewMode = leftViewMode - self.rightViewMode = rigthViewMode - self.leadingAddOnOption = leadingAddOnOption - self.trailingAddOnOption = trailingAddOnOption - self.clearButtonMode = clearButtonMode - } -} - -// MARK: - Navigation -extension TextFieldComponentUIViewModel { - - @objc func presentThemeSheet() { - self.showThemeSheetSubject.send(themes) - } - - @objc func presentIntentSheet() { - self.showIntentSheetSubject.send(TextFieldIntent.allCases) - } - - @objc func presentLeftViewModeSheet() { - self.showLeftViewModeSheetSubject.send(ViewMode.allCases) - } - - @objc func presentRightViewModeSheet() { - self.showRightViewModeSheetSubject.send(ViewMode.allCases) - } - - @objc func presentLeadingAddOnSheet() { - self.showLeadingAddOnSheetSubject.send(AddOnOption.allCases) - } - - @objc func presentTrailingAddOnSheet() { - self.showTrailingAddOnSheetSubject.send(AddOnOption.allCases) - } - - @objc func buttonTapped(_ sender: UIButton) { - sender.imageView?.transform = sender.imageView?.transform.rotated(by: CGFloat(Double.pi / 2)) ?? CGAffineTransform() - } - - @objc func presetClearButtonModeSheet() { - self.showClearButtonModeSheetSubject.send(ViewMode.allCases) - } -} - -public enum ViewMode: Int, CaseIterable { - case never = 0 - - case whileEditing = 1 - - case unlessEditing = 2 - - case always = 3 -} - -public enum AddOnOption: CaseIterable { - case none - case button - case shortText - case longText -} diff --git a/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldCell.swift b/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldCell.swift deleted file mode 100644 index 63681e0de..000000000 --- a/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldCell.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// AddOnTextFieldCell.swift -// SparkDemo -// -// Created by alican.aycil on 21.12.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import SparkCore - -final class AddOnTextFieldCell: UITableViewCell, Configurable { - - typealias CellConfigartion = AddOnTextFieldConfiguration - typealias Component = AddOnTextFieldUIView - - lazy var component: AddOnTextFieldUIView = { - let view = AddOnTextFieldUIView( - theme: SparkTheme.shared, - intent: .neutral, - leadingAddOn: nil, - trailingAddOn: nil - ) - view.textField.leftView = UIImageView(image: UIImage(systemName: "rectangle.lefthalf.filled")) - view.textField.rightView = UIImageView(image: UIImage(systemName: "square.rightthird.inset.filled")) - return view - }() - - var stackViewAlignment: UIStackView.Alignment { - return .fill - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configureCell(configuration: CellConfigartion) { - self.component.theme = configuration.theme - self.component.intent = configuration.intent - self.component.textField.leftViewMode = .init(rawValue: configuration.leftViewMode.rawValue) ?? .never - self.component.textField.rightViewMode = .init(rawValue: configuration.rightViewMode.rawValue) ?? .never - self.component.textField.clearButtonMode = .init(rawValue: configuration.clearButtonMode.rawValue) ?? .never - self.component.leadingAddOn = createAddOnView(option: configuration.leadingAddOnOption) - self.component.trailingAddOn = createAddOnView(option: configuration.trailingAddOnOption) - self.component.textField.text = configuration.text - } -} - -// MARK: - Helper -extension AddOnTextFieldCell { - - func createAddOnView(option: AddOnOption) -> UIView? { - switch option { - case .button: - return self.createButtonAddOn() - case .shortText: - return self.createTextAddOn(text: "short") - case .longText: - return self.createTextAddOn(text: "very long text") - case .none: - return nil - } - } - - func createButtonAddOn() -> UIButton { - let button = UIButton() - button.translatesAutoresizingMaskIntoConstraints = false - var buttonConfig = UIButton.Configuration.plain() - buttonConfig.image = UIImage( - systemName: "square.and.arrow.up", - withConfiguration: UIImage.SymbolConfiguration(scale: .small) - ) - button.configuration = buttonConfig - button.widthAnchor.constraint(equalToConstant: button.intrinsicContentSize.width).isActive = true - return button - } - - private func createTextAddOn(text: String) -> UIView { - let container = UIView() - let label = UILabel() - container.translatesAutoresizingMaskIntoConstraints = false - label.translatesAutoresizingMaskIntoConstraints = false - label.text = text - container.addSubview(label) - NSLayoutConstraint.activate([ - container.widthAnchor.constraint(equalToConstant: label.intrinsicContentSize.width + 32), - label.centerXAnchor.constraint(equalTo: container.centerXAnchor), - label.centerYAnchor.constraint(equalTo: container.centerYAnchor) - ]) - label.textColor = .black - return container - } -} diff --git a/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldConfiguration.swift b/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldConfiguration.swift deleted file mode 100644 index 092535bbc..000000000 --- a/spark/Demo/Classes/View/ListView/Cells/AddOnTextFieldCell/AddOnTextFieldConfiguration.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AddOnTextFieldConfiguration.swift -// SparkDemo -// -// Created by alican.aycil on 21.12.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import SparkCore - -struct AddOnTextFieldConfiguration: ComponentConfiguration { - var theme: Theme - var intent: TextFieldIntent - var leftViewMode: ViewMode - var rightViewMode: ViewMode - var leadingAddOnOption: AddOnOption - var trailingAddOnOption: AddOnOption - var clearButtonMode: ViewMode - var text: String? - var icon: UIImage? -} diff --git a/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldCell.swift b/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldCell.swift deleted file mode 100644 index 3053825c4..000000000 --- a/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// TextFieldCell.swift -// SparkDemo -// -// Created by alican.aycil on 19.12.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import SparkCore - -final class TextFieldCell: UITableViewCell, Configurable { - - typealias CellConfigartion = TextFieldConfiguration - typealias Component = TextFieldUIView - - lazy var component: TextFieldUIView = { - let view = TextFieldUIView(theme: SparkTheme.shared) - view.leftView = UIImageView(image: UIImage(systemName: "rectangle.lefthalf.filled")) - view.rightView = UIImageView(image: UIImage(systemName: "square.rightthird.inset.filled")) - return view - }() - - var stackViewAlignment: UIStackView.Alignment { - return .fill - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configureCell(configuration: CellConfigartion) { - self.component.theme = configuration.theme - self.component.intent = configuration.intent - self.component.leftViewMode = .init(rawValue: configuration.leftViewMode.rawValue) ?? .never - self.component.rightViewMode = .init(rawValue: configuration.rightViewMode.rawValue) ?? .never - self.component.clearButtonMode = .init(rawValue: configuration.clearButtonMode.rawValue) ?? .never - self.component.text = configuration.text - } -} diff --git a/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldConfiguration.swift b/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldConfiguration.swift deleted file mode 100644 index 18c9e2517..000000000 --- a/spark/Demo/Classes/View/ListView/Cells/TextFieldCell/TextFieldConfiguration.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// TextFieldConfiguration.swift -// SparkDemo -// -// Created by alican.aycil on 19.12.23. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import UIKit -import SparkCore - -struct TextFieldConfiguration: ComponentConfiguration { - var theme: Theme - var intent: TextFieldIntent - var leftViewMode: ViewMode - var rightViewMode: ViewMode - var clearButtonMode: ViewMode - var text: String? -} diff --git a/spark/Demo/Classes/View/ListView/ListComponentsViewController.swift b/spark/Demo/Classes/View/ListView/ListComponentsViewController.swift index 7ac8b27af..0ed21fc56 100644 --- a/spark/Demo/Classes/View/ListView/ListComponentsViewController.swift +++ b/spark/Demo/Classes/View/ListView/ListComponentsViewController.swift @@ -22,7 +22,6 @@ final class ListComponentsViewController: UICollectionViewController { var all = UIComponent.allCases all.insert(.checkboxGroup, at: 3) all.insert(.radioButtonGroup, at: 9) - all.append(.addOnTextField) return all } @@ -125,10 +124,6 @@ extension ListComponentsViewController { return ListViewController() case .tag: return ListViewController() - case .textField: - return ListViewController() - case .addOnTextField: - return ListViewController() default: return nil } @@ -163,5 +158,4 @@ private extension ListComponentsViewController { private extension UIComponent { static let checkboxGroup = UIComponent(rawValue: "Checkbox Group") static let radioButtonGroup = UIComponent(rawValue: "Radio Button Group") - static let addOnTextField = UIComponent(rawValue: "Add-on Text Field") } diff --git a/spark/Demo/Classes/View/ListView/ListViewController.swift b/spark/Demo/Classes/View/ListView/ListViewController.swift index 9b95d9db2..e3edb36e9 100644 --- a/spark/Demo/Classes/View/ListView/ListViewController.swift +++ b/spark/Demo/Classes/View/ListView/ListViewController.swift @@ -83,12 +83,6 @@ final class ListViewController: NSObject, } return UITableViewCell() - /// Text Field - case let textFieldConfiguration as TextFieldConfiguration: - if let cell = tableView.dequeueReusableCell(withIdentifier: TextFieldCell.reuseIdentifier, for: indexPath) as? TextFieldCell { - cell.configureCell(configuration: textFieldConfiguration) - return cell - } - return UITableViewCell() - - /// Add On Text Field - case let addOnTextFieldConfiguration as AddOnTextFieldConfiguration: - if let cell = tableView.dequeueReusableCell(withIdentifier: AddOnTextFieldCell.reuseIdentifier, for: indexPath) as? AddOnTextFieldCell { - cell.configureCell(configuration: addOnTextFieldConfiguration) - return cell - } - return UITableViewCell() - default: return UITableViewCell() } @@ -244,12 +228,6 @@ extension ListViewDataSource { case is TagConfiguration.Type: data = self.createTagConfigurations() - case is TextFieldConfiguration.Type: - data = self.createTextFieldConfigurations() - - case is AddOnTextFieldConfiguration.Type: - data = self.createAddOnTextFieldConfigurations() - default: break } @@ -381,17 +359,4 @@ extension ListViewDataSource { TagConfiguration(theme: SparkTheme.shared, intent: .success, variant: .tinted, content: .iconAndText)] } - /// Text Field - func createTextFieldConfigurations() -> [TextFieldConfiguration] { - [TextFieldConfiguration(theme: SparkTheme.shared, intent: .success, leftViewMode: .always, rightViewMode: .never, clearButtonMode: .whileEditing), - TextFieldConfiguration(theme: SparkTheme.shared, intent: .neutral, leftViewMode: .always, rightViewMode: .whileEditing, clearButtonMode: .whileEditing, text: "Hello world"), - TextFieldConfiguration(theme: SparkTheme.shared, intent: .error, leftViewMode: .always, rightViewMode: .always, clearButtonMode: .always)] - } - - /// Add On Text Field - func createAddOnTextFieldConfigurations() -> [AddOnTextFieldConfiguration] { - [AddOnTextFieldConfiguration(theme: SparkTheme.shared, intent: .success, leftViewMode: .always, rightViewMode: .never, leadingAddOnOption: .button, trailingAddOnOption: .none, clearButtonMode: .whileEditing), - AddOnTextFieldConfiguration(theme: SparkTheme.shared, intent: .neutral, leftViewMode: .always, rightViewMode: .whileEditing, leadingAddOnOption: .button, trailingAddOnOption: .shortText, clearButtonMode: .whileEditing, text: "Hello world"), - AddOnTextFieldConfiguration(theme: SparkTheme.shared, intent: .error, leftViewMode: .always, rightViewMode: .always, leadingAddOnOption: .button, trailingAddOnOption: .longText, clearButtonMode: .always)] - } } From 45fb41d8d33b6dd0460cdbb3dd7a66a4beb19bb7 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 26 Mar 2024 17:19:11 +0100 Subject: [PATCH 028/117] [TextField] Added TextFieldUIView --- .../TextFieldScenario+SnapshotTests.swift | 183 +++++++++ .../View/UIKit/TextFieldUIView.swift | 368 ++++++++++++++++++ .../UIKit/TextFieldUIViewSnapshotTests.swift | 131 +++++++ spark/Demo/Classes/Enum/UIComponent.swift | 2 + .../Components/ComponentsViewController.swift | 2 + .../TextField/TextFieldContentSide.swift | 14 + .../TextField/TextFieldSideViewContent.swift | 17 + .../UIKit/TextFieldComponentUIView.swift | 157 ++++++++ .../TextFieldComponentUIViewController.swift | 152 ++++++++ .../UIKit/TextFieldComponentUIViewModel.swift | 202 ++++++++++ 10 files changed, 1228 insertions(+) create mode 100644 core/Sources/Components/TextField/View/TextFieldScenario+SnapshotTests.swift create mode 100644 core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift create mode 100644 core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/TextFieldContentSide.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/TextFieldSideViewContent.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift diff --git a/core/Sources/Components/TextField/View/TextFieldScenario+SnapshotTests.swift b/core/Sources/Components/TextField/View/TextFieldScenario+SnapshotTests.swift new file mode 100644 index 000000000..c94ab10e1 --- /dev/null +++ b/core/Sources/Components/TextField/View/TextFieldScenario+SnapshotTests.swift @@ -0,0 +1,183 @@ +// +// TextFieldScenario+SnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by louis.borlee on 12/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore +import UIKit + +struct TextFieldScenario: CustomStringConvertible { + let description: String + let statesArray: [TextFieldScenarioStates] + let intents: [TextFieldIntent] + let texts: [TextFieldScenarioText] + let leftContents: [TextFieldScenarioSideContentOptionSet] + let rightContents: [TextFieldScenarioSideContentOptionSet] + let modes: [ComponentSnapshotTestMode] + let sizes: [UIContentSizeCategory] + + static let test1: TextFieldScenario = .init( + description: "Test1", + statesArray: [ + .disabled, + .focused, + .readOnly, + .`default`, + .readyOnlyAndDisabled + ], + intents: [.neutral], + texts: [.normal], + leftContents: [.none], + rightContents: [.none], + modes: ComponentSnapshotTestConstants.Modes.all, + sizes: ComponentSnapshotTestConstants.Sizes.default + ) + + static let test2: TextFieldScenario = .init( + description: "Test2", + statesArray: [.`default`], + intents: [.neutral], + texts: [.empty, .placeholder, .normal, .long], + leftContents: [.none], + rightContents: [.none], + modes: [.light], + sizes: ComponentSnapshotTestConstants.Sizes.default + ) + + static let test3: TextFieldScenario = .init( + description: "Test3", + statesArray: [.focused], + intents: [.success], + texts: [.normal], + leftContents: [[.button], [.image]], + rightContents: [[.button], [.image]], + modes: [.light], + sizes: [.extraSmall, .medium] + ) + + static let test4: TextFieldScenario = .init( + description: "Test4", + statesArray: [.disabled], + intents: [.error], + texts: [.placeholder, .long], + leftContents: [[.button, .image]], + rightContents: [[.button, .image]], + modes: [.light], + sizes: ComponentSnapshotTestConstants.Sizes.default + ) + + static let test5: TextFieldScenario = .init( + description: "Test5", + statesArray: [.`default`], + intents: TextFieldIntent.allCases, + texts: [.normal], + leftContents: [.none], + rightContents: [.none], + modes: ComponentSnapshotTestConstants.Modes.all, + sizes: [.medium, .accessibilityExtraExtraExtraLarge] + ) + + func getTestName(intent: TextFieldIntent, states: TextFieldScenarioStates, text: TextFieldScenarioText, leftContent: TextFieldScenarioSideContentOptionSet, rightContent: TextFieldScenarioSideContentOptionSet) -> String { + var testName = "\(self)-\(intent)Intent-\(states.name)State-\(text.rawValue)Text" + if leftContent.isEmpty == false { + testName.append("-left\(leftContent.name)") + } + if rightContent.isEmpty == false { + testName.append("-right\(rightContent.name)") + } + return testName + } +} + +struct TextFieldScenarioStates { + let isEnabled: Bool + let isFocused: Bool + let isReadOnly: Bool + let name: String + + static let disabled: TextFieldScenarioStates = .init( + isEnabled: false, + isFocused: false, + isReadOnly: false, + name: "disabled" + ) + static let readOnly: TextFieldScenarioStates = .init( + isEnabled: true, + isFocused: false, + isReadOnly: true, + name: "readOnly" + ) + static let focused: TextFieldScenarioStates = .init( + isEnabled: true, + isFocused: true, + isReadOnly: false, + name: "focused" + ) + static let `default`: TextFieldScenarioStates = .init( + isEnabled: true, + isFocused: false, + isReadOnly: false, + name: "default" + ) + static let readyOnlyAndDisabled: TextFieldScenarioStates = .init( + isEnabled: false, + isFocused: false, + isReadOnly: true, + name: "readyOnlyAndDisabled" + ) +} + +enum TextFieldScenarioText: String { + case empty + case placeholder + case normal + case long + + var placeholder: String? { + switch self { + case .empty: + return nil + default: + return "Placeholder" + } + } + + var text: String? { + switch self { + case .empty, .placeholder: + return nil + case .normal: + return "Hello there" + case .long: + return """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus ac faucibus metus. Praesent feugiat commodo nibh, at placerat enim pharetra ac. Integer sed dictum eros. + """ + } + } +} + +struct TextFieldScenarioSideContentOptionSet: OptionSet { + let rawValue: UInt + + static let none = TextFieldScenarioSideContentOptionSet(rawValue: 1 << 0) + static let button = TextFieldScenarioSideContentOptionSet(rawValue: 1 << 1) + static let image = TextFieldScenarioSideContentOptionSet(rawValue: 1 << 2) + + var name: String { + var contents = [String]() + if self.contains(.none) { + contents.append("None") + } + if self.contains(.button) { + contents.append("Button") + } + if self.contains(.image) { + contents.append("Image") + } + return contents.joined(separator: "_") + } +} diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift new file mode 100644 index 000000000..075a57671 --- /dev/null +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift @@ -0,0 +1,368 @@ +// +// TextFieldUIView.swift +// SparkCore +// +// Created by louis.borlee on 05/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine + +/// Spark TextField, subclasses UITextField +public final class TextFieldUIView: UITextField { + + private let viewModel: TextFieldViewModel + private var cancellables = Set() + + @ScaledUIMetric private var height: CGFloat = 44 + @ScaledUIMetric private var scaleFactor: CGFloat = 1.0 + + private var statusImageSize: CGFloat { + return 16 * self.scaleFactor + } + + private let defaultClearButtonRightSpacing = 5.0 + + public override var placeholder: String? { + didSet { + self.setPlaceholder(self.placeholder, foregroundColor: self.viewModel.placeholderColor, font: self.viewModel.font) + } + } + + public override var isEnabled: Bool { + didSet { + self.viewModel.isEnabled = self.isEnabled + } + } + + public override var isUserInteractionEnabled: Bool { + didSet { + self.viewModel.isUserInteractionEnabled = self.isUserInteractionEnabled + } + } + + public override var borderStyle: UITextField.BorderStyle { + @available(*, unavailable) + set {} + get { return .init(self.viewModel.borderStyle) } + } + + private var statusImageView = UIImageView() + private var statusImageHeightConstraint: NSLayoutConstraint = NSLayoutConstraint() + private var statusImageWidthConstraint: NSLayoutConstraint = NSLayoutConstraint() + private var statusImageContainerView = UIView() + private lazy var rightStackView: UIStackView = UIStackView() + private var userRightView: UIView? + public override var rightView: UIView? { + get { return self.userRightView } + set { + if let userRightView { + self.rightStackView.removeArrangedSubview(userRightView) + userRightView.removeFromSuperview() + } + if let newValue { + self.rightStackView.addArrangedSubview(newValue) + } + self.userRightView = newValue + self.setRightView() + } + } + + /// The textfield's current theme. + public var theme: Theme { + get { + return self.viewModel.theme + } + set { + self.viewModel.theme = newValue + } + } + /// The textfield's current intent. + public var intent: TextFieldIntent { + get { + return self.viewModel.intent + } + set { + self.viewModel.intent = newValue + } + } + + internal init(viewModel: TextFieldViewModel) { + self.viewModel = viewModel + super.init(frame: .init(origin: .zero, size: .init(width: 0, height: 44))) + self.adjustsFontForContentSizeCategory = true + self.adjustsFontSizeToFitWidth = false + self.setupView() + } + + internal convenience init( + theme: Theme, + intent: TextFieldIntent, + borderStyle: TextFieldBorderStyle, + successImage: UIImage, + alertImage: UIImage, + errorImage: UIImage + ) { + self.init( + viewModel: .init( + theme: theme, + intent: intent, + borderStyle: borderStyle, + successImage: .left(successImage), + alertImage: .left(alertImage), + errorImage: .left(errorImage) + ) + ) + } + + /// TextFieldUIView initializer + /// - Parameters: + /// - theme: The textfield's current theme + /// - intent: The textfield's current intent + /// - successImage: Success image, will be shown in the rightView when intent = .success + /// - alertImage: Alert image, will be shown in the rightView when intent = .alert + /// - errorImage: Error image, will be shown in the rightView when intent = .error + public convenience init( + theme: Theme, + intent: TextFieldIntent, + successImage: UIImage, + alertImage: UIImage, + errorImage: UIImage + ) { + self.init( + theme: theme, + intent: intent, + borderStyle: .roundedRect, + successImage: successImage, + alertImage: alertImage, + errorImage: errorImage + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + self.rightStackView.spacing = self.viewModel.contentSpacing + } + + private func setupView() { + self.setupRightStackView() + self.subscribeToViewModel() + self.setRightView() + self.setContentCompressionResistancePriority(.required, for: .vertical) + } + + private func subscribeToViewModel() { + self.viewModel.$textColor.subscribe(in: &self.cancellables) { [weak self] textColor in + guard let self else { return } + self.textColor = textColor.uiColor + self.tintColor = textColor.uiColor + } + + self.viewModel.$backgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in + guard let self else { return } + self.backgroundColor = backgroundColor.uiColor + } + + self.viewModel.$borderColor.subscribe(in: &self.cancellables) { [weak self] borderColor in + guard let self else { return } + self.setBorderColor(from: borderColor) + } + + self.viewModel.$statusIconColor.subscribe(in: &self.cancellables) { [weak self] statusIconColor in + guard let self else { return } + self.statusImageView.tintColor = statusIconColor.uiColor + } + + self.viewModel.$placeholderColor.subscribe(in: &self.cancellables) { [weak self] placeholderColor in + guard let self else { return } + self.setPlaceholder(self.placeholder, foregroundColor: placeholderColor, font: self.viewModel.font) + } + + self.viewModel.$borderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in + guard let self else { return } + self.setBorderWidth(borderWidth * self.scaleFactor) + } + + self.viewModel.$borderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in + guard let self else { return } + self.setCornerRadius(borderRadius) + } + + self.viewModel.$leftSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.setNeedsLayout() + } + + self.viewModel.$rightSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.setNeedsLayout() + } + + self.viewModel.$contentSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.setNeedsLayout() + } + + self.viewModel.$dim.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.alpha = dim + } + + self.viewModel.$font.subscribe(in: &self.cancellables) { [weak self] font in + guard let self else { return } + self.font = font.uiFont + self.setPlaceholder(self.placeholder, foregroundColor: self.viewModel.placeholderColor, font: font) + } + + self.viewModel.$statusImage.subscribe(in: &self.cancellables) { [weak self] statusImage in + guard let self else { return } + self.statusImageView.image = statusImage?.leftValue + self.statusImageContainerView.isHidden = self.statusImageView.image == nil + self.setRightView() + self.setNeedsLayout() + } + } + + private func setAttributedPlaceholder(string: String, foregroundColor: UIColor, font: UIFont) { + self.attributedPlaceholder = NSAttributedString( + string: string, + attributes: [ + NSAttributedString.Key.foregroundColor: foregroundColor, + NSAttributedString.Key.font: font + ] + ) + } + + private func setPlaceholder(_ placeholder: String?, foregroundColor: any ColorToken, font: TypographyFontToken) { + if let placeholder { + self.setAttributedPlaceholder(string: placeholder, foregroundColor: foregroundColor.uiColor, font: font.uiFont) + } else { + self.attributedPlaceholder = nil + } + } + + private func setupRightStackView() { + self.statusImageView.contentMode = .scaleAspectFit + self.statusImageView.clipsToBounds = true + self.statusImageContainerView.addSubview(self.statusImageView) + self.statusImageView.translatesAutoresizingMaskIntoConstraints = false + self.statusImageHeightConstraint = self.statusImageView.heightAnchor.constraint(equalToConstant: self.statusImageSize) + self.statusImageWidthConstraint = self.statusImageView.widthAnchor.constraint(equalToConstant: self.statusImageSize) + self.statusImageWidthConstraint.priority = UILayoutPriority.defaultHigh + self.statusImageHeightConstraint.priority = UILayoutPriority.defaultHigh + NSLayoutConstraint.activate([ + self.statusImageWidthConstraint, + self.statusImageHeightConstraint, + self.statusImageView.topAnchor.constraint(greaterThanOrEqualTo: self.statusImageContainerView.topAnchor), + self.statusImageView.leadingAnchor.constraint(equalTo: self.statusImageContainerView.leadingAnchor), + self.statusImageView.centerXAnchor.constraint(equalTo: self.statusImageContainerView.centerXAnchor), + self.statusImageView.centerYAnchor.constraint(equalTo: self.statusImageContainerView.centerYAnchor), + ]) + self.rightStackView.addArrangedSubview(self.statusImageContainerView) + self.rightStackView.alignment = .center + self.rightStackView.distribution = .fill + } + + private func setRightView() { + if self.statusImageContainerView.isHidden, + self.userRightView == nil { + super.rightView = nil + } else { + super.rightView = self.rightStackView + } + } + + public override func becomeFirstResponder() -> Bool { + let bool = super.becomeFirstResponder() + self.viewModel.isFocused = bool + return bool + } + + public override func resignFirstResponder() -> Bool { + super.resignFirstResponder() + self.viewModel.isFocused = false + return true + } + + // MARK: - Rects + private func setInsets(forBounds bounds: CGRect) -> CGRect { + var totalInsets = UIEdgeInsets( + top: 0, + left: self.viewModel.leftSpacing, + bottom: 0, + right: self.viewModel.rightSpacing + ) + let contentSpacing = self.viewModel.contentSpacing + if let leftView, + leftView.isDescendant(of: self) { + totalInsets.left += leftView.bounds.size.width + contentSpacing + } + if self.rightStackView.isDescendant(of: self) { + totalInsets.right += self.rightStackView.bounds.size.width + contentSpacing + } + if let clearButton = self.value(forKeyPath: "_clearButton") as? UIButton, + clearButton.isDescendant(of: self) { + totalInsets.right += clearButton.bounds.size.width + contentSpacing + } + return bounds.inset(by: totalInsets) + } + + public override func textRect(forBounds bounds: CGRect) -> CGRect { + return self.setInsets(forBounds: bounds) + } + + public override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + return self.setInsets(forBounds: bounds) + } + public override func editingRect(forBounds bounds: CGRect) -> CGRect { + return self.setInsets(forBounds: bounds) + } + + private func getClearButtonXOffset() -> CGFloat { + return -self.viewModel.rightSpacing + self.defaultClearButtonRightSpacing + } + + public override func clearButtonRect(forBounds bounds: CGRect) -> CGRect { + return super.clearButtonRect(forBounds: bounds) + .offsetBy(dx: self.getClearButtonXOffset(), dy: 0) + } + + public override func leftViewRect(forBounds bounds: CGRect) -> CGRect { + return super.leftViewRect(forBounds: bounds) + .offsetBy(dx: self.viewModel.leftSpacing, dy: 0) + } + + public override func rightViewRect(forBounds bounds: CGRect) -> CGRect { + return super.rightViewRect(forBounds: bounds) + .offsetBy(dx: -self.viewModel.rightSpacing, dy: 0) + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + self.setBorderColor(from: self.viewModel.borderColor) + } + + guard previousTraitCollection?.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory else { return } + + self._height.update(traitCollection: self.traitCollection) + self._scaleFactor.update(traitCollection: self.traitCollection) + self.statusImageWidthConstraint.constant = self.statusImageSize + self.statusImageHeightConstraint.constant = self.statusImageSize + self.setBorderWidth(self.viewModel.borderWidth * self.scaleFactor) + self.invalidateIntrinsicContentSize() + } + + public override var intrinsicContentSize: CGSize { + return .init( + width: super.intrinsicContentSize.width, + height: self.height + ) + } +} diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift new file mode 100644 index 000000000..9571e9219 --- /dev/null +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift @@ -0,0 +1,131 @@ +// +// TextFieldUIViewSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by louis.borlee on 12/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import UIKit +@testable import SparkCore + +final class TextFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { + + private let theme = SparkTheme.shared + private let successImage = UIImage(systemName: "checkmark") ?? UIImage() + private let alertImage = UIImage(systemName: "exclamationmark.triangle") ?? UIImage() + private let errorImage = UIImage(systemName: "exclamationmark.circle") ?? UIImage() + + private func _test(scenario: TextFieldScenario) { + let configurations = self.createConfigurations(from: scenario) + for configuration in configurations { + self.assertSnapshot(matching: configuration.view, modes: scenario.modes, sizes: scenario.sizes, testName: configuration.testName) + } + } + + func test1() { + self._test(scenario: TextFieldScenario.test1) + } + + func test2() { + self._test(scenario: TextFieldScenario.test2) + } + + func test3() { + self._test(scenario: TextFieldScenario.test3) + } + + func test4() { + self._test(scenario: TextFieldScenario.test4) + } + + func test5() { + self._test(scenario: TextFieldScenario.test5) + } + + private func createConfigurations(from scenario: TextFieldScenario) -> [(testName: String, view: UIView)] { + var configurations: [(testName: String, view: UIView)] = [] + for intent in scenario.intents { + for states in scenario.statesArray { + for text in scenario.texts { + for leftContent in scenario.leftContents { + for rightContent in scenario.rightContents { + let viewModel = TextFieldViewModel( + theme: self.theme, + intent: intent, + borderStyle: .roundedRect, + successImage: .left(self.successImage), + alertImage: .left(self.alertImage), + errorImage: .left(self.errorImage) + ) + viewModel.isEnabled = states.isEnabled + viewModel.isUserInteractionEnabled = states.isReadOnly != true + viewModel.isFocused = states.isFocused + let textField = TextFieldUIView(viewModel: viewModel) + textField.text = text.text + textField.placeholder = text.placeholder + textField.clearButtonMode = .always + textField.leftViewMode = .always + textField.rightViewMode = states.isFocused ? .never : .always + textField.leftView = self.getContentViews(from: leftContent) + textField.rightView = self.getContentViews(from: rightContent) + + let backgroundView = UIView() + backgroundView.backgroundColor = .systemBackground + backgroundView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + backgroundView.widthAnchor.constraint(equalToConstant: 300) + ]) + + backgroundView.addSubview(textField) + textField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.stickEdges(from: textField, to: backgroundView, insets: .init(all: 12)) + + let testName = scenario.getTestName( + intent: intent, + states: states, + text: text, + leftContent: + leftContent, + rightContent: rightContent) + configurations.append((testName: testName, view: backgroundView)) + } + } + } + } + } + return configurations + } + + private func getContentViews(from optionSet: TextFieldScenarioSideContentOptionSet) -> UIView? { + var contentViews: [UIView] = [] + if optionSet.contains(.button) { + contentViews.append(self.createButton()) + } + if optionSet.contains(.image) { + contentViews.append(self.createImage()) + } + guard contentViews.isEmpty == false else { return nil } + if contentViews.count == 1 { + return contentViews.first + } + let stackView = UIStackView(arrangedSubviews: contentViews) + stackView.axis = .horizontal + stackView.spacing = 4 + return stackView + } + + private func createButton() -> UIButton { + let button = UIButton(configuration: .filled()) + button.setTitle("Button", for: .normal) + return button + } + + private func createImage() -> UIImageView { + let imageView = UIImageView(image: .init(systemName: "eject.circle.fill")) + imageView.adjustsImageSizeForAccessibilityContentSizeCategory = true + imageView.contentMode = .scaleAspectFit + return imageView + } +} diff --git a/spark/Demo/Classes/Enum/UIComponent.swift b/spark/Demo/Classes/Enum/UIComponent.swift index 7c8a7037c..7c6691194 100644 --- a/spark/Demo/Classes/Enum/UIComponent.swift +++ b/spark/Demo/Classes/Enum/UIComponent.swift @@ -29,6 +29,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { .switchButton, .tab, .tag, + .textField, .textLink ] @@ -52,5 +53,6 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { static let switchButton = UIComponent(rawValue: "Switch Button") static let tab = UIComponent(rawValue: "Tab") static let tag = UIComponent(rawValue: "Tag") + static let textField = UIComponent(rawValue: "TextField") static let textLink = UIComponent(rawValue: "TextLink") } diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index 371881dfb..f923439ec 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -108,6 +108,8 @@ extension ComponentsViewController { viewController = TabComponentUIViewController.build() case .tag: viewController = TagComponentUIViewController.build() + case .textField: + viewController = TextFieldComponentUIViewController.build() case .textLink: viewController = TextLinkComponentUIViewController.build() default: diff --git a/spark/Demo/Classes/View/Components/TextField/TextFieldContentSide.swift b/spark/Demo/Classes/View/Components/TextField/TextFieldContentSide.swift new file mode 100644 index 000000000..0e9f316ef --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/TextFieldContentSide.swift @@ -0,0 +1,14 @@ +// +// TextFieldContentSide.swift +// SparkDemo +// +// Created by louis.borlee on 12/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +enum TextFieldContentSide: String { + case left + case right +} diff --git a/spark/Demo/Classes/View/Components/TextField/TextFieldSideViewContent.swift b/spark/Demo/Classes/View/Components/TextField/TextFieldSideViewContent.swift new file mode 100644 index 000000000..99a5f2126 --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/TextFieldSideViewContent.swift @@ -0,0 +1,17 @@ +// +// TextFieldSideViewContent.swift +// SparkDemo +// +// Created by louis.borlee on 08/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +enum TextFieldSideViewContent: CaseIterable { + case none + case button + case image + case text + case all +} diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift new file mode 100644 index 000000000..fbe749a07 --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift @@ -0,0 +1,157 @@ +// +// TextFieldComponentUIView.swift +// SparkDemo +// +// Created by louis.borlee on 24/01/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Combine +import UIKit +import SparkCore + +// swiftlint:disable no_debugging_method +final class TextFieldComponentUIView: ComponentUIView { + + private let viewModel: TextFieldComponentUIViewModel + private let textField: TextFieldUIView + private var cancellables: Set = [] + + init(viewModel: TextFieldComponentUIViewModel) { + self.viewModel = viewModel + + self.textField = .init( + theme: viewModel.theme, + intent: viewModel.intent, + successImage: .init(named: "check") ?? UIImage(), + alertImage: .init(named: "alert") ?? UIImage(), + errorImage: .init(named: "alert-circle") ?? UIImage() + ) + + super.init(viewModel: viewModel, componentView: self.textField) + + self.textField.placeholder = "Placeholder" + self.textField.delegate = self + self.setupSubscriptions() + } + + func setupSubscriptions() { + self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in + guard let self else { return } + let themes = self.viewModel.themes + let themeTitle: String? = theme is SparkTheme ? themes.first?.title : themes.last?.title + + self.viewModel.themeConfigurationItemViewModel.buttonTitle = themeTitle + self.viewModel.configurationViewModel.update(theme: theme) + self.textField.theme = theme + } + + self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + guard let self else { return } + self.viewModel.intentConfigurationItemViewModel.buttonTitle = intent.name + self.textField.intent = intent + } + + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in + guard let self = self else { return } + self.viewModel.isEnabledConfigurationItemViewModel.isOn = isEnabled + self.textField.isEnabled = isEnabled + } + + self.viewModel.$isUserInteractionEnabled.subscribe(in: &self.cancellables) { [weak self] isUserInteractionEnabled in + guard let self = self else { return } + self.viewModel.isUserInteractionEnabledConfigurationItemViewModel.isOn = isUserInteractionEnabled + self.textField.isUserInteractionEnabled = isUserInteractionEnabled + } + + self.viewModel.$clearButtonMode.subscribe(in: &self.cancellables) { [weak self] viewMode in + guard let self else { return } + self.viewModel.clearButtonModeConfigurationItemViewModel.buttonTitle = viewMode.name + self.textField.clearButtonMode = viewMode + } + + self.viewModel.$leftViewMode.subscribe(in: &self.cancellables) { [weak self] viewMode in + guard let self else { return } + self.viewModel.leftViewModeConfigurationItemViewModel.buttonTitle = viewMode.name + self.textField.leftViewMode = viewMode + } + + self.viewModel.$rightViewMode.subscribe(in: &self.cancellables) { [weak self] viewMode in + guard let self else { return } + self.viewModel.rightViewModeConfigurationItemViewModel.buttonTitle = viewMode.name + self.textField.rightViewMode = viewMode + } + + self.viewModel.$leftViewContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.leftViewContentConfigurationItemViewModel.buttonTitle = content.name + self.textField.leftView = self.getContentView(from: content, side: .left) + } + + self.viewModel.$rightViewContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.rightViewContentConfigurationItemViewModel.buttonTitle = content.name + self.textField.rightView = self.getContentView(from: content, side: .right) + } + } + + private func getContentView(from content: TextFieldSideViewContent, side: TextFieldContentSide) -> UIView? { + switch content { + case .button: + return self.createButton(side: side) + case .image: + return self.createImage(side: side) + case .text: + return self.createText(side: side) + case .all: + let stackView = UIStackView(arrangedSubviews: [ + self.createButton(side: side), + self.createImage(side: side), + self.createText(side: side) + ]) + stackView.spacing = 4 + stackView.axis = .horizontal + return stackView + case .none: return nil + } + } + + private func createButton(side: TextFieldContentSide) -> UIView { + let button = ButtonUIView( + theme: self.viewModel.theme, + intent: side == .right ? .info : .alert, + variant: .tinted, + size: .small, + shape: .pill, + alignment: .trailingImage) + if side == .left { + button.setImage(.init(systemName: "pencil"), for: .normal) + } else { + button.setTitle("This is a long text", for: .normal) + } + return button + } + + private func createImage(side: TextFieldContentSide) -> UIImageView { + let imageView = UIImageView(image: .init(systemName: side == .left ? "power" : "eject.circle.fill")) + imageView.contentMode = .scaleAspectFit + return imageView + } + + private func createText(side: TextFieldContentSide) -> UILabel { + let label = UILabel() + label.text = side.rawValue + return label + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension TextFieldComponentUIView: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift new file mode 100644 index 000000000..42007c1ba --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift @@ -0,0 +1,152 @@ +// +// TextFieldComponentUIViewController.swift +// SparkDemo +// +// Created by louis.borlee on 24/01/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine +import SwiftUI +import SparkCore + +final class TextFieldComponentUIViewController: UIViewController { + + // MARK: - Properties + let componentView: TextFieldComponentUIView + let viewModel: TextFieldComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Published Properties + @ObservedObject private var themePublisher = SparkThemePublisher.shared + + // MARK: - Initializer + init(viewModel: TextFieldComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = TextFieldComponentUIView(viewModel: viewModel) + super.init(nibName: nil, bundle: nil) + self.componentView.viewController = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + view = self.componentView + } + + // MARK: - ViewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + self.navigationItem.title = "TextField " + self.setupSubscriptions() + } + + private func setupSubscriptions() { + self.themePublisher + .$theme + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.theme = theme + self.navigationController?.navigationBar.tintColor = theme.colors.main.main.uiColor + } + .store(in: &self.cancellables) + + self.viewModel.showThemeSheet.subscribe(in: &self.cancellables) { theme in + self.presentThemeActionSheet(theme) + } + + self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in + self.presentIntentActionSheet(intents) + } + + self.viewModel.showClearButtonModeSheet.subscribe(in: &self.cancellables) { viewModes in + self.presentViewModeActionSheet(viewModes) { viewMode in + self.viewModel.clearButtonMode = viewMode + } + } + + self.viewModel.showLeftViewModeSheet.subscribe(in: &self.cancellables) { viewModes in + self.presentViewModeActionSheet(viewModes) { viewMode in + self.viewModel.leftViewMode = viewMode + } + } + + self.viewModel.showRightViewModeSheet.subscribe(in: &self.cancellables) { viewModes in + self.presentViewModeActionSheet(viewModes) { viewMode in + self.viewModel.rightViewMode = viewMode + } + } + + self.viewModel.showLeftViewContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentSideViewContentActionSheet(contents) { content in + self.viewModel.leftViewContent = content + } + } + + self.viewModel.showRightViewContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentSideViewContentActionSheet(contents) { content in + self.viewModel.rightViewContent = content + } + } + } + + private func presentThemeActionSheet(_ themes: [ThemeCellModel]) { + let actionSheet = SparkActionSheet.init( + values: themes.map { $0.theme }, + texts: themes.map { $0.title }) { theme in + self.themePublisher.theme = theme + } + self.present(actionSheet, animated: true) + } + + private func presentIntentActionSheet(_ intents: [TextFieldIntent]) { + let actionSheet = SparkActionSheet.init( + values: intents, + texts: intents.map { $0.name }) { intent in + self.viewModel.intent = intent + } + self.present(actionSheet, animated: true) + } + + private func presentViewModeActionSheet(_ viewModes: [UITextField.ViewMode], completion: @escaping (UITextField.ViewMode) -> Void) { + let actionSheet = SparkActionSheet.init( + values: viewModes, + texts: viewModes.map { $0.description }, + completion: completion) + self.present(actionSheet, animated: true) + } + + private func presentSideViewContentActionSheet(_ contents: [TextFieldSideViewContent], completion: @escaping (TextFieldSideViewContent) -> Void) { + let actionSheet = SparkActionSheet.init( + values: contents, + texts: contents.map { $0.name }, + completion: completion) + self.present(actionSheet, animated: true) + } +} + +extension TextFieldComponentUIViewController { + static func build() -> TextFieldComponentUIViewController { + let viewModel = TextFieldComponentUIViewModel(theme: SparkThemePublisher.shared.theme) + let viewController = TextFieldComponentUIViewController(viewModel: viewModel) + return viewController + } +} + +extension UITextField.ViewMode: CustomStringConvertible { + public var description: String { + switch self { + case .always: return "always" + case .never: return "never" + case .unlessEditing: return "unlessEditing" + case .whileEditing: return "whileEditing" + @unknown default: + fatalError() + } + } +} diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift new file mode 100644 index 000000000..1e11e3e5e --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift @@ -0,0 +1,202 @@ +// +// TextFieldComponentUIViewModel.swift +// SparkDemo +// +// Created by louis.borlee on 24/01/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine +import SparkCore + +final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObject { + + @Published var theme: Theme + @Published var intent: TextFieldIntent + @Published var isEnabled: Bool = true + @Published var isUserInteractionEnabled: Bool = true + @Published var leftViewMode: UITextField.ViewMode = .always + @Published var rightViewMode: UITextField.ViewMode = .always + @Published var leftViewContent: TextFieldSideViewContent = .none + @Published var rightViewContent: TextFieldSideViewContent = .none + @Published var clearButtonMode: UITextField.ViewMode = .always + + // MARK: - Published Properties + var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { + self.showThemeSheetSubject + .eraseToAnyPublisher() + } + var showIntentSheet: AnyPublisher<[TextFieldIntent], Never> { + self.showIntentSheetSubject + .eraseToAnyPublisher() + } + var showClearButtonModeSheet: AnyPublisher<[UITextField.ViewMode], Never> { + self.showClearButtonModeSheetSubject + .eraseToAnyPublisher() + } + var showLeftViewModeSheet: AnyPublisher<[UITextField.ViewMode], Never> { + self.showLeftViewModeSheetSubject + .eraseToAnyPublisher() + } + var showRightViewModeSheet: AnyPublisher<[UITextField.ViewMode], Never> { + self.showRightViewModeSheetSubject + .eraseToAnyPublisher() + } + var showLeftViewContentSheet: AnyPublisher<[TextFieldSideViewContent], Never> { + self.showLeftViewContentSheetSubject + .eraseToAnyPublisher() + } + var showRightViewContentSheet: AnyPublisher<[TextFieldSideViewContent], Never> { + self.showRightViewContentSheetSubject + .eraseToAnyPublisher() + } + + let themes = ThemeCellModel.themes + + lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Theme", + type: .button, + target: (source: self, action: #selector(self.presentThemeSheet)) + ) + }() + + lazy var intentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Intent", + type: .button, + target: (source: self, action: #selector(self.presentIntentSheet)) + ) + }() + + lazy var isEnabledConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "IsEnabled", + type: .toggle(isOn: self.isEnabled), + target: (source: self, action: #selector(self.toggleIsEnabled)) + ) + }() + + lazy var isUserInteractionEnabledConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "IsUserInteractionEnabled", + type: .toggle(isOn: self.isUserInteractionEnabled), + target: (source: self, action: #selector(self.toggleIsUserInteractionEnabled)) + ) + }() + + lazy var clearButtonModeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "ClearButtonMode", + type: .button, + target: (source: self, action: #selector(self.presentClearButtonMode)) + ) + }() + + lazy var leftViewModeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "LeftViewMode", + type: .button, + target: (source: self, action: #selector(self.presentLeftViewMode)) + ) + }() + lazy var rightViewModeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RightViewMode", + type: .button, + target: (source: self, action: #selector(self.presentRightViewMode)) + ) + }() + + lazy var leftViewContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "LeftViewContent", + type: .button, + target: (source: self, action: #selector(self.presentLeftViewContent)) + ) + }() + lazy var rightViewContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RightViewContent", + type: .button, + target: (source: self, action: #selector(self.presentRightViewContent)) + ) + }() + + private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() + private var showIntentSheetSubject: PassthroughSubject<[TextFieldIntent], Never> = .init() + private var showClearButtonModeSheetSubject: PassthroughSubject<[UITextField.ViewMode], Never> = .init() + private var showLeftViewModeSheetSubject: PassthroughSubject<[UITextField.ViewMode], Never> = .init() + private var showRightViewModeSheetSubject: PassthroughSubject<[UITextField.ViewMode], Never> = .init() + private var showLeftViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() + private var showRightViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() + + init( + theme: Theme, + intent: TextFieldIntent = .neutral + ) { + self.theme = theme + self.intent = intent + super.init(identifier: "TextField") + } + + override func configurationItemsViewModel() -> [ComponentsConfigurationItemUIViewModel] { + return [ + self.themeConfigurationItemViewModel, + self.intentConfigurationItemViewModel, + self.isEnabledConfigurationItemViewModel, + self.isUserInteractionEnabledConfigurationItemViewModel, + self.clearButtonModeConfigurationItemViewModel, + self.leftViewModeConfigurationItemViewModel, + self.rightViewModeConfigurationItemViewModel, + self.leftViewContentConfigurationItemViewModel, + self.rightViewContentConfigurationItemViewModel + ] + } + + @objc func presentThemeSheet() { + self.showThemeSheetSubject.send(self.themes) + } + + @objc func presentIntentSheet() { + self.showIntentSheetSubject.send(TextFieldIntent.allCases) + } + + @objc func toggleIsEnabled() { + self.isEnabled.toggle() + } + + @objc func toggleIsUserInteractionEnabled() { + self.isUserInteractionEnabled.toggle() + } + + @objc func presentClearButtonMode() { + self.showClearButtonModeSheetSubject.send(UITextField.ViewMode.allCases) + } + + @objc func presentLeftViewMode() { + self.showLeftViewModeSheetSubject.send(UITextField.ViewMode.allCases) + } + + @objc func presentRightViewMode() { + self.showRightViewModeSheetSubject.send(UITextField.ViewMode.allCases) + } + + @objc func presentLeftViewContent() { + self.showLeftViewContentSheetSubject.send(TextFieldSideViewContent.allCases) + } + + @objc func presentRightViewContent() { + self.showRightViewContentSheetSubject.send(TextFieldSideViewContent.allCases) + } +} + +extension UITextField.ViewMode: CaseIterable { + public static var allCases: [UITextField.ViewMode] = [ + .never, + .whileEditing, + .unlessEditing, + .always + ] +} From 757b0afe946eebe8b21c4cb342dc1c30397d9c4c Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 09:42:37 +0100 Subject: [PATCH 029/117] [TextField] Add TextfieldView --- .../View/SwiftUI/TextFieldView.swift | 145 +++++++++++++++++ .../View/Components/ComponentsView.swift | 4 + .../SwiftUI/TextFieldComponentView.swift | 148 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift new file mode 100644 index 000000000..3a4edb1a5 --- /dev/null +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -0,0 +1,145 @@ +// +// TextFieldView.swift +// SparkCore +// +// Created by louis.borlee on 07/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +public struct TextFieldView: View { + + @ScaledMetric var height: CGFloat = 44 + @ScaledMetric private var imageSize: CGFloat = 16 + @ScaledMetric private var scaleFactor: CGFloat = 1.0 + + @FocusState private var isFocused: Bool + @ObservedObject var viewModel: TextFieldViewModel + + private let titleKey: LocalizedStringKey + @Binding private var text: String + private var isSecure: Bool + + private let leftView: () -> LeftView + private let rightView: () -> RightView + + init(titleKey: LocalizedStringKey, + text: Binding, + viewModel: TextFieldViewModel, + isSecure: Bool, + leftView: @escaping (() -> LeftView), + rightView: @escaping (() -> RightView)) { + self.titleKey = titleKey + self._text = text + self.viewModel = viewModel + self.isSecure = isSecure + self.leftView = leftView + self.rightView = rightView + } + + init( + _ titleKey: LocalizedStringKey, + text: Binding, + theme: Theme, + intent: TextFieldIntent, + borderStyle: TextFieldBorderStyle, + successImage: Image, + alertImage: Image, + errorImage: Image, + isSecure: Bool, + isReadOnly: Bool, + leftView: @escaping (() -> LeftView), + rightView: @escaping (() -> RightView) + ) { + let viewModel = TextFieldViewModel( + theme: theme, + intent: intent, + borderStyle: borderStyle, + successImage: .right(successImage), + alertImage: .right(alertImage), + errorImage: .right(errorImage) + ) + viewModel.isUserInteractionEnabled = isReadOnly != true + self.init( + titleKey: titleKey, + text: text, + viewModel: viewModel, + isSecure: isSecure, + leftView: leftView, + rightView: rightView + ) + } + + public init(_ titleKey: LocalizedStringKey, + text: Binding, + theme: Theme, + intent: TextFieldIntent, + successImage: Image, + alertImage: Image, + errorImage: Image, + isSecure: Bool = false, + isReadOnly: Bool = false, + leftView: @escaping () -> LeftView = { EmptyView() }, + rightView: @escaping () -> RightView = { EmptyView() }) { + self.init( + titleKey, + text: text, + theme: theme, + intent: intent, + borderStyle: .roundedRect, + successImage: successImage, + alertImage: alertImage, + errorImage: errorImage, + isSecure: isSecure, + isReadOnly: isReadOnly, + leftView: leftView, + rightView: rightView + ) + } + + public var body: some View { + ZStack { + self.viewModel.backgroundColor.color + contentViewBuilder() + .padding(EdgeInsets(top: .zero, leading: self.viewModel.leftSpacing, bottom: .zero, trailing: self.viewModel.rightSpacing)) + } + .tint(self.viewModel.textColor.color) + .isEnabledChanged { isEnabled in + self.viewModel.isEnabled = isEnabled + } + .allowsHitTesting(self.viewModel.isUserInteractionEnabled) + .focused($isFocused) + .onChange(of: isFocused) { newValue in + self.viewModel.isFocused = newValue + } + .border(width: self.viewModel.borderWidth * self.scaleFactor, radius: self.viewModel.borderRadius, colorToken: self.viewModel.borderColor) + .frame(height: self.height) + .opacity(self.viewModel.dim) + } + + // MARK: - Content + @ViewBuilder + private func contentViewBuilder() -> some View { + HStack(spacing: self.viewModel.contentSpacing) { + leftView() + Group { + if isSecure { + SecureField(titleKey, text: $text) + .font(self.viewModel.font.font) + } else { + TextField(titleKey, text: $text) + .font(self.viewModel.font.font) + } + } + .textFieldStyle(.plain) + .foregroundStyle(self.viewModel.textColor.color) + if let statusImage = viewModel.statusImage { + statusImage.rightValue + .resizable() + .frame(width: imageSize, height: imageSize) + } + rightView() + } + } +} diff --git a/spark/Demo/Classes/View/Components/ComponentsView.swift b/spark/Demo/Classes/View/Components/ComponentsView.swift index 1d4752647..944a1e3d5 100644 --- a/spark/Demo/Classes/View/Components/ComponentsView.swift +++ b/spark/Demo/Classes/View/Components/ComponentsView.swift @@ -101,6 +101,10 @@ struct ComponentsView: View { self.navigateToView(TagComponentView()) } + Button("TextField") { + self.navigateToView(TextFieldComponentView()) + } + Button("TextLink") { self.navigateToView(TextLinkComponentView()) } diff --git a/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift new file mode 100644 index 000000000..36e9d253b --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift @@ -0,0 +1,148 @@ +// +// TextFieldComponentView.swift +// Spark +// +// Created by louis.borlee on 08/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI +import SparkCore + +// swiftlint:disable no_debugging_method +struct TextFieldComponentView: View { + + @State private var theme: Theme = SparkThemePublisher.shared.theme + @State private var intent: TextFieldIntent = .neutral + + @State private var isEnabledState: CheckboxSelectionState = .selected + @State private var isSecureState: CheckboxSelectionState = .unselected + @State private var isReadOnlyState: CheckboxSelectionState = .unselected + + @State private var leftViewContent: TextFieldSideViewContent = .none + @State private var rightViewContent: TextFieldSideViewContent = .none + + @FocusState private var isFocusedState: Bool + + @State private var isShowingLeftAlert: Bool = false + @State private var isShowingRightAlert: Bool = false + + @State var text: String = "Hello" + + var body: some View { + Component( + name: "TextField", + configuration: { + ThemeSelector(theme: self.$theme) + EnumSelector( + title: "Intent", + dialogTitle: "Select an intent", + values: TextFieldIntent.allCases, + value: self.$intent + ) + EnumSelector( + title: "LeftView", + dialogTitle: "Select LeftView content", + values: TextFieldSideViewContent.allCases, + value: self.$leftViewContent + ) + EnumSelector( + title: "RightView", + dialogTitle: "Select RightView content", + values: TextFieldSideViewContent.allCases, + value: self.$rightViewContent + ) + Checkbox(title: "IsEnabled", selectionState: $isEnabledState) + Checkbox(title: "IsSecure", selectionState: $isSecureState) + Checkbox(title: "IsReadOnly", selectionState: $isReadOnlyState) + }, + integration: { + SparkCore.TextFieldView( + "Placeholder", + text: $text, + theme: self.theme, + intent: self.intent, + successImage: Image("check"), + alertImage: Image("alert"), + errorImage: Image("alert-circle"), + isSecure: self.isSecureState == .selected, + isReadOnly: self.isReadOnlyState == .selected, + leftView: { + self.view(side: .left) + }, + rightView: { + self.view(side: .right) + } + ) + .frame(maxWidth: .infinity) + .disabled(self.isEnabledState == .unselected) + .focused($isFocusedState) + .onSubmit { + self.isFocusedState = false + } + } + ) + } + + enum ContentSide: String { + case left + case right + } + + @ViewBuilder + private func view(side: ContentSide) -> some View { + Group { + let content = side == .left ? self.leftViewContent : self.rightViewContent + switch content { + case .none: EmptyView() + case .button: + createButton(side: side) + case .text: + createText(side: side) + case .image: + createImage(side: side) + case .all: + HStack(spacing: 6) { + createButton(side: side) + createImage(side: side) + createText(side: side) + } + } + } + } + + @ViewBuilder + private func createImage(side: ContentSide) -> some View { + let imageName = side == .right ? "delete.left" : "command" + Image(systemName: imageName) + .foregroundStyle(side == .left ? Color.green : Color.purple) + } + + @ViewBuilder + private func createText(side: ContentSide) -> some View { + Text("\(side.rawValue) text") + .foregroundStyle(side == .left ? Color.orange : Color.teal) + } + + @ViewBuilder + private func createButton(side: ContentSide) -> some View { + ButtonView( + theme: self.theme, + intent: side == .left ? .danger : .alert, + variant: .filled, + size: .small, + shape: .pill, + alignment: .leadingImage) { + switch side { + case .left: + self.isShowingLeftAlert = true + case .right: + self.isShowingRightAlert = true + } + } + .title("This is the \(side.rawValue) button", for: .normal) + .alert(isPresented: side == .left ? self.$isShowingLeftAlert : self.$isShowingRightAlert) { + Alert(title: Text("\(side.rawValue) button has been pressed"), message: nil, dismissButton: Alert.Button.cancel()) + } + } +} From 85ca2c16cd1438eaced092df31f288348fa1d18c Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 09:48:45 +0100 Subject: [PATCH 030/117] [TextField] Added addons ViewModels --- .../ViewModel/TextFieldAddonsViewModel.swift | 99 +++++ .../TextFieldAddonsViewModelTests.swift | 377 ++++++++++++++++++ .../TextFieldViewModelForAddons.swift | 85 ++++ .../TextFieldViewModelForAddonsTests.swift | 128 ++++++ 4 files changed, 689 insertions(+) create mode 100644 core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift create mode 100644 core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift create mode 100644 core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddons.swift create mode 100644 core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddonsTests.swift diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift new file mode 100644 index 000000000..ce45c538f --- /dev/null +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift @@ -0,0 +1,99 @@ +// +// TextFieldAddonsViewModel.swift +// SparkCore +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +import Combine + +final class TextFieldAddonsViewModel: ObservableObject, Updateable { + + private var cancellables = Set() + + @Published private(set) var backgroundColor: any ColorToken + + // BorderLayout + @Published private(set) var borderRadius: CGFloat + @Published private(set) var borderWidth: CGFloat + + // Spacings + @Published private(set) var leftSpacing: CGFloat + @Published private(set) var contentSpacing: CGFloat + @Published private(set) var rightSpacing: CGFloat + + @Published private(set) var dim: CGFloat + + var textFieldViewModel: TextFieldViewModelForAddons + + init(theme: Theme, + intent: TextFieldIntent, + successImage: ImageEither, + alertImage: ImageEither, + errorImage: ImageEither, + getColorsUseCase: TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase(), + getBorderLayoutUseCase: TextFieldGetBorderLayoutUseCasable = TextFieldGetBorderLayoutUseCase(), + getSpacingsUseCase: TextFieldGetSpacingsUseCasable = TextFieldGetSpacingsUseCase()) { + let viewModel = TextFieldViewModelForAddons( + theme: theme, + intent: intent, + successImage: successImage, + alertImage: alertImage, + errorImage: errorImage, + getColorsUseCase: getColorsUseCase, + getBorderLayoutUseCase: getBorderLayoutUseCase, + getSpacingsUseCase: getSpacingsUseCase + ) + self.backgroundColor = viewModel.addonsBackgroundColor + self.borderRadius = viewModel.addonsBorderWidth + self.borderWidth = viewModel.addonsBorderWidth + self.leftSpacing = viewModel.addonsLeftSpacing + self.contentSpacing = viewModel.addonsContentSpacing + self.rightSpacing = viewModel.addonsRightSpacing + self.dim = viewModel.addonsDim + + self.textFieldViewModel = viewModel + + self.subscribe() + } + + private func subscribe() { + self.textFieldViewModel.$addonsBackgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in + guard let self else { return } + self.updateIfNeeded(keyPath: \.backgroundColor, newValue: backgroundColor) + } + + self.textFieldViewModel.$addonsLeftSpacing.subscribe(in: &self.cancellables) { [weak self] leftSpacing in + guard let self else { return } + self.updateIfNeeded(keyPath: \.leftSpacing, newValue: leftSpacing) + } + + self.textFieldViewModel.$addonsContentSpacing.subscribe(in: &self.cancellables) { [weak self] contentSpacing in + guard let self else { return } + self.updateIfNeeded(keyPath: \.contentSpacing, newValue: contentSpacing) + } + + self.textFieldViewModel.$addonsRightSpacing.subscribe(in: &self.cancellables) { [weak self] rightSpacing in + guard let self else { return } + self.updateIfNeeded(keyPath: \.rightSpacing, newValue: rightSpacing) + } + + self.textFieldViewModel.$addonsBorderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in + guard let self else { return } + self.updateIfNeeded(keyPath: \.borderWidth, newValue: borderWidth) + } + + self.textFieldViewModel.$addonsBorderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in + guard let self else { return } + self.updateIfNeeded(keyPath: \.borderRadius, newValue: borderRadius) + } + + self.textFieldViewModel.$addonsDim.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.updateIfNeeded(keyPath: \.dim, newValue: dim) + } + + } +} diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift new file mode 100644 index 000000000..e1b342b46 --- /dev/null +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift @@ -0,0 +1,377 @@ +// +// TextFieldAddonsViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 21/03/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +import Combine +@testable import SparkCore + +final class TextFieldAddonsViewModelTests: XCTestCase { + private var theme: ThemeGeneratedMock! + private var publishers: TextFieldAddonsPublishers! + private var getColorsUseCase: TextFieldGetColorsUseCasableGeneratedMock! + private var getBorderLayoutUseCase: TextFieldGetBorderLayoutUseCasableGeneratedMock! + private var getSpacingsUseCase: TextFieldGetSpacingsUseCasableGeneratedMock! + private var viewModel: TextFieldAddonsViewModel! + + private let intent = TextFieldIntent.success + private let borderStyle = TextFieldBorderStyle.roundedRect + + private var expectedColors: TextFieldColors! + private var expectedBorderLayout: TextFieldBorderLayout! + private var expectedSpacings: TextFieldSpacings! + + private let successImage: ImageEither = .left(UIImage(systemName: "square.and.arrow.up.fill")!) + private let alertImage: ImageEither = .right(Image(systemName: "rectangle.portrait.and.arrow.right.fill")) + private let errorImage: ImageEither = .left(UIImage(systemName: "eraser.fill")!) + + override func setUp() { + super.setUp() + self.theme = ThemeGeneratedMock.mocked() + + self.expectedColors = .mocked( + text: .blue(), + placeholder: .green(), + border: .yellow(), + statusIcon: .red(), + background: .purple() + ) + self.expectedBorderLayout = .mocked(radius: 1, width: 2) + self.expectedSpacings = .mocked(left: 1, content: 2, right: 3) + + self.getColorsUseCase = .mocked(returnedColors: self.expectedColors) + self.getBorderLayoutUseCase = .mocked(returnedBorderLayout: self.expectedBorderLayout) + self.getSpacingsUseCase = .mocked(returnedSpacings: self.expectedSpacings) + self.viewModel = .init( + theme: self.theme, + intent: self.intent, + successImage: self.successImage, + alertImage: self.alertImage, + errorImage: self.errorImage, + getColorsUseCase: self.getColorsUseCase, + getBorderLayoutUseCase: self.getBorderLayoutUseCase, + getSpacingsUseCase: self.getSpacingsUseCase + ) + + self.setupPublishers() + } + + func test_init() throws { + // GIVEN / WHEN - Inits from setUp() + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") + let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") + XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") + XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") + XCTAssertFalse(getColorsReceivedArguments.isFocused, "Wrong getColorsReceivedArguments.isFocused") + XCTAssertTrue(getColorsReceivedArguments.isEnabled, "Wrong getColorsReceivedArguments.isEnabled") + XCTAssertTrue(getColorsReceivedArguments.isUserInteractionEnabled, "Wrong getColorsReceivedArguments.isUserInteractionEnabled") + XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") + + // THEN - Border Layout + XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 2, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called twice (one for textfield, one for addons)") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertIdentical(getBorderLayoutReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getBorderLayoutReceivedArguments.theme") + XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .roundedRect, "Wrong getBorderLayoutReceivedArguments.borderStyle") + XCTAssertFalse(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") + XCTAssertEqual(self.viewModel.borderWidth, self.expectedBorderLayout.width, "Wrong borderWidth") + XCTAssertEqual(self.viewModel.borderRadius, self.expectedBorderLayout.radius, "Wrong borderRadius") + + // THEN - Spacings + XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 2, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called twice (one for textfield, one for addons)") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertIdentical(getSpacingsUseCaseReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getSpacingsUseCaseReceivedArguments.theme") + XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .roundedRect, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") + XCTAssertEqual(self.viewModel.leftSpacing, self.expectedSpacings.left, "Wrong leftSpacing") + XCTAssertEqual(self.viewModel.contentSpacing, self.expectedSpacings.content, "Wrong contentSpacing") + XCTAssertEqual(self.viewModel.rightSpacing, self.expectedSpacings.right, "Wrong rightSpacing") + + XCTAssertEqual(self.viewModel.dim, self.theme.dims.none, "Wrong dim") + + // THEN - Publishers + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") + + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") + + XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "$leftSpacing should have been called once") + XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "$contentSpacing should have been called once") + XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "$rightSpacing should have been called once") + + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + } + + func test_backgroundColor_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newBackgroundColor = self.expectedColors.background + + // WHEN + self.viewModel.textFieldViewModel.backgroundColor = newBackgroundColor + + // THEN + XCTAssertFalse(self.publishers.backgroundColor.sinkCalled) + } + + func test_backgroundColor_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newBackgroundColor = ColorTokenDefault.clear + + // WHEN + self.viewModel.textFieldViewModel.backgroundColor = newBackgroundColor + + // THEN + XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "backgroundColor should have been called once") + XCTAssertTrue(self.viewModel.backgroundColor.equals(newBackgroundColor), "Wrong backgroundColor") + } + + func test_borderWidth_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.textFieldViewModel.setBorderLayout() + + // THEN + XCTAssertFalse(self.publishers.borderWidth.sinkCalled) + } + + func test_borderWidth_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newValue = TextFieldBorderLayout(radius: -1, width: -2) + self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newValue + + // WHEN + self.viewModel.textFieldViewModel.setBorderLayout() + + // THEN + XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "borderWidth should have been called once") + XCTAssertEqual(self.viewModel.borderWidth, newValue.width, "Wrong borderWidth") + } + + func test_borderRadius_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.textFieldViewModel.setBorderLayout() + + // THEN + XCTAssertFalse(self.publishers.borderRadius.sinkCalled) + } + + func test_borderRadius_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newValue = TextFieldBorderLayout(radius: -1, width: -2) + self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newValue + + // WHEN + self.viewModel.textFieldViewModel.setBorderLayout() + + // THEN + XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "borderRadius should have been called once") + XCTAssertEqual(self.viewModel.borderRadius, newValue.radius, "Wrong borderRadius") + } + + func test_spacingLeft_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertFalse(self.publishers.leftSpacing.sinkCalled) + } + + func test_leftSpacing_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newValue = TextFieldSpacings(left: -1, content: -2, right: -3) + self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newValue + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "leftSpacing should have been called once") + XCTAssertEqual(self.viewModel.leftSpacing, newValue.left, "Wrong leftSpacing") + } + + func test_spacingContent_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertFalse(self.publishers.contentSpacing.sinkCalled) + } + + func test_contentSpacing_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newValue = TextFieldSpacings(left: -1, content: -2, right: -3) + self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newValue + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "contentSpacing should have been called once") + XCTAssertEqual(self.viewModel.contentSpacing, newValue.content, "Wrong contentSpacing") + } + + func test_spacingRight_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertFalse(self.publishers.rightSpacing.sinkCalled) + } + + func test_rightSpacing_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newValue = TextFieldSpacings(left: -1, content: -2, right: -3) + self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newValue + + // WHEN + self.viewModel.textFieldViewModel.setSpacings() + + // THEN + XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "rightSpacing should have been called once") + XCTAssertEqual(self.viewModel.rightSpacing, newValue.right, "Wrong rightSpacing") + } + + func test_dim_set_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newDim = self.theme.dims.none + + // WHEN + self.viewModel.textFieldViewModel.dim = newDim + + // THEN + XCTAssertFalse(self.publishers.dim.sinkCalled) + } + + func test_dim_set_not_equal() { + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // GIVEN + let newDim = self.theme.dims.none + 1 + + // WHEN + self.viewModel.textFieldViewModel.dim = newDim + + // THEN + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "dim should have been called once") + XCTAssertEqual(self.viewModel.dim, newDim, "Wrong dim") + } + + // MARK: - Utils + private func setupPublishers() { + self.publishers = .init( + backgroundColor: PublisherMock(publisher: self.viewModel.$backgroundColor), + borderRadius: PublisherMock(publisher: self.viewModel.$borderRadius), + borderWidth: PublisherMock(publisher: self.viewModel.$borderWidth), + leftSpacing: PublisherMock(publisher: self.viewModel.$leftSpacing), + contentSpacing: PublisherMock(publisher: self.viewModel.$contentSpacing), + rightSpacing: PublisherMock(publisher: self.viewModel.$rightSpacing), + dim: PublisherMock(publisher: self.viewModel.$dim) + ) + self.publishers.load() + } + + private func resetUseCases() { + self.getColorsUseCase.reset() + self.getBorderLayoutUseCase.reset() + self.getSpacingsUseCase.reset() + } +} + +final class TextFieldAddonsPublishers { + var cancellables = Set() + + var backgroundColor: PublisherMock.Publisher> + + var borderRadius: PublisherMock.Publisher> + var borderWidth: PublisherMock.Publisher> + + var leftSpacing: PublisherMock.Publisher> + var contentSpacing: PublisherMock.Publisher> + var rightSpacing: PublisherMock.Publisher> + + var dim: PublisherMock.Publisher> + + init( + backgroundColor: PublisherMock.Publisher>, + borderRadius: PublisherMock.Publisher>, + borderWidth: PublisherMock.Publisher>, + leftSpacing: PublisherMock.Publisher>, + contentSpacing: PublisherMock.Publisher>, + rightSpacing: PublisherMock.Publisher>, + dim: PublisherMock.Publisher> + ) { + self.backgroundColor = backgroundColor + self.borderRadius = borderRadius + self.borderWidth = borderWidth + self.leftSpacing = leftSpacing + self.contentSpacing = contentSpacing + self.rightSpacing = rightSpacing + self.dim = dim + } + + func load() { + self.cancellables = Set() + + [self.backgroundColor].forEach { + $0.loadTesting(on: &self.cancellables) + } + + [self.borderWidth, self.borderRadius, self.leftSpacing, self.contentSpacing, self.rightSpacing, self.dim].forEach { + $0.loadTesting(on: &self.cancellables) + } + } + + func reset() { + [self.backgroundColor].forEach { + $0.reset() + } + + [self.borderWidth, self.borderRadius, self.leftSpacing, self.contentSpacing, self.rightSpacing, self.dim].forEach { + $0.reset() + } + } +} diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddons.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddons.swift new file mode 100644 index 000000000..c3f74a455 --- /dev/null +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddons.swift @@ -0,0 +1,85 @@ +// +// TextFieldViewModelForAddons.swift +// SparkCore +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine + +final class TextFieldViewModelForAddons: TextFieldViewModel { + + override var backgroundColor: any ColorToken { + get { + return ColorTokenDefault.clear + } + set { + self.addonsBackgroundColor = newValue + } + } + + override var dim: CGFloat { + get { + return 1.0 + } + set { + self.addonsDim = newValue + } + } + + @Published private(set) var addonsBackgroundColor: any ColorToken = ColorTokenDefault.clear + @Published private(set) var addonsBorderWidth: CGFloat = .zero + @Published private(set) var addonsBorderRadius: CGFloat = .zero + @Published private(set) var addonsLeftSpacing: CGFloat = .zero + @Published private(set) var addonsContentSpacing: CGFloat = .zero + @Published private(set) var addonsRightSpacing: CGFloat = .zero + @Published private(set) var addonsDim: CGFloat = 1.0 + + init( + theme: Theme, + intent: TextFieldIntent, + successImage: ImageEither, + alertImage: ImageEither, + errorImage: ImageEither, + getColorsUseCase: TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase(), + getBorderLayoutUseCase: TextFieldGetBorderLayoutUseCasable = TextFieldGetBorderLayoutUseCase(), + getSpacingsUseCase: TextFieldGetSpacingsUseCasable = TextFieldGetSpacingsUseCase() + ) { + super.init( + theme: theme, + intent: intent, + borderStyle: .none, + successImage: successImage, + alertImage: alertImage, + errorImage: errorImage, + getColorsUseCase: getColorsUseCase, + getBorderLayoutUseCase: getBorderLayoutUseCase, + getSpacingsUseCase: getSpacingsUseCase) + + self.addonsBackgroundColor = super.backgroundColor + self.setBorderLayout() + self.setSpacings() + self.addonsDim = super.dim + } + + override func setBorderLayout() { + let borderLayout = self.getBorderLayoutUseCase.execute( + theme: self.theme, + borderStyle: .roundedRect, + isFocused: self.isFocused) + + self.addonsBorderWidth = borderLayout.width + self.addonsBorderRadius = borderLayout.radius + } + + override func setSpacings() { + let spacings = self.getSpacingsUseCase.execute( + theme: self.theme, + borderStyle: .roundedRect) + self.addonsLeftSpacing = spacings.left + self.addonsContentSpacing = spacings.content + self.addonsRightSpacing = spacings.right + } +} diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddonsTests.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddonsTests.swift new file mode 100644 index 000000000..66c63bea7 --- /dev/null +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldViewModelForAddonsTests.swift @@ -0,0 +1,128 @@ +// +// TextFieldViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 01/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import Combine +import UIKit +import SwiftUI +@testable import SparkCore + +final class TextFieldViewModelForAddonsTests: XCTestCase { + + private let superTests: TextFieldViewModelTests = .init() + private var viewModel: TextFieldViewModelForAddons! + + override func setUp() { + super.setUp() + self.superTests.setUp() + + self.viewModel = .init( + theme: self.superTests.theme, + intent: self.superTests.intent, + successImage: self.superTests.successImage, + alertImage: self.superTests.alertImage, + errorImage: self.superTests.errorImage, + getColorsUseCase: self.superTests.getColorsUseCase, + getBorderLayoutUseCase: self.superTests.getBorderLayoutUseCase, + getSpacingsUseCase: self.superTests.getSpacingsUseCase + ) + } + + func test_init_borderStyle() { + XCTAssertEqual(self.viewModel.borderStyle, .none, "Wrong borderStyle") + XCTAssertTrue(self.viewModel.backgroundColor.equals(ColorTokenDefault.clear), "Wrong backgroundColor") + XCTAssertEqual(self.viewModel.dim, 1, "Wrong dim") + } + + func test_backgroundColor() { + self.superTests.publishers.reset() + self.superTests.resetUseCases() + + XCTAssertTrue(self.viewModel.backgroundColor.equals(ColorTokenDefault.clear), "Wrong viewModel.backgroundColor before set") + + let newColor = ColorTokenGeneratedMock(uiColor: .brown) + self.viewModel.backgroundColor = newColor + + XCTAssertTrue(self.viewModel.backgroundColor.equals(ColorTokenDefault.clear), "Wrong viewModel.backgroundColor after set") + XCTAssertTrue(self.viewModel.addonsBackgroundColor.equals(newColor), "Wrong delegate.backgroundColor") + + XCTAssertFalse(self.superTests.publishers.backgroundColor.sinkCalled, "backgroundColor should not have sinked") + } + + func test_dim() { + self.superTests.publishers.reset() + self.superTests.resetUseCases() + + XCTAssertEqual(self.viewModel.dim, 1.0, "Wrong viewModel.dim before set") + + let newDim = 0.2 + self.viewModel.dim = newDim + + XCTAssertEqual(self.viewModel.dim, 1.0, "Wrong viewModel.dim after set") + XCTAssertEqual(self.viewModel.addonsDim, newDim, "Wrong delegate.dim") + + XCTAssertFalse(self.superTests.publishers.dim.sinkCalled, "dim should not have sinked") + } + + func test_setBorderLayout() throws { + self.superTests.publishers.reset() + self.superTests.resetUseCases() + + let newExpectedBorderLayout: TextFieldBorderLayout = .mocked(radius: 40, width: 40) + self.superTests.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newExpectedBorderLayout + + // WHEN + self.viewModel.setBorderLayout() + + // THEN + XCTAssertEqual(self.viewModel.addonsBorderWidth, newExpectedBorderLayout.width, "Wrong delegate.boderWidth") + XCTAssertEqual(self.viewModel.addonsBorderRadius, newExpectedBorderLayout.radius, "Wrong delegate.boderRadius") + // Border with & Radius shouldn't change, the delegate takes charge + XCTAssertEqual(self.viewModel.borderWidth, self.superTests.expectedBorderLayout.width, "Wrong viewModel.borderRadius") + XCTAssertEqual(self.viewModel.borderRadius, self.superTests.expectedBorderLayout.radius, "Wrong viewModel.borderWidth") + + XCTAssertEqual(self.superTests.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") + let getBorderLayoutReceivedArguments = try XCTUnwrap(self.superTests.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") + XCTAssertIdentical(getBorderLayoutReceivedArguments.theme as? ThemeGeneratedMock, self.superTests.theme, "Wrong getBorderLayoutReceivedArguments.theme") + XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .roundedRect, "Wrong getBorderLayoutReceivedArguments.borderStyle") + XCTAssertFalse(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") + + XCTAssertFalse(self.superTests.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") + XCTAssertFalse(self.superTests.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") + } + + func test_setSpacings() throws { + self.superTests.publishers.reset() + self.superTests.resetUseCases() + + let newExpectedSpacings = TextFieldSpacings.mocked(left: 2, content: 4, right: 3) + self.superTests.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newExpectedSpacings + + // WHEN + self.viewModel.setSpacings() + + // THEN + XCTAssertEqual(self.viewModel.addonsLeftSpacing, newExpectedSpacings.left) + XCTAssertEqual(self.viewModel.addonsContentSpacing, newExpectedSpacings.content) + XCTAssertEqual(self.viewModel.addonsRightSpacing, newExpectedSpacings.right) + + // Spacings shouldn't change, the delegate takes charge + XCTAssertEqual(self.viewModel.leftSpacing, self.superTests.expectedSpacings.left) + XCTAssertEqual(self.viewModel.contentSpacing, self.superTests.expectedSpacings.content) + XCTAssertEqual(self.viewModel.rightSpacing, self.superTests.expectedSpacings.right) + + XCTAssertEqual(self.superTests.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") + let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.superTests.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") + XCTAssertIdentical(getSpacingsUseCaseReceivedArguments.theme as? ThemeGeneratedMock, self.superTests.theme, "Wrong getSpacingsUseCaseReceivedArguments.theme") + XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .roundedRect, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") + + XCTAssertFalse(self.superTests.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") + XCTAssertFalse(self.superTests.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") + XCTAssertFalse(self.superTests.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") + } +} From 09ea59be4bafb4ddeb3a4db707b00b55d6fbdf21 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 09:59:32 +0100 Subject: [PATCH 031/117] [TextField] Added TextFieldAddonsUIView --- .../View/UIKit/TextFieldAddonsUIView.swift | 224 ++++++++++++++++++ spark/Demo/Classes/Enum/UIComponent.swift | 2 + .../Components/ComponentsViewController.swift | 2 + .../Addons/TextFieldAddonContent.swift | 17 ++ .../TextFieldAddonsComponentUIView.swift | 180 ++++++++++++++ ...FieldAddonsComponentUIViewController.swift | 133 +++++++++++ .../TextFieldAddonsComponentUIViewModel.swift | 186 +++++++++++++++ 7 files changed, 744 insertions(+) create mode 100644 core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/Addons/TextFieldAddonContent.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift new file mode 100644 index 000000000..a25120f7d --- /dev/null +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -0,0 +1,224 @@ +// +// TextFieldAddonsUIView.swift +// SparkCore +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine + +public final class TextFieldAddonsUIView: UIControl { + + @ScaledUIMetric private var scaleFactor: CGFloat = 1.0 + + public let textField: TextFieldUIView + private(set) public var leftAddon: UIView? + private(set) public var rightAddon: UIView? + + private var leftAddonContainer = UIView() + private var leftSeparatorView = UIView() + private var leftSeparatorWidthConstraint = NSLayoutConstraint() + private var rightAddonContainer = UIView() + private var rightSeparatorView = UIView() + private var rightSeparatorWidthConstraint = NSLayoutConstraint() + + private let viewModel: TextFieldAddonsViewModel + private var cancellables = Set() + + private var leadingConstraint: NSLayoutConstraint = NSLayoutConstraint() + private var trailingConstraint: NSLayoutConstraint = NSLayoutConstraint() + + private var separatorWidth: CGFloat { + return self.viewModel.borderWidth * self.scaleFactor + } + + private lazy var stackView = UIStackView(arrangedSubviews: [ + self.leftAddonContainer, + self.textField, + self.rightAddonContainer + ]) + + public override var isEnabled: Bool { + didSet { + self.textField.isEnabled = self.isEnabled + } + } + + public override var isUserInteractionEnabled: Bool { + didSet { + self.textField.isUserInteractionEnabled = self.isUserInteractionEnabled + } + } + + public init( + theme: Theme, + intent: TextFieldIntent, + successImage: UIImage, + alertImage: UIImage, + errorImage: UIImage) { + let viewModel = TextFieldAddonsViewModel( + theme: theme, + intent: intent, + successImage: .left(successImage), + alertImage: .left(alertImage), + errorImage: .left(errorImage)) + self.viewModel = viewModel + self.textField = TextFieldUIView(viewModel: viewModel.textFieldViewModel) + self.leftAddon = nil + self.rightAddon = nil + super.init(frame: .init(origin: .zero, size: .init(width: 0, height: 44))) + self.textField.backgroundColor = ColorTokenDefault.clear.uiColor + self.setupViews() + self.subscribeToViewModel() + self.textField.backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + self.clipsToBounds = true + self.addSubview(self.stackView) + + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.leadingConstraint = self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor) + self.trailingConstraint = self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + NSLayoutConstraint.activate([ + self.leadingConstraint, + self.trailingConstraint, + self.stackView.topAnchor.constraint(equalTo: self.topAnchor), + self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + self.setupSeparators() + } + + private func setupSeparators() { + self.leftAddonContainer.addSubview(self.leftSeparatorView) + self.leftSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.leftSeparatorWidthConstraint = self.leftSeparatorView.widthAnchor.constraint(equalToConstant: self.separatorWidth) + + self.rightAddonContainer.addSubview(self.rightSeparatorView) + self.rightSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.rightSeparatorWidthConstraint = self.rightSeparatorView.widthAnchor.constraint(equalToConstant: self.separatorWidth) + + NSLayoutConstraint.activate([ + self.leftSeparatorWidthConstraint, + self.leftSeparatorView.topAnchor.constraint(equalTo: self.leftAddonContainer.topAnchor), + self.leftSeparatorView.bottomAnchor.constraint(equalTo: self.leftAddonContainer.bottomAnchor), + self.leftSeparatorView.trailingAnchor.constraint(equalTo: self.leftAddonContainer.trailingAnchor), + + self.rightSeparatorWidthConstraint, + self.rightSeparatorView.topAnchor.constraint(equalTo: self.rightAddonContainer.topAnchor), + self.rightSeparatorView.bottomAnchor.constraint(equalTo: self.rightAddonContainer.bottomAnchor), + self.rightSeparatorView.leadingAnchor.constraint(equalTo: self.rightAddonContainer.leadingAnchor) + ]) + } + + private func subscribeToViewModel() { + self.viewModel.$backgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in + guard let self else { return } + self.backgroundColor = backgroundColor.uiColor + self.textField.backgroundColor = .clear + } + + self.viewModel.textFieldViewModel.$borderColor.subscribe(in: &self.cancellables) { [weak self] borderColor in + guard let self else { return } + self.setBorderColor(from: borderColor) + self.leftSeparatorView.backgroundColor = borderColor.uiColor + self.rightSeparatorView.backgroundColor = borderColor.uiColor + } + + self.viewModel.$borderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in + guard let self else { return } + let width = borderWidth * self.scaleFactor + self.setBorderWidth(width) + self.leftSeparatorWidthConstraint.constant = width + self.rightSeparatorWidthConstraint.constant = width + } + + self.viewModel.$borderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in + guard let self else { return } + self.setCornerRadius(borderRadius) + } + + self.viewModel.$leftSpacing.subscribe(in: &self.cancellables) { [weak self] leftSpacing in + guard let self else { return } + self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? leftSpacing : .zero + self.setNeedsLayout() + } + + self.viewModel.$rightSpacing.subscribe(in: &self.cancellables) { [weak self] rightSpacing in + guard let self else { return } + self.trailingConstraint.constant = self.rightAddonContainer.isHidden ? -rightSpacing : .zero + self.setNeedsLayout() + } + + self.viewModel.$contentSpacing.subscribe(in: &self.cancellables) { [weak self] contentSpacing in + guard let self else { return } + self.stackView.spacing = contentSpacing + self.setNeedsLayout() + } + + self.viewModel.$dim.subscribe(in: &self.cancellables) { [weak self] dim in + guard let self else { return } + self.alpha = dim + } + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + self.setBorderColor(from: self.viewModel.textFieldViewModel.borderColor) + } + + guard previousTraitCollection?.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory else { return } + + self._scaleFactor.update(traitCollection: self.traitCollection) + let width = self.viewModel.borderWidth * self.scaleFactor + self.setBorderWidth(width) + self.leftSeparatorWidthConstraint.constant = width + self.rightSeparatorWidthConstraint.constant = width + self.invalidateIntrinsicContentSize() + } + + public func setLeftAddon(_ leftAddon: UIView?, withPadding: Bool = false) { + if let oldValue = self.leftAddon, oldValue.isDescendant(of: self.leftAddonContainer) { + oldValue.removeFromSuperview() + } + if let leftAddon { + self.leftAddonContainer.addSubview(leftAddon) + leftAddon.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + leftAddon.trailingAnchor.constraint(lessThanOrEqualTo: self.leftSeparatorView.leadingAnchor, constant: withPadding ? -self.viewModel.leftSpacing : 0), + leftAddon.centerXAnchor.constraint(equalTo: self.leftAddonContainer.centerXAnchor, constant: -self.separatorWidth / 2.0), + leftAddon.centerYAnchor.constraint(equalTo: self.leftAddonContainer.centerYAnchor) + ]) + } + self.leftAddon = leftAddon + self.leftAddonContainer.isHidden = self.leftAddon == nil + self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? self.viewModel.leftSpacing : .zero + } + + public func setRightAddon(_ rightAddon: UIView? = nil, withPadding: Bool = false) { + if let oldValue = self.rightAddon, oldValue.isDescendant(of: self.rightAddonContainer) { + oldValue.removeFromSuperview() + } + if let rightAddon { + self.rightAddonContainer.addSubview(rightAddon) + rightAddon.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + rightAddon.leadingAnchor.constraint(greaterThanOrEqualTo: self.rightSeparatorView.trailingAnchor, constant: withPadding ? self.viewModel.rightSpacing : 0), + rightAddon.centerXAnchor.constraint(equalTo: self.rightAddonContainer.centerXAnchor, constant: self.separatorWidth / 2.0), + rightAddon.centerYAnchor.constraint(equalTo: self.rightAddonContainer.centerYAnchor) + ]) + } + self.rightAddon = rightAddon + self.rightAddonContainer.isHidden = self.rightAddon == nil + self.trailingConstraint.constant = self.rightAddonContainer.isHidden ? -self.viewModel.rightSpacing : .zero + } +} diff --git a/spark/Demo/Classes/Enum/UIComponent.swift b/spark/Demo/Classes/Enum/UIComponent.swift index 7c6691194..099c54e9f 100644 --- a/spark/Demo/Classes/Enum/UIComponent.swift +++ b/spark/Demo/Classes/Enum/UIComponent.swift @@ -30,6 +30,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { .tab, .tag, .textField, + .textFieldAddons, .textLink ] @@ -54,5 +55,6 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { static let tab = UIComponent(rawValue: "Tab") static let tag = UIComponent(rawValue: "Tag") static let textField = UIComponent(rawValue: "TextField") + static let textFieldAddons = UIComponent(rawValue: "TextFieldAddons") static let textLink = UIComponent(rawValue: "TextLink") } diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index f923439ec..82129c121 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -110,6 +110,8 @@ extension ComponentsViewController { viewController = TagComponentUIViewController.build() case .textField: viewController = TextFieldComponentUIViewController.build() + case .textFieldAddons: + viewController = TextFieldAddonsComponentUIViewController.build() case .textLink: viewController = TextLinkComponentUIViewController.build() default: diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/TextFieldAddonContent.swift b/spark/Demo/Classes/View/Components/TextField/Addons/TextFieldAddonContent.swift new file mode 100644 index 000000000..5486882cd --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/Addons/TextFieldAddonContent.swift @@ -0,0 +1,17 @@ +// +// TextFieldAddonContent.swift +// SparkDemo +// +// Created by louis.borlee on 12/03/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +enum TextFieldAddonContent: CaseIterable { + case none + case button + case buttonFull + case icon + case text +} diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift new file mode 100644 index 000000000..e167c2084 --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift @@ -0,0 +1,180 @@ +// +// TextFieldAddonsComponentUIView.swift +// SparkDemo +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Combine +import UIKit +import SparkCore + +// swiftlint:disable no_debugging_method +final class TextFieldAddonsComponentUIView: ComponentUIView { + private let viewModel: TextFieldAddonsComponentUIViewModel + private let textFieldAddons: TextFieldAddonsUIView + private var cancellables: Set = [] + + init(viewModel: TextFieldAddonsComponentUIViewModel) { + self.viewModel = viewModel + self.textFieldAddons = .init( + theme: viewModel.theme, + intent: viewModel.intent, + successImage: .init(named: "check") ?? UIImage(), + alertImage: .init(named: "alert") ?? UIImage(), + errorImage: .init(named: "alert-circle") ?? UIImage() + ) + self.textFieldAddons.textField.leftViewMode = .always + self.textFieldAddons.textField.rightViewMode = .always + super.init(viewModel: viewModel, componentView: self.textFieldAddons) + self.textFieldAddons.textField.placeholder = "Placeholder" + self.setupSubscriptions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubscriptions() { + self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in + guard let self else { return } + let themes = self.viewModel.themes + let themeTitle: String? = theme is SparkTheme ? themes.first?.title : themes.last?.title + + self.viewModel.themeConfigurationItemViewModel.buttonTitle = themeTitle + self.viewModel.configurationViewModel.update(theme: theme) + self.textFieldAddons.textField.theme = theme + } + + self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + guard let self else { return } + self.viewModel.intentConfigurationItemViewModel.buttonTitle = intent.name + self.textFieldAddons.textField.intent = intent + } + + self.viewModel.$isEnabled.subscribe(in: &self.cancellables) { [weak self] isEnabled in + guard let self = self else { return } + self.viewModel.isEnabledConfigurationItemViewModel.isOn = isEnabled + self.textFieldAddons.isEnabled = isEnabled + } + + self.viewModel.$isUserInteractionEnabled.subscribe(in: &self.cancellables) { [weak self] isUserInteractionEnabled in + guard let self = self else { return } + self.viewModel.isUserInteractionEnabledConfigurationItemViewModel.isOn = isUserInteractionEnabled + self.textFieldAddons.isUserInteractionEnabled = isUserInteractionEnabled + } + + self.viewModel.$leftViewContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.leftViewContentConfigurationItemViewModel.buttonTitle = content.name + self.textFieldAddons.textField.leftView = self.getContentView(from: content, side: .left) + } + + self.viewModel.$rightViewContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.rightViewContentConfigurationItemViewModel.buttonTitle = content.name + self.textFieldAddons.textField.rightView = self.getContentView(from: content, side: .right) + } + + self.viewModel.$leftAddonContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.leftAddonContentConfigurationItemViewModel.buttonTitle = content.name + self.textFieldAddons.setLeftAddon(self.getAddonContentView(from: content, side: .left), withPadding: self.viewModel.addonPadding) + } + + self.viewModel.$rightAddonContent.subscribe(in: &self.cancellables) { [weak self] content in + guard let self else { return } + self.viewModel.rightAddonContentConfigurationItemViewModel.buttonTitle = content.name + self.textFieldAddons.setRightAddon(self.getAddonContentView(from: content, side: .right), withPadding: self.viewModel.addonPadding) + } + + self.viewModel.$addonPadding.subscribe(in: &self.cancellables) { [weak self] addonPadding in + guard let self else { return } + self.textFieldAddons.setLeftAddon(self.textFieldAddons.leftAddon, withPadding: addonPadding) + self.textFieldAddons.setRightAddon(self.textFieldAddons.rightAddon, withPadding: addonPadding) + } + } + + private func getContentView(from content: TextFieldSideViewContent, side: TextFieldContentSide) -> UIView? { + switch content { + case .button: + return self.createButton(side: side) + case .image: + return self.createImage(side: side) + case .text: + return self.createText(side: side) + case .all: + let stackView = UIStackView(arrangedSubviews: [ + self.createButton(side: side), + self.createImage(side: side), + self.createText(side: side) + ]) + stackView.spacing = 4 + stackView.axis = .horizontal + return stackView + case .none: return nil + } + } + + private func getAddonContentView(from content: TextFieldAddonContent, side: TextFieldContentSide) -> UIView? { + switch content { + case .button: + return self.createButton(side: side) + case .icon: + return self.createIcon(side: side) + case .text: + return self.createText(side: side) + case .buttonFull: + return self.createButtonFull(side: side) + case .none: return nil + } + } + + private func createButton(side: TextFieldContentSide) -> ButtonUIView { + let button = ButtonUIView( + theme: self.viewModel.theme, + intent: side == .right ? .info : .alert, + variant: .tinted, + size: .small, + shape: .pill, + alignment: .trailingImage) + button.setImage(.init(systemName: side == .left ? "pencil" : "eraser.fill"), for: .normal) + return button + } + + private func createImage(side: TextFieldContentSide) -> UIImageView { + let imageView = UIImageView(image: .init(systemName: side == .left ? "power" : "eject.circle.fill")) + imageView.contentMode = .scaleAspectFit + return imageView + } + + private func createIcon(side: TextFieldContentSide) -> IconUIView { + let icon = IconUIView( + iconImage: .init(systemName: side == .left ? "power" : "eject.circle.fill"), + theme: self.viewModel.theme, + intent: .support, + size: .extraLarge + ) + return icon + } + + private func createText(side: TextFieldContentSide) -> UILabel { + let label = UILabel() + label.text = side.rawValue + return label + } + + private func createButtonFull(side: TextFieldContentSide) -> ButtonUIView { + let button = ButtonUIView( + theme: self.viewModel.theme, + intent: side == .right ? .danger : .success, + variant: .tinted, + size: .large, + shape: .square, + alignment: .leadingImage) + button.setTitle(side == .right ? "This is a very long text" : "Add", for: .normal) + button.isAnimated = false + return button + } +} diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift new file mode 100644 index 000000000..80a10196a --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift @@ -0,0 +1,133 @@ +// +// TextFieldAddonsComponentUIViewController.swift +// SparkDemo +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine +import SwiftUI +import SparkCore + +final class TextFieldAddonsComponentUIViewController: UIViewController { + + // MARK: - Properties + let componentView: TextFieldAddonsComponentUIView + let viewModel: TextFieldAddonsComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Published Properties + @ObservedObject private var themePublisher = SparkThemePublisher.shared + + // MARK: - Initializer + init(viewModel: TextFieldAddonsComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = TextFieldAddonsComponentUIView(viewModel: viewModel) + super.init(nibName: nil, bundle: nil) + self.componentView.viewController = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + view = self.componentView + } + + // MARK: - ViewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + self.navigationItem.title = "TextFieldAddons" + self.setupSubscriptions() + } + + private func setupSubscriptions() { + self.themePublisher + .$theme + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.theme = theme + self.navigationController?.navigationBar.tintColor = theme.colors.main.main.uiColor + } + .store(in: &self.cancellables) + + self.viewModel.showThemeSheet.subscribe(in: &self.cancellables) { theme in + self.presentThemeActionSheet(theme) + } + + self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in + self.presentIntentActionSheet(intents) + } + + self.viewModel.showLeftViewContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentSideViewContentActionSheet(contents) { content in + self.viewModel.leftViewContent = content + } + } + + self.viewModel.showRightViewContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentSideViewContentActionSheet(contents) { content in + self.viewModel.rightViewContent = content + } + } + + self.viewModel.showLeftAddonContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentAddonContentActionSheet(contents) { content in + self.viewModel.leftAddonContent = content + } + } + + self.viewModel.showRightAddonContentSheet.subscribe(in: &self.cancellables) { contents in + self.presentAddonContentActionSheet(contents) { content in + self.viewModel.rightAddonContent = content + } + } + } + + private func presentThemeActionSheet(_ themes: [ThemeCellModel]) { + let actionSheet = SparkActionSheet.init( + values: themes.map { $0.theme }, + texts: themes.map { $0.title }) { theme in + self.themePublisher.theme = theme + } + self.present(actionSheet, animated: true) + } + + private func presentIntentActionSheet(_ intents: [TextFieldIntent]) { + let actionSheet = SparkActionSheet.init( + values: intents, + texts: intents.map { $0.name }) { intent in + self.viewModel.intent = intent + } + self.present(actionSheet, animated: true) + } + + private func presentSideViewContentActionSheet(_ contents: [TextFieldSideViewContent], completion: @escaping (TextFieldSideViewContent) -> Void) { + let actionSheet = SparkActionSheet.init( + values: contents, + texts: contents.map { $0.name }, + completion: completion) + self.present(actionSheet, animated: true) + } + + private func presentAddonContentActionSheet(_ contents: [TextFieldAddonContent], completion: @escaping (TextFieldAddonContent) -> Void) { + let actionSheet = SparkActionSheet.init( + values: contents, + texts: contents.map { $0.name }, + completion: completion) + self.present(actionSheet, animated: true) + } +} + +extension TextFieldAddonsComponentUIViewController { + static func build() -> TextFieldAddonsComponentUIViewController { + let viewModel = TextFieldAddonsComponentUIViewModel(theme: SparkThemePublisher.shared.theme) + let viewController = TextFieldAddonsComponentUIViewController(viewModel: viewModel) + return viewController + } +} diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift new file mode 100644 index 000000000..04428b9c4 --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift @@ -0,0 +1,186 @@ +// +// TextFieldAddonsComponentUIViewModel.swift +// SparkDemo +// +// Created by louis.borlee on 14/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import Combine +import SparkCore + +final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, ObservableObject { + + @Published var theme: Theme + @Published var intent: TextFieldIntent + @Published var isEnabled: Bool = true + @Published var isUserInteractionEnabled: Bool = true + @Published var leftViewContent: TextFieldSideViewContent = .none + @Published var rightViewContent: TextFieldSideViewContent = .none + @Published var leftAddonContent: TextFieldAddonContent = .buttonFull + @Published var rightAddonContent: TextFieldAddonContent = .icon + @Published var addonPadding: Bool = false + + // MARK: - Published Properties + var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { + self.showThemeSheetSubject + .eraseToAnyPublisher() + } + var showIntentSheet: AnyPublisher<[TextFieldIntent], Never> { + self.showIntentSheetSubject + .eraseToAnyPublisher() + } + var showLeftViewContentSheet: AnyPublisher<[TextFieldSideViewContent], Never> { + self.showLeftViewContentSheetSubject + .eraseToAnyPublisher() + } + var showRightViewContentSheet: AnyPublisher<[TextFieldSideViewContent], Never> { + self.showRightViewContentSheetSubject + .eraseToAnyPublisher() + } + var showLeftAddonContentSheet: AnyPublisher<[TextFieldAddonContent], Never> { + self.showLeftAddonContentSheetSubject + .eraseToAnyPublisher() + } + var showRightAddonContentSheet: AnyPublisher<[TextFieldAddonContent], Never> { + self.showRightAddonContentSheetSubject + .eraseToAnyPublisher() + } + + lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Theme", + type: .button, + target: (source: self, action: #selector(self.presentThemeSheet)) + ) + }() + + lazy var intentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Intent", + type: .button, + target: (source: self, action: #selector(self.presentIntentSheet)) + ) + }() + + lazy var isEnabledConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "IsEnabled", + type: .toggle(isOn: self.isEnabled), + target: (source: self, action: #selector(self.toggleIsEnabled)) + ) + }() + + lazy var isUserInteractionEnabledConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "IsUserInteractionEnabled", + type: .toggle(isOn: self.isUserInteractionEnabled), + target: (source: self, action: #selector(self.toggleIsUserInteractionEnabled)) + ) + }() + lazy var leftViewContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "LeftViewContent", + type: .button, + target: (source: self, action: #selector(self.presentLeftViewContent)) + ) + }() + lazy var rightViewContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RightViewContent", + type: .button, + target: (source: self, action: #selector(self.presentRightViewContent)) + ) + }() + lazy var leftAddonContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "LeftAddonContent", + type: .button, + target: (source: self, action: #selector(self.presentLeftAddonContent)) + ) + }() + lazy var rightAddonContentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RightAddonContent", + type: .button, + target: (source: self, action: #selector(self.presentRightAddonContent)) + ) + }() + + lazy var addonPaddingConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "With addon padding", + type: .toggle(isOn: self.addonPadding), + target: (source: self, action: #selector(self.toggleAddonPadding)) + ) + }() + + private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() + private var showIntentSheetSubject: PassthroughSubject<[TextFieldIntent], Never> = .init() + private var showLeftViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() + private var showRightViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() + private var showLeftAddonContentSheetSubject: PassthroughSubject<[TextFieldAddonContent], Never> = .init() + private var showRightAddonContentSheetSubject: PassthroughSubject<[TextFieldAddonContent], Never> = .init() + + let themes = ThemeCellModel.themes + + init( + theme: Theme, + intent: TextFieldIntent = .neutral + ) { + self.theme = theme + self.intent = intent + super.init(identifier: "TextFieldAddons") + } + + override func configurationItemsViewModel() -> [ComponentsConfigurationItemUIViewModel] { + return [ + self.themeConfigurationItemViewModel, + self.intentConfigurationItemViewModel, + self.isEnabledConfigurationItemViewModel, + self.isUserInteractionEnabledConfigurationItemViewModel, + self.leftViewContentConfigurationItemViewModel, + self.rightViewContentConfigurationItemViewModel, + self.leftAddonContentConfigurationItemViewModel, + self.rightAddonContentConfigurationItemViewModel, + self.addonPaddingConfigurationItemViewModel + ] + } + + @objc func presentThemeSheet() { + self.showThemeSheetSubject.send(self.themes) + } + + @objc func presentIntentSheet() { + self.showIntentSheetSubject.send(TextFieldIntent.allCases) + } + + @objc func toggleIsEnabled() { + self.isEnabled.toggle() + } + + @objc func toggleIsUserInteractionEnabled() { + self.isUserInteractionEnabled.toggle() + } + + @objc func presentLeftViewContent() { + self.showLeftViewContentSheetSubject.send(TextFieldSideViewContent.allCases) + } + + @objc func presentRightViewContent() { + self.showRightViewContentSheetSubject.send(TextFieldSideViewContent.allCases) + } + + @objc func presentLeftAddonContent() { + self.showLeftAddonContentSheetSubject.send(TextFieldAddonContent.allCases) + } + + @objc func presentRightAddonContent() { + self.showRightAddonContentSheetSubject.send(TextFieldAddonContent.allCases) + } + + @objc func toggleAddonPadding() { + self.addonPadding.toggle() + } +} From 02d0e16b8cf751adbc61ee1afa28da4a5b8273b9 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 10:18:53 +0100 Subject: [PATCH 032/117] [TextField] Added TextFieldAddons --- .../Addons/View/SwiftUI/TextFieldAddon.swift | 29 +++ .../Addons/View/SwiftUI/TextFieldAddons.swift | 131 +++++++++++ .../View/Components/ComponentsView.swift | 4 + .../TextFieldAddonsComponentView.swift | 212 ++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift create mode 100644 core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift create mode 100644 spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift new file mode 100644 index 000000000..09473941e --- /dev/null +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift @@ -0,0 +1,29 @@ +// +// TextFieldAddon.swift +// SparkCore +// +// Created by louis.borlee on 21/03/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +public struct TextFieldAddon: View { + + let withPadding: Bool + let layoutPriority: Double + private let content: () -> Content + + public init( + withPadding: Bool = false, + layoutPriority: Double = 1.0, + content: @escaping () -> Content) { + self.withPadding = withPadding + self.layoutPriority = layoutPriority + self.content = content + } + + public var body: Content { + content() + } +} diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift new file mode 100644 index 000000000..5205009e7 --- /dev/null +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -0,0 +1,131 @@ +// +// TextFieldAddons.swift +// SparkCore +// +// Created by louis.borlee on 21/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +public struct TextFieldAddons: View { + + @ScaledMetric private var scaleFactor: CGFloat = 1.0 + @ScaledMetric private var maxHeight: CGFloat = 44.0 + @ObservedObject private var viewModel: TextFieldAddonsViewModel + private let leftAddon: () -> TextFieldAddon + private let rightAddon: () -> TextFieldAddon + + private let titleKey: LocalizedStringKey + @Binding private var text: String + private var isSecure: Bool + private let leftView: () -> LeftView + private let rightView: () -> RightView + + public init( + _ titleKey: LocalizedStringKey, + text: Binding, + theme: Theme, + intent: TextFieldIntent, + successImage: Image, + alertImage: Image, + errorImage: Image, + isSecure: Bool, + isReadOnly: Bool, + leftView: @escaping (() -> LeftView) = { EmptyView() }, + rightView: @escaping (() -> RightView) = { EmptyView() }, + leftAddon: @escaping (() -> TextFieldAddon) = { .init(withPadding: false) { EmptyView() } }, + rightAddon: @escaping (() -> TextFieldAddon) = { .init(withPadding: false) { EmptyView() } } + ) { + let viewModel = TextFieldAddonsViewModel( + theme: theme, + intent: intent, + successImage: .right(successImage), + alertImage: .right(alertImage), + errorImage: .right(errorImage) + ) + self.viewModel = viewModel + + self.titleKey = titleKey + self._text = text + self.isSecure = isSecure + self.leftView = leftView + self.rightView = rightView + self.leftAddon = leftAddon + self.rightAddon = rightAddon + + self.viewModel.textFieldViewModel.isUserInteractionEnabled = isReadOnly != true + } + + private func getLeftAddonPadding(withPadding: Bool) -> EdgeInsets { + guard withPadding else { return .init(all: 0) } + return .init( + top: .zero, + leading: self.viewModel.leftSpacing, + bottom: .zero, + trailing: self.viewModel.leftSpacing + ) + } + + private func getRightAddonPadding(withPadding: Bool) -> EdgeInsets { + guard withPadding else { return .init(all: 0) } + return .init( + top: .zero, + leading: self.viewModel.rightSpacing, + bottom: .zero, + trailing: self.viewModel.rightSpacing + ) + } + + private func getContentPadding() -> EdgeInsets { + return EdgeInsets( + top: .zero, + leading: LeftAddon.self is EmptyView.Type ? self.viewModel.leftSpacing : .zero, + bottom: .zero, + trailing: RightAddon.self is EmptyView.Type ? self.viewModel.rightSpacing : .zero + ) + } + + public var body: some View { + ZStack { + self.viewModel.backgroundColor.color + let leftAddon = leftAddon() + let rightAddon = rightAddon() + HStack(spacing: self.viewModel.contentSpacing) { + if LeftAddon.self is EmptyView.Type == false { + HStack(spacing: 0) { + leftAddon + .padding(getLeftAddonPadding(withPadding: leftAddon.withPadding)) + separator() + } + .layoutPriority(leftAddon.layoutPriority) + } + textField() + if RightAddon.self is EmptyView.Type == false { + HStack(spacing: 0) { + separator() + rightAddon + .padding(getRightAddonPadding(withPadding: rightAddon.withPadding)) + } + .layoutPriority(leftAddon.layoutPriority) + } + } + .padding(getContentPadding()) + } + .frame(maxHeight: maxHeight) + .allowsHitTesting(self.viewModel.textFieldViewModel.isUserInteractionEnabled) + .border(width: self.viewModel.borderWidth * self.scaleFactor, radius: self.viewModel.borderRadius, colorToken: self.viewModel.textFieldViewModel.borderColor) + .opacity(self.viewModel.dim) + } + + @ViewBuilder + private func separator() -> some View { + self.viewModel.textFieldViewModel.borderColor.color + .frame(width: self.viewModel.borderWidth * self.scaleFactor) + } + + @ViewBuilder + func textField() -> TextFieldView { + TextFieldView(titleKey: titleKey, text: $text, viewModel: viewModel.textFieldViewModel, isSecure: isSecure, leftView: leftView, rightView: rightView) + } +} diff --git a/spark/Demo/Classes/View/Components/ComponentsView.swift b/spark/Demo/Classes/View/Components/ComponentsView.swift index 944a1e3d5..d61c48c28 100644 --- a/spark/Demo/Classes/View/Components/ComponentsView.swift +++ b/spark/Demo/Classes/View/Components/ComponentsView.swift @@ -105,6 +105,10 @@ struct ComponentsView: View { self.navigateToView(TextFieldComponentView()) } + Button("TextFieldAddons") { + self.navigateToView(TextFieldAddonsComponentView()) + } + Button("TextLink") { self.navigateToView(TextLinkComponentView()) } diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift new file mode 100644 index 000000000..597dea650 --- /dev/null +++ b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift @@ -0,0 +1,212 @@ +// +// TextFieldAddonsComponentView.swift +// Spark +// +// Created by louis.borlee on 21/02/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI +import SparkCore + +// swiftlint:disable no_debugging_method +struct TextFieldAddonsComponentView: View { + + @State private var theme: Theme = SparkThemePublisher.shared.theme + @State private var intent: TextFieldIntent = .neutral + + @State private var isEnabledState: CheckboxSelectionState = .selected + @State private var isSecureState: CheckboxSelectionState = .unselected + @State private var isReadOnlyState: CheckboxSelectionState = .unselected + @State private var withPaddingState: CheckboxSelectionState = .unselected + + @State private var leftViewContent: TextFieldSideViewContent = .none + @State private var rightViewContent: TextFieldSideViewContent = .none + + @State private var leftAddonContent: TextFieldSideViewContent = .button + @State private var rightAddonContent: TextFieldSideViewContent = .text + + @FocusState private var isFocusedState: Bool + + @State private var isShowingLeftAlert: Bool = false + @State private var isShowingRightAlert: Bool = false + + @State var text: String = "Hello" + + var body: some View { + Component( + name: "TextFieldAddons", + configuration: { + ThemeSelector(theme: self.$theme) + EnumSelector( + title: "Intent", + dialogTitle: "Select an intent", + values: TextFieldIntent.allCases, + value: self.$intent + ) + EnumSelector( + title: "LeftView", + dialogTitle: "Select LeftView content", + values: TextFieldSideViewContent.allCases, + value: self.$leftViewContent + ) + EnumSelector( + title: "RightView", + dialogTitle: "Select RightView content", + values: TextFieldSideViewContent.allCases, + value: self.$rightViewContent + ) + EnumSelector( + title: "LeftAddon", + dialogTitle: "Select LeftAddon content", + values: TextFieldSideViewContent.allCases, + value: self.$leftAddonContent + ) + EnumSelector( + title: "RightAddon", + dialogTitle: "Select RightAddon content", + values: TextFieldSideViewContent.allCases, + value: self.$rightAddonContent + ) + Checkbox(title: "IsEnabled", selectionState: $isEnabledState) + Checkbox(title: "IsSecure", selectionState: $isSecureState) + Checkbox(title: "IsReadOnly", selectionState: $isReadOnlyState) + Checkbox(title: "WithPadding", selectionState: $withPaddingState) + }, + integration: { + SparkCore.TextFieldAddons( + "Placeholder", + text: $text, + theme: self.theme, + intent: self.intent, + successImage: Image("check"), + alertImage: Image("alert"), + errorImage: Image("alert-circle"), + isSecure: self.isSecureState == .selected, + isReadOnly: self.isReadOnlyState == .selected, + leftView: { + self.view(side: .left) + }, + rightView: { + self.view(side: .right) + }, + leftAddon: { + TextFieldAddon(withPadding: withPaddingState == .selected) { + self.addon(side: .left) + } + }, + rightAddon: { + TextFieldAddon(withPadding: withPaddingState == .selected) { + self.addon(side: .right) + } + } + ) + .disabled(self.isEnabledState == .unselected) + .focused($isFocusedState) + .onSubmit { + self.isFocusedState = false + } + } + ) + } + + enum ContentSide: String { + case left + case right + } + + @ViewBuilder + private func view(side: ContentSide) -> some View { + let content = side == .left ? self.leftViewContent : self.rightViewContent + switch content { + case .none: EmptyView() + case .button: + createButton(side: side) + case .text: + createText(side: side) + case .image: + createImage(side: side) + case .all: + HStack(spacing: 6) { + createButton(side: side) + createImage(side: side) + createText(side: side) + } + } + } + + @ViewBuilder + private func addon(side: ContentSide) -> some View { + let content = side == .left ? self.leftAddonContent : self.rightAddonContent + switch content { + case .none: EmptyView() + case .button: + createSparkButton(side: side) + case .text: + createText(side: side) + case .image: + createImage(side: side) + case .all: + HStack(spacing: 6) { + createSparkButton(side: side) + createImage(side: side) + createText(side: side) + } + } + } + + + @ViewBuilder + private func createImage(side: ContentSide) -> some View { + let imageName = side == .right ? "delete.left" : "command" + Image(systemName: imageName) + .foregroundStyle(side == .left ? Color.green : Color.purple) + } + + @ViewBuilder + private func createText(side: ContentSide) -> some View { + Text("\(side.rawValue) text") + .foregroundStyle(side == .left ? Color.orange : Color.teal) + } + + @ViewBuilder + private func createSparkButton(side: ContentSide) -> some View { + ButtonView( + theme: self.theme, + intent: side == .left ? .danger : .info, + variant: .tinted, + size: .large, + shape: .square, + alignment: .trailingImage) { + switch side { + case .left: + self.isShowingLeftAlert = true + case .right: + self.isShowingRightAlert = true + } + } + .title("This is the \(side.rawValue) button", for: .normal) + .alert(isPresented: side == .left ? self.$isShowingLeftAlert : self.$isShowingRightAlert) { + Alert(title: Text("\(side.rawValue) button has been pressed"), message: nil, dismissButton: Alert.Button.cancel()) + } + } + + @ViewBuilder + private func createButton(side: ContentSide) -> some View { + Button { + switch side { + case .left: + self.isShowingLeftAlert = true + case .right: + self.isShowingRightAlert = true + } + } label: { + Text("This is the \(side.rawValue) button") + } + .buttonStyle(.bordered) + .alert(isPresented: side == .left ? self.$isShowingLeftAlert : self.$isShowingRightAlert) { + Alert(title: Text("\(side.rawValue) button has been pressed"), message: nil, dismissButton: Alert.Button.cancel()) + } + .tint(side == .left ? .red : .blue) + } +} From 16a58b7c6075ffc32ed90ee8249fce54b867df7d Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 27 Mar 2024 10:43:28 +0100 Subject: [PATCH 033/117] [Formfield#782] Add test cases --- .../FormField/Model/FormFieldViewModel.swift | 8 +- .../Model/FormFieldViewModelTests.swift | 130 ++++++++++++++++++ .../UseCase/FormFieldColorsUseCaseTests.swift | 56 ++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift create mode 100644 core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 7453446b7..18fcce612 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -45,8 +45,9 @@ final class FormFieldViewModel: ObservableObject { } } + var colors: FormFieldColors + private var colorUseCase: FormFieldColorsUseCaseable - private var colors: FormFieldColors private var userDefinedTitle: Either? private var asterisk: NSAttributedString = NSAttributedString() @@ -61,7 +62,7 @@ final class FormFieldViewModel: ObservableObject { ) { self.theme = theme self.feedbackState = feedbackState - self.title = title + self.userDefinedTitle = title self.description = description self.isTitleRequired = isTitleRequired self.colorUseCase = colorUseCase @@ -71,6 +72,9 @@ final class FormFieldViewModel: ObservableObject { self.descriptionFont = self.theme.typography.caption self.titleColor = self.colors.titleColor self.descriptionColor = self.colors.descriptionColor + + self.updateAsterisk() + self.setTitle(title) } private func updateColors() { diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift b/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift new file mode 100644 index 000000000..56dc7dd48 --- /dev/null +++ b/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift @@ -0,0 +1,130 @@ +// +// FormFieldViewModelTests.swift +// SparkCoreUnitTests +// +// Created by alican.aycil on 26.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Combine +import SwiftUI +import XCTest +@testable import SparkCore + +final class FormFieldViewModelTests: XCTestCase { + + var theme: ThemeGeneratedMock! + var cancellable = Set() + var checkedImage = IconographyTests.shared.checkmark + + // MARK: - Setup + override func setUpWithError() throws { + try super.setUpWithError() + + self.theme = ThemeGeneratedMock.mocked() + } + + // MARK: - Tests + func test_init() throws { + + // Given + let viewModel = FormFieldViewModel( + theme: self.theme, + feedbackState: .default, + title: .left(NSAttributedString(string: "Title")), + description: .left(NSAttributedString(string: "Description")), + isTitleRequired: true + ) + + // Then + XCTAssertNotNil(viewModel.theme, "No theme set") + XCTAssertNotNil(viewModel.feedbackState, "No feedback state set") + XCTAssertNotNil(viewModel.isTitleRequired, "No title required set") + XCTAssertTrue(viewModel.title?.leftValue?.string.contains("*") ?? false) + XCTAssertEqual(viewModel.title?.leftValue?.string, "Title *") + XCTAssertEqual(viewModel.description?.leftValue?.string, "Description") + XCTAssertEqual(viewModel.spacing, self.theme.layout.spacing.small) + XCTAssertEqual(viewModel.titleFont.uiFont, self.theme.typography.body2.uiFont) + XCTAssertEqual(viewModel.descriptionFont.uiFont, self.theme.typography.caption.uiFont) + XCTAssertEqual(viewModel.titleColor.uiColor, viewModel.colors.titleColor.uiColor) + XCTAssertEqual(viewModel.descriptionColor.uiColor, viewModel.colors.descriptionColor.uiColor) + } + + func test_texts_right_value() { + // Given + let viewModel = FormFieldViewModel( + theme: self.theme, + feedbackState: .default, + title: .right(AttributedString("Title")), + description: .right(AttributedString("Description")), + isTitleRequired: false + ) + + // Then + XCTAssertEqual(viewModel.title?.rightValue, AttributedString("Title")) + XCTAssertEqual(viewModel.description?.rightValue, AttributedString("Description")) + } + + func test_isTitleRequired() async { + // Given + let viewModel = FormFieldViewModel( + theme: self.theme, + feedbackState: .default, + title: .left(NSAttributedString("Title")), + description: .left(NSAttributedString("Description")), + isTitleRequired: false + ) + + let expectation = expectation(description: "Title is updated") + expectation.expectedFulfillmentCount = 2 + var isTitleUpdated = false + + + viewModel.$title.sink { title in + isTitleUpdated = title?.leftValue?.string.contains("*") ?? false + expectation.fulfill() + }.store(in: &cancellable) + + // When + viewModel.isTitleRequired = true + + await fulfillment(of: [expectation]) + + // Then + XCTAssertTrue(isTitleUpdated) + } + + func test_set_title() { + // Given + let viewModel = FormFieldViewModel( + theme: self.theme, + feedbackState: .default, + title: .left(NSAttributedString("Title")), + description: .left(NSAttributedString("Description")), + isTitleRequired: true + ) + + // When + viewModel.setTitle(.left(NSAttributedString("Title2"))) + + // Then + XCTAssertEqual(viewModel.title?.leftValue?.string, "Title2 *") + } + + func test_set_feedback_state() { + // Given + let viewModel = FormFieldViewModel( + theme: self.theme, + feedbackState: .default, + title: .left(NSAttributedString("Title")), + description: .left(NSAttributedString("Description")), + isTitleRequired: false + ) + + // When + viewModel.feedbackState = .error + + // Then + XCTAssertEqual(viewModel.feedbackState, .error) + } +} diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift new file mode 100644 index 000000000..0f220ddc9 --- /dev/null +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift @@ -0,0 +1,56 @@ +// +// FormFieldColorsUseCaseTests.swift +// SparkCore +// +// Created by alican.aycil on 26.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +@testable import SparkCore + +final class FormFieldColorsUseCaseTests: XCTestCase { + + var sut: FormFieldColorsUseCase! + var theme: ThemeGeneratedMock! + + override func setUp() { + super.setUp() + + self.sut = .init() + self.theme = .mocked() + } + + // MARK: - Tests + + func test_execute_for_all_feedback_cases() { + let feedbacks = FormFieldFeedbackState.allCases + + feedbacks.forEach { + + let formfieldColors = sut.execute(from: theme, feedback: $0) + + let expectedFormfieldColor: FormFieldColors + + switch $0 { + case .default: + expectedFormfieldColor = FormFieldColors( + titleColor: theme.colors.base.onSurface, + descriptionColor: theme.colors.base.onSurface.opacity(theme.dims.dim1), + asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + ) + case .error: + expectedFormfieldColor = FormFieldColors( + titleColor: theme.colors.base.onSurface, + descriptionColor: theme.colors.feedback.error, + asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + ) + } + + XCTAssertEqual(formfieldColors.titleColor.uiColor, expectedFormfieldColor.titleColor.uiColor) + XCTAssertEqual(formfieldColors.descriptionColor.uiColor, expectedFormfieldColor.descriptionColor.uiColor) + XCTAssertEqual(formfieldColors.asteriskColor.uiColor, expectedFormfieldColor.asteriskColor.uiColor) + } + } +} From 90617764f1038c00fa7241834c69c27bdb11c612 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 11:09:00 +0100 Subject: [PATCH 034/117] [TextField] Added public documentation --- .../Addons/View/SwiftUI/TextFieldAddon.swift | 8 +++++- .../Addons/View/SwiftUI/TextFieldAddons.swift | 18 ++++++++++++- .../View/UIKit/TextFieldAddonsUIView.swift | 25 ++++++++++++++++--- .../View/SwiftUI/TextFieldView.swift | 18 +++++++++++-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift index 09473941e..32d69d300 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddon.swift @@ -8,12 +8,18 @@ import SwiftUI +/// Single TextFieldAddon embedding a Content View public struct TextFieldAddon: View { let withPadding: Bool let layoutPriority: Double private let content: () -> Content - + + /// TextFieldAddon initializer + /// - Parameters: + /// - withPadding: Add addon padding if `true`, default is `false` + /// - layoutPriority: Set addon .layoutPriority(), default is `1.0` + /// - content: Addon's content View public init( withPadding: Bool = false, layoutPriority: Double = 1.0, diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift index 5205009e7..1104894cb 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -8,6 +8,7 @@ import SwiftUI +/// A Spark TextField that can be surrounded by left and/or right addons public struct TextFieldAddons: View { @ScaledMetric private var scaleFactor: CGFloat = 1.0 @@ -22,6 +23,21 @@ public struct TextFieldAddons LeftView private let rightView: () -> RightView + /// TextFieldAddons initializer + /// - Parameters: + /// - titleKey: The textfield's current placeholder + /// - text: The textfield's text binding + /// - theme: The textfield's current theme + /// - intent: The textfield's current intent + /// - successImage: Success image, will be shown in the rightView when intent = .success + /// - alertImage: Alert image, will be shown in the rightView when intent = .alert + /// - errorImage: Error image, will be shown in the rightView when intent = .error + /// - isSecure: Set this to true if you want a SecureField, default is `false` + /// - isReadOnly: Set this to true if you want the textfield to be readOnly, default is `false` + /// - leftView: The TextField's left view, default is `EmptyView` + /// - rightView: The TextField's right view, default is `EmptyView` + /// - leftAddon: The TextField's left addon, default is `EmptyView` + /// - rightAddon: The TextField's right addon, default is `EmptyView` public init( _ titleKey: LocalizedStringKey, text: Binding, @@ -125,7 +141,7 @@ public struct TextFieldAddons TextFieldView { + private func textField() -> TextFieldView { TextFieldView(titleKey: titleKey, text: $text, viewModel: viewModel.textFieldViewModel, isSecure: isSecure, leftView: leftView, rightView: rightView) } } diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift index a25120f7d..98d277d6c 100644 --- a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -9,12 +9,16 @@ import UIKit import Combine +/// A Spark TextField that can be surrounded by left and/or right addons public final class TextFieldAddonsUIView: UIControl { @ScaledUIMetric private var scaleFactor: CGFloat = 1.0 - + + /// Embbeded textField public let textField: TextFieldUIView + /// Current leftAddon, set using setLeftAddon(_:, _withPadding:) private(set) public var leftAddon: UIView? + /// Current rightAddon, set using setRightAddon(_:, _withPadding:) private(set) public var rightAddon: UIView? private var leftAddonContainer = UIView() @@ -51,7 +55,14 @@ public final class TextFieldAddonsUIView: UIControl { self.textField.isUserInteractionEnabled = self.isUserInteractionEnabled } } - + + /// TextFieldAddonsUIView initializer + /// - Parameters: + /// - theme: The textfield's current theme + /// - intent: The textfield's current intent + /// - successImage: Success image, will be shown in the rightView when intent = .success + /// - alertImage: Alert image, will be shown in the rightView when intent = .alert + /// - errorImage: Error image, will be shown in the rightView when intent = .error public init( theme: Theme, intent: TextFieldIntent, @@ -185,7 +196,11 @@ public final class TextFieldAddonsUIView: UIControl { self.rightSeparatorWidthConstraint.constant = width self.invalidateIntrinsicContentSize() } - + + /// Set the textfield's left addon + /// - Parameters: + /// - leftAddon: the view to be set as leftAddon + /// - withPadding: adds a padding on the addon if `true`, default is `false` public func setLeftAddon(_ leftAddon: UIView?, withPadding: Bool = false) { if let oldValue = self.leftAddon, oldValue.isDescendant(of: self.leftAddonContainer) { oldValue.removeFromSuperview() @@ -204,6 +219,10 @@ public final class TextFieldAddonsUIView: UIControl { self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? self.viewModel.leftSpacing : .zero } + /// Set the textfield's right addon + /// - Parameters: + /// - leftAddon: the view to be set as rightAddon + /// - withPadding: adds a padding on the addon if `true`, default is `false` public func setRightAddon(_ rightAddon: UIView? = nil, withPadding: Bool = false) { if let oldValue = self.rightAddon, oldValue.isDescendant(of: self.rightAddonContainer) { oldValue.removeFromSuperview() diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift index 3a4edb1a5..3243c7ff7 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -8,9 +8,10 @@ import SwiftUI +/// A TextField that can be surrounded by left and/or right views public struct TextFieldView: View { - @ScaledMetric var height: CGFloat = 44 + @ScaledMetric private var height: CGFloat = 44 @ScaledMetric private var imageSize: CGFloat = 16 @ScaledMetric private var scaleFactor: CGFloat = 1.0 @@ -70,7 +71,20 @@ public struct TextFieldView: View { rightView: rightView ) } - + + /// TextFieldView initializer + /// - Parameters: + /// - titleKey: The textfield's current placeholder + /// - text: The textfield's text binding + /// - theme: The textfield's current theme + /// - intent: The textfield's current intent + /// - successImage: Success image, will be shown in the rightView when intent = .success + /// - alertImage: Alert image, will be shown in the rightView when intent = .alert + /// - errorImage: Error image, will be shown in the rightView when intent = .error + /// - isSecure: Set this to true if you want a SecureField, default is `false` + /// - isReadOnly: Set this to true if you want the textfield to be readOnly, default is `false` + /// - leftView: The TextField's left view, default is `EmptyView` + /// - rightView: The TextField's right view, default is `EmptyView` public init(_ titleKey: LocalizedStringKey, text: Binding, theme: Theme, From 063f3542f7df5790a790e4e7bfff2d05fefa4bfc Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 27 Mar 2024 11:55:46 +0100 Subject: [PATCH 035/117] [Formfield#858] Remove unnecessary parameters --- .../Components/FormField/View/SwiftUI/FormFieldView.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift index 14f994a42..55f399d03 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift @@ -22,16 +22,13 @@ public struct FormFieldView: View { /// - title: The formfield title. /// - description: The formfield helper message. /// - isTitleRequired: The asterisk symbol at the end of title. - /// - isEnabled: The formfield's component isEnabled value. public init( theme: Theme, @ViewBuilder component: @escaping () -> Component, feedbackState: FormFieldFeedbackState = .default, title: String? = nil, description: String? = nil, - isTitleRequired: Bool = false, - isEnabled: Bool = true, - isSelected: Bool = false + isTitleRequired: Bool = false ) { let attributedTitle: AttributedString? = title.map(AttributedString.init) let attributedDescription: AttributedString? = description.map(AttributedString.init) From 9fa36f124bc46e3b79c518b9652f57b56c223d82 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 27 Mar 2024 12:05:50 +0100 Subject: [PATCH 036/117] [Formfield#858] Add identifier for child component --- .../Components/FormField/View/SwiftUI/FormFieldView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift index 55f399d03..c73367999 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift @@ -87,6 +87,7 @@ public struct FormFieldView: View { .foregroundStyle(self.viewModel.descriptionColor.color) } } + .accessibilityElement(children: .contain) .accessibilityIdentifier(FormFieldAccessibilityIdentifier.formField) } } From 7f87f471305364368a6911a4f0b66b937786249e Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 12:06:40 +0100 Subject: [PATCH 037/117] [TextField] Added accessibilityIdentifiers --- .../TextFieldAddonsAccessibilityIdentifier.swift | 16 ++++++++++++++++ .../Addons/View/SwiftUI/TextFieldAddons.swift | 2 ++ .../View/UIKit/TextFieldAddonsUIView.swift | 12 ++++++++---- .../TextFieldAccessibilityIdentifier.swift | 16 ++++++++++++++++ .../TextField/View/SwiftUI/TextFieldView.swift | 1 + .../TextField/View/UIKit/TextFieldUIView.swift | 1 + 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 core/Sources/Components/TextField/Addons/View/AccessibilityIdentifiier/TextFieldAddonsAccessibilityIdentifier.swift create mode 100644 core/Sources/Components/TextField/View/AccessibilityIdentifiier/TextFieldAccessibilityIdentifier.swift diff --git a/core/Sources/Components/TextField/Addons/View/AccessibilityIdentifiier/TextFieldAddonsAccessibilityIdentifier.swift b/core/Sources/Components/TextField/Addons/View/AccessibilityIdentifiier/TextFieldAddonsAccessibilityIdentifier.swift new file mode 100644 index 000000000..7187bc6b5 --- /dev/null +++ b/core/Sources/Components/TextField/Addons/View/AccessibilityIdentifiier/TextFieldAddonsAccessibilityIdentifier.swift @@ -0,0 +1,16 @@ +// +// TextFieldAddonsAccessibilityIdentifier.swift +// SparkCore +// +// Created by louis.borlee on 27/03/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +/// The accessibility identifiers for the textfieldaddons. +public enum TextFieldAddonsAccessibilityIdentifier { + + /// The textfieldaddons accessibility identifier. + public static let view = "spark-textfieldaddons" +} diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift index 1104894cb..4c3f342ce 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -132,6 +132,8 @@ public struct TextFieldAddons: View { } rightView() } + .accessibilityIdentifier(TextFieldAccessibilityIdentifier.view) } } diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift index 075a57671..c10520fa0 100644 --- a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift @@ -154,6 +154,7 @@ public final class TextFieldUIView: UITextField { self.subscribeToViewModel() self.setRightView() self.setContentCompressionResistancePriority(.required, for: .vertical) + self.accessibilityIdentifier = TextFieldAccessibilityIdentifier.view } private func subscribeToViewModel() { From d5c6fd59c809d3e39ed474b29bcd1927210cc043 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 27 Mar 2024 12:20:48 +0100 Subject: [PATCH 038/117] [Formfield#782] Add accessibility element to child component --- .../Components/FormField/View/UIKit/FormFieldUIView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 91f1c3a3b..8ed2f7709 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -147,6 +147,7 @@ public final class FormFieldUIView: UIControl { public var component: Component { didSet { oldValue.removeFromSuperview() + self.component.isAccessibilityElement = true self.stackView.insertArrangedSubview(self.component, at: 1) } } From 4f87d022f151356a699f40081cab9e1249838690 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 27 Mar 2024 15:14:36 +0100 Subject: [PATCH 039/117] [TextField] Switched from updateIfNeeded to removeDuplicates to handle duplication logic --- .../View/UIKit/TextFieldAddonsUIView.swift | 22 +- .../ViewModel/TextFieldAddonsViewModel.swift | 15 +- .../TextFieldAddonsViewModelTests.swift | 133 +--------- .../View/UIKit/TextFieldUIView.swift | 34 ++- .../ViewModel/TextFieldViewModel.swift | 23 +- .../ViewModel/TextFieldViewModelTests.swift | 234 +----------------- 6 files changed, 63 insertions(+), 398 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift index a8af6d532..8b8b08ad0 100644 --- a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -134,20 +134,23 @@ public final class TextFieldAddonsUIView: UIControl { } private func subscribeToViewModel() { - self.viewModel.$backgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in + self.viewModel.$backgroundColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] backgroundColor in guard let self else { return } self.backgroundColor = backgroundColor.uiColor - self.textField.backgroundColor = .clear } - self.viewModel.textFieldViewModel.$borderColor.subscribe(in: &self.cancellables) { [weak self] borderColor in + self.viewModel.textFieldViewModel.$borderColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] borderColor in guard let self else { return } self.setBorderColor(from: borderColor) self.leftSeparatorView.backgroundColor = borderColor.uiColor self.rightSeparatorView.backgroundColor = borderColor.uiColor } - self.viewModel.$borderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in + self.viewModel.$borderWidth.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] borderWidth in guard let self else { return } let width = borderWidth * self.scaleFactor self.setBorderWidth(width) @@ -155,32 +158,33 @@ public final class TextFieldAddonsUIView: UIControl { self.rightSeparatorWidthConstraint.constant = width } - self.viewModel.$borderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in + self.viewModel.$borderRadius.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] borderRadius in guard let self else { return } self.setCornerRadius(borderRadius) } - self.viewModel.$leftSpacing.subscribe(in: &self.cancellables) { [weak self] leftSpacing in + self.viewModel.$leftSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] leftSpacing in guard let self else { return } self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? leftSpacing : .zero self.setNeedsLayout() } - self.viewModel.$rightSpacing.subscribe(in: &self.cancellables) { [weak self] rightSpacing in + self.viewModel.$rightSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] rightSpacing in guard let self else { return } self.trailingConstraint.constant = self.rightAddonContainer.isHidden ? -rightSpacing : .zero self.setNeedsLayout() } - self.viewModel.$contentSpacing.subscribe(in: &self.cancellables) { [weak self] contentSpacing in + self.viewModel.$contentSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] contentSpacing in guard let self else { return } self.stackView.spacing = contentSpacing self.setNeedsLayout() } - self.viewModel.$dim.subscribe(in: &self.cancellables) { [weak self] dim in + self.viewModel.$dim.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } self.alpha = dim + self.setNeedsLayout() } } diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift index ce45c538f..76f3c2f4e 100644 --- a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModel.swift @@ -62,38 +62,37 @@ final class TextFieldAddonsViewModel: ObservableObject, Updateable { private func subscribe() { self.textFieldViewModel.$addonsBackgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in guard let self else { return } - self.updateIfNeeded(keyPath: \.backgroundColor, newValue: backgroundColor) + self.backgroundColor = backgroundColor } self.textFieldViewModel.$addonsLeftSpacing.subscribe(in: &self.cancellables) { [weak self] leftSpacing in guard let self else { return } - self.updateIfNeeded(keyPath: \.leftSpacing, newValue: leftSpacing) + self.leftSpacing = leftSpacing } self.textFieldViewModel.$addonsContentSpacing.subscribe(in: &self.cancellables) { [weak self] contentSpacing in guard let self else { return } - self.updateIfNeeded(keyPath: \.contentSpacing, newValue: contentSpacing) + self.contentSpacing = contentSpacing } self.textFieldViewModel.$addonsRightSpacing.subscribe(in: &self.cancellables) { [weak self] rightSpacing in guard let self else { return } - self.updateIfNeeded(keyPath: \.rightSpacing, newValue: rightSpacing) + self.rightSpacing = rightSpacing } self.textFieldViewModel.$addonsBorderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in guard let self else { return } - self.updateIfNeeded(keyPath: \.borderWidth, newValue: borderWidth) + self.borderWidth = borderWidth } self.textFieldViewModel.$addonsBorderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in guard let self else { return } - self.updateIfNeeded(keyPath: \.borderRadius, newValue: borderRadius) + self.borderRadius = borderRadius } self.textFieldViewModel.$addonsDim.subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } - self.updateIfNeeded(keyPath: \.dim, newValue: dim) + self.dim = dim } - } } diff --git a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift index e1b342b46..a2368d331 100644 --- a/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift +++ b/core/Sources/Components/TextField/Addons/ViewModel/TextFieldAddonsViewModelTests.swift @@ -107,21 +107,7 @@ final class TextFieldAddonsViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") } - func test_backgroundColor_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // GIVEN - let newBackgroundColor = self.expectedColors.background - - // WHEN - self.viewModel.textFieldViewModel.backgroundColor = newBackgroundColor - - // THEN - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled) - } - - func test_backgroundColor_set_not_equal() { + func test_set_backgroundColor() { self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init @@ -136,18 +122,7 @@ final class TextFieldAddonsViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.backgroundColor.equals(newBackgroundColor), "Wrong backgroundColor") } - func test_borderWidth_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.textFieldViewModel.setBorderLayout() - - // THEN - XCTAssertFalse(self.publishers.borderWidth.sinkCalled) - } - - func test_borderWidth_set_not_equal() { + func test_setBorderLayout() { self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init @@ -161,47 +136,11 @@ final class TextFieldAddonsViewModelTests: XCTestCase { // THEN XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "borderWidth should have been called once") XCTAssertEqual(self.viewModel.borderWidth, newValue.width, "Wrong borderWidth") - } - - func test_borderRadius_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.textFieldViewModel.setBorderLayout() - - // THEN - XCTAssertFalse(self.publishers.borderRadius.sinkCalled) - } - - func test_borderRadius_set_not_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // GIVEN - let newValue = TextFieldBorderLayout(radius: -1, width: -2) - self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReturnValue = newValue - - // WHEN - self.viewModel.textFieldViewModel.setBorderLayout() - - // THEN XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "borderRadius should have been called once") XCTAssertEqual(self.viewModel.borderRadius, newValue.radius, "Wrong borderRadius") } - func test_spacingLeft_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.textFieldViewModel.setSpacings() - - // THEN - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled) - } - - func test_leftSpacing_set_not_equal() { + func test_setSpacings() { self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init @@ -215,77 +154,13 @@ final class TextFieldAddonsViewModelTests: XCTestCase { // THEN XCTAssertEqual(self.publishers.leftSpacing.sinkCount, 1, "leftSpacing should have been called once") XCTAssertEqual(self.viewModel.leftSpacing, newValue.left, "Wrong leftSpacing") - } - - func test_spacingContent_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.textFieldViewModel.setSpacings() - - // THEN - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled) - } - - func test_contentSpacing_set_not_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // GIVEN - let newValue = TextFieldSpacings(left: -1, content: -2, right: -3) - self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newValue - - // WHEN - self.viewModel.textFieldViewModel.setSpacings() - - // THEN XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "contentSpacing should have been called once") XCTAssertEqual(self.viewModel.contentSpacing, newValue.content, "Wrong contentSpacing") - } - - func test_spacingRight_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.textFieldViewModel.setSpacings() - - // THEN - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled) - } - - func test_rightSpacing_set_not_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // GIVEN - let newValue = TextFieldSpacings(left: -1, content: -2, right: -3) - self.getSpacingsUseCase.executeWithThemeAndBorderStyleReturnValue = newValue - - // WHEN - self.viewModel.textFieldViewModel.setSpacings() - - // THEN XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "rightSpacing should have been called once") XCTAssertEqual(self.viewModel.rightSpacing, newValue.right, "Wrong rightSpacing") } - func test_dim_set_equal() { - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // GIVEN - let newDim = self.theme.dims.none - - // WHEN - self.viewModel.textFieldViewModel.dim = newDim - - // THEN - XCTAssertFalse(self.publishers.dim.sinkCalled) - } - - func test_dim_set_not_equal() { + func test_set_dim() { self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift index c10520fa0..3c2dd8f7d 100644 --- a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift @@ -158,58 +158,68 @@ public final class TextFieldUIView: UITextField { } private func subscribeToViewModel() { - self.viewModel.$textColor.subscribe(in: &self.cancellables) { [weak self] textColor in + self.viewModel.$textColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] textColor in guard let self else { return } self.textColor = textColor.uiColor self.tintColor = textColor.uiColor } - self.viewModel.$backgroundColor.subscribe(in: &self.cancellables) { [weak self] backgroundColor in + self.viewModel.$backgroundColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] backgroundColor in guard let self else { return } self.backgroundColor = backgroundColor.uiColor } - self.viewModel.$borderColor.subscribe(in: &self.cancellables) { [weak self] borderColor in + self.viewModel.$borderColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] borderColor in guard let self else { return } self.setBorderColor(from: borderColor) } - self.viewModel.$statusIconColor.subscribe(in: &self.cancellables) { [weak self] statusIconColor in + self.viewModel.$statusIconColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] statusIconColor in guard let self else { return } self.statusImageView.tintColor = statusIconColor.uiColor } - self.viewModel.$placeholderColor.subscribe(in: &self.cancellables) { [weak self] placeholderColor in + self.viewModel.$placeholderColor.removeDuplicates(by: { lhs, rhs in + lhs.equals(rhs) + }).subscribe(in: &self.cancellables) { [weak self] placeholderColor in guard let self else { return } self.setPlaceholder(self.placeholder, foregroundColor: placeholderColor, font: self.viewModel.font) } - self.viewModel.$borderWidth.subscribe(in: &self.cancellables) { [weak self] borderWidth in + self.viewModel.$borderWidth.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] borderWidth in guard let self else { return } self.setBorderWidth(borderWidth * self.scaleFactor) } - self.viewModel.$borderRadius.subscribe(in: &self.cancellables) { [weak self] borderRadius in + self.viewModel.$borderRadius.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] borderRadius in guard let self else { return } self.setCornerRadius(borderRadius) } - self.viewModel.$leftSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + self.viewModel.$leftSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } self.setNeedsLayout() } - self.viewModel.$rightSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + self.viewModel.$rightSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } self.setNeedsLayout() } - self.viewModel.$contentSpacing.subscribe(in: &self.cancellables) { [weak self] dim in + self.viewModel.$contentSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } self.setNeedsLayout() } - self.viewModel.$dim.subscribe(in: &self.cancellables) { [weak self] dim in + self.viewModel.$dim.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] dim in guard let self else { return } self.alpha = dim } @@ -220,7 +230,7 @@ public final class TextFieldUIView: UITextField { self.setPlaceholder(self.placeholder, foregroundColor: self.viewModel.placeholderColor, font: font) } - self.viewModel.$statusImage.subscribe(in: &self.cancellables) { [weak self] statusImage in + self.viewModel.$statusImage.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] statusImage in guard let self else { return } self.statusImageView.image = statusImage?.leftValue self.statusImageContainerView.isHidden = self.statusImageView.image == nil diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift index 5a1fb24ef..c4a6f7338 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift @@ -157,11 +157,11 @@ class TextFieldViewModel: ObservableObject, Updateable { isEnabled: self.isEnabled, isUserInteractionEnabled: self.isUserInteractionEnabled ) - self.updateIfNeeded(keyPath: \.textColor, newValue: colors.text) - self.updateIfNeeded(keyPath: \.placeholderColor, newValue: colors.placeholder) - self.updateIfNeeded(keyPath: \.borderColor, newValue: colors.border) - self.updateIfNeeded(keyPath: \.statusIconColor, newValue: colors.statusIcon) - self.updateIfNeeded(keyPath: \.backgroundColor, newValue: colors.background) + self.textColor = colors.text + self.placeholderColor = colors.placeholder + self.borderColor = colors.border + self.statusIconColor = colors.statusIcon + self.backgroundColor = colors.background } func setBorderLayout() { @@ -170,20 +170,19 @@ class TextFieldViewModel: ObservableObject, Updateable { borderStyle: self.borderStyle, //.none isFocused: self.isFocused ) - self.updateIfNeeded(keyPath: \.borderWidth, newValue: borderLayout.width) - self.updateIfNeeded(keyPath: \.borderRadius, newValue: borderLayout.radius) + self.borderWidth = borderLayout.width + self.borderRadius = borderLayout.radius } func setSpacings() { let spacings = self.getSpacingsUseCase.execute(theme: self.theme, borderStyle: self.borderStyle) - self.updateIfNeeded(keyPath: \.leftSpacing, newValue: spacings.left) - self.updateIfNeeded(keyPath: \.contentSpacing, newValue: spacings.content) - self.updateIfNeeded(keyPath: \.rightSpacing, newValue: spacings.right) + self.leftSpacing = spacings.left + self.contentSpacing = spacings.content + self.rightSpacing = spacings.right } func setDim() { - let dim = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 - self.updateIfNeeded(keyPath: \.dim, newValue: dim) + self.dim = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 } private func setFont() { diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift index c87ec7b31..dc7a112d6 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift @@ -202,7 +202,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.contentSpacing.sinkCount, 1, "$contentSpacing should have been called once") XCTAssertEqual(self.publishers.rightSpacing.sinkCount, 1, "$rightSpacing should have been called once") - XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") XCTAssertEqual(self.publishers.font.sinkCount, 1, "$font should have been called once") XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } @@ -244,48 +244,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } - func test_intent_didSet_notEqual_samePublishedValues() throws { - // GIVEN - Inits from setUp() - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.intent = .error - - // THEN - XCTAssertEqual(self.viewModel.statusImage, self.errorImage, "Wrong statusImage") - - // Then - Colors - XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") - let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") - XCTAssertEqual(getColorsReceivedArguments.intent, .error, "Wrong getColorsReceivedArguments.intent") - - // THEN - Border Layout - XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") - - // THEN - Spacings - XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") - - // THEN - Publishers - XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") - XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") - XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") - - XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") - XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") - - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") - - XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") - XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertEqual(self.publishers.statusImage.sinkCount, 1, "$statusImage should have been called once") - } - - func test_intent_didSet_notEqual_differentPublishedValues() throws { + func test_intent_didSet_notEqual() throws { // GIVEN - Inits from setUp() self.viewModel.intent = .alert self.resetUseCases() // Removes execute from init @@ -378,47 +337,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } - func test_borderStyle_didSet_notEqual_samePublishedValues() throws { - // GIVEN - Inits from setUp() - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.borderStyle = .none - - // Then - Colors - XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCalled, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should not have been called") - - // THEN - Border Layout - XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") - let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") - XCTAssertEqual(getBorderLayoutReceivedArguments.borderStyle, .none, "Wrong getBorderLayoutReceivedArguments.borderStyle") - - // THEN - Spacings - XCTAssertEqual(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCallsCount, 1, "getSpacingsUseCase.executeWithThemeAndBorderStyle should have been called once") - let getSpacingsUseCaseReceivedArguments = try XCTUnwrap(self.getSpacingsUseCase.executeWithThemeAndBorderStyleReceivedArguments, "Couldn't unwrap getSpacingsUseCaseReceivedArguments") - XCTAssertEqual(getSpacingsUseCaseReceivedArguments.borderStyle, .none, "Wrong getSpacingsUseCaseReceivedArguments.borderStyle") - - // THEN - Publishers - XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") - XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") - XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") - - XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") - XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") - - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") - - XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") - XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") - } - - func test_borderStyle_didSet_notEqual_differentPublishedValues() throws { + func test_borderStyle_didSet_notEqual() throws { // GIVEN - Inits from setUp() self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init @@ -506,56 +425,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } - func test_isFocused_didSet_notEqual_samePublishedValues() throws { - // GIVEN - Inits from setUp() - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.isFocused = true - - // Then - Colors - XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") - let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") - XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") - XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") - XCTAssertTrue(getColorsReceivedArguments.isFocused, "Wrong getColorsReceivedArguments.isFocused") - XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") - XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") - XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") - XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") - - // THEN - Border Layout - XCTAssertEqual(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCallsCount, 1, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should have been called once") - let getBorderLayoutReceivedArguments = try XCTUnwrap(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedReceivedArguments, "Couldn't unwrap getBorderLayoutReceivedArguments") - XCTAssertTrue(getBorderLayoutReceivedArguments.isFocused, "Wrong getBorderLayoutReceivedArguments.isFocused") - XCTAssertEqual(self.viewModel.borderWidth, self.expectedBorderLayout.width, "Wrong borderWidth") - XCTAssertEqual(self.viewModel.borderRadius, self.expectedBorderLayout.radius, "Wrong borderRadius") - - // THEN - Spacings - XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") - - // THEN - Publishers - XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") - XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") - XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") - - XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") - XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") - - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") - - XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") - XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") - } - - func test_isFocused_didSet_notEqual_differentPublishedValues() throws { + func test_isFocused_didSet_notEqual() throws { // GIVEN - Inits from setUp() self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init @@ -653,54 +523,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } - func test_isEnabled_didSet_notEqual_samePublishedValues() throws { - // GIVEN - Inits from setUp() - self.viewModel.intent = .neutral - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.isEnabled = false - - XCTAssertNil(self.viewModel.statusImage, "statusImage should be nil when isEnabled is false") - // Then - Colors - XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") - let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") - XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") - XCTAssertEqual(getColorsReceivedArguments.intent, .neutral, "Wrong getColorsReceivedArguments.intent") - XCTAssertFalse(getColorsReceivedArguments.isEnabled, "Wrong getColorsReceivedArguments.isEnabled") - XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") - XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") - XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") - XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") - - // THEN - Border Layout - XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") - - // THEN - Spacings - XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") - - // THEN - Publishers - XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") - XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") - XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") - - XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") - XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") - - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") - - XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") - XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should not have been called") - } - - func test_isEnabled_didSet_notEqual_differentPublishedValues() throws { + func test_isEnabled_didSet_notEqual() throws { // GIVEN - Inits from setUp() self.viewModel.isEnabled = false self.resetUseCases() // Removes execute from init @@ -793,52 +616,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } - func test_isUserInteractionEnabled_didSet_notEqual_samePublishedValues() throws { - // GIVEN - Inits from setUp() - self.resetUseCases() // Removes execute from init - self.publishers.reset() // Removes publishes from init - - // WHEN - self.viewModel.isUserInteractionEnabled = false - - // Then - Colors - XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") - let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") - XCTAssertIdentical(getColorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong getColorsReceivedArguments.theme") - XCTAssertEqual(getColorsReceivedArguments.intent, self.intent, "Wrong getColorsReceivedArguments.intent") - XCTAssertFalse(getColorsReceivedArguments.isUserInteractionEnabled, "Wrong getColorsReceivedArguments.isUserInteractionEnabled") - XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") - XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") - XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") - XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") - - // THEN - Border Layout - XCTAssertFalse(self.getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocusedCalled, "getBorderLayoutUseCase.executeWithThemeAndBorderStyleAndIsFocused should not have been called") - - // THEN - Spacings - XCTAssertFalse(self.getSpacingsUseCase.executeWithThemeAndBorderStyleCalled, "getSpacingsUseCase.executeWithThemeAndBorderStyle should not have been called") - - // THEN - Publishers - XCTAssertFalse(self.publishers.textColor.sinkCalled, "$textColor should not have been called") - XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") - XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") - XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") - - XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") - XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") - - XCTAssertFalse(self.publishers.leftSpacing.sinkCalled, "$leftSpacing should not have been called") - XCTAssertFalse(self.publishers.contentSpacing.sinkCalled, "$contentSpacing should not have been called") - XCTAssertFalse(self.publishers.rightSpacing.sinkCalled, "$rightSpacing should not have been called") - - XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") - XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") - } - - func test_isUserInteractionEnabled_didSet_notEqual_differentPublishedValues() throws { + func test_isUserInteractionEnabled_didSet_notEqual() throws { // GIVEN - Inits from setUp() self.resetUseCases() // Removes execute from init self.publishers.reset() // Removes publishes from init From ec9aaed1f224043a6073c9aae6dd61362031b891 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 28 Mar 2024 14:08:35 +0100 Subject: [PATCH 040/117] [TextField] Added forgotten demo asset alert-outline --- .../alert-circle.imageset/Contents.json | 16 ++++++++++++++++ .../alert-circle.imageset/alert-outline.svg | 4 ++++ 2 files changed, 20 insertions(+) create mode 100644 spark/Demo/Assets.xcassets/alert-circle.imageset/Contents.json create mode 100644 spark/Demo/Assets.xcassets/alert-circle.imageset/alert-outline.svg diff --git a/spark/Demo/Assets.xcassets/alert-circle.imageset/Contents.json b/spark/Demo/Assets.xcassets/alert-circle.imageset/Contents.json new file mode 100644 index 000000000..04fa717e8 --- /dev/null +++ b/spark/Demo/Assets.xcassets/alert-circle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "alert-outline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/spark/Demo/Assets.xcassets/alert-circle.imageset/alert-outline.svg b/spark/Demo/Assets.xcassets/alert-circle.imageset/alert-outline.svg new file mode 100644 index 000000000..834358d6c --- /dev/null +++ b/spark/Demo/Assets.xcassets/alert-circle.imageset/alert-outline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 41b216c2312b7db00c0c1a3eac23f1dd2d3af0df Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 28 Mar 2024 16:08:44 +0100 Subject: [PATCH 041/117] [TextField] Added forgotten .accessibilityElement(children: .contain) in textfield swiftui --- .../Components/TextField/View/SwiftUI/TextFieldView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift index 17ac0c1f7..f89aa63a3 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -155,6 +155,7 @@ public struct TextFieldView: View { } rightView() } + .accessibilityElement(children: .contain) .accessibilityIdentifier(TextFieldAccessibilityIdentifier.view) } } From 88bd427c0cd32427c6dbe03d8f312a267f14009e Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 28 Mar 2024 17:23:28 +0100 Subject: [PATCH 042/117] [TextField] Added refresh layout button in demos --- .../TextFieldAddonsComponentUIView.swift | 7 +++++++ .../TextFieldAddonsComponentUIViewModel.swift | 20 ++++++++++++++++++- .../UIKit/TextFieldComponentUIView.swift | 7 +++++++ .../UIKit/TextFieldComponentUIViewModel.swift | 20 ++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift index e167c2084..d5245f3fa 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift @@ -94,6 +94,13 @@ final class TextFieldAddonsComponentUIView: ComponentUIView { self.textFieldAddons.setLeftAddon(self.textFieldAddons.leftAddon, withPadding: addonPadding) self.textFieldAddons.setRightAddon(self.textFieldAddons.rightAddon, withPadding: addonPadding) } + + self.viewModel.refreshLayout.subscribe(in: &self.cancellables) { [weak self] in + guard let self else { return } + self.textFieldAddons.textField.invalidateIntrinsicContentSize() + self.textFieldAddons.textField.setNeedsLayout() + self.textFieldAddons.textField.layoutIfNeeded() + } } private func getContentView(from content: TextFieldSideViewContent, side: TextFieldContentSide) -> UIView? { diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift index 04428b9c4..5720dc4c7 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewModel.swift @@ -47,6 +47,10 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl self.showRightAddonContentSheetSubject .eraseToAnyPublisher() } + var refreshLayout: AnyPublisher { + self.refreshLayoutSubject + .eraseToAnyPublisher() + } lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { return .init( @@ -115,6 +119,13 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl target: (source: self, action: #selector(self.toggleAddonPadding)) ) }() + lazy var refreshLayoutConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RefreshLayout", + type: .button, + target: (source: self, #selector(self.triggerLayoutRefresh)) + ) + }() private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() private var showIntentSheetSubject: PassthroughSubject<[TextFieldIntent], Never> = .init() @@ -122,6 +133,7 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl private var showRightViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() private var showLeftAddonContentSheetSubject: PassthroughSubject<[TextFieldAddonContent], Never> = .init() private var showRightAddonContentSheetSubject: PassthroughSubject<[TextFieldAddonContent], Never> = .init() + private var refreshLayoutSubject: PassthroughSubject = .init() let themes = ThemeCellModel.themes @@ -132,6 +144,7 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl self.theme = theme self.intent = intent super.init(identifier: "TextFieldAddons") + self.refreshLayoutConfigurationItemViewModel.buttonTitle = "Refresh layout" } override func configurationItemsViewModel() -> [ComponentsConfigurationItemUIViewModel] { @@ -144,7 +157,8 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl self.rightViewContentConfigurationItemViewModel, self.leftAddonContentConfigurationItemViewModel, self.rightAddonContentConfigurationItemViewModel, - self.addonPaddingConfigurationItemViewModel + self.addonPaddingConfigurationItemViewModel, + self.refreshLayoutConfigurationItemViewModel ] } @@ -183,4 +197,8 @@ final class TextFieldAddonsComponentUIViewModel: ComponentUIViewModel, Observabl @objc func toggleAddonPadding() { self.addonPadding.toggle() } + + @objc func triggerLayoutRefresh() { + self.refreshLayoutSubject.send() + } } diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift index fbe749a07..eb1d02d79 100644 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift @@ -93,6 +93,13 @@ final class TextFieldComponentUIView: ComponentUIView { self.viewModel.rightViewContentConfigurationItemViewModel.buttonTitle = content.name self.textField.rightView = self.getContentView(from: content, side: .right) } + + self.viewModel.refreshLayout.subscribe(in: &self.cancellables) { [weak self] in + guard let self else { return } + self.textField.invalidateIntrinsicContentSize() + self.textField.setNeedsLayout() + self.textField.layoutIfNeeded() + } } private func getContentView(from content: TextFieldSideViewContent, side: TextFieldContentSide) -> UIView? { diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift index 1e11e3e5e..037518c8e 100644 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewModel.swift @@ -51,6 +51,10 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec self.showRightViewContentSheetSubject .eraseToAnyPublisher() } + var refreshLayout: AnyPublisher { + self.refreshLayoutSubject + .eraseToAnyPublisher() + } let themes = ThemeCellModel.themes @@ -123,6 +127,13 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec target: (source: self, action: #selector(self.presentRightViewContent)) ) }() + lazy var refreshLayoutConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "RefreshLayout", + type: .button, + target: (source: self, #selector(self.triggerLayoutRefresh)) + ) + }() private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() private var showIntentSheetSubject: PassthroughSubject<[TextFieldIntent], Never> = .init() @@ -131,6 +142,7 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec private var showRightViewModeSheetSubject: PassthroughSubject<[UITextField.ViewMode], Never> = .init() private var showLeftViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() private var showRightViewContentSheetSubject: PassthroughSubject<[TextFieldSideViewContent], Never> = .init() + private var refreshLayoutSubject: PassthroughSubject = .init() init( theme: Theme, @@ -139,6 +151,7 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec self.theme = theme self.intent = intent super.init(identifier: "TextField") + self.refreshLayoutConfigurationItemViewModel.buttonTitle = "Refresh layout" } override func configurationItemsViewModel() -> [ComponentsConfigurationItemUIViewModel] { @@ -151,7 +164,8 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec self.leftViewModeConfigurationItemViewModel, self.rightViewModeConfigurationItemViewModel, self.leftViewContentConfigurationItemViewModel, - self.rightViewContentConfigurationItemViewModel + self.rightViewContentConfigurationItemViewModel, + self.refreshLayoutConfigurationItemViewModel ] } @@ -190,6 +204,10 @@ final class TextFieldComponentUIViewModel: ComponentUIViewModel, ObservableObjec @objc func presentRightViewContent() { self.showRightViewContentSheetSubject.send(TextFieldSideViewContent.allCases) } + + @objc func triggerLayoutRefresh() { + self.refreshLayoutSubject.send() + } } extension UITextField.ViewMode: CaseIterable { From 990761894f23d173aa3b45dd0ab8b0d51a3b107e Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 2 Apr 2024 10:14:06 +0200 Subject: [PATCH 043/117] [TextField] Changed SwiftUI isSecure to type --- .../Addons/View/SwiftUI/TextFieldAddons.swift | 10 ++++---- .../View/SwiftUI/TextFieldView.swift | 25 ++++++++++--------- .../View/SwiftUI/TextFieldViewType.swift | 13 ++++++++++ .../TextFieldAddonsComponentView.swift | 17 ++++++++++++- .../SwiftUI/TextFieldComponentView.swift | 17 ++++++++++++- 5 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 core/Sources/Components/TextField/View/SwiftUI/TextFieldViewType.swift diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift index 4c3f342ce..2528b20c6 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -19,7 +19,7 @@ public struct TextFieldAddons LeftView private let rightView: () -> RightView @@ -32,7 +32,7 @@ public struct TextFieldAddons LeftView) = { EmptyView() }, rightView: @escaping (() -> RightView) = { EmptyView() }, @@ -64,7 +64,7 @@ public struct TextFieldAddons TextFieldView { - TextFieldView(titleKey: titleKey, text: $text, viewModel: viewModel.textFieldViewModel, isSecure: isSecure, leftView: leftView, rightView: rightView) + TextFieldView(titleKey: titleKey, text: $text, viewModel: viewModel.textFieldViewModel, type: type, leftView: leftView, rightView: rightView) } } diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift index f89aa63a3..1323a2889 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -20,7 +20,7 @@ public struct TextFieldView: View { private let titleKey: LocalizedStringKey @Binding private var text: String - private var isSecure: Bool + private var type: TextFieldViewType private let leftView: () -> LeftView private let rightView: () -> RightView @@ -28,13 +28,13 @@ public struct TextFieldView: View { init(titleKey: LocalizedStringKey, text: Binding, viewModel: TextFieldViewModel, - isSecure: Bool, + type: TextFieldViewType, leftView: @escaping (() -> LeftView), rightView: @escaping (() -> RightView)) { self.titleKey = titleKey self._text = text self.viewModel = viewModel - self.isSecure = isSecure + self.type = type self.leftView = leftView self.rightView = rightView } @@ -48,7 +48,7 @@ public struct TextFieldView: View { successImage: Image, alertImage: Image, errorImage: Image, - isSecure: Bool, + type: TextFieldViewType, isReadOnly: Bool, leftView: @escaping (() -> LeftView), rightView: @escaping (() -> RightView) @@ -66,7 +66,7 @@ public struct TextFieldView: View { titleKey: titleKey, text: text, viewModel: viewModel, - isSecure: isSecure, + type: type, leftView: leftView, rightView: rightView ) @@ -81,7 +81,7 @@ public struct TextFieldView: View { /// - successImage: Success image, will be shown in the rightView when intent = .success /// - alertImage: Alert image, will be shown in the rightView when intent = .alert /// - errorImage: Error image, will be shown in the rightView when intent = .error - /// - isSecure: Set this to true if you want a SecureField, default is `false` + /// - type: The type of field with its associated callback(s), default is `.standard()` /// - isReadOnly: Set this to true if you want the textfield to be readOnly, default is `false` /// - leftView: The TextField's left view, default is `EmptyView` /// - rightView: The TextField's right view, default is `EmptyView` @@ -92,7 +92,7 @@ public struct TextFieldView: View { successImage: Image, alertImage: Image, errorImage: Image, - isSecure: Bool = false, + type: TextFieldViewType = .standard(), isReadOnly: Bool = false, leftView: @escaping () -> LeftView = { EmptyView() }, rightView: @escaping () -> RightView = { EmptyView() }) { @@ -105,7 +105,7 @@ public struct TextFieldView: View { successImage: successImage, alertImage: alertImage, errorImage: errorImage, - isSecure: isSecure, + type: type, isReadOnly: isReadOnly, leftView: leftView, rightView: rightView @@ -138,11 +138,12 @@ public struct TextFieldView: View { HStack(spacing: self.viewModel.contentSpacing) { leftView() Group { - if isSecure { - SecureField(titleKey, text: $text) + switch type { + case .secure(let onCommit): + SecureField(titleKey, text: $text, onCommit: onCommit) .font(self.viewModel.font.font) - } else { - TextField(titleKey, text: $text) + case .standard(let onEditingChanged, let onCommit): + TextField(titleKey, text: $text, onEditingChanged: onEditingChanged, onCommit: onCommit) .font(self.viewModel.font.font) } } diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewType.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewType.swift new file mode 100644 index 000000000..46b28247a --- /dev/null +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewType.swift @@ -0,0 +1,13 @@ +// +// TextFieldViewType.swift +// SparkCore +// +// Created by louis.borlee on 02/04/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +/// A TextField type with its associated callback(s) +public enum TextFieldViewType { + case secure(onCommit: () -> Void = {}) + case standard(onEditingChanged: (Bool) -> Void = { _ in }, onCommit: () -> Void = {}) +} diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift index 597dea650..431e4860f 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift @@ -82,7 +82,7 @@ struct TextFieldAddonsComponentView: View { successImage: Image("check"), alertImage: Image("alert"), errorImage: Image("alert-circle"), - isSecure: self.isSecureState == .selected, + type: self.getTypeFromIsSecure(), isReadOnly: self.isReadOnlyState == .selected, leftView: { self.view(side: .left) @@ -110,6 +110,21 @@ struct TextFieldAddonsComponentView: View { ) } + private func getTypeFromIsSecure() -> TextFieldViewType { + switch isSecureState { + case .selected: + return .secure { + print("Secure: On commit called") + } + case .indeterminate, .unselected: + return .standard { isEditing in + print("Standard: On editing changed called with isEditing \(isEditing)") + } onCommit: { + print("Standard: On commit called") + } + } + } + enum ContentSide: String { case left case right diff --git a/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift index 36e9d253b..d4e59eda2 100644 --- a/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift +++ b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift @@ -65,7 +65,7 @@ struct TextFieldComponentView: View { successImage: Image("check"), alertImage: Image("alert"), errorImage: Image("alert-circle"), - isSecure: self.isSecureState == .selected, + type: self.getTypeFromIsSecure(), isReadOnly: self.isReadOnlyState == .selected, leftView: { self.view(side: .left) @@ -84,6 +84,21 @@ struct TextFieldComponentView: View { ) } + private func getTypeFromIsSecure() -> TextFieldViewType { + switch isSecureState { + case .selected: + return .secure { + print("Secure: On commit called") + } + case .indeterminate, .unselected: + return .standard { isEditing in + print("Standard: On editing changed called with isEditing \(isEditing)") + } onCommit: { + print("Standard: On commit called") + } + } + } + enum ContentSide: String { case left case right From af5942bfff853f35fe4988f22c1772648dd7f62f Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 2 Apr 2024 20:08:57 +0200 Subject: [PATCH 044/117] [Formfield#782] Change Either enum as a SparkAttributedString protocol --- .../FormField/Model/FormFieldColors.swift | 6 +-- .../FormField/Model/FormFieldViewModel.swift | 42 +++++++--------- .../Model/FormFieldViewModelTests.swift | 50 +++++++++---------- .../UseCase/FormFieldColorsUseCase.swift | 12 ++--- .../UseCase/FormFieldColorsUseCaseTests.swift | 18 +++---- .../View/UIKit/FormFieldUIView.swift | 24 ++++----- .../SparkAttributedString.swift | 13 +++++ 7 files changed, 86 insertions(+), 79 deletions(-) create mode 100644 core/Sources/Extension/SparkAttributedString/SparkAttributedString.swift diff --git a/core/Sources/Components/FormField/Model/FormFieldColors.swift b/core/Sources/Components/FormField/Model/FormFieldColors.swift index ab10b8820..023a80d0c 100644 --- a/core/Sources/Components/FormField/Model/FormFieldColors.swift +++ b/core/Sources/Components/FormField/Model/FormFieldColors.swift @@ -9,7 +9,7 @@ import Foundation struct FormFieldColors { - let titleColor: any ColorToken - let descriptionColor: any ColorToken - let asteriskColor: any ColorToken + let title: any ColorToken + let description: any ColorToken + let asterisk: any ColorToken } diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 18fcce612..e3b9d6c57 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -10,12 +10,11 @@ import Combine import SwiftUI import UIKit -final class FormFieldViewModel: ObservableObject { +final class FormFieldViewModel: ObservableObject { // MARK: - Internal properties - @Published private(set) var title: Either? - @Published var description: Either? - @Published var asteriskText: Either? + @Published private(set) var title: AS? + @Published var description: AS? @Published var titleFont: any TypographyFontToken @Published var descriptionFont: any TypographyFontToken @Published var titleColor: any ColorToken @@ -48,15 +47,15 @@ final class FormFieldViewModel: ObservableObject { var colors: FormFieldColors private var colorUseCase: FormFieldColorsUseCaseable - private var userDefinedTitle: Either? + private var userDefinedTitle: AS? private var asterisk: NSAttributedString = NSAttributedString() // MARK: - Init init( theme: Theme, feedbackState: FormFieldFeedbackState, - title: Either?, - description: Either?, + title: AS?, + description: AS?, isTitleRequired: Bool = false, colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase() ) { @@ -70,8 +69,8 @@ final class FormFieldViewModel: ObservableObject { self.spacing = self.theme.layout.spacing.small self.titleFont = self.theme.typography.body2 self.descriptionFont = self.theme.typography.caption - self.titleColor = self.colors.titleColor - self.descriptionColor = self.colors.descriptionColor + self.titleColor = self.colors.title + self.descriptionColor = self.colors.description self.updateAsterisk() self.setTitle(title) @@ -79,8 +78,8 @@ final class FormFieldViewModel: ObservableObject { private func updateColors() { self.colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) - self.titleColor = self.colors.titleColor - self.descriptionColor = self.colors.descriptionColor + self.titleColor = self.colors.title + self.descriptionColor = self.colors.description } private func updateFonts() { @@ -96,37 +95,32 @@ final class FormFieldViewModel: ObservableObject { self.asterisk = NSAttributedString( string: " *", attributes: [ - NSAttributedString.Key.foregroundColor: self.colors.asteriskColor.uiColor, + NSAttributedString.Key.foregroundColor: self.colors.asterisk.uiColor, NSAttributedString.Key.font : self.theme.typography.caption.uiFont ] ) } - func setTitle(_ title: Either?) { + func setTitle(_ title: AS?) { self.userDefinedTitle = title self.title = self.getTitleWithAsteriskIfNeeded() } - private func getTitleWithAsteriskIfNeeded() -> Either? { - switch self.userDefinedTitle { - case .left(let attributedString): - guard let attributedString else { return nil } + private func getTitleWithAsteriskIfNeeded() -> AS? { + if let attributedString = self.userDefinedTitle as? NSAttributedString { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) if self.isTitleRequired { mutableAttributedString.append(self.asterisk) } - return .left(mutableAttributedString) - - case .right(let attributedString): - guard var attributedString else { return nil } + return mutableAttributedString as? AS + } else if var attributedString = self.userDefinedTitle as? AttributedString { if self.isTitleRequired { attributedString.append(AttributedString(self.asterisk)) } - return .right(attributedString) - - case .none: return nil + return attributedString as? AS } + return nil } } diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift b/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift index 56dc7dd48..25737c155 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModelTests.swift @@ -28,11 +28,11 @@ final class FormFieldViewModelTests: XCTestCase { func test_init() throws { // Given - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: self.theme, feedbackState: .default, - title: .left(NSAttributedString(string: "Title")), - description: .left(NSAttributedString(string: "Description")), + title: NSAttributedString(string: "Title"), + description: NSAttributedString(string: "Description"), isTitleRequired: true ) @@ -40,38 +40,38 @@ final class FormFieldViewModelTests: XCTestCase { XCTAssertNotNil(viewModel.theme, "No theme set") XCTAssertNotNil(viewModel.feedbackState, "No feedback state set") XCTAssertNotNil(viewModel.isTitleRequired, "No title required set") - XCTAssertTrue(viewModel.title?.leftValue?.string.contains("*") ?? false) - XCTAssertEqual(viewModel.title?.leftValue?.string, "Title *") - XCTAssertEqual(viewModel.description?.leftValue?.string, "Description") + XCTAssertTrue(viewModel.title?.string.contains("*") ?? false) + XCTAssertEqual(viewModel.title?.string, "Title *") + XCTAssertEqual(viewModel.description?.string, "Description") XCTAssertEqual(viewModel.spacing, self.theme.layout.spacing.small) XCTAssertEqual(viewModel.titleFont.uiFont, self.theme.typography.body2.uiFont) XCTAssertEqual(viewModel.descriptionFont.uiFont, self.theme.typography.caption.uiFont) - XCTAssertEqual(viewModel.titleColor.uiColor, viewModel.colors.titleColor.uiColor) - XCTAssertEqual(viewModel.descriptionColor.uiColor, viewModel.colors.descriptionColor.uiColor) + XCTAssertEqual(viewModel.titleColor.uiColor, viewModel.colors.title.uiColor) + XCTAssertEqual(viewModel.descriptionColor.uiColor, viewModel.colors.description.uiColor) } func test_texts_right_value() { // Given - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: self.theme, feedbackState: .default, - title: .right(AttributedString("Title")), - description: .right(AttributedString("Description")), + title: AttributedString("Title"), + description: AttributedString("Description"), isTitleRequired: false ) // Then - XCTAssertEqual(viewModel.title?.rightValue, AttributedString("Title")) - XCTAssertEqual(viewModel.description?.rightValue, AttributedString("Description")) + XCTAssertEqual(viewModel.title, AttributedString("Title")) + XCTAssertEqual(viewModel.description, AttributedString("Description")) } func test_isTitleRequired() async { // Given - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: self.theme, feedbackState: .default, - title: .left(NSAttributedString("Title")), - description: .left(NSAttributedString("Description")), + title: NSAttributedString("Title"), + description: NSAttributedString("Description"), isTitleRequired: false ) @@ -81,7 +81,7 @@ final class FormFieldViewModelTests: XCTestCase { viewModel.$title.sink { title in - isTitleUpdated = title?.leftValue?.string.contains("*") ?? false + isTitleUpdated = title?.string.contains("*") ?? false expectation.fulfill() }.store(in: &cancellable) @@ -96,28 +96,28 @@ final class FormFieldViewModelTests: XCTestCase { func test_set_title() { // Given - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: self.theme, feedbackState: .default, - title: .left(NSAttributedString("Title")), - description: .left(NSAttributedString("Description")), + title: NSAttributedString("Title"), + description: NSAttributedString("Description"), isTitleRequired: true ) // When - viewModel.setTitle(.left(NSAttributedString("Title2"))) + viewModel.setTitle(NSAttributedString("Title2")) // Then - XCTAssertEqual(viewModel.title?.leftValue?.string, "Title2 *") + XCTAssertEqual(viewModel.title?.string, "Title2 *") } func test_set_feedback_state() { // Given - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: self.theme, feedbackState: .default, - title: .left(NSAttributedString("Title")), - description: .left(NSAttributedString("Description")), + title: NSAttributedString("Title"), + description: NSAttributedString("Description"), isTitleRequired: false ) diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift index 82a4a74fa..959c25d1f 100644 --- a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift @@ -19,15 +19,15 @@ struct FormFieldColorsUseCase: FormFieldColorsUseCaseable { switch state { case .default: return FormFieldColors( - titleColor: theme.colors.base.onSurface, - descriptionColor: theme.colors.base.onSurface.opacity(theme.dims.dim1), - asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + title: theme.colors.base.onSurface, + description: theme.colors.base.onSurface.opacity(theme.dims.dim1), + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) case .error: return FormFieldColors( - titleColor: theme.colors.base.onSurface, - descriptionColor: theme.colors.feedback.error, - asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + title: theme.colors.base.onSurface, + description: theme.colors.feedback.error, + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) } } diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift index 0f220ddc9..23c8ec570 100644 --- a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift @@ -36,21 +36,21 @@ final class FormFieldColorsUseCaseTests: XCTestCase { switch $0 { case .default: expectedFormfieldColor = FormFieldColors( - titleColor: theme.colors.base.onSurface, - descriptionColor: theme.colors.base.onSurface.opacity(theme.dims.dim1), - asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + title: theme.colors.base.onSurface, + description: theme.colors.base.onSurface.opacity(theme.dims.dim1), + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) case .error: expectedFormfieldColor = FormFieldColors( - titleColor: theme.colors.base.onSurface, - descriptionColor: theme.colors.feedback.error, - asteriskColor: theme.colors.base.onSurface.opacity(theme.dims.dim3) + title: theme.colors.base.onSurface, + description: theme.colors.feedback.error, + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) ) } - XCTAssertEqual(formfieldColors.titleColor.uiColor, expectedFormfieldColor.titleColor.uiColor) - XCTAssertEqual(formfieldColors.descriptionColor.uiColor, expectedFormfieldColor.descriptionColor.uiColor) - XCTAssertEqual(formfieldColors.asteriskColor.uiColor, expectedFormfieldColor.asteriskColor.uiColor) + XCTAssertEqual(formfieldColors.title.uiColor, expectedFormfieldColor.title.uiColor) + XCTAssertEqual(formfieldColors.description.uiColor, expectedFormfieldColor.description.uiColor) + XCTAssertEqual(formfieldColors.asterisk.uiColor, expectedFormfieldColor.asterisk.uiColor) } } } diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 8ed2f7709..441f302fe 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -51,7 +51,7 @@ public final class FormFieldUIView: UIControl { return self.titleLabel.text } set { - self.viewModel.setTitle(.left(newValue.map(NSAttributedString.init))) + self.viewModel.setTitle(newValue.map(NSAttributedString.init)) } } @@ -61,7 +61,7 @@ public final class FormFieldUIView: UIControl { return self.titleLabel.attributedText } set { - self.viewModel.setTitle(.left(newValue)) + self.viewModel.setTitle(newValue) } } @@ -80,7 +80,7 @@ public final class FormFieldUIView: UIControl { return self.descriptionLabel.text } set { - self.viewModel.description = .left(newValue.map(NSAttributedString.init)) + self.viewModel.description = newValue.map(NSAttributedString.init) } } @@ -90,7 +90,7 @@ public final class FormFieldUIView: UIControl { return self.descriptionLabel.attributedText } set { - self.viewModel.description = .left(newValue) + self.viewModel.description = newValue } } @@ -152,7 +152,7 @@ public final class FormFieldUIView: UIControl { } } - var viewModel: FormFieldViewModel + var viewModel: FormFieldViewModel // MARK: - Initialization @@ -216,11 +216,11 @@ public final class FormFieldUIView: UIControl { isEnabled: Bool = true, isSelected: Bool = false ) { - let viewModel = FormFieldViewModel( + let viewModel = FormFieldViewModel( theme: theme, feedbackState: feedbackState, - title: .left(attributedTitle), - description: .left(attributedDescription), + title: attributedTitle, + description: attributedDescription, isTitleRequired: isTitleRequired ) @@ -263,11 +263,11 @@ public final class FormFieldUIView: UIControl { self.viewModel.$titleColor ).subscribe(in: &self.cancellables) { [weak self] title, font, color in guard let self else { return } - let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty + let labelHidden: Bool = (title?.string ?? "").isEmpty self.titleLabel.isHidden = labelHidden self.titleLabel.font = font.uiFont self.titleLabel.textColor = color.uiColor - self.titleLabel.attributedText = title?.leftValue + self.titleLabel.attributedText = title } Publishers.CombineLatest3( @@ -276,11 +276,11 @@ public final class FormFieldUIView: UIControl { self.viewModel.$descriptionColor ).subscribe(in: &self.cancellables) { [weak self] title, font, color in guard let self else { return } - let labelHidden: Bool = (title?.leftValue?.string ?? "").isEmpty + let labelHidden: Bool = (title?.string ?? "").isEmpty self.descriptionLabel.isHidden = labelHidden self.descriptionLabel.font = font.uiFont self.descriptionLabel.textColor = color.uiColor - self.descriptionLabel.attributedText = title?.leftValue + self.descriptionLabel.attributedText = title } self.viewModel.$spacing.subscribe(in: &self.cancellables) { [weak self] spacing in diff --git a/core/Sources/Extension/SparkAttributedString/SparkAttributedString.swift b/core/Sources/Extension/SparkAttributedString/SparkAttributedString.swift new file mode 100644 index 000000000..88f18757a --- /dev/null +++ b/core/Sources/Extension/SparkAttributedString/SparkAttributedString.swift @@ -0,0 +1,13 @@ +// +// SparkAttributedString.swift +// SparkCore +// +// Created by alican.aycil on 02.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +protocol SparkAttributedString {} +extension NSAttributedString: SparkAttributedString {} +extension AttributedString: SparkAttributedString {} From 94fe6614e0646e90b1b7fe72ae3543e301f20cea Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 3 Apr 2024 14:03:21 +0200 Subject: [PATCH 045/117] [TextField] Removed status icons --- .../Addons/View/SwiftUI/TextFieldAddons.swift | 11 +- .../View/UIKit/TextFieldAddonsUIView.swift | 14 +-- .../ViewModel/TextFieldAddonsViewModel.swift | 6 - .../TextFieldAddonsViewModelTests.swift | 8 -- .../TextFieldViewModelForAddons.swift | 6 - .../TextFieldViewModelForAddonsTests.swift | 3 - .../TextFieldColors+ExtensionTests.swift | 2 - .../TextField/Model/TextFieldColors.swift | 2 - .../GetColors/TextFieldGetColorsUseCase.swift | 3 - .../TextFieldGetColorsUseCaseTests.swift | 4 - .../View/SwiftUI/TextFieldView.swift | 23 +--- .../View/UIKit/TextFieldUIView.swift | 108 ++---------------- .../UIKit/TextFieldUIViewSnapshotTests.swift | 15 +-- .../ViewModel/TextFieldViewModel.swift | 41 ------- .../ViewModel/TextFieldViewModelTests.swift | 72 +----------- .../TextFieldAddonsComponentView.swift | 3 - .../TextFieldAddonsComponentUIView.swift | 5 +- .../SwiftUI/TextFieldComponentView.swift | 3 - .../UIKit/TextFieldComponentUIView.swift | 5 +- 19 files changed, 24 insertions(+), 310 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift index 2528b20c6..92bef1ede 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -29,9 +29,6 @@ public struct TextFieldAddons, theme: Theme, intent: TextFieldIntent, - successImage: Image, - alertImage: Image, - errorImage: Image, type: TextFieldViewType = .standard(), isReadOnly: Bool, leftView: @escaping (() -> LeftView) = { EmptyView() }, @@ -55,10 +49,7 @@ public struct TextFieldAddons TextFieldColors { return .init( text: text, placeholder: placeholder, border: border, - statusIcon: statusIcon, background: background ) } diff --git a/core/Sources/Components/TextField/Model/TextFieldColors.swift b/core/Sources/Components/TextField/Model/TextFieldColors.swift index 35b90f15a..f558eb223 100644 --- a/core/Sources/Components/TextField/Model/TextFieldColors.swift +++ b/core/Sources/Components/TextField/Model/TextFieldColors.swift @@ -12,14 +12,12 @@ struct TextFieldColors: Equatable { let text: any ColorToken let placeholder: any ColorToken let border: any ColorToken - let statusIcon: any ColorToken let background: any ColorToken static func == (lhs: TextFieldColors, rhs: TextFieldColors) -> Bool { return lhs.text.equals(rhs.text) && lhs.placeholder.equals(rhs.placeholder) && lhs.border.equals(rhs.border) && - lhs.statusIcon.equals(rhs.statusIcon) && lhs.background.equals(rhs.background) } } diff --git a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift index 2406360d8..b6e5ea644 100644 --- a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift +++ b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCase.swift @@ -45,13 +45,10 @@ struct TextFieldGetColorsUseCase: TextFieldGetColorsUseCasable { background = theme.colors.base.onSurface.opacity(theme.dims.dim5) } - let statusIcon = theme.colors.feedback.neutral - return .init( text: text, placeholder: placeholder, border: border, - statusIcon: statusIcon, background: background ) } diff --git a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift index 37f013b22..2dcb1cfd2 100644 --- a/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift +++ b/core/Sources/Components/TextField/UseCase/GetColors/TextFieldGetColorsUseCaseTests.swift @@ -50,7 +50,6 @@ final class TextFieldGetColorsUseCaseTests: XCTestCase { XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") - XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") XCTAssertTrue(colors.background.equals(self.theme.colors.base.surface), "Wrong background color for intent: \(intent)") } @@ -91,7 +90,6 @@ final class TextFieldGetColorsUseCaseTests: XCTestCase { XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") - XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") XCTAssertTrue(colors.background.equals(self.theme.colors.base.surface), "Wrong background color for intent: \(intent)") } @@ -132,7 +130,6 @@ final class TextFieldGetColorsUseCaseTests: XCTestCase { XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") - XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") XCTAssertTrue(colors.background.equals(self.theme.colors.base.onSurface.opacity(theme.dims.dim5)), "Wrong background color for intent: \(intent)") } @@ -173,7 +170,6 @@ final class TextFieldGetColorsUseCaseTests: XCTestCase { XCTAssertTrue(colors.text.equals(self.theme.colors.base.onSurface), "Wrong text color for intent: \(intent)") XCTAssertTrue(colors.placeholder.equals(self.theme.colors.base.onSurface.opacity(self.theme.dims.dim1)), "Wrong placeholder color for intent: \(intent)") XCTAssertTrue(colors.border.equals(expectedBorderColor), "Wrong border color for intent: \(intent)") - XCTAssertTrue(colors.statusIcon.equals(theme.colors.feedback.neutral), "Wrong statusIcon color for intent: \(intent)") XCTAssertTrue(colors.background.equals(self.theme.colors.base.onSurface.opacity(theme.dims.dim5)), "Wrong background color for intent: \(intent)") } } diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift index 1323a2889..b9048aaf6 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -12,7 +12,6 @@ import SwiftUI public struct TextFieldView: View { @ScaledMetric private var height: CGFloat = 44 - @ScaledMetric private var imageSize: CGFloat = 16 @ScaledMetric private var scaleFactor: CGFloat = 1.0 @FocusState private var isFocused: Bool @@ -45,9 +44,6 @@ public struct TextFieldView: View { theme: Theme, intent: TextFieldIntent, borderStyle: TextFieldBorderStyle, - successImage: Image, - alertImage: Image, - errorImage: Image, type: TextFieldViewType, isReadOnly: Bool, leftView: @escaping (() -> LeftView), @@ -56,10 +52,7 @@ public struct TextFieldView: View { let viewModel = TextFieldViewModel( theme: theme, intent: intent, - borderStyle: borderStyle, - successImage: .right(successImage), - alertImage: .right(alertImage), - errorImage: .right(errorImage) + borderStyle: borderStyle ) viewModel.isUserInteractionEnabled = isReadOnly != true self.init( @@ -78,9 +71,6 @@ public struct TextFieldView: View { /// - text: The textfield's text binding /// - theme: The textfield's current theme /// - intent: The textfield's current intent - /// - successImage: Success image, will be shown in the rightView when intent = .success - /// - alertImage: Alert image, will be shown in the rightView when intent = .alert - /// - errorImage: Error image, will be shown in the rightView when intent = .error /// - type: The type of field with its associated callback(s), default is `.standard()` /// - isReadOnly: Set this to true if you want the textfield to be readOnly, default is `false` /// - leftView: The TextField's left view, default is `EmptyView` @@ -89,9 +79,6 @@ public struct TextFieldView: View { text: Binding, theme: Theme, intent: TextFieldIntent, - successImage: Image, - alertImage: Image, - errorImage: Image, type: TextFieldViewType = .standard(), isReadOnly: Bool = false, leftView: @escaping () -> LeftView = { EmptyView() }, @@ -102,9 +89,6 @@ public struct TextFieldView: View { theme: theme, intent: intent, borderStyle: .roundedRect, - successImage: successImage, - alertImage: alertImage, - errorImage: errorImage, type: type, isReadOnly: isReadOnly, leftView: leftView, @@ -149,11 +133,6 @@ public struct TextFieldView: View { } .textFieldStyle(.plain) .foregroundStyle(self.viewModel.textColor.color) - if let statusImage = viewModel.statusImage { - statusImage.rightValue - .resizable() - .frame(width: imageSize, height: imageSize) - } rightView() } .accessibilityElement(children: .contain) diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift index 3c2dd8f7d..8ab48b95a 100644 --- a/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIView.swift @@ -18,10 +18,6 @@ public final class TextFieldUIView: UITextField { @ScaledUIMetric private var height: CGFloat = 44 @ScaledUIMetric private var scaleFactor: CGFloat = 1.0 - private var statusImageSize: CGFloat { - return 16 * self.scaleFactor - } - private let defaultClearButtonRightSpacing = 5.0 public override var placeholder: String? { @@ -48,27 +44,6 @@ public final class TextFieldUIView: UITextField { get { return .init(self.viewModel.borderStyle) } } - private var statusImageView = UIImageView() - private var statusImageHeightConstraint: NSLayoutConstraint = NSLayoutConstraint() - private var statusImageWidthConstraint: NSLayoutConstraint = NSLayoutConstraint() - private var statusImageContainerView = UIView() - private lazy var rightStackView: UIStackView = UIStackView() - private var userRightView: UIView? - public override var rightView: UIView? { - get { return self.userRightView } - set { - if let userRightView { - self.rightStackView.removeArrangedSubview(userRightView) - userRightView.removeFromSuperview() - } - if let newValue { - self.rightStackView.addArrangedSubview(newValue) - } - self.userRightView = newValue - self.setRightView() - } - } - /// The textfield's current theme. public var theme: Theme { get { @@ -99,19 +74,13 @@ public final class TextFieldUIView: UITextField { internal convenience init( theme: Theme, intent: TextFieldIntent, - borderStyle: TextFieldBorderStyle, - successImage: UIImage, - alertImage: UIImage, - errorImage: UIImage + borderStyle: TextFieldBorderStyle ) { self.init( viewModel: .init( theme: theme, intent: intent, - borderStyle: borderStyle, - successImage: .left(successImage), - alertImage: .left(alertImage), - errorImage: .left(errorImage) + borderStyle: borderStyle ) ) } @@ -120,23 +89,14 @@ public final class TextFieldUIView: UITextField { /// - Parameters: /// - theme: The textfield's current theme /// - intent: The textfield's current intent - /// - successImage: Success image, will be shown in the rightView when intent = .success - /// - alertImage: Alert image, will be shown in the rightView when intent = .alert - /// - errorImage: Error image, will be shown in the rightView when intent = .error public convenience init( theme: Theme, - intent: TextFieldIntent, - successImage: UIImage, - alertImage: UIImage, - errorImage: UIImage + intent: TextFieldIntent ) { self.init( theme: theme, intent: intent, - borderStyle: .roundedRect, - successImage: successImage, - alertImage: alertImage, - errorImage: errorImage + borderStyle: .roundedRect ) } @@ -144,15 +104,8 @@ public final class TextFieldUIView: UITextField { fatalError("init(coder:) has not been implemented") } - public override func layoutSubviews() { - super.layoutSubviews() - self.rightStackView.spacing = self.viewModel.contentSpacing - } - private func setupView() { - self.setupRightStackView() self.subscribeToViewModel() - self.setRightView() self.setContentCompressionResistancePriority(.required, for: .vertical) self.accessibilityIdentifier = TextFieldAccessibilityIdentifier.view } @@ -180,13 +133,6 @@ public final class TextFieldUIView: UITextField { self.setBorderColor(from: borderColor) } - self.viewModel.$statusIconColor.removeDuplicates(by: { lhs, rhs in - lhs.equals(rhs) - }).subscribe(in: &self.cancellables) { [weak self] statusIconColor in - guard let self else { return } - self.statusImageView.tintColor = statusIconColor.uiColor - } - self.viewModel.$placeholderColor.removeDuplicates(by: { lhs, rhs in lhs.equals(rhs) }).subscribe(in: &self.cancellables) { [weak self] placeholderColor in @@ -229,14 +175,6 @@ public final class TextFieldUIView: UITextField { self.font = font.uiFont self.setPlaceholder(self.placeholder, foregroundColor: self.viewModel.placeholderColor, font: font) } - - self.viewModel.$statusImage.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] statusImage in - guard let self else { return } - self.statusImageView.image = statusImage?.leftValue - self.statusImageContainerView.isHidden = self.statusImageView.image == nil - self.setRightView() - self.setNeedsLayout() - } } private func setAttributedPlaceholder(string: String, foregroundColor: UIColor, font: UIFont) { @@ -257,37 +195,6 @@ public final class TextFieldUIView: UITextField { } } - private func setupRightStackView() { - self.statusImageView.contentMode = .scaleAspectFit - self.statusImageView.clipsToBounds = true - self.statusImageContainerView.addSubview(self.statusImageView) - self.statusImageView.translatesAutoresizingMaskIntoConstraints = false - self.statusImageHeightConstraint = self.statusImageView.heightAnchor.constraint(equalToConstant: self.statusImageSize) - self.statusImageWidthConstraint = self.statusImageView.widthAnchor.constraint(equalToConstant: self.statusImageSize) - self.statusImageWidthConstraint.priority = UILayoutPriority.defaultHigh - self.statusImageHeightConstraint.priority = UILayoutPriority.defaultHigh - NSLayoutConstraint.activate([ - self.statusImageWidthConstraint, - self.statusImageHeightConstraint, - self.statusImageView.topAnchor.constraint(greaterThanOrEqualTo: self.statusImageContainerView.topAnchor), - self.statusImageView.leadingAnchor.constraint(equalTo: self.statusImageContainerView.leadingAnchor), - self.statusImageView.centerXAnchor.constraint(equalTo: self.statusImageContainerView.centerXAnchor), - self.statusImageView.centerYAnchor.constraint(equalTo: self.statusImageContainerView.centerYAnchor), - ]) - self.rightStackView.addArrangedSubview(self.statusImageContainerView) - self.rightStackView.alignment = .center - self.rightStackView.distribution = .fill - } - - private func setRightView() { - if self.statusImageContainerView.isHidden, - self.userRightView == nil { - super.rightView = nil - } else { - super.rightView = self.rightStackView - } - } - public override func becomeFirstResponder() -> Bool { let bool = super.becomeFirstResponder() self.viewModel.isFocused = bool @@ -313,8 +220,9 @@ public final class TextFieldUIView: UITextField { leftView.isDescendant(of: self) { totalInsets.left += leftView.bounds.size.width + contentSpacing } - if self.rightStackView.isDescendant(of: self) { - totalInsets.right += self.rightStackView.bounds.size.width + contentSpacing + if let rightView, + rightView.isDescendant(of: self) { + totalInsets.right += rightView.bounds.size.width + contentSpacing } if let clearButton = self.value(forKeyPath: "_clearButton") as? UIButton, clearButton.isDescendant(of: self) { @@ -364,8 +272,6 @@ public final class TextFieldUIView: UITextField { self._height.update(traitCollection: self.traitCollection) self._scaleFactor.update(traitCollection: self.traitCollection) - self.statusImageWidthConstraint.constant = self.statusImageSize - self.statusImageHeightConstraint.constant = self.statusImageSize self.setBorderWidth(self.viewModel.borderWidth * self.scaleFactor) self.invalidateIntrinsicContentSize() } diff --git a/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift b/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift index 9571e9219..309fbb2a3 100644 --- a/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift +++ b/core/Sources/Components/TextField/View/UIKit/TextFieldUIViewSnapshotTests.swift @@ -13,9 +13,6 @@ import UIKit final class TextFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { private let theme = SparkTheme.shared - private let successImage = UIImage(systemName: "checkmark") ?? UIImage() - private let alertImage = UIImage(systemName: "exclamationmark.triangle") ?? UIImage() - private let errorImage = UIImage(systemName: "exclamationmark.circle") ?? UIImage() private func _test(scenario: TextFieldScenario) { let configurations = self.createConfigurations(from: scenario) @@ -54,10 +51,7 @@ final class TextFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { let viewModel = TextFieldViewModel( theme: self.theme, intent: intent, - borderStyle: .roundedRect, - successImage: .left(self.successImage), - alertImage: .left(self.alertImage), - errorImage: .left(self.errorImage) + borderStyle: .roundedRect ) viewModel.isEnabled = states.isEnabled viewModel.isUserInteractionEnabled = states.isReadOnly != true @@ -65,9 +59,9 @@ final class TextFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { let textField = TextFieldUIView(viewModel: viewModel) textField.text = text.text textField.placeholder = text.placeholder - textField.clearButtonMode = .always + textField.clearButtonMode = states.isFocused ? .always : .never textField.leftViewMode = .always - textField.rightViewMode = states.isFocused ? .never : .always + textField.rightViewMode = .always textField.leftView = self.getContentViews(from: leftContent) textField.rightView = self.getContentViews(from: rightContent) @@ -80,6 +74,9 @@ final class TextFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { backgroundView.addSubview(textField) textField.translatesAutoresizingMaskIntoConstraints = false + textField.invalidateIntrinsicContentSize() + textField.setNeedsLayout() + textField.layoutIfNeeded() NSLayoutConstraint.stickEdges(from: textField, to: backgroundView, insets: .init(all: 12)) let testName = scenario.getTestName( diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift index c4a6f7338..8f14248ec 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift @@ -16,7 +16,6 @@ class TextFieldViewModel: ObservableObject, Updateable { @Published private(set) var textColor: any ColorToken @Published private(set) var placeholderColor: any ColorToken @Published var borderColor: any ColorToken - @Published private(set) var statusIconColor: any ColorToken @Published var backgroundColor: any ColorToken // BorderLayout @@ -32,12 +31,6 @@ class TextFieldViewModel: ObservableObject, Updateable { @Published private(set) var font: any TypographyFontToken - @Published private(set) var statusImage: Either? - - var successImage: ImageEither //TODO: Add get/set in views - var alertImage: ImageEither //TODO: Add get/set in views - var errorImage: ImageEither //TODO: Add get/set in views - let getColorsUseCase: any TextFieldGetColorsUseCasable let getBorderLayoutUseCase: any TextFieldGetBorderLayoutUseCasable let getSpacingsUseCase: any TextFieldGetSpacingsUseCasable @@ -55,7 +48,6 @@ class TextFieldViewModel: ObservableObject, Updateable { didSet { guard oldValue != self.intent else { return } self.setColors() - self.setStatusImage() } } var borderStyle: TextFieldBorderStyle { @@ -79,7 +71,6 @@ class TextFieldViewModel: ObservableObject, Updateable { guard oldValue != self.isEnabled else { return } self.setColors() self.setDim() - self.setStatusImage() } } @@ -93,9 +84,6 @@ class TextFieldViewModel: ObservableObject, Updateable { init(theme: Theme, intent: TextFieldIntent, borderStyle: TextFieldBorderStyle, - successImage: ImageEither, - alertImage: ImageEither, - errorImage: ImageEither, getColorsUseCase: any TextFieldGetColorsUseCasable = TextFieldGetColorsUseCase(), getBorderLayoutUseCase: any TextFieldGetBorderLayoutUseCasable = TextFieldGetBorderLayoutUseCase(), getSpacingsUseCase: any TextFieldGetSpacingsUseCasable = TextFieldGetSpacingsUseCase()) { @@ -103,10 +91,6 @@ class TextFieldViewModel: ObservableObject, Updateable { self.intent = intent self.borderStyle = borderStyle - self.successImage = successImage - self.alertImage = alertImage - self.errorImage = errorImage - self.getColorsUseCase = getColorsUseCase self.getBorderLayoutUseCase = getBorderLayoutUseCase self.getSpacingsUseCase = getSpacingsUseCase @@ -122,7 +106,6 @@ class TextFieldViewModel: ObservableObject, Updateable { self.textColor = colors.text self.placeholderColor = colors.placeholder self.borderColor = colors.border - self.statusIconColor = colors.statusIcon self.backgroundColor = colors.background // BorderLayout @@ -143,9 +126,6 @@ class TextFieldViewModel: ObservableObject, Updateable { self.dim = theme.dims.none self.font = theme.typography.body1 - - self.statusImage = nil - self.setStatusImage() } func setColors() { @@ -160,7 +140,6 @@ class TextFieldViewModel: ObservableObject, Updateable { self.textColor = colors.text self.placeholderColor = colors.placeholder self.borderColor = colors.border - self.statusIconColor = colors.statusIcon self.backgroundColor = colors.background } @@ -188,24 +167,4 @@ class TextFieldViewModel: ObservableObject, Updateable { private func setFont() { self.font = self.theme.typography.body1 } - - private func setStatusImage() { - let image: ImageEither? - if self.isEnabled { - switch self.intent { - case .alert: - image = self.alertImage - case .error: - image = self.errorImage - case .success: - image = self.successImage - default: - image = nil - } - } else { - image = nil - } - guard self.statusImage != image else { return } - self.statusImage = image - } } diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift index dc7a112d6..bee749261 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift @@ -28,10 +28,6 @@ final class TextFieldViewModelTests: XCTestCase { var expectedBorderLayout: TextFieldBorderLayout! var expectedSpacings: TextFieldSpacings! - let successImage: ImageEither = .left(UIImage(systemName: "square.and.arrow.up.fill")!) - let alertImage: ImageEither = .right(Image(systemName: "rectangle.portrait.and.arrow.right.fill")) - let errorImage: ImageEither = .left(UIImage(systemName: "eraser.fill")!) - override func setUp() { super.setUp() self.theme = ThemeGeneratedMock.mocked() @@ -40,7 +36,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .blue(), placeholder: .green(), border: .yellow(), - statusIcon: .red(), background: .purple() ) self.expectedBorderLayout = .mocked(radius: 1, width: 2) @@ -53,9 +48,6 @@ final class TextFieldViewModelTests: XCTestCase { theme: self.theme, intent: self.intent, borderStyle: self.borderStyle, - successImage: self.successImage, - alertImage: self.alertImage, - errorImage: self.errorImage, getColorsUseCase: self.getColorsUseCase, getBorderLayoutUseCase: self.getBorderLayoutUseCase, getSpacingsUseCase: self.getSpacingsUseCase @@ -74,11 +66,7 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.isEnabled, "Wrong isEnabled") XCTAssertTrue(self.viewModel.isUserInteractionEnabled, "Wrong isUserInteractionEnabled") XCTAssertFalse(self.viewModel.isFocused, "Wrong isFocused") - XCTAssertEqual(self.viewModel.successImage, self.successImage, "Wrong successImage") - XCTAssertEqual(self.viewModel.alertImage, self.alertImage, "Wrong alertImage") - XCTAssertEqual(self.viewModel.errorImage, self.errorImage, "Wrong errorImage") XCTAssertEqual(self.viewModel.dim, self.theme.dims.none, "Wrong dim") - XCTAssertEqual(self.viewModel.statusImage, self.successImage, "Wrong statusImage") XCTAssertIdentical(self.viewModel.font as? TypographyFontTokenGeneratedMock, self.theme.typography.body1 as? TypographyFontTokenGeneratedMock, "Wrong font") // THEN - Colors @@ -92,7 +80,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(self.expectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(self.expectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(self.expectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(self.expectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(self.expectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -118,7 +105,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") @@ -129,7 +115,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") XCTAssertEqual(self.publishers.font.sinkCount, 1, "$font should have been called once") - XCTAssertEqual(self.publishers.statusImage.sinkCount, 1, "$statusImage should have been called once") } // MARK: Theme @@ -146,7 +131,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .red(), placeholder: .blue(), border: .green(), - statusIcon: .purple(), background: .red() ) self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors @@ -170,7 +154,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -193,7 +176,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") @@ -204,7 +186,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") XCTAssertEqual(self.publishers.font.sinkCount, 1, "$font should have been called once") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } // MARK: - Intent @@ -230,7 +211,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -241,7 +221,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } func test_intent_didSet_notEqual() throws { @@ -254,7 +233,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .red(), placeholder: .blue(), border: .green(), - statusIcon: .purple(), background: .red() ) self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors @@ -262,9 +240,6 @@ final class TextFieldViewModelTests: XCTestCase { // WHEN self.viewModel.intent = .neutral - // THEN - XCTAssertNil(self.viewModel.statusImage, "Wrong statusImage") - // THEN - Colors XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") @@ -272,7 +247,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -286,7 +260,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -297,7 +270,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertEqual(self.publishers.statusImage.sinkCount, 1,"$statusImage should have been called once") } // MARK: - Border Style @@ -323,7 +295,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -334,7 +305,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } func test_borderStyle_didSet_notEqual() throws { @@ -374,7 +344,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") @@ -385,7 +354,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") } // MARK: - Is Focused @@ -411,7 +379,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -422,7 +389,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } func test_isFocused_didSet_notEqual() throws { @@ -434,7 +400,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .red(), placeholder: .blue(), border: .green(), - statusIcon: .purple(), background: .red() ) self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors @@ -454,7 +419,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -472,7 +436,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertEqual(self.publishers.borderWidth.sinkCount, 1, "$borderWidth should have been called once") XCTAssertEqual(self.publishers.borderRadius.sinkCount, 1, "$borderRadius should have been called once") @@ -483,7 +446,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") } // MARK: - Is Enabled @@ -509,7 +471,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -520,7 +481,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } func test_isEnabled_didSet_notEqual() throws { @@ -533,7 +493,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .red(), placeholder: .blue(), border: .green(), - statusIcon: .purple(), background: .red() ) self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors @@ -541,7 +500,6 @@ final class TextFieldViewModelTests: XCTestCase { // WHEN self.viewModel.isEnabled = true - XCTAssertEqual(self.viewModel.statusImage, self.successImage, "Wrong statusImage") // THEN - Colors XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabled should have been called once") let getColorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReceivedArguments, "Couldn't unwrap getColorsReceivedArguments") @@ -551,7 +509,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -565,7 +522,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -576,7 +532,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertEqual(self.publishers.statusImage.sinkCount, 1,"$statusImage should have been called once") } // MARK: - Is User Interaction Enabled @@ -602,7 +557,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.borderColor.sinkCalled, "$borderColorIndicatorColor should not have been called") XCTAssertFalse(self.publishers.backgroundColor.sinkCalled, "$backgroundColor should not have been called") XCTAssertFalse(self.publishers.placeholderColor.sinkCalled, "$placeholderColor should not have been called") - XCTAssertFalse(self.publishers.statusIconColor.sinkCalled, "$statusIconColor should not have been called") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -613,7 +567,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled, "$statusImage should not have been called") } func test_isUserInteractionEnabled_didSet_notEqual() throws { @@ -625,7 +578,6 @@ final class TextFieldViewModelTests: XCTestCase { text: .red(), placeholder: .blue(), border: .green(), - statusIcon: .purple(), background: .red() ) self.getColorsUseCase.executeWithThemeAndIntentAndIsFocusedAndIsEnabledAndIsUserInteractionEnabledReturnValue = newExpectedColors @@ -642,7 +594,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertTrue(self.viewModel.textColor.equals(newExpectedColors.text), "Wrong textColor") XCTAssertTrue(self.viewModel.placeholderColor.equals(newExpectedColors.placeholder), "Wrong placeholderColor") XCTAssertTrue(self.viewModel.borderColor.equals(newExpectedColors.border), "Wrong borderColor") - XCTAssertTrue(self.viewModel.statusIconColor.equals(newExpectedColors.statusIcon), "Wrong statusColor") XCTAssertTrue(self.viewModel.backgroundColor.equals(newExpectedColors.background), "Wrong backgroundColor") // THEN - Border Layout @@ -656,7 +607,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertEqual(self.publishers.borderColor.sinkCount, 1, "$borderColorIndicatorColor should have been called once") XCTAssertEqual(self.publishers.backgroundColor.sinkCount, 1, "$backgroundColor should have been called once") XCTAssertEqual(self.publishers.placeholderColor.sinkCount, 1, "$placeholderColor should have been called once") - XCTAssertEqual(self.publishers.statusIconColor.sinkCount, 1, "$statusIconColor should have been called once") XCTAssertFalse(self.publishers.borderWidth.sinkCalled, "$borderWidth should not have been called") XCTAssertFalse(self.publishers.borderRadius.sinkCalled, "$borderRadius should not have been called") @@ -667,7 +617,6 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") - XCTAssertFalse(self.publishers.statusImage.sinkCalled,"$statusImage should npt have been called") } // MARK: - Utils @@ -676,7 +625,6 @@ final class TextFieldViewModelTests: XCTestCase { textColor: PublisherMock(publisher: self.viewModel.$textColor), placeholderColor: PublisherMock(publisher: self.viewModel.$placeholderColor), borderColor: PublisherMock(publisher: self.viewModel.$borderColor), - statusIconColor: PublisherMock(publisher: self.viewModel.$statusIconColor), backgroundColor: PublisherMock(publisher: self.viewModel.$backgroundColor), borderRadius: PublisherMock(publisher: self.viewModel.$borderRadius), borderWidth: PublisherMock(publisher: self.viewModel.$borderWidth), @@ -684,8 +632,7 @@ final class TextFieldViewModelTests: XCTestCase { contentSpacing: PublisherMock(publisher: self.viewModel.$contentSpacing), rightSpacing: PublisherMock(publisher: self.viewModel.$rightSpacing), dim: PublisherMock(publisher: self.viewModel.$dim), - font: PublisherMock(publisher: self.viewModel.$font), - statusImage: PublisherMock(publisher: self.viewModel.$statusImage) + font: PublisherMock(publisher: self.viewModel.$font) ) self.publishers.load() } @@ -703,7 +650,6 @@ final class TextFieldPublishers { var textColor: PublisherMock.Publisher> var placeholderColor: PublisherMock.Publisher> var borderColor: PublisherMock.Publisher> - var statusIconColor: PublisherMock.Publisher> var backgroundColor: PublisherMock.Publisher> var borderRadius: PublisherMock.Publisher> @@ -717,13 +663,10 @@ final class TextFieldPublishers { var font: PublisherMock.Publisher> - var statusImage: PublisherMock.Publisher> - init( textColor: PublisherMock.Publisher>, placeholderColor: PublisherMock.Publisher>, borderColor: PublisherMock.Publisher>, - statusIconColor: PublisherMock.Publisher>, backgroundColor: PublisherMock.Publisher>, borderRadius: PublisherMock.Publisher>, borderWidth: PublisherMock.Publisher>, @@ -731,13 +674,11 @@ final class TextFieldPublishers { contentSpacing: PublisherMock.Publisher>, rightSpacing: PublisherMock.Publisher>, dim: PublisherMock.Publisher>, - font: PublisherMock.Publisher>, - statusImage: PublisherMock.Publisher> + font: PublisherMock.Publisher> ) { self.textColor = textColor self.placeholderColor = placeholderColor self.borderColor = borderColor - self.statusIconColor = statusIconColor self.backgroundColor = backgroundColor self.borderRadius = borderRadius self.borderWidth = borderWidth @@ -746,13 +687,12 @@ final class TextFieldPublishers { self.rightSpacing = rightSpacing self.dim = dim self.font = font - self.statusImage = statusImage } func load() { self.cancellables = Set() - [self.textColor, self.placeholderColor, self.borderColor, self.statusIconColor, self.backgroundColor].forEach { + [self.textColor, self.placeholderColor, self.borderColor, self.backgroundColor].forEach { $0.loadTesting(on: &self.cancellables) } @@ -761,12 +701,10 @@ final class TextFieldPublishers { } self.font.loadTesting(on: &self.cancellables) - - self.statusImage.loadTesting(on: &self.cancellables) } func reset() { - [self.textColor, self.placeholderColor, self.borderColor, self.statusIconColor, self.backgroundColor].forEach { + [self.textColor, self.placeholderColor, self.borderColor, self.backgroundColor].forEach { $0.reset() } @@ -775,7 +713,5 @@ final class TextFieldPublishers { } self.font.reset() - - self.statusImage.reset() } } diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift index 431e4860f..a698b3c7f 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/SwiftUI/TextFieldAddonsComponentView.swift @@ -79,9 +79,6 @@ struct TextFieldAddonsComponentView: View { text: $text, theme: self.theme, intent: self.intent, - successImage: Image("check"), - alertImage: Image("alert"), - errorImage: Image("alert-circle"), type: self.getTypeFromIsSecure(), isReadOnly: self.isReadOnlyState == .selected, leftView: { diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift index d5245f3fa..c3e16e3d0 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift @@ -20,10 +20,7 @@ final class TextFieldAddonsComponentUIView: ComponentUIView { self.viewModel = viewModel self.textFieldAddons = .init( theme: viewModel.theme, - intent: viewModel.intent, - successImage: .init(named: "check") ?? UIImage(), - alertImage: .init(named: "alert") ?? UIImage(), - errorImage: .init(named: "alert-circle") ?? UIImage() + intent: viewModel.intent ) self.textFieldAddons.textField.leftViewMode = .always self.textFieldAddons.textField.rightViewMode = .always diff --git a/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift index d4e59eda2..c81c09393 100644 --- a/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift +++ b/spark/Demo/Classes/View/Components/TextField/SwiftUI/TextFieldComponentView.swift @@ -62,9 +62,6 @@ struct TextFieldComponentView: View { text: $text, theme: self.theme, intent: self.intent, - successImage: Image("check"), - alertImage: Image("alert"), - errorImage: Image("alert-circle"), type: self.getTypeFromIsSecure(), isReadOnly: self.isReadOnlyState == .selected, leftView: { diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift index eb1d02d79..3086ed2bb 100644 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIView.swift @@ -22,10 +22,7 @@ final class TextFieldComponentUIView: ComponentUIView { self.textField = .init( theme: viewModel.theme, - intent: viewModel.intent, - successImage: .init(named: "check") ?? UIImage(), - alertImage: .init(named: "alert") ?? UIImage(), - errorImage: .init(named: "alert-circle") ?? UIImage() + intent: viewModel.intent ) super.init(viewModel: viewModel, componentView: self.textField) From 72bb08ab29753bfd855dba24aef1e50c5d5c131d Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 4 Apr 2024 18:59:28 +0200 Subject: [PATCH 046/117] [Checkboxr#876] Fix single checkbox accesibility --- .../CheckboxAccessibilityIdentifier.swift | 5 ++++ .../Checkbox/View/SwiftUI/CheckboxView.swift | 7 +++--- .../Checkbox/View/UIKit/CheckboxUIView.swift | 23 ++++++++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift b/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift index 70249cb66..f99d1d3b9 100644 --- a/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift +++ b/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift @@ -17,3 +17,8 @@ public enum CheckboxAccessibilityIdentifier { /// The identifier of checkbox group ui view title public static let checkboxGroupTitle = "spark-check-box-group-title" } + +public enum CheckboxAccessibilityValue { + public static let ticked = "1" + public static let unticked = "0" +} diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift index 68a6d3d2c..b3bf52fc2 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift @@ -104,10 +104,12 @@ public struct CheckboxView: View { } ) .buttonStyle(PressedButtonStyle(isPressed: self.$isPressed)) - .accessibilityIdentifier(CheckboxAccessibilityIdentifier.checkbox) .isEnabledChanged { isEnabled in self.viewModel.isEnabled = isEnabled } + .accessibilityIdentifier(CheckboxAccessibilityIdentifier.checkbox) + .accessibilityValue(self.viewModel.selectionState == .selected ? CheckboxAccessibilityValue.ticked : CheckboxAccessibilityValue.unticked) + .accessibilityRemoveTraits(.isButton) } @ViewBuilder @@ -148,9 +150,6 @@ public struct CheckboxView: View { .frame(width: self.checkboxIndeterminateWidth, height: self.checkboxIndeterminateHeight) } } - .if(self.selectionState == .selected) { - $0.accessibilityAddTraits(.isSelected) - } .id(Identifier.checkbox.rawValue) .matchedGeometryEffect(id: Identifier.checkbox.rawValue, in: self.namespace) } diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift index cd8dddfd0..a95d5c31f 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift @@ -259,8 +259,6 @@ public final class CheckboxUIView: UIControl { } private func commonInit() { - self.accessibilityIdentifier = CheckboxAccessibilityIdentifier.checkbox - self.setupViews() self.enableTouch() self.subscribe() @@ -340,11 +338,13 @@ public final class CheckboxUIView: UIControl { self.viewModel.$opacity.subscribe(in: &self.cancellables) { [weak self] opacity in guard let self else { return } self.layer.opacity = Float(opacity) + self.setAccessibilityEnable() } self.viewModel.$selectionState.subscribe(in: &self.cancellables) { [weak self] selectionState in guard let self else { return } self.controlView.selectionState = selectionState + self.setAccessibilityValue(isSelected: selectionState == .selected) } self.viewModel.$alignment.subscribe(in: &self.cancellables) { [weak self] alignment in @@ -358,6 +358,7 @@ public final class CheckboxUIView: UIControl { self.textLabel.isHidden = labelHidden self.textLabel.font = self.viewModel.font.uiFont self.textLabel.attributedText = text.leftValue + self.setAccessibilityLabel(text.leftValue?.string) } self.viewModel.$checkedImage.subscribe(in: &self.cancellables) { [weak self] icon in @@ -382,7 +383,23 @@ public final class CheckboxUIView: UIControl { private extension CheckboxUIView { private func updateAccessibility() { - self.accessibilityLabel = self.textLabel.text + self.accessibilityIdentifier = CheckboxAccessibilityIdentifier.checkbox + self.isAccessibilityElement = true + self.setAccessibilityLabel(self.textLabel.text) + self.setAccessibilityValue(isSelected: self.isSelected) + self.setAccessibilityEnable() + } + + private func setAccessibilityLabel(_ label: String?) { + self.accessibilityLabel = label + } + + private func setAccessibilityValue(isSelected: Bool) { + self.accessibilityValue = isSelected ? CheckboxAccessibilityValue.ticked : CheckboxAccessibilityValue.unticked + } + + private func setAccessibilityEnable() { + self.accessibilityTraits = self.isEnabled ? [.none] : [.notEnabled] } private func updateTheme(colors: CheckboxColors) { From 65b33873f4e14dafb0f4eb1bfd1d0fe1f61e64bf Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 4 Apr 2024 20:25:30 +0200 Subject: [PATCH 047/117] [Checkboxr#876] Fix checkbox group accesibility --- .../Model/CheckboxGroupViewModel.swift | 3 -- .../View/SwiftUI/CheckboxGroupView.swift | 35 ++++++++++++-- .../View/UIKit/CheckboxGroupUIView.swift | 46 ++++++++++++++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModel.swift b/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModel.swift index c670dbd4d..5bff7906d 100644 --- a/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModel.swift +++ b/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModel.swift @@ -17,7 +17,6 @@ final class CheckboxGroupViewModel: ObservableObject { @Published var checkedImage: Image @Published var layout: CheckboxGroupLayout @Published var spacing: LayoutSpacing - @Published var accessibilityIdentifierPrefix: String @Published var titleFont: TypographyFontToken @Published var titleColor: any ColorToken @Published var intent: CheckboxIntent @@ -28,7 +27,6 @@ final class CheckboxGroupViewModel: ObservableObject { init( title: String?, checkedImage: Image, - accessibilityIdentifierPrefix: String, theme: Theme, intent: CheckboxIntent = .main, alignment: CheckboxAlignment = .left, @@ -43,7 +41,6 @@ final class CheckboxGroupViewModel: ObservableObject { self.spacing = theme.layout.spacing self.titleFont = theme.typography.subhead self.titleColor = theme.colors.base.onSurface - self.accessibilityIdentifierPrefix = accessibilityIdentifierPrefix } func calculateSingleCheckboxWidth(string: String?) -> CGFloat { diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift index 41fc056df..c0f30ab3b 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift @@ -38,6 +38,7 @@ public struct CheckboxGroupView: View { /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. /// - theme: The Spark-Theme. /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. + @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was gived as a static string.") public init( title: String? = nil, checkedImage: Image, @@ -47,11 +48,38 @@ public struct CheckboxGroupView: View { theme: Theme, intent: CheckboxIntent = .main, accessibilityIdentifierPrefix: String + ) { + self.init( + title: title, + checkedImage: checkedImage, + items: items, + layout: layout, + alignment: alignment, + theme: theme, + intent: intent + ) + } + + /// Initialize a group of one or multiple checkboxes. + /// - Parameters: + /// - title: An optional group title displayed on top of the checkbox group.. + /// - checkedImage: The tick-checkbox image for checked-state. + /// - items: An array containing of multiple `CheckboxGroupItemProtocol`. Each array item is used to render a single checkbox. + /// - layout: The layout of the group can be horizontal or vertical. + /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. + /// - theme: The Spark-Theme. + public init( + title: String? = nil, + checkedImage: Image, + items: Binding<[any CheckboxGroupItemProtocol]>, + layout: CheckboxGroupLayout = .vertical, + alignment: CheckboxAlignment, + theme: Theme, + intent: CheckboxIntent = .main ) { let viewModel = CheckboxGroupViewModel( title: title, checkedImage: checkedImage, - accessibilityIdentifierPrefix: accessibilityIdentifierPrefix, theme: theme, intent: intent, alignment: alignment, @@ -94,7 +122,8 @@ public struct CheckboxGroupView: View { .onChange(of: self.itemContents) { newValue in self.isScrollableHStack = true } - .accessibilityIdentifier("\(self.viewModel.accessibilityIdentifierPrefix).\(CheckboxAccessibilityIdentifier.checkboxGroup)") + .accessibilityElement(children: .contain) + .accessibilityIdentifier(CheckboxAccessibilityIdentifier.checkboxGroup) } private func makeHStackView() -> some View { @@ -156,7 +185,6 @@ public struct CheckboxGroupView: View { } private func checkBoxView(item: Binding) -> some View { - let identifier = "\(self.viewModel.accessibilityIdentifierPrefix).\(item.id.wrappedValue)" return CheckboxView( text: item.title.wrappedValue, checkedImage: self.viewModel.checkedImage, @@ -166,7 +194,6 @@ public struct CheckboxGroupView: View { isEnabled: item.isEnabled.wrappedValue, selectionState: item.selectionState ) - .accessibilityIdentifier(identifier) } } diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 77003e11a..2e7fb422c 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -56,7 +56,6 @@ public final class CheckboxGroupUIView: UIControl { private var subscriptions = Set() private var items: [any CheckboxGroupItemProtocol] private var subject = PassthroughSubject<[any CheckboxGroupItemProtocol], Never>() - private var accessibilityIdentifierPrefix: String @ScaledUIMetric private var spacingLarge: CGFloat @ScaledUIMetric private var padding: CGFloat = CheckboxControlUIView.Constants.lineWidthPressed @@ -142,7 +141,8 @@ public final class CheckboxGroupUIView: UIControl { /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - public init( + @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was gived as a static string.") + public convenience init( title: String? = nil, checkedImage: UIImage, items: [any CheckboxGroupItemProtocol], @@ -151,6 +151,36 @@ public final class CheckboxGroupUIView: UIControl { theme: Theme, intent: CheckboxIntent = .main, accessibilityIdentifierPrefix: String + ) { + self.init( + title: title, + checkedImage: checkedImage, + items: items, + layout: layout, + alignment: alignment, + theme: theme, + intent: intent + ) + } + + /// Initialize a group of one or multiple checkboxes. + /// - Parameters: + /// - title: An optional group title displayed on top of the checkbox group.. + /// - checkedImage: The tick-checkbox image for checked-state. + /// - items: An array containing of multiple `CheckboxGroupItemProtocol`. Each array item is used to render a single checkbox. + /// - layout: The layout of the group can be horizontal or vertical. + /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. + /// - theme: The Spark-Theme. + /// - intent: Current intent of checkbox group + /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. + public init( + title: String? = nil, + checkedImage: UIImage, + items: [any CheckboxGroupItemProtocol], + layout: CheckboxGroupLayout = .vertical, + alignment: CheckboxAlignment = .left, + theme: Theme, + intent: CheckboxIntent = .main ) { self.title = title self.checkedImage = checkedImage @@ -160,7 +190,6 @@ public final class CheckboxGroupUIView: UIControl { self.checkboxAlignment = alignment self.theme = theme self.intent = intent - self.accessibilityIdentifierPrefix = accessibilityIdentifierPrefix self.spacingLarge = theme.layout.spacing.large self.spacingSmall = theme.layout.spacing.small super.init(frame: .zero) @@ -172,6 +201,7 @@ public final class CheckboxGroupUIView: UIControl { self.setupView() self.enableTouch() self.updateTitle() + self.updateAccessibility() } // MARK: - Methods @@ -185,6 +215,12 @@ public final class CheckboxGroupUIView: UIControl { self._padding.update(traitCollection: self.traitCollection) } + private func updateAccessibility() { + self.accessibilityIdentifier = CheckboxAccessibilityIdentifier.checkboxGroup + self.isAccessibilityElement = false + self.accessibilityContainerType = .semanticGroup + } + private func setupItemsStackView() { self.updateLayout() @@ -207,9 +243,7 @@ public final class CheckboxGroupUIView: UIControl { selectionState: item.selectionState, alignment: self.alignment ) - let identifier = "\(self.accessibilityIdentifierPrefix).\(item.id)" - checkbox.accessibilityIdentifier = identifier checkbox.publisher.sink { [weak self] in guard let self, @@ -231,8 +265,6 @@ public final class CheckboxGroupUIView: UIControl { private func setupView() { - self.accessibilityIdentifier = "\(self.accessibilityIdentifierPrefix).\(CheckboxAccessibilityIdentifier.checkboxGroup)" - self.addSubview(self.titleStackView) self.scrollView.addSubview(self.itemsStackView) self.addSubview(self.scrollView) From d4cabc451aa7c29fb20de0455762bf4eab6427f7 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 4 Apr 2024 20:53:08 +0200 Subject: [PATCH 048/117] [Checkboxr#876] Add accessibilityLabel for checkbox group on demo project --- .../Components/Checkbox/SwiftUI/CheckboxGroupView.swift | 2 ++ .../CheckboxGroup/CheckboxGroupComponentUIView.swift | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift b/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift index 11a2b8def..baa370a62 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift @@ -115,6 +115,8 @@ struct CheckboxGroupListView: View { self.items = self.setItems(groupType: newValue) } .disabled(self.isEnabled == .unselected) +// .accessibilityLabel("I am checkbox group") + /// If you want to modifiy component accessibility, open the above command lines. First It will read checkbox group labels then start to read single checkbox items. } ) } diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index a64c580e9..20858143e 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -29,8 +29,11 @@ final class CheckboxGroupComponentUIView: ComponentUIView { viewModel: viewModel, componentView: self.componentView ) - self.componentView.delegate = self + + /// If you want to modifiy component accessibility, open the command lines. First It will read checkbox group labels then start to read single checkbox items. +// self.updateCheckboxGroupAccessibility() + // Setup self.setupSubscriptions() @@ -41,6 +44,10 @@ final class CheckboxGroupComponentUIView: ComponentUIView { fatalError("init(coder:) has not been implemented") } + private func updateCheckboxGroupAccessibility() { + self.componentView.accessibilityLabel = "I am checkbox group" + } + // MARK: - Subscribe private func setupSubscriptions() { self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in From 66a5484811fe410e14b24cf41dd4e89f9efbd876 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 5 Apr 2024 09:30:18 +0200 Subject: [PATCH 049/117] [Checkboxr#876] Fix texts --- .../Components/Checkbox/Model/CheckboxGroupViewModelTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModelTests.swift b/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModelTests.swift index 6b61e3df5..88280339c 100644 --- a/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModelTests.swift +++ b/core/Sources/Components/Checkbox/Model/CheckboxGroupViewModelTests.swift @@ -25,7 +25,6 @@ final class CheckboxGroupViewModelTests: XCTestCase { self.sut = CheckboxGroupViewModel( title: "Title", checkedImage: Image(uiImage: self.checkedImage), - accessibilityIdentifierPrefix: "id", theme: self.theme ) } @@ -45,7 +44,6 @@ final class CheckboxGroupViewModelTests: XCTestCase { XCTAssertEqual(sut.intent, .main, "Intent does not match") XCTAssertEqual(sut.titleFont.uiFont, self.theme.typography.subhead.uiFont, "Title font does not match" ) XCTAssertEqual(sut.titleColor.uiColor, self.theme.colors.base.onSurface.uiColor, "Title color does not match" ) - XCTAssertEqual(sut.accessibilityIdentifierPrefix, "id", "Accessibility identifier does not match" ) } func test_singleCheckbox_width() throws { From c29122d6c9366a11f964f20c004da088e00f014e Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 5 Apr 2024 20:26:39 +0200 Subject: [PATCH 050/117] [Checkboxr#876] Fix accessibilty traits as a button --- .../CheckboxAccessibilityIdentifier.swift | 13 +++++++---- .../View/SwiftUI/CheckboxGroupView.swift | 1 + .../Checkbox/View/SwiftUI/CheckboxView.swift | 15 ++++++++++-- .../View/UIKit/CheckboxGroupUIView.swift | 1 + .../Checkbox/View/UIKit/CheckboxUIView.swift | 23 +++++++++++++++---- .../CheckboxGroupComponentUIView.swift | 2 +- 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift b/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift index f99d1d3b9..04bfd76f9 100644 --- a/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift +++ b/core/Sources/Components/Checkbox/AccessibilityIdentifier/CheckboxAccessibilityIdentifier.swift @@ -10,15 +10,20 @@ import Foundation /// The accessibility identifiers for the checkbox. public enum CheckboxAccessibilityIdentifier { - /// The default accessibility identifier. Can be changed by the consumer + /// The default checkbox accessibility identifier. public static let checkbox = "spark-check-box" - /// The default accessibility identifier. Can be changed by the consumer + /// The default checkbox group accessibility identifier. public static let checkboxGroup = "spark-check-box-group" /// The identifier of checkbox group ui view title public static let checkboxGroupTitle = "spark-check-box-group-title" + /// The default checkbox group item accessibility identifier. + public static func checkboxGroupItem(_ id: String) -> String { + Self.checkbox + "-\(id)" + } } public enum CheckboxAccessibilityValue { - public static let ticked = "1" - public static let unticked = "0" + public static let checked = "1" + public static let indeterminate = "0.5" + public static let unchecked = "0" } diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift index c0f30ab3b..dde21315a 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift @@ -194,6 +194,7 @@ public struct CheckboxGroupView: View { isEnabled: item.isEnabled.wrappedValue, selectionState: item.selectionState ) + .accessibilityIdentifier(CheckboxAccessibilityIdentifier.checkboxGroupItem(item.id.wrappedValue)) } } diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift index b3bf52fc2..3c738fa8f 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift @@ -108,8 +108,19 @@ public struct CheckboxView: View { self.viewModel.isEnabled = isEnabled } .accessibilityIdentifier(CheckboxAccessibilityIdentifier.checkbox) - .accessibilityValue(self.viewModel.selectionState == .selected ? CheckboxAccessibilityValue.ticked : CheckboxAccessibilityValue.unticked) - .accessibilityRemoveTraits(.isButton) + .accessibilityValue(setAccessibilityValue(selectionState: self.viewModel.selectionState)) + .accessibilityRemoveTraits(.isSelected) + } + + private func setAccessibilityValue(selectionState: CheckboxSelectionState) -> String { + switch selectionState { + case .selected: + CheckboxAccessibilityValue.checked + case .indeterminate: + CheckboxAccessibilityValue.indeterminate + case .unselected: + CheckboxAccessibilityValue.unchecked + } } @ViewBuilder diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 2e7fb422c..9e61d1f98 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -243,6 +243,7 @@ public final class CheckboxGroupUIView: UIControl { selectionState: item.selectionState, alignment: self.alignment ) + checkbox.accessibilityIdentifier = CheckboxAccessibilityIdentifier.checkboxGroupItem(item.id) checkbox.publisher.sink { [weak self] in guard diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift index a95d5c31f..43cd16a49 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift @@ -344,7 +344,7 @@ public final class CheckboxUIView: UIControl { self.viewModel.$selectionState.subscribe(in: &self.cancellables) { [weak self] selectionState in guard let self else { return } self.controlView.selectionState = selectionState - self.setAccessibilityValue(isSelected: selectionState == .selected) + self.setAccessibilityValue(selectionState: selectionState) } self.viewModel.$alignment.subscribe(in: &self.cancellables) { [weak self] alignment in @@ -385,8 +385,10 @@ private extension CheckboxUIView { private func updateAccessibility() { self.accessibilityIdentifier = CheckboxAccessibilityIdentifier.checkbox self.isAccessibilityElement = true + self.accessibilityTraits.insert(.button) + self.accessibilityTraits.remove(.selected) self.setAccessibilityLabel(self.textLabel.text) - self.setAccessibilityValue(isSelected: self.isSelected) + self.setAccessibilityValue(selectionState: self.selectionState) self.setAccessibilityEnable() } @@ -394,12 +396,23 @@ private extension CheckboxUIView { self.accessibilityLabel = label } - private func setAccessibilityValue(isSelected: Bool) { - self.accessibilityValue = isSelected ? CheckboxAccessibilityValue.ticked : CheckboxAccessibilityValue.unticked + private func setAccessibilityValue(selectionState: CheckboxSelectionState) { + switch selectionState { + case .selected: + self.accessibilityValue = CheckboxAccessibilityValue.checked + case .indeterminate: + self.accessibilityValue = CheckboxAccessibilityValue.indeterminate + case .unselected: + self.accessibilityValue = CheckboxAccessibilityValue.unchecked + } } private func setAccessibilityEnable() { - self.accessibilityTraits = self.isEnabled ? [.none] : [.notEnabled] + if self.isEnabled { + self.accessibilityTraits.remove(.notEnabled) + } else { + self.accessibilityTraits.insert(.notEnabled) + } } private func updateTheme(colors: CheckboxColors) { diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index 20858143e..bd615f51b 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -32,7 +32,7 @@ final class CheckboxGroupComponentUIView: ComponentUIView { self.componentView.delegate = self /// If you want to modifiy component accessibility, open the command lines. First It will read checkbox group labels then start to read single checkbox items. -// self.updateCheckboxGroupAccessibility() + self.updateCheckboxGroupAccessibility() // Setup self.setupSubscriptions() From 6c4a7740d2b703025898bc1794c30c6efca4c6c4 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 5 Apr 2024 20:32:44 +0200 Subject: [PATCH 051/117] [Checkbox#876] remove unnecessary command lines --- .../Checkbox/SwiftUI/CheckboxGroupView.swift | 5 +---- .../CheckboxGroup/CheckboxGroupComponentUIView.swift | 10 +--------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift b/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift index baa370a62..fb4583ecc 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/SwiftUI/CheckboxGroupView.swift @@ -108,15 +108,12 @@ struct CheckboxGroupListView: View { layout: self.layout == .selected ? .vertical : .horizontal, alignment: self.alignment, theme: self.theme, - intent: self.intent, - accessibilityIdentifierPrefix: "checkbox-group" + intent: self.intent ) .onChange(of: self.groupType) { newValue in self.items = self.setItems(groupType: newValue) } .disabled(self.isEnabled == .unselected) -// .accessibilityLabel("I am checkbox group") - /// If you want to modifiy component accessibility, open the above command lines. First It will read checkbox group labels then start to read single checkbox items. } ) } diff --git a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift index bd615f51b..181918b54 100644 --- a/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Checkbox/UIKit/CheckboxGroup/CheckboxGroupComponentUIView.swift @@ -31,9 +31,6 @@ final class CheckboxGroupComponentUIView: ComponentUIView { ) self.componentView.delegate = self - /// If you want to modifiy component accessibility, open the command lines. First It will read checkbox group labels then start to read single checkbox items. - self.updateCheckboxGroupAccessibility() - // Setup self.setupSubscriptions() @@ -44,10 +41,6 @@ final class CheckboxGroupComponentUIView: ComponentUIView { fatalError("init(coder:) has not been implemented") } - private func updateCheckboxGroupAccessibility() { - self.componentView.accessibilityLabel = "I am checkbox group" - } - // MARK: - Subscribe private func setupSubscriptions() { self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in @@ -109,8 +102,7 @@ final class CheckboxGroupComponentUIView: ComponentUIView { items: CheckboxGroupComponentUIViewModel.makeCheckboxGroupItems(type: viewModel.groupType), alignment: viewModel.isAlignmentLeft ? .left : .right, theme: viewModel.theme, - intent: viewModel.intent, - accessibilityIdentifierPrefix: "Checkbox" + intent: viewModel.intent ) } } From a5b606266776ea3ff392bd323bbb3ccf27c4f45b Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 8 Apr 2024 10:55:12 +0200 Subject: [PATCH 052/117] [Checkbox#876] fix pr comments --- .../Components/Checkbox/View/SwiftUI/CheckboxView.swift | 6 +++--- .../Components/Checkbox/View/UIKit/CheckboxUIView.swift | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift index 3c738fa8f..e20ddaca9 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift @@ -115,11 +115,11 @@ public struct CheckboxView: View { private func setAccessibilityValue(selectionState: CheckboxSelectionState) -> String { switch selectionState { case .selected: - CheckboxAccessibilityValue.checked + return CheckboxAccessibilityValue.checked case .indeterminate: - CheckboxAccessibilityValue.indeterminate + return CheckboxAccessibilityValue.indeterminate case .unselected: - CheckboxAccessibilityValue.unchecked + return CheckboxAccessibilityValue.unchecked } } diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift index 43cd16a49..4f82f18e5 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift @@ -344,7 +344,7 @@ public final class CheckboxUIView: UIControl { self.viewModel.$selectionState.subscribe(in: &self.cancellables) { [weak self] selectionState in guard let self else { return } self.controlView.selectionState = selectionState - self.setAccessibilityValue(selectionState: selectionState) + self.setAccessibilityValue() } self.viewModel.$alignment.subscribe(in: &self.cancellables) { [weak self] alignment in @@ -388,7 +388,7 @@ private extension CheckboxUIView { self.accessibilityTraits.insert(.button) self.accessibilityTraits.remove(.selected) self.setAccessibilityLabel(self.textLabel.text) - self.setAccessibilityValue(selectionState: self.selectionState) + self.setAccessibilityValue() self.setAccessibilityEnable() } @@ -396,8 +396,8 @@ private extension CheckboxUIView { self.accessibilityLabel = label } - private func setAccessibilityValue(selectionState: CheckboxSelectionState) { - switch selectionState { + private func setAccessibilityValue() { + switch self.selectionState { case .selected: self.accessibilityValue = CheckboxAccessibilityValue.checked case .indeterminate: From 30750c9d27fa23dcf51ef192fd5af7924556f996 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 9 Apr 2024 10:52:48 +0200 Subject: [PATCH 053/117] [Formfield#859] Add snapshot test scenario classes --- .../FormfieldConfigurationSnapshotTests.swift | 45 ++++++++++++ .../FormfieldScenarioSnapshotTests.swift | 73 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift create mode 100644 core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift new file mode 100644 index 000000000..8964b2ec2 --- /dev/null +++ b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift @@ -0,0 +1,45 @@ +// +// FormfieldConfigurationSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by alican.aycil on 08.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +@testable import SparkCore + +struct FormfieldConfigurationSnapshotTests { + + // MARK: - Properties + + let scenario: FormfieldScenarioSnapshotTests + let feedbackState: FormFieldFeedbackState + let component: FormfieldComponentType + let label: String + let helperMessage: String + let isRequired: Bool + let isEnabled: Bool + let modes: [ComponentSnapshotTestMode] + let sizes: [UIContentSizeCategory] + + // MARK: - Getter + + func testName() -> String { + return [ + "\(self.scenario.rawValue)", + "\(self.feedbackState)", + "IsRequired:\(self.isRequired)", + "IsEnabled:\(self.isRequired)", + "\(self.component.rawValue)" + ].joined(separator: "-") + } +} + +enum FormfieldComponentType: String, CaseIterable { + case singleCheckbox + case checkboxGroup + case singleRadioButton + case radioButtonGroup +} diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift new file mode 100644 index 000000000..13a902e3f --- /dev/null +++ b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift @@ -0,0 +1,73 @@ +// +// FormfieldScenarioSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by alican.aycil on 08.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +@testable import SparkCore + +enum FormfieldScenarioSnapshotTests: String, CaseIterable { + case test1 + case test2 + case test3 + case test4 + case test5 + case test6 + case test7 + case test8 + + // MARK: - Type Alias + + private typealias Constants = ComponentSnapshotTestConstants + + // MARK: - Configurations + + func configuration() -> [FormfieldConfigurationSnapshotTests] { + switch self { + case .test2: + return self.test2() + default: + return [] + } + } + + // MARK: - Scenarios + + /// Test 2 + /// + /// Description: To test all feedback state for other components + /// + /// Content: + /// - feedbackState: all + /// - component: all + /// - label: short + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: true + /// - modes: all + /// - sizes (accessibility): default + private func test2() -> [FormfieldConfigurationSnapshotTests] { + let feedbackStates = FormFieldFeedbackState.allCases + let components = FormfieldComponentType.allCases + + return feedbackStates.flatMap { feedbackState in + components.map { component in + return .init( + scenario: self, + feedbackState: feedbackState, + component: component, + label: "Agreement", + helperMessage: "Your agreement is important.", + isRequired: false, + isEnabled: true, + modes: Constants.Modes.all, + sizes: Constants.Sizes.default + ) + } + } + } +} From 94c1acbd4389c05179ae4bab3612e8d4b84f4238 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 9 Apr 2024 22:28:06 +0200 Subject: [PATCH 054/117] [Formfield#782] Add formfield title use case --- .../FormField/Model/FormFieldViewModel.swift | 46 +++++-------------- .../UseCase/FormfieldTitleUseCase.swift | 43 +++++++++++++++++ 2 files changed, 54 insertions(+), 35 deletions(-) create mode 100644 core/Sources/Components/FormField/UseCase/FormfieldTitleUseCase.swift diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index e3b9d6c57..5d9c7e104 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -26,7 +26,7 @@ final class FormFieldViewModel: ObservableObject { self.updateColors() self.updateFonts() self.updateSpacing() - self.updateAsterisk() + self.updateTitle() } } @@ -40,13 +40,14 @@ final class FormFieldViewModel: ObservableObject { var isTitleRequired: Bool { didSet { guard isTitleRequired != oldValue else { return } - self.title = self.getTitleWithAsteriskIfNeeded() + self.updateTitle() } } var colors: FormFieldColors private var colorUseCase: FormFieldColorsUseCaseable + private var titleUseCase: FormFieldTitleUseCaseable private var userDefinedTitle: AS? private var asterisk: NSAttributedString = NSAttributedString() @@ -57,25 +58,28 @@ final class FormFieldViewModel: ObservableObject { title: AS?, description: AS?, isTitleRequired: Bool = false, - colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase() + colorUseCase: FormFieldColorsUseCaseable = FormFieldColorsUseCase(), + titleUseCase: FormFieldTitleUseCaseable = FormFieldTitleUseCase() ) { self.theme = theme self.feedbackState = feedbackState - self.userDefinedTitle = title self.description = description self.isTitleRequired = isTitleRequired self.colorUseCase = colorUseCase + self.titleUseCase = titleUseCase self.colors = colorUseCase.execute(from: theme, feedback: feedbackState) self.spacing = self.theme.layout.spacing.small self.titleFont = self.theme.typography.body2 self.descriptionFont = self.theme.typography.caption self.titleColor = self.colors.title self.descriptionColor = self.colors.description - - self.updateAsterisk() self.setTitle(title) } + private func updateTitle() { + self.title = self.titleUseCase.execute(title: self.userDefinedTitle, isTitleRequired: self.isTitleRequired, colors: self.colors, typography: self.theme.typography) as? AS + } + private func updateColors() { self.colors = colorUseCase.execute(from: self.theme, feedback: self.feedbackState) self.titleColor = self.colors.title @@ -91,36 +95,8 @@ final class FormFieldViewModel: ObservableObject { self.spacing = self.theme.layout.spacing.small } - private func updateAsterisk() { - self.asterisk = NSAttributedString( - string: " *", - attributes: [ - NSAttributedString.Key.foregroundColor: self.colors.asterisk.uiColor, - NSAttributedString.Key.font : self.theme.typography.caption.uiFont - ] - ) - } - func setTitle(_ title: AS?) { self.userDefinedTitle = title - self.title = self.getTitleWithAsteriskIfNeeded() - } - - private func getTitleWithAsteriskIfNeeded() -> AS? { - - if let attributedString = self.userDefinedTitle as? NSAttributedString { - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) - if self.isTitleRequired { - mutableAttributedString.append(self.asterisk) - } - return mutableAttributedString as? AS - - } else if var attributedString = self.userDefinedTitle as? AttributedString { - if self.isTitleRequired { - attributedString.append(AttributedString(self.asterisk)) - } - return attributedString as? AS - } - return nil + self.updateTitle() } } diff --git a/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCase.swift b/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCase.swift new file mode 100644 index 000000000..5e36c91cc --- /dev/null +++ b/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCase.swift @@ -0,0 +1,43 @@ +// +// FormfieldTitleUseCase.swift +// SparkCore +// +// Created by alican.aycil on 09.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +// sourcery: AutoMockable +protocol FormFieldTitleUseCaseable { + func execute(title: SparkAttributedString?, isTitleRequired: Bool, colors: FormFieldColors, typography: Typography) -> SparkAttributedString? +} + +struct FormFieldTitleUseCase: FormFieldTitleUseCaseable { + + func execute(title: SparkAttributedString?, isTitleRequired: Bool, colors: FormFieldColors, typography: Typography) -> SparkAttributedString? { + + let asterisk = NSAttributedString( + string: " *", + attributes: [ + NSAttributedString.Key.foregroundColor: colors.asterisk.uiColor, + NSAttributedString.Key.font : typography.caption.uiFont + ] + ) + + if let attributedString = title as? NSAttributedString { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + if isTitleRequired { + mutableAttributedString.append(asterisk) + } + return mutableAttributedString + + } else if var attributedString = title as? AttributedString { + if isTitleRequired { + attributedString.append(AttributedString(asterisk)) + } + return attributedString + } + return nil + } +} From 7787f942c4aed9ffeaa7995d72be31326072b0e9 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 11 Apr 2024 09:23:03 +0200 Subject: [PATCH 055/117] [Formfield#782] Add formfield title use case test --- .../FormField/Model/FormFieldViewModel.swift | 11 ++-- .../UseCase/FormfieldTitleUseCaseTests.swift | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 core/Sources/Components/FormField/UseCase/FormfieldTitleUseCaseTests.swift diff --git a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift index 5d9c7e104..d748e7827 100644 --- a/core/Sources/Components/FormField/Model/FormFieldViewModel.swift +++ b/core/Sources/Components/FormField/Model/FormFieldViewModel.swift @@ -49,7 +49,6 @@ final class FormFieldViewModel: ObservableObject { private var colorUseCase: FormFieldColorsUseCaseable private var titleUseCase: FormFieldTitleUseCaseable private var userDefinedTitle: AS? - private var asterisk: NSAttributedString = NSAttributedString() // MARK: - Init init( @@ -76,6 +75,11 @@ final class FormFieldViewModel: ObservableObject { self.setTitle(title) } + func setTitle(_ title: AS?) { + self.userDefinedTitle = title + self.updateTitle() + } + private func updateTitle() { self.title = self.titleUseCase.execute(title: self.userDefinedTitle, isTitleRequired: self.isTitleRequired, colors: self.colors, typography: self.theme.typography) as? AS } @@ -94,9 +98,4 @@ final class FormFieldViewModel: ObservableObject { private func updateSpacing() { self.spacing = self.theme.layout.spacing.small } - - func setTitle(_ title: AS?) { - self.userDefinedTitle = title - self.updateTitle() - } } diff --git a/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCaseTests.swift b/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCaseTests.swift new file mode 100644 index 000000000..5b68396d7 --- /dev/null +++ b/core/Sources/Components/FormField/UseCase/FormfieldTitleUseCaseTests.swift @@ -0,0 +1,51 @@ +// +// FormfieldTitleUseCaseTests.swift +// SparkCoreUnitTests +// +// Created by alican.aycil on 10.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +@testable import SparkCore + +final class FormFieldTitleUseCaseTests: XCTestCase { + + var sut: FormFieldTitleUseCase! + var theme: ThemeGeneratedMock! + + override func setUp() { + super.setUp() + + self.sut = .init() + self.theme = .mocked() + } + + // MARK: - Tests + + func test_execute_title_required_cases() { + let isTitleRequireds = [true, false] + let titleUseCase = FormFieldColorsUseCase() + + isTitleRequireds.forEach { isTitleRequired in + + let formfieldTitle = sut.execute( + title: NSAttributedString(string: "Agreement"), + isTitleRequired: isTitleRequired, + colors: titleUseCase.execute( + from: self.theme, + feedback: .default + ), + typography: self.theme.typography + ) + let formfieldTitleString = (formfieldTitle as? NSAttributedString)?.string ?? "" + + if isTitleRequired { + XCTAssertEqual(formfieldTitleString, "Agreement *") + } else { + XCTAssertEqual(formfieldTitleString, "Agreement") + } + } + } +} From da7a4ef2d72cef4f756d762e915121733fc48671 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 12 Apr 2024 00:31:24 +0200 Subject: [PATCH 056/117] [Formfield#859] Add test scenarios --- .../FormfieldConfigurationSnapshotTests.swift | 4 +- .../FormfieldScenarioSnapshotTests.swift | 209 +++++++++++++++++- 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift index 8964b2ec2..716a403ed 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift @@ -17,8 +17,8 @@ struct FormfieldConfigurationSnapshotTests { let scenario: FormfieldScenarioSnapshotTests let feedbackState: FormFieldFeedbackState let component: FormfieldComponentType - let label: String - let helperMessage: String + let label: String? + let helperMessage: String? let isRequired: Bool let isEnabled: Bool let modes: [ComponentSnapshotTestMode] diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift index 13a902e3f..a07abddbc 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift @@ -11,12 +11,12 @@ import UIKit @testable import SparkCore enum FormfieldScenarioSnapshotTests: String, CaseIterable { - case test1 + case test1 //This will be added after textField case test2 case test3 case test4 case test5 - case test6 + case test6 //This will be added after textField case test7 case test8 @@ -30,6 +30,16 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { switch self { case .test2: return self.test2() + case .test3: + return self.test3() + case .test4: + return self.test4() + case .test5: + return self.test5() + case .test7: + return self.test7() + case .test8: + return self.test8() default: return [] } @@ -48,7 +58,7 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { /// - helperMessage: short /// - isRequired: false, /// - isEnabled: true - /// - modes: all + /// - modes: light /// - sizes (accessibility): default private func test2() -> [FormfieldConfigurationSnapshotTests] { let feedbackStates = FormFieldFeedbackState.allCases @@ -64,10 +74,201 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { helperMessage: "Your agreement is important.", isRequired: false, isEnabled: true, - modes: Constants.Modes.all, + modes: Constants.Modes.default, sizes: Constants.Sizes.default ) } } } + + /// Test 3 + /// + /// Description: To test label's content resilience + /// + /// Content: + /// - feedbackState: 'default' + /// - component: single checkbox + /// - label: all + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: true + /// - modes: light + /// - sizes (accessibility): default + private func test3() -> [FormfieldConfigurationSnapshotTests] { + let labels: [String?] = [ + "Lorem Ipsum", + "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + nil + ] + + return labels.map { label in + return .init( + scenario: self, + feedbackState: .default, + component: .singleCheckbox, + label: label, + helperMessage: "Your agreement is important.", + isRequired: false, + isEnabled: true, + modes: Constants.Modes.default, + sizes: Constants.Sizes.default + ) + } + } + + /// Test 4 + /// + /// Description: To test required option + /// + /// Content: + /// - feedbackState: 'default' + /// - component: single checkbox + /// - label: all + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: true + /// - modes: light + /// - sizes (accessibility): default + private func test4() -> [FormfieldConfigurationSnapshotTests] { + return [.init( + scenario: self, + feedbackState: .default, + component: .radioButtonGroup, + label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + helperMessage: "Your agreement is important.", + isRequired: true, + isEnabled: true, + modes: Constants.Modes.default, + sizes: Constants.Sizes.default + )] + } + + /// Test 5 + /// + /// Description: To test helper text's content resilience + /// + /// Content: + /// - feedbackState: error + /// - component: checkbox group + /// - label: short + /// - helperMessage: all + /// - isRequired: false, + /// - isEnabled: true + /// - modes: light + /// - sizes (accessibility): default + private func test5() -> [FormfieldConfigurationSnapshotTests] { + let messages: [String?] = [ + "Lorem Ipsum", + "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + nil + ] + + return messages.map { message in + return .init( + scenario: self, + feedbackState: .error, + component: .checkboxGroup, + label: "Agreement", + helperMessage: message, + isRequired: false, + isEnabled: true, + modes: Constants.Modes.default, + sizes: Constants.Sizes.default + ) + } + } + + /// Test 7 + /// + /// Description: To test disabled state for other components + /// + /// Content: + /// - feedbackState: 'default' + /// - component: all + /// - label: short + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: true + /// - modes: light + /// - sizes (accessibility): default + private func test7() -> [FormfieldConfigurationSnapshotTests] { + let feedbackStates = FormFieldFeedbackState.allCases + let components = FormfieldComponentType.allCases + + return feedbackStates.flatMap { feedbackState in + components.map { component in + return .init( + scenario: self, + feedbackState: feedbackState, + component: component, + label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + helperMessage: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + isRequired: false, + isEnabled: false, + modes: Constants.Modes.default, + sizes: Constants.Sizes.default + ) + } + } + } + + /// Test 8 + /// + /// Description: To test dark & light mode + /// + /// Content: + /// - feedbackState: all + /// - component: single checkbox + /// - label: short + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: false + /// - modes: dark + /// - sizes (accessibility): default + private func test8() -> [FormfieldConfigurationSnapshotTests] { + let feedbackStates = FormFieldFeedbackState.allCases + + return feedbackStates.map { feedbackState in + return .init( + scenario: self, + feedbackState: feedbackState, + component: .singleCheckbox, + label: "Agreement", + helperMessage: "Your agreement is important.", + isRequired: false, + isEnabled: true, + modes: [.dark], + sizes: Constants.Sizes.default + ) + } + } + + /// Test 9 + /// + /// Description: To test a11y sizes + /// + /// Content: + /// - feedbackState: error + /// - component: single checkbox + /// - label: short + /// - helperMessage: short + /// - isRequired: false, + /// - isEnabled: false + /// - modes: light + /// - sizes (accessibility): all + private func test9() -> [FormfieldConfigurationSnapshotTests] { + let feedbackStates = FormFieldFeedbackState.allCases + + return [.init( + scenario: self, + feedbackState: .error, + component: .singleCheckbox, + label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + helperMessage: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + isRequired: true, + isEnabled: true, + modes: Constants.Modes.default, + sizes: Constants.Sizes.all + )] + } } From 588431900c73897f79f88d1cd1f2a3eb22fcad24 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 12 Apr 2024 14:14:55 +0200 Subject: [PATCH 057/117] [Checkbox#876] Fix typo issues --- .../Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift | 2 +- .../Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift index dde21315a..ced62fc0c 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift @@ -38,7 +38,7 @@ public struct CheckboxGroupView: View { /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. /// - theme: The Spark-Theme. /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was gived as a static string.") + @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was given as a static string.") public init( title: String? = nil, checkedImage: Image, diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index 9e61d1f98..da7baddcc 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -141,7 +141,7 @@ public final class CheckboxGroupUIView: UIControl { /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. - @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was gived as a static string.") + @available(*, deprecated, message: "Please use init without accessibilityIdentifierPrefix. It was given as a static string.") public convenience init( title: String? = nil, checkedImage: UIImage, @@ -172,7 +172,6 @@ public final class CheckboxGroupUIView: UIControl { /// - checkboxAlignment: The checkbox is positioned on the leading or trailing edge of the view. /// - theme: The Spark-Theme. /// - intent: Current intent of checkbox group - /// - accessibilityIdentifierPrefix: All checkbox-views are prefixed by this identifier followed by the `CheckboxGroupItemProtocol`-identifier. public init( title: String? = nil, checkedImage: UIImage, From 4440e2ef6909699a28984ada523e2a0e3b028f67 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Sun, 14 Apr 2024 14:50:13 +0200 Subject: [PATCH 058/117] [Formfield#859] Add snapshots --- .../FormfieldConfigurationSnapshotTests.swift | 2 +- .../FormfieldScenarioSnapshotTests.swift | 34 ++--- .../SwiftUI/FormFieldViewSnapshotTests.swift | 106 +++++++++++++++ .../View/UIKit/FormFieldUIView.swift | 9 +- .../UIKit/FormFieldUIViewSnapshotTests.swift | 122 ++++++++++++++++++ 5 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift create mode 100644 core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift index 716a403ed..79608be1d 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift @@ -31,7 +31,7 @@ struct FormfieldConfigurationSnapshotTests { "\(self.scenario.rawValue)", "\(self.feedbackState)", "IsRequired:\(self.isRequired)", - "IsEnabled:\(self.isRequired)", + "IsEnabled:\(self.isEnabled)", "\(self.component.rawValue)" ].joined(separator: "-") } diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift index a07abddbc..a8b4832c0 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift @@ -19,6 +19,7 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { case test6 //This will be added after textField case test7 case test8 + case test9 // MARK: - Type Alias @@ -40,6 +41,8 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { return self.test7() case .test8: return self.test8() + case .test9: + return self.test9() default: return [] } @@ -159,7 +162,7 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { private func test5() -> [FormfieldConfigurationSnapshotTests] { let messages: [String?] = [ "Lorem Ipsum", - "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", nil ] @@ -192,23 +195,20 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { /// - modes: light /// - sizes (accessibility): default private func test7() -> [FormfieldConfigurationSnapshotTests] { - let feedbackStates = FormFieldFeedbackState.allCases let components = FormfieldComponentType.allCases - return feedbackStates.flatMap { feedbackState in - components.map { component in - return .init( - scenario: self, - feedbackState: feedbackState, - component: component, - label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", - helperMessage: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", - isRequired: false, - isEnabled: false, - modes: Constants.Modes.default, - sizes: Constants.Sizes.default - ) - } + return components.map { component in + return .init( + scenario: self, + feedbackState: .default, + component: component, + label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + helperMessage: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", + isRequired: false, + isEnabled: false, + modes: Constants.Modes.default, + sizes: Constants.Sizes.default + ) } } @@ -264,7 +264,7 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { feedbackState: .error, component: .singleCheckbox, label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", - helperMessage: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + helperMessage: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", isRequired: true, isEnabled: true, modes: Constants.Modes.default, diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift new file mode 100644 index 000000000..931e2ddaf --- /dev/null +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift @@ -0,0 +1,106 @@ +// +// FormFieldViewSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by alican.aycil on 14.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +@testable import SparkCore + +final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { + + // MARK: - Properties + + private let theme: Theme = SparkTheme.shared + + @State private var checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ] + @State private var selectedID: Int? = 0 + + // MARK: - Tests + + func test() { + let scenarios = FormfieldScenarioSnapshotTests.allCases + + for scenario in scenarios { + let configurations = scenario.configuration() + + for configuration in configurations { + + @ViewBuilder + var component: some View { + switch configuration.component { + case .singleCheckbox: + CheckboxView( + text: "Hello World", + checkedImage: Image.mock, + theme: self.theme, + intent: .success, + selectionState: .constant(.selected) + ) + case .checkboxGroup: + CheckboxGroupView( + checkedImage: Image.mock, + items: self.$checkboxGroupItems, + alignment: .left, + theme: self.theme, + accessibilityIdentifierPrefix: "checkbox-group" + ) + case .singleRadioButton: + RadioButtonGroupView( + theme: self.theme, + intent: .accent, + selectedID: self.$selectedID, + items: [ + RadioButtonItem(id: 0, label: "Radio Button 1") + ], + labelAlignment: .trailing + ) + case .radioButtonGroup: + RadioButtonGroupView( + theme: self.theme, + intent: .danger, + selectedID: self.$selectedID, + items: [ + RadioButtonItem(id: 0, label: "Radio Button 1"), + RadioButtonItem(id: 1, label: "Radio Button 2"), + ], + labelAlignment: .trailing + ) + } + } + + let view = FormFieldView( + theme: self.theme, + component: { + component + }, + feedbackState: configuration.feedbackState, + title: configuration.label, + description: configuration.helperMessage, + isTitleRequired: configuration.isRequired + ) + .disabled(configuration.isEnabled) + .frame(width: UIScreen.main.bounds.size.width) + .fixedSize() + .background(.systemBackground) + + self.assertSnapshot( + matching: view, + modes: configuration.modes, + sizes: configuration.sizes, + testName: configuration.testName() + ) + } + } + } +} + +private extension Image { + static let mock: Image = Image(systemName: "checkmark") +} diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 441f302fe..8b30b3f76 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -147,8 +147,7 @@ public final class FormFieldUIView: UIControl { public var component: Component { didSet { oldValue.removeFromSuperview() - self.component.isAccessibilityElement = true - self.stackView.insertArrangedSubview(self.component, at: 1) + self.setComponent() } } @@ -238,9 +237,15 @@ public final class FormFieldUIView: UIControl { private func commonInit() { self.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formField self.setupViews() + self.setComponent() self.subscribe() } + private func setComponent() { + self.component.isAccessibilityElement = true + self.stackView.insertArrangedSubview(self.component, at: 1) + } + private func setupViews() { self.addSubview(self.stackView) NSLayoutConstraint.stickEdges(from: self.stackView, to: self) diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift new file mode 100644 index 000000000..84a8a59f5 --- /dev/null +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift @@ -0,0 +1,122 @@ +// +// FormFieldUIViewSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by alican.aycil on 14.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +@testable import SparkCore + +final class FormFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { + + // MARK: - Properties + + private let theme: Theme = SparkTheme.shared + + // MARK: - Tests + + func test() { + let scenarios = FormfieldScenarioSnapshotTests.allCases + + for scenario in scenarios { + let configurations = scenario.configuration() + + for configuration in configurations { + + let component: UIControl! + + switch configuration.component { + case .singleCheckbox: + component = Self.makeSingleCheckbox() + case .checkboxGroup: + component = Self.makeVerticalCheckbox() + case .singleRadioButton: + component = Self.makeSingleRadioButton() + case .radioButtonGroup: + component = Self.makeVerticalRadioButton() + } + + let view = FormFieldUIView( + theme: self.theme, + component: component, + feedbackState: configuration.feedbackState, + title: configuration.label, + description: configuration.helperMessage, + isTitleRequired: configuration.isRequired, + isEnabled: configuration.isEnabled + ) + view.backgroundColor = UIColor.systemBackground + view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: UIScreen.main.bounds.size.width) + ]) + + self.assertSnapshot( + matching: view, + modes: configuration.modes, + sizes: configuration.sizes, + testName: configuration.testName() + ) + } + } + } + + static func makeSingleCheckbox() -> UIControl { + let view = CheckboxUIView( + theme: SparkTheme.shared, + text: "Hello World", + checkedImage: UIImage.mock, + selectionState: .unselected, + alignment: .left + ) + return view + } + + static func makeVerticalCheckbox() -> UIControl { + let view = CheckboxGroupUIView( + checkedImage: UIImage.mock, + items: [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ], + theme: SparkTheme.shared, + intent: .success, + accessibilityIdentifierPrefix: "checkbox" + ) + view.layout = .vertical + return view + } + + static func makeSingleRadioButton() -> UIControl { + let view = RadioButtonUIView( + theme: SparkTheme.shared, + intent: .info, + id: "radiobutton", + label: NSAttributedString(string: "Hello World"), + isSelected: true + ) + return view + } + + static func makeVerticalRadioButton() -> UIControl { + let view = RadioButtonUIGroupView( + theme: SparkTheme.shared, + intent: .danger, + selectedID: "radiobutton", + items: [ + RadioButtonUIItem(id: "1", label: "Radio Button 1"), + RadioButtonUIItem(id: "2", label: "Radio Button 2"), + ], + groupLayout: .vertical + ) + return view + } +} + +private extension UIImage { + static let mock: UIImage = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate) ?? UIImage() +} From c770e895bf352b0a33e793a3b80e6ad5a05613df Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 15 Apr 2024 11:04:46 +0200 Subject: [PATCH 059/117] [Formfield#859] Fix memory warning --- .../SwiftUI/FormFieldViewSnapshotTests.swift | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift index 931e2ddaf..cbbac3ada 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift @@ -16,24 +16,42 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { private let theme: Theme = SparkTheme.shared - @State private var checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ - CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), - CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), - ] - @State private var selectedID: Int? = 0 - // MARK: - Tests func test() { let scenarios = FormfieldScenarioSnapshotTests.allCases + var _selectedID: Int? = 0 + lazy var selectedID: Binding = { + return Binding( + get: { return _selectedID }, + set: { newValue in + _selectedID = newValue + } + ) + }() + + var _checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ + CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), + CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), + ] + lazy var checkboxGroupItems: Binding<[any CheckboxGroupItemProtocol]> = { + return Binding<[any CheckboxGroupItemProtocol]>( + get: { return _checkboxGroupItems }, + set: { newValue in + _checkboxGroupItems = newValue + } + ) + }() + for scenario in scenarios { let configurations = scenario.configuration() for configuration in configurations { - + @ViewBuilder var component: some View { + switch configuration.component { case .singleCheckbox: CheckboxView( @@ -46,7 +64,7 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { case .checkboxGroup: CheckboxGroupView( checkedImage: Image.mock, - items: self.$checkboxGroupItems, + items: checkboxGroupItems, alignment: .left, theme: self.theme, accessibilityIdentifierPrefix: "checkbox-group" @@ -55,7 +73,7 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { RadioButtonGroupView( theme: self.theme, intent: .accent, - selectedID: self.$selectedID, + selectedID: selectedID, items: [ RadioButtonItem(id: 0, label: "Radio Button 1") ], @@ -65,7 +83,7 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { RadioButtonGroupView( theme: self.theme, intent: .danger, - selectedID: self.$selectedID, + selectedID: selectedID, items: [ RadioButtonItem(id: 0, label: "Radio Button 1"), RadioButtonItem(id: 1, label: "Radio Button 2"), From 0c74a8d6969db473523daa4c976eec7627f2324d Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 15 Apr 2024 13:55:46 +0200 Subject: [PATCH 060/117] [Formfield#859] Fix accessibility for ui tests --- .../FormFieldAccessibilityIdentifier.swift | 2 ++ .../FormField/View/SwiftUI/FormFieldView.swift | 2 ++ .../FormField/View/UIKit/FormFieldUIView.swift | 12 +++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift b/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift index 48db89456..ba41140fe 100644 --- a/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift +++ b/core/Sources/Components/FormField/AccessibilityIdentifier/FormFieldAccessibilityIdentifier.swift @@ -13,4 +13,6 @@ public enum FormFieldAccessibilityIdentifier { // MARK: - Properties public static let formField = "spark-formfield" + public static let formFieldLabel = "spark-formfield-label" + public static let formFieldHelperMessage = "spark-formfield-helper-message" } diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift index 81149817e..cf05563b2 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldView.swift @@ -78,6 +78,7 @@ public struct FormFieldView: View { Text(title) .font(self.viewModel.titleFont.font) .foregroundStyle(self.viewModel.titleColor.color) + .accessibilityIdentifier(FormFieldAccessibilityIdentifier.formFieldLabel) } self.component @@ -85,6 +86,7 @@ public struct FormFieldView: View { Text(description) .font(self.viewModel.descriptionFont.font) .foregroundStyle(self.viewModel.descriptionColor.color) + .accessibilityIdentifier(FormFieldAccessibilityIdentifier.formFieldHelperMessage) } } .accessibilityElement(children: .contain) diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 8b30b3f76..8b2afd78e 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -20,6 +20,8 @@ public final class FormFieldUIView: UIControl { label.backgroundColor = .clear label.numberOfLines = 0 label.adjustsFontForContentSizeCategory = true + label.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formFieldLabel + label.isAccessibilityElement = true return label }() @@ -28,6 +30,8 @@ public final class FormFieldUIView: UIControl { label.backgroundColor = .clear label.numberOfLines = 0 label.adjustsFontForContentSizeCategory = true + label.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formFieldHelperMessage + label.isAccessibilityElement = true return label }() @@ -235,10 +239,16 @@ public final class FormFieldUIView: UIControl { } private func commonInit() { - self.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formField self.setupViews() self.setComponent() self.subscribe() + self.updateAccessibility() + } + + private func updateAccessibility() { + self.accessibilityIdentifier = FormFieldAccessibilityIdentifier.formField + self.isAccessibilityElement = false + self.accessibilityContainerType = .semanticGroup } private func setComponent() { From 16b2601662e7c27d00cb711b71049a017b02b53e Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 16 Apr 2024 10:28:11 +0200 Subject: [PATCH 061/117] [TextField#886] Fixed iPad demo issue with modals --- .../Classes/View/Components/Main/ComponentUIView.swift | 2 +- .../UIKit/TextFieldAddonsComponentUIViewController.swift | 8 ++++---- .../UIKit/TextFieldComponentUIViewController.swift | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spark/Demo/Classes/View/Components/Main/ComponentUIView.swift b/spark/Demo/Classes/View/Components/Main/ComponentUIView.swift index b67451f6e..f04b20f61 100644 --- a/spark/Demo/Classes/View/Components/Main/ComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Main/ComponentUIView.swift @@ -265,7 +265,7 @@ class ComponentUIView: UIView { texts: spaceContainerTypes.map { $0.name }) { type in self.viewModel.spaceContainerType = type } - viewController.present(actionSheet, animated: true) + viewController.present(actionSheet, isAnimated: true) } // MARK: - Action diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift index 80a10196a..a7fa26885 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIViewController.swift @@ -95,7 +95,7 @@ final class TextFieldAddonsComponentUIViewController: UIViewController { texts: themes.map { $0.title }) { theme in self.themePublisher.theme = theme } - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentIntentActionSheet(_ intents: [TextFieldIntent]) { @@ -104,7 +104,7 @@ final class TextFieldAddonsComponentUIViewController: UIViewController { texts: intents.map { $0.name }) { intent in self.viewModel.intent = intent } - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentSideViewContentActionSheet(_ contents: [TextFieldSideViewContent], completion: @escaping (TextFieldSideViewContent) -> Void) { @@ -112,7 +112,7 @@ final class TextFieldAddonsComponentUIViewController: UIViewController { values: contents, texts: contents.map { $0.name }, completion: completion) - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentAddonContentActionSheet(_ contents: [TextFieldAddonContent], completion: @escaping (TextFieldAddonContent) -> Void) { @@ -120,7 +120,7 @@ final class TextFieldAddonsComponentUIViewController: UIViewController { values: contents, texts: contents.map { $0.name }, completion: completion) - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } } diff --git a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift index 42007c1ba..e7b16b629 100644 --- a/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift +++ b/spark/Demo/Classes/View/Components/TextField/UIKit/TextFieldComponentUIViewController.swift @@ -101,7 +101,7 @@ final class TextFieldComponentUIViewController: UIViewController { texts: themes.map { $0.title }) { theme in self.themePublisher.theme = theme } - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentIntentActionSheet(_ intents: [TextFieldIntent]) { @@ -110,7 +110,7 @@ final class TextFieldComponentUIViewController: UIViewController { texts: intents.map { $0.name }) { intent in self.viewModel.intent = intent } - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentViewModeActionSheet(_ viewModes: [UITextField.ViewMode], completion: @escaping (UITextField.ViewMode) -> Void) { @@ -118,7 +118,7 @@ final class TextFieldComponentUIViewController: UIViewController { values: viewModes, texts: viewModes.map { $0.description }, completion: completion) - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } private func presentSideViewContentActionSheet(_ contents: [TextFieldSideViewContent], completion: @escaping (TextFieldSideViewContent) -> Void) { @@ -126,7 +126,7 @@ final class TextFieldComponentUIViewController: UIViewController { values: contents, texts: contents.map { $0.name }, completion: completion) - self.present(actionSheet, animated: true) + self.present(actionSheet, isAnimated: true) } } From f190fedd70a67b06e693ff9bf2c71a9476ce1468 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 16 Apr 2024 10:41:55 +0200 Subject: [PATCH 062/117] [TextField#886] Added enter key to dismiss keyboard in TextFieldAddons UIKit demo --- .../Addons/UIKit/TextFieldAddonsComponentUIView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift index c3e16e3d0..9a4b1cfb1 100644 --- a/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/TextField/Addons/UIKit/TextFieldAddonsComponentUIView.swift @@ -26,6 +26,7 @@ final class TextFieldAddonsComponentUIView: ComponentUIView { self.textFieldAddons.textField.rightViewMode = .always super.init(viewModel: viewModel, componentView: self.textFieldAddons) self.textFieldAddons.textField.placeholder = "Placeholder" + self.textFieldAddons.textField.delegate = self self.setupSubscriptions() } @@ -182,3 +183,10 @@ final class TextFieldAddonsComponentUIView: ComponentUIView { return button } } + +extension TextFieldAddonsComponentUIView: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} From 09fe74da2462fedefbb1944f3be3eed2c53cc7fd Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Tue, 16 Apr 2024 11:47:54 +0200 Subject: [PATCH 063/117] [TextField#886] Adjusted leading / trailing constraints to take borderWidth into account --- .../View/UIKit/TextFieldAddonsUIView.swift | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift index 27340db52..fa3d22a46 100644 --- a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -34,7 +34,7 @@ public final class TextFieldAddonsUIView: UIControl { private var leadingConstraint: NSLayoutConstraint = NSLayoutConstraint() private var trailingConstraint: NSLayoutConstraint = NSLayoutConstraint() - private var separatorWidth: CGFloat { + private var borderWidth: CGFloat { return self.viewModel.borderWidth * self.scaleFactor } @@ -91,8 +91,9 @@ public final class TextFieldAddonsUIView: UIControl { self.addSubview(self.stackView) self.stackView.translatesAutoresizingMaskIntoConstraints = false - self.leadingConstraint = self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor) - self.trailingConstraint = self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + // Adding constant padding to set borders outline instead of inline + self.leadingConstraint = self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: self.borderWidth) + self.trailingConstraint = self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.borderWidth) NSLayoutConstraint.activate([ self.leadingConstraint, self.trailingConstraint, @@ -106,11 +107,11 @@ public final class TextFieldAddonsUIView: UIControl { private func setupSeparators() { self.leftAddonContainer.addSubview(self.leftSeparatorView) self.leftSeparatorView.translatesAutoresizingMaskIntoConstraints = false - self.leftSeparatorWidthConstraint = self.leftSeparatorView.widthAnchor.constraint(equalToConstant: self.separatorWidth) + self.leftSeparatorWidthConstraint = self.leftSeparatorView.widthAnchor.constraint(equalToConstant: self.borderWidth) self.rightAddonContainer.addSubview(self.rightSeparatorView) self.rightSeparatorView.translatesAutoresizingMaskIntoConstraints = false - self.rightSeparatorWidthConstraint = self.rightSeparatorView.widthAnchor.constraint(equalToConstant: self.separatorWidth) + self.rightSeparatorWidthConstraint = self.rightSeparatorView.widthAnchor.constraint(equalToConstant: self.borderWidth) NSLayoutConstraint.activate([ self.leftSeparatorWidthConstraint, @@ -146,6 +147,8 @@ public final class TextFieldAddonsUIView: UIControl { guard let self else { return } let width = borderWidth * self.scaleFactor self.setBorderWidth(width) + self.setLeftSpacing(self.viewModel.leftSpacing, borderWidth: width) + self.setRightSpacing(self.viewModel.rightSpacing, borderWidth: width) self.leftSeparatorWidthConstraint.constant = width self.rightSeparatorWidthConstraint.constant = width } @@ -157,13 +160,13 @@ public final class TextFieldAddonsUIView: UIControl { self.viewModel.$leftSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] leftSpacing in guard let self else { return } - self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? leftSpacing : .zero + self.setLeftSpacing(leftSpacing, borderWidth: self.borderWidth) self.setNeedsLayout() } self.viewModel.$rightSpacing.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] rightSpacing in guard let self else { return } - self.trailingConstraint.constant = self.rightAddonContainer.isHidden ? -rightSpacing : .zero + self.setRightSpacing(rightSpacing, borderWidth: self.borderWidth) self.setNeedsLayout() } @@ -180,6 +183,14 @@ public final class TextFieldAddonsUIView: UIControl { } } + private func setLeftSpacing(_ leftSpacing: CGFloat, borderWidth: CGFloat) { + self.leadingConstraint.constant = (self.leftAddonContainer.isHidden ? leftSpacing : .zero) + borderWidth + } + + private func setRightSpacing(_ rightSpacing: CGFloat, borderWidth: CGFloat) { + self.trailingConstraint.constant = (self.rightAddonContainer.isHidden ? -rightSpacing : .zero) - borderWidth + } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -190,10 +201,11 @@ public final class TextFieldAddonsUIView: UIControl { guard previousTraitCollection?.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory else { return } self._scaleFactor.update(traitCollection: self.traitCollection) - let width = self.viewModel.borderWidth * self.scaleFactor - self.setBorderWidth(width) - self.leftSeparatorWidthConstraint.constant = width - self.rightSeparatorWidthConstraint.constant = width + self.setBorderWidth(self.borderWidth) + self.setLeftSpacing(self.viewModel.leftSpacing, borderWidth: self.borderWidth) + self.setRightSpacing(self.viewModel.rightSpacing, borderWidth: self.borderWidth) + self.leftSeparatorWidthConstraint.constant = self.borderWidth + self.rightSeparatorWidthConstraint.constant = self.borderWidth self.invalidateIntrinsicContentSize() } @@ -210,13 +222,13 @@ public final class TextFieldAddonsUIView: UIControl { leftAddon.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ leftAddon.trailingAnchor.constraint(lessThanOrEqualTo: self.leftSeparatorView.leadingAnchor, constant: withPadding ? -self.viewModel.leftSpacing : 0), - leftAddon.centerXAnchor.constraint(equalTo: self.leftAddonContainer.centerXAnchor, constant: -self.separatorWidth / 2.0), + leftAddon.centerXAnchor.constraint(equalTo: self.leftAddonContainer.centerXAnchor, constant: -self.borderWidth / 2.0), leftAddon.centerYAnchor.constraint(equalTo: self.leftAddonContainer.centerYAnchor) ]) } self.leftAddon = leftAddon self.leftAddonContainer.isHidden = self.leftAddon == nil - self.leadingConstraint.constant = self.leftAddonContainer.isHidden ? self.viewModel.leftSpacing : .zero + self.setLeftSpacing(self.viewModel.leftSpacing, borderWidth: self.borderWidth) } /// Set the textfield's right addon @@ -232,12 +244,12 @@ public final class TextFieldAddonsUIView: UIControl { rightAddon.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ rightAddon.leadingAnchor.constraint(greaterThanOrEqualTo: self.rightSeparatorView.trailingAnchor, constant: withPadding ? self.viewModel.rightSpacing : 0), - rightAddon.centerXAnchor.constraint(equalTo: self.rightAddonContainer.centerXAnchor, constant: self.separatorWidth / 2.0), + rightAddon.centerXAnchor.constraint(equalTo: self.rightAddonContainer.centerXAnchor, constant: self.borderWidth / 2.0), rightAddon.centerYAnchor.constraint(equalTo: self.rightAddonContainer.centerYAnchor) ]) } self.rightAddon = rightAddon self.rightAddonContainer.isHidden = self.rightAddon == nil - self.trailingConstraint.constant = self.rightAddonContainer.isHidden ? -self.viewModel.rightSpacing : .zero + self.setRightSpacing(self.viewModel.rightSpacing, borderWidth: self.borderWidth) } } From 86bf270125d8947757f07bd82370fd6d54c8c7b5 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 17 Apr 2024 13:52:17 +0200 Subject: [PATCH 064/117] [Formfield#859] Fix snapshots without components --- .../FormfieldConfigurationSnapshotTests.swift | 11 +-- .../FormfieldScenarioSnapshotTests.swift | 74 +++++++----------- .../SwiftUI/FormFieldViewSnapshotTests.swift | 78 +++---------------- .../UIKit/FormFieldUIViewSnapshotTests.swift | 14 +--- 4 files changed, 41 insertions(+), 136 deletions(-) diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift index 79608be1d..b2e0da15b 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldConfigurationSnapshotTests.swift @@ -16,7 +16,6 @@ struct FormfieldConfigurationSnapshotTests { let scenario: FormfieldScenarioSnapshotTests let feedbackState: FormFieldFeedbackState - let component: FormfieldComponentType let label: String? let helperMessage: String? let isRequired: Bool @@ -31,15 +30,7 @@ struct FormfieldConfigurationSnapshotTests { "\(self.scenario.rawValue)", "\(self.feedbackState)", "IsRequired:\(self.isRequired)", - "IsEnabled:\(self.isEnabled)", - "\(self.component.rawValue)" + "IsEnabled:\(self.isEnabled)" ].joined(separator: "-") } } - -enum FormfieldComponentType: String, CaseIterable { - case singleCheckbox - case checkboxGroup - case singleRadioButton - case radioButtonGroup -} diff --git a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift index a8b4832c0..2ffc2f1e6 100644 --- a/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift +++ b/core/Sources/Components/FormField/TestHelper/FormfieldScenarioSnapshotTests.swift @@ -11,15 +11,13 @@ import UIKit @testable import SparkCore enum FormfieldScenarioSnapshotTests: String, CaseIterable { - case test1 //This will be added after textField + case test1 case test2 case test3 case test4 case test5 - case test6 //This will be added after textField + case test6 case test7 - case test8 - case test9 // MARK: - Type Alias @@ -29,6 +27,8 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { func configuration() -> [FormfieldConfigurationSnapshotTests] { switch self { + case .test1: + return self.test1() case .test2: return self.test2() case .test3: @@ -37,42 +37,34 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { return self.test4() case .test5: return self.test5() + case .test6: + return self.test6() case .test7: return self.test7() - case .test8: - return self.test8() - case .test9: - return self.test9() - default: - return [] } } // MARK: - Scenarios - /// Test 2 + /// Test 1 /// - /// Description: To test all feedback state for other components + /// Description: To test all feedback states /// /// Content: /// - feedbackState: all - /// - component: all /// - label: short /// - helperMessage: short /// - isRequired: false, /// - isEnabled: true /// - modes: light /// - sizes (accessibility): default - private func test2() -> [FormfieldConfigurationSnapshotTests] { + private func test1() -> [FormfieldConfigurationSnapshotTests] { let feedbackStates = FormFieldFeedbackState.allCases - let components = FormfieldComponentType.allCases - return feedbackStates.flatMap { feedbackState in - components.map { component in + return feedbackStates.map { feedbackState in return .init( scenario: self, feedbackState: feedbackState, - component: component, label: "Agreement", helperMessage: "Your agreement is important.", isRequired: false, @@ -80,24 +72,22 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { modes: Constants.Modes.default, sizes: Constants.Sizes.default ) - } } } - /// Test 3 + /// Test 2 /// /// Description: To test label's content resilience /// /// Content: /// - feedbackState: 'default' - /// - component: single checkbox /// - label: all /// - helperMessage: short /// - isRequired: false, /// - isEnabled: true /// - modes: light /// - sizes (accessibility): default - private func test3() -> [FormfieldConfigurationSnapshotTests] { + private func test2() -> [FormfieldConfigurationSnapshotTests] { let labels: [String?] = [ "Lorem Ipsum", "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", @@ -108,7 +98,6 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { return .init( scenario: self, feedbackState: .default, - component: .singleCheckbox, label: label, helperMessage: "Your agreement is important.", isRequired: false, @@ -119,24 +108,22 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { } } - /// Test 4 + /// Test 3 /// /// Description: To test required option /// /// Content: /// - feedbackState: 'default' - /// - component: single checkbox /// - label: all /// - helperMessage: short /// - isRequired: false, /// - isEnabled: true /// - modes: light /// - sizes (accessibility): default - private func test4() -> [FormfieldConfigurationSnapshotTests] { + private func test3() -> [FormfieldConfigurationSnapshotTests] { return [.init( scenario: self, feedbackState: .default, - component: .radioButtonGroup, label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", helperMessage: "Your agreement is important.", isRequired: true, @@ -146,20 +133,19 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { )] } - /// Test 5 + /// Test 4 /// /// Description: To test helper text's content resilience /// /// Content: /// - feedbackState: error - /// - component: checkbox group /// - label: short /// - helperMessage: all /// - isRequired: false, /// - isEnabled: true /// - modes: light /// - sizes (accessibility): default - private func test5() -> [FormfieldConfigurationSnapshotTests] { + private func test4() -> [FormfieldConfigurationSnapshotTests] { let messages: [String?] = [ "Lorem Ipsum", "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", @@ -170,7 +156,6 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { return .init( scenario: self, feedbackState: .error, - component: .checkboxGroup, label: "Agreement", helperMessage: message, isRequired: false, @@ -181,27 +166,25 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { } } - /// Test 7 + /// Test 5 /// - /// Description: To test disabled state for other components + /// Description: To test disabled state /// /// Content: /// - feedbackState: 'default' - /// - component: all /// - label: short /// - helperMessage: short /// - isRequired: false, /// - isEnabled: true /// - modes: light /// - sizes (accessibility): default - private func test7() -> [FormfieldConfigurationSnapshotTests] { - let components = FormfieldComponentType.allCases + private func test5() -> [FormfieldConfigurationSnapshotTests] { + let feedbackStates = FormFieldFeedbackState.allCases - return components.map { component in + return feedbackStates.map { feedbackState in return .init( scenario: self, - feedbackState: .default, - component: component, + feedbackState: feedbackState, label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", helperMessage: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", isRequired: false, @@ -212,27 +195,25 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { } } - /// Test 8 + /// Test 6 /// /// Description: To test dark & light mode /// /// Content: /// - feedbackState: all - /// - component: single checkbox /// - label: short /// - helperMessage: short /// - isRequired: false, /// - isEnabled: false /// - modes: dark /// - sizes (accessibility): default - private func test8() -> [FormfieldConfigurationSnapshotTests] { + private func test6() -> [FormfieldConfigurationSnapshotTests] { let feedbackStates = FormFieldFeedbackState.allCases return feedbackStates.map { feedbackState in return .init( scenario: self, feedbackState: feedbackState, - component: .singleCheckbox, label: "Agreement", helperMessage: "Your agreement is important.", isRequired: false, @@ -243,26 +224,23 @@ enum FormfieldScenarioSnapshotTests: String, CaseIterable { } } - /// Test 9 + /// Test 7 /// /// Description: To test a11y sizes /// /// Content: /// - feedbackState: error - /// - component: single checkbox /// - label: short /// - helperMessage: short /// - isRequired: false, /// - isEnabled: false /// - modes: light /// - sizes (accessibility): all - private func test9() -> [FormfieldConfigurationSnapshotTests] { - let feedbackStates = FormFieldFeedbackState.allCases + private func test7() -> [FormfieldConfigurationSnapshotTests] { return [.init( scenario: self, feedbackState: .error, - component: .singleCheckbox, label: "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", helperMessage: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.", isRequired: true, diff --git a/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift index cbbac3ada..8d09a9676 100644 --- a/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift +++ b/core/Sources/Components/FormField/View/SwiftUI/FormFieldViewSnapshotTests.swift @@ -21,25 +21,12 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { func test() { let scenarios = FormfieldScenarioSnapshotTests.allCases - var _selectedID: Int? = 0 - lazy var selectedID: Binding = { - return Binding( - get: { return _selectedID }, + var _isOn: Bool = true + lazy var isOn: Binding = { + return Binding( + get: { return _isOn }, set: { newValue in - _selectedID = newValue - } - ) - }() - - var _checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ - CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), - CheckboxGroupItemDefault(title: "Checkbox 2", id: "2", selectionState: .selected, isEnabled: true), - ] - lazy var checkboxGroupItems: Binding<[any CheckboxGroupItemProtocol]> = { - return Binding<[any CheckboxGroupItemProtocol]>( - get: { return _checkboxGroupItems }, - set: { newValue in - _checkboxGroupItems = newValue + _isOn = newValue } ) }() @@ -49,48 +36,10 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { for configuration in configurations { - @ViewBuilder - var component: some View { - - switch configuration.component { - case .singleCheckbox: - CheckboxView( - text: "Hello World", - checkedImage: Image.mock, - theme: self.theme, - intent: .success, - selectionState: .constant(.selected) - ) - case .checkboxGroup: - CheckboxGroupView( - checkedImage: Image.mock, - items: checkboxGroupItems, - alignment: .left, - theme: self.theme, - accessibilityIdentifierPrefix: "checkbox-group" - ) - case .singleRadioButton: - RadioButtonGroupView( - theme: self.theme, - intent: .accent, - selectedID: selectedID, - items: [ - RadioButtonItem(id: 0, label: "Radio Button 1") - ], - labelAlignment: .trailing - ) - case .radioButtonGroup: - RadioButtonGroupView( - theme: self.theme, - intent: .danger, - selectedID: selectedID, - items: [ - RadioButtonItem(id: 0, label: "Radio Button 1"), - RadioButtonItem(id: 1, label: "Radio Button 2"), - ], - labelAlignment: .trailing - ) - } + let component = HStack { + Toggle("", isOn: isOn) + .labelsHidden() + Spacer() } let view = FormFieldView( @@ -103,22 +52,19 @@ final class FormfieldViewSnapshotTests: SwiftUIComponentSnapshotTestCase { description: configuration.helperMessage, isTitleRequired: configuration.isRequired ) - .disabled(configuration.isEnabled) .frame(width: UIScreen.main.bounds.size.width) - .fixedSize() + .fixedSize(horizontal: false, vertical: true) + .disabled(!configuration.isEnabled) .background(.systemBackground) self.assertSnapshot( matching: view, modes: configuration.modes, sizes: configuration.sizes, + record: true, testName: configuration.testName() ) } } } } - -private extension Image { - static let mock: Image = Image(systemName: "checkmark") -} diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift index 84a8a59f5..68a864929 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIViewSnapshotTests.swift @@ -26,18 +26,8 @@ final class FormFieldUIViewSnapshotTests: UIKitComponentSnapshotTestCase { for configuration in configurations { - let component: UIControl! - - switch configuration.component { - case .singleCheckbox: - component = Self.makeSingleCheckbox() - case .checkboxGroup: - component = Self.makeVerticalCheckbox() - case .singleRadioButton: - component = Self.makeSingleRadioButton() - case .radioButtonGroup: - component = Self.makeVerticalRadioButton() - } + let component = UISwitch() + component.setOn(true, animated: false) let view = FormFieldUIView( theme: self.theme, From 6933a9ec5119ce79096b775c310ff4b7a4d23c56 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 17 Apr 2024 15:27:20 +0200 Subject: [PATCH 065/117] [TextField#884] Fixed separator not hiding when addon is EmptyView (left & right) --- .../Addons/View/SwiftUI/TextFieldAddons.swift | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift index 92bef1ede..5a1997069 100644 --- a/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift +++ b/core/Sources/Components/TextField/Addons/View/SwiftUI/TextFieldAddons.swift @@ -84,40 +84,24 @@ public struct TextFieldAddons EdgeInsets { + private func getTextFieldPadding() -> EdgeInsets { return EdgeInsets( top: .zero, - leading: LeftAddon.self is EmptyView.Type ? self.viewModel.leftSpacing : .zero, + leading: self.viewModel.leftSpacing, bottom: .zero, - trailing: RightAddon.self is EmptyView.Type ? self.viewModel.rightSpacing : .zero + trailing: self.viewModel.rightSpacing ) } public var body: some View { ZStack { self.viewModel.backgroundColor.color - let leftAddon = leftAddon() - let rightAddon = rightAddon() - HStack(spacing: self.viewModel.contentSpacing) { - if LeftAddon.self is EmptyView.Type == false { - HStack(spacing: 0) { - leftAddon - .padding(getLeftAddonPadding(withPadding: leftAddon.withPadding)) - separator() - } - .layoutPriority(leftAddon.layoutPriority) - } + HStack(spacing: 0) { + leftAddonIfNeeded() textField() - if RightAddon.self is EmptyView.Type == false { - HStack(spacing: 0) { - separator() - rightAddon - .padding(getRightAddonPadding(withPadding: rightAddon.withPadding)) - } - .layoutPriority(leftAddon.layoutPriority) - } + .padding(getTextFieldPadding()) + rightAddonIfNeeded() } - .padding(getContentPadding()) } .frame(maxHeight: maxHeight) .allowsHitTesting(self.viewModel.textFieldViewModel.isUserInteractionEnabled) @@ -127,10 +111,35 @@ public struct TextFieldAddons some View { + // If the content of leftAddon is EmptyView, it will show nothing + let leftAddon = self.leftAddon() + leftAddon + .padding(getLeftAddonPadding(withPadding: leftAddon.withPadding)) + .overlay(alignment: .trailing) { + separator() + } + .layoutPriority(leftAddon.layoutPriority) + } + + @ViewBuilder + private func rightAddonIfNeeded() -> some View { + // If the content of rightAddon is EmptyView, it will show nothing + let rightAddon = self.rightAddon() + rightAddon + .padding(getRightAddonPadding(withPadding: rightAddon.withPadding)) + .overlay(alignment: .leading) { + separator() + } + .layoutPriority(rightAddon.layoutPriority) + } + @ViewBuilder private func separator() -> some View { self.viewModel.textFieldViewModel.borderColor.color - .frame(width: self.viewModel.borderWidth * self.scaleFactor) + .frame(width: self.viewModel.borderWidth * self.scaleFactor, + height: maxHeight) } @ViewBuilder From 34397fc34c5491f5d1bedf4250ddc381fb3a5009 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 18 Apr 2024 14:11:06 +0200 Subject: [PATCH 066/117] [TextField#885] Handled focused + disabled states in TextFieldSwiftUI --- .../View/SwiftUI/TextFieldView.swift | 64 ++++------------ .../View/SwiftUI/TextFieldViewInternal.swift | 74 +++++++++++++++++++ .../ViewModel/TextFieldViewModel.swift | 10 +++ .../ViewModel/TextFieldViewModelTests.swift | 58 +++++++++++++++ 4 files changed, 158 insertions(+), 48 deletions(-) create mode 100644 core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift index b9048aaf6..a76d33e71 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldView.swift @@ -11,19 +11,16 @@ import SwiftUI /// A TextField that can be surrounded by left and/or right views public struct TextFieldView: View { - @ScaledMetric private var height: CGFloat = 44 - @ScaledMetric private var scaleFactor: CGFloat = 1.0 - - @FocusState private var isFocused: Bool - @ObservedObject var viewModel: TextFieldViewModel - private let titleKey: LocalizedStringKey - @Binding private var text: String - private var type: TextFieldViewType - + private let text: Binding + private let type: TextFieldViewType + private let viewModel: TextFieldViewModel private let leftView: () -> LeftView private let rightView: () -> RightView + @Environment(\.isEnabled) private var isEnabled: Bool + @FocusState private var isFocused: Bool + init(titleKey: LocalizedStringKey, text: Binding, viewModel: TextFieldViewModel, @@ -31,7 +28,7 @@ public struct TextFieldView: View { leftView: @escaping (() -> LeftView), rightView: @escaping (() -> RightView)) { self.titleKey = titleKey - self._text = text + self.text = text self.viewModel = viewModel self.type = type self.leftView = leftView @@ -97,45 +94,16 @@ public struct TextFieldView: View { } public var body: some View { - ZStack { - self.viewModel.backgroundColor.color - contentViewBuilder() - .padding(EdgeInsets(top: .zero, leading: self.viewModel.leftSpacing, bottom: .zero, trailing: self.viewModel.rightSpacing)) - } - .tint(self.viewModel.textColor.color) - .isEnabledChanged { isEnabled in - self.viewModel.isEnabled = isEnabled - } - .allowsHitTesting(self.viewModel.isUserInteractionEnabled) + TextFieldViewInternal( + titleKey: self.titleKey, + text: self.text, + viewModel: self.viewModel + .enabled(self.isEnabled) + .focused(self.isFocused), + type: self.type, + leftView: self.leftView, + rightView: self.rightView) .focused($isFocused) - .onChange(of: isFocused) { newValue in - self.viewModel.isFocused = newValue - } - .border(width: self.viewModel.borderWidth * self.scaleFactor, radius: self.viewModel.borderRadius, colorToken: self.viewModel.borderColor) - .frame(height: self.height) - .opacity(self.viewModel.dim) } - // MARK: - Content - @ViewBuilder - private func contentViewBuilder() -> some View { - HStack(spacing: self.viewModel.contentSpacing) { - leftView() - Group { - switch type { - case .secure(let onCommit): - SecureField(titleKey, text: $text, onCommit: onCommit) - .font(self.viewModel.font.font) - case .standard(let onEditingChanged, let onCommit): - TextField(titleKey, text: $text, onEditingChanged: onEditingChanged, onCommit: onCommit) - .font(self.viewModel.font.font) - } - } - .textFieldStyle(.plain) - .foregroundStyle(self.viewModel.textColor.color) - rightView() - } - .accessibilityElement(children: .contain) - .accessibilityIdentifier(TextFieldAccessibilityIdentifier.view) - } } diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift new file mode 100644 index 000000000..95e9bc631 --- /dev/null +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift @@ -0,0 +1,74 @@ +// +// TextFieldViewInternal.swift +// SparkCore +// +// Created by louis.borlee on 18/04/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI + +struct TextFieldViewInternal: View { + + @ScaledMetric private var height: CGFloat = 44 + @ScaledMetric private var scaleFactor: CGFloat = 1.0 + + @ObservedObject private var viewModel: TextFieldViewModel + @Binding private var text: String + + private let titleKey: LocalizedStringKey + private var type: TextFieldViewType + + private let leftView: () -> LeftView + private let rightView: () -> RightView + + init(titleKey: LocalizedStringKey, + text: Binding, + viewModel: TextFieldViewModel, + type: TextFieldViewType, + leftView: @escaping (() -> LeftView), + rightView: @escaping (() -> RightView)) { + self.titleKey = titleKey + self._text = text + self.viewModel = viewModel + self.type = type + self.leftView = leftView + self.rightView = rightView + } + + var body: some View { + ZStack { + self.viewModel.backgroundColor.color + contentViewBuilder() + .padding(EdgeInsets(top: .zero, leading: self.viewModel.leftSpacing, bottom: .zero, trailing: self.viewModel.rightSpacing)) + } + .tint(self.viewModel.textColor.color) + .allowsHitTesting(self.viewModel.isUserInteractionEnabled) + .border(width: self.viewModel.borderWidth * self.scaleFactor, radius: self.viewModel.borderRadius, colorToken: self.viewModel.borderColor) + .frame(height: self.height) + .opacity(self.viewModel.dim) + } + + // MARK: - Content + @ViewBuilder + private func contentViewBuilder() -> some View { + HStack(spacing: self.viewModel.contentSpacing) { + leftView() + Group { + switch type { + case .secure(let onCommit): + SecureField(titleKey, text: $text, onCommit: onCommit) + .font(self.viewModel.font.font) + case .standard(let onEditingChanged, let onCommit): + TextField(titleKey, text: $text, onEditingChanged: onEditingChanged, onCommit: onCommit) + .font(self.viewModel.font.font) + } + } + .textFieldStyle(.plain) + .foregroundStyle(self.viewModel.textColor.color) + rightView() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(TextFieldAccessibilityIdentifier.view) + } +} diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift index 8f14248ec..6ac305aa4 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModel.swift @@ -167,4 +167,14 @@ class TextFieldViewModel: ObservableObject, Updateable { private func setFont() { self.font = self.theme.typography.body1 } + + func enabled(_ isEnabled: Bool) -> Self { + self.isEnabled = isEnabled + return self + } + + func focused(_ isFocused: Bool) -> Self { + self.isFocused = isFocused + return self + } } diff --git a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift index bee749261..2cb7e96d1 100644 --- a/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift +++ b/core/Sources/Components/TextField/ViewModel/TextFieldViewModelTests.swift @@ -619,6 +619,64 @@ final class TextFieldViewModelTests: XCTestCase { XCTAssertFalse(self.publishers.font.sinkCalled, "$font should not have been called") } + // MARK: - Enabled() + func test_enabled_false() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + let viewModel = self.viewModel.enabled(false) + + // THEN + XCTAssertIdentical(viewModel, self.viewModel, "Wrong returned viewModel") + XCTAssertFalse(viewModel.isEnabled, "isEnabled should be false") + } + + + func test_enabled_true() { + // GIVEN - Inits from setUp() + self.viewModel.isEnabled = false + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + let viewModel = self.viewModel.enabled(true) + + // THEN + XCTAssertIdentical(viewModel, self.viewModel, "Wrong returned viewModel") + XCTAssertTrue(viewModel.isEnabled, "isEnabled should be true") + } + + // MARK: - Focused() + func test_focused_true() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + let viewModel = self.viewModel.focused(true) + + // THEN + XCTAssertIdentical(viewModel, self.viewModel, "Wrong returned viewModel") + XCTAssertTrue(viewModel.isFocused, "isFocused should be true") + } + + + func test_focused_false() { + // GIVEN - Inits from setUp() + self.viewModel.isFocused = true + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + let viewModel = self.viewModel.focused(false) + + // THEN + XCTAssertIdentical(viewModel, self.viewModel, "Wrong returned viewModel") + XCTAssertFalse(viewModel.isFocused, "isFocused should be false") + } + // MARK: - Utils func setupPublishers() { self.publishers = .init( From 7263a58796844391272142692e42b10a75ef5993 Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 25 Apr 2024 15:41:33 +0200 Subject: [PATCH 067/117] [TextField#885] Corrected SwiftUI accessibility identifier --- .../View/SwiftUI/TextFieldViewInternal.swift | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift index 95e9bc631..650fa49af 100644 --- a/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift +++ b/core/Sources/Components/TextField/View/SwiftUI/TextFieldViewInternal.swift @@ -39,8 +39,7 @@ struct TextFieldViewInternal: View { var body: some View { ZStack { self.viewModel.backgroundColor.color - contentViewBuilder() - .padding(EdgeInsets(top: .zero, leading: self.viewModel.leftSpacing, bottom: .zero, trailing: self.viewModel.rightSpacing)) + contentView() } .tint(self.viewModel.textColor.color) .allowsHitTesting(self.viewModel.isUserInteractionEnabled) @@ -51,24 +50,30 @@ struct TextFieldViewInternal: View { // MARK: - Content @ViewBuilder - private func contentViewBuilder() -> some View { + private func contentView() -> some View { HStack(spacing: self.viewModel.contentSpacing) { leftView() - Group { - switch type { - case .secure(let onCommit): - SecureField(titleKey, text: $text, onCommit: onCommit) - .font(self.viewModel.font.font) - case .standard(let onEditingChanged, let onCommit): - TextField(titleKey, text: $text, onEditingChanged: onEditingChanged, onCommit: onCommit) - .font(self.viewModel.font.font) - } - } - .textFieldStyle(.plain) - .foregroundStyle(self.viewModel.textColor.color) + textField() rightView() } - .accessibilityElement(children: .contain) + .padding(EdgeInsets(top: .zero, leading: self.viewModel.leftSpacing, bottom: .zero, trailing: self.viewModel.rightSpacing)) + } + + // MARK: - TextField + @ViewBuilder + private func textField() -> some View { + Group { + switch type { + case .secure(let onCommit): + SecureField(titleKey, text: $text, onCommit: onCommit) + .font(self.viewModel.font.font) + case .standard(let onEditingChanged, let onCommit): + TextField(titleKey, text: $text, onEditingChanged: onEditingChanged, onCommit: onCommit) + .font(self.viewModel.font.font) + } + } + .textFieldStyle(.plain) + .foregroundStyle(self.viewModel.textColor.color) .accessibilityIdentifier(TextFieldAccessibilityIdentifier.view) } } From d19e52c2a4476696ef32d9dbadf57b02b68cc662 Mon Sep 17 00:00:00 2001 From: Robin Lemaire Date: Fri, 26 Apr 2024 17:52:40 +0200 Subject: [PATCH 068/117] [Sourcery-914] Fix compilation error --- .github/workflows/build-and-test.yml | 7 ++----- .gitignore | 2 -- .sourcery-version | 1 - PrivacyInfo.xcprivacy | 14 -------------- .../ViewModel/Button/ButtonViewModelTests.swift | 7 +------ .../SparkCoreAutoPublisherTest.stencil | 2 +- .../SparkCoreAutoViewModelStub.stencil | 16 ++++++++-------- 7 files changed, 12 insertions(+), 37 deletions(-) delete mode 100644 .sourcery-version delete mode 100644 PrivacyInfo.xcprivacy diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f349fff3a..38446aa07 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -33,11 +33,8 @@ jobs: uses: actions/checkout@v3 - name: Run sourcery run: | - VERSION=$(cat .sourcery-version) - echo "Downloading Sourcery from https://github.com/krzysztofzablocki/Sourcery/releases/download/$VERSION/sourcery-$VERSION.zip" - curl -L https://github.com/krzysztofzablocki/Sourcery/releases/download/$VERSION/sourcery-$VERSION.zip -o sourcery.zip - unzip sourcery.zip -d sourcery-bin - ./sourcery-bin/bin/sourcery + brew install sourcery + sourcery - name: Run xcodegen uses: xavierLowmiller/xcodegen-action@1.1.2 with: diff --git a/.gitignore b/.gitignore index aa96fa316..ef00d3a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -110,8 +110,6 @@ spark-ios-snapshots #sourcery *Generated/ -sourcery-bin -sourcery.zip vendor/ *.lock diff --git a/.sourcery-version b/.sourcery-version deleted file mode 100644 index 9671f9a9b..000000000 --- a/.sourcery-version +++ /dev/null @@ -1 +0,0 @@ -2.1.7 \ No newline at end of file diff --git a/PrivacyInfo.xcprivacy b/PrivacyInfo.xcprivacy deleted file mode 100644 index e08a130bc..000000000 --- a/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,14 +0,0 @@ - - - - - NSPrivacyTracking - - NSPrivacyTrackingDomains - - NSPrivacyCollectedDataTypes - - NSPrivacyAccessedAPITypes - - - diff --git a/core/Sources/Components/Button/ViewModel/Button/ButtonViewModelTests.swift b/core/Sources/Components/Button/ViewModel/Button/ButtonViewModelTests.swift index f0c637bcc..0014ff258 100644 --- a/core/Sources/Components/Button/ViewModel/Button/ButtonViewModelTests.swift +++ b/core/Sources/Components/Button/ViewModel/Button/ButtonViewModelTests.swift @@ -264,12 +264,7 @@ private final class Stub: ButtonViewModelStub { super.init( viewModel: viewModel, - getSpacingsUseCaseMock: getSpacingsUseCaseMock, - getBorderUseCaseMock: ButtonGetBorderUseCaseableGeneratedMock(), - getColorsUseCaseMock: ButtonGetColorsUseCaseableGeneratedMock(), - getCurrentColorsUseCaseMock: ButtonGetCurrentColorsUseCaseableGeneratedMock(), - getSizesUseCaseMock: ButtonGetSizesUseCaseableGeneratedMock(), - getStateUseCaseMock: ButtonGetStateUseCaseableGeneratedMock() + getSpacingsUseCaseMock: getSpacingsUseCaseMock ) } } diff --git a/stencil/sourcery-template/SparkCoreAutoPublisherTest.stencil b/stencil/sourcery-template/SparkCoreAutoPublisherTest.stencil index 791871edb..fb47fd62b 100644 --- a/stencil/sourcery-template/SparkCoreAutoPublisherTest.stencil +++ b/stencil/sourcery-template/SparkCoreAutoPublisherTest.stencil @@ -31,7 +31,7 @@ final class {{ class.name }}PublisherTest { // MARK: - Tests - {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.attributes["Published"] != nil %} + {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.definedInTypeName|contains: class.name and variable.attributes["Published"] != nil %} static func XCTSinksCount( {{ variable.name }} mock: PublisherMock.Publisher>, diff --git a/stencil/sourcery-template/SparkCoreAutoViewModelStub.stencil b/stencil/sourcery-template/SparkCoreAutoViewModelStub.stencil index 5d0273e90..5e87bcbf2 100644 --- a/stencil/sourcery-template/SparkCoreAutoViewModelStub.stencil +++ b/stencil/sourcery-template/SparkCoreAutoViewModelStub.stencil @@ -36,31 +36,31 @@ class {{ class.name }}Stub { // MARK: - Published Properties - {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.attributes["Published"] != nil %} + {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.definedInTypeName|contains: class.name and variable.attributes["Published"] != nil %} let {% call publisherName variable %}: PublisherMock.Publisher> {% endfor %} // MARK: Dependencies - {% for variable in class.allVariables|!definedInExtension where variable.name|contains: "UseCase" %} + {% for variable in class.allVariables|!definedInExtension where variable.definedInTypeName|contains: class.name and variable.name|contains: "UseCase" %} let {% call useCaseName variable %}: {% call genericTypeName class variable %} {% endfor %} // MARK: - Initialization init( - viewModel: {% call viewModelType class %}{% for variable in class.allVariables|!definedInExtension where variable.name|contains: "UseCase" %}{% if forloop.first %},{% endif %} + viewModel: {% call viewModelType class %}{% for variable in class.allVariables|!definedInExtension where variable.definedInTypeName|contains: class.name and variable.name|contains: "UseCase" %}{% if forloop.first %},{% endif %} {% call useCaseName variable %}: {% call genericTypeName class variable %}{% if not forloop.last %},{% endif %}{% endfor %} ) { self.viewModel = viewModel // UseCases - {% for variable in class.allVariables|!definedInExtension where variable.name|contains: "UseCase" %} + {% for variable in class.allVariables|!definedInExtension where variable.definedInTypeName|contains: class.name and variable.name|contains: "UseCase" %} self.{% call useCaseName variable %} = {% call useCaseName variable %} {% endfor %} // Publishers - {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.attributes["Published"] != nil %} + {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.definedInTypeName|contains: class.name and variable.attributes["Published"] != nil %} self.{% call publisherName variable %} = .init(publisher: viewModel.${{ variable.name }}) {% endfor %} } @@ -68,7 +68,7 @@ class {{ class.name }}Stub { // MARK: - Subscription func subscribePublishers(on subscriptions: inout Set) { - {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.attributes["Published"] != nil %} + {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.definedInTypeName|contains: class.name and variable.attributes["Published"] != nil %} self.{% call publisherName variable %}.loadTesting(on: &subscriptions) {% endfor %} } @@ -76,11 +76,11 @@ class {{ class.name }}Stub { // MARK: - Reset func resetMockedData() { - {% for variable in class.allVariables|!definedInExtension where variable.name|contains: "UseCase" %}{% if forloop.first %}// Clear UseCases Mock{% endif %} + {% for variable in class.allVariables|!definedInExtension where variable.definedInTypeName|contains: class.name and variable.name|contains: "UseCase" %}{% if forloop.first %}// Clear UseCases Mock{% endif %} self.{% call useCaseName variable %}.reset() {% endfor %} - {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.attributes["Published"] != nil %}{% if forloop.first %}// Reset published sink counter{% endif %} + {% for variable in class.allVariables|!definedInExtension where variable.readAccess != "private" and variable.definedInTypeName|contains: class.name and variable.attributes["Published"] != nil %}{% if forloop.first %}// Reset published sink counter{% endif %} self.{% call publisherName variable %}.reset() {% endfor %} } From 56a0cca0fc19b022180965c8fc140ec8ae5b4eb1 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Mon, 29 Apr 2024 12:52:27 +0200 Subject: [PATCH 069/117] [Checkbox#876] Fix wrong accessibility value --- .../Components/Checkbox/View/UIKit/CheckboxUIView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift index 4f82f18e5..1cdab5dda 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxUIView.swift @@ -344,7 +344,7 @@ public final class CheckboxUIView: UIControl { self.viewModel.$selectionState.subscribe(in: &self.cancellables) { [weak self] selectionState in guard let self else { return } self.controlView.selectionState = selectionState - self.setAccessibilityValue() + self.setAccessibilityValue(state: selectionState) } self.viewModel.$alignment.subscribe(in: &self.cancellables) { [weak self] alignment in @@ -388,7 +388,7 @@ private extension CheckboxUIView { self.accessibilityTraits.insert(.button) self.accessibilityTraits.remove(.selected) self.setAccessibilityLabel(self.textLabel.text) - self.setAccessibilityValue() + self.setAccessibilityValue(state: self.selectionState) self.setAccessibilityEnable() } @@ -396,8 +396,8 @@ private extension CheckboxUIView { self.accessibilityLabel = label } - private func setAccessibilityValue() { - switch self.selectionState { + private func setAccessibilityValue(state: CheckboxSelectionState) { + switch state { case .selected: self.accessibilityValue = CheckboxAccessibilityValue.checked case .indeterminate: From 0af04ecbb2d29e1344b28c1686ec68c5e37bd610 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Thu, 2 May 2024 17:17:21 +0200 Subject: [PATCH 070/117] [Formfield#858] Fix checkbox demo project issues --- .../Components/FormField/SwiftUI/FormFieldComponentView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift index fd88cbef5..0915f9f8c 100644 --- a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift +++ b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift @@ -32,6 +32,7 @@ struct FormFieldComponentView: View { ] @State private var texfieldText: String = "" @State private var selectedID: Int? = 0 + @State private var checkboxSelected: CheckboxSelectionState = .selected @State private var rating: CGFloat = 2 // MARK: - View @@ -95,6 +96,7 @@ struct FormFieldComponentView: View { isTitleRequired: self.isTitleRequired == .selected ? true : false ) .disabled(self.isEnabled == .selected ? false : true) + .layoutPriority(1) } ) } @@ -144,7 +146,7 @@ struct FormFieldComponentView: View { checkedImage: DemoIconography.shared.checkmark.image, theme: self.theme, intent: .success, - selectionState: .constant(.selected) + selectionState: self.$checkboxSelected ) .fixedSize(horizontal: false, vertical: true) From 8cdfe3044c8baa52ab50d73efa6ce5d00025b1ce Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 21 Feb 2024 18:29:06 +0100 Subject: [PATCH 071/117] [ProgressTracker#824] Fix touch when parent has gesture recognizer. --- .../UIKit/Extension/UIControl/UIControl+Extensions.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift b/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift index b0abe6d9d..9fc24e41c 100644 --- a/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift +++ b/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift @@ -9,10 +9,16 @@ import UIKit extension UIControl { +<<<<<<< HEAD // Add a default tap gesture recognizer without any action to detect the action/publisher/target action even if the parent view has a gesture recognizer // Why? UIControl action/publisher/target doesn't work if the parent contains a gesture recognizer. // Note: Native UIButton add the same default recognizer to manage this use case. +======= + //Add a default tap gesture recognizer without any action to detect the action/publisher/target action even if the parent view has a gesture recognizer + //Why ? UIControl action/publisher/target doesn't work if the parent contains a gesture recognizer. + //Note: Native UIButton add the same default recognizer to manage this use case. +>>>>>>> 7963ca03 ([ProgressTracker#824] Fix touch when parent has gesture recognizer.) func enableTouch() { let gestureRecognizer = UITapGestureRecognizer() gestureRecognizer.cancelsTouchesInView = false From a1fc873f7868b754c2669bbe01ea9fcb79e52078 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Thu, 22 Feb 2024 17:18:40 +0100 Subject: [PATCH 072/117] [ProgressTracker#830] Added basic interaction. --- .../View/SwiftUI/ProgressTrackerView.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift index 2cc1781c7..e05f48b1c 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift @@ -11,6 +11,7 @@ import SwiftUI /// A progress tracker, similar to the UIPageControl public struct ProgressTrackerView: View { typealias Content = ProgressTrackerContent + typealias AccessibilityIdentifier = ProgressTrackerAccessibilityIdentifier //MARK: - Private properties @ObservedObject private var viewModel: ProgressTrackerViewModel @@ -18,6 +19,7 @@ public struct ProgressTrackerView: View { private let variant: ProgressTrackerVariant private let size: ProgressTrackerSize @Binding var currentPageIndex: Int +// @State private var indicatorPositions = [Int: CGRect]() //MARK: - Initialization /// Initializer @@ -98,9 +100,34 @@ public struct ProgressTrackerView: View { .isEnabledChanged { isEnabled in self.viewModel.isEnabled = isEnabled } + .backgroundPreferenceValue(ProgressTrackerSizePreferences.self) { preferences in + GeometryReader { geometry in + Color.black.opacity(0.000001) + .gesture(self.dragGesture(bounds: geometry.frame(in: .local), preferences: preferences)) + } + } } //MARK: - Private functions + private func dragGesture(bounds: CGRect?, preferences: [Int: CGRect]) -> some Gesture { + + let indicators = preferences.sorted { $0.key < $1.key }.map(\.value) + let frame = bounds ?? .zero + + return DragGesture(minimumDistance: .zero) + .onChanged({ value in + guard frame.contains(value.location) else { return } + let index = indicators.index(closestTo: value.location) + print("ON CHANGED \(String(describing: index)) \(value.location)") + }) + .onEnded({ value in + guard frame.contains(value.location) else { + return } + let index = indicators.index(closestTo: value.location) + print("ON ENDED \(String(describing: index))") + }) + } + @ViewBuilder private var progressTrackerView: some View { if self.viewModel.orientation == .horizontal { From bd1210735ff0ca22dbe854e7d9fd70bd1b95185f Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 20 Mar 2024 16:56:00 +0100 Subject: [PATCH 073/117] [ProgressTracker#830] Add first interaction. --- .../View/ProgressTrackerViewModel.swift | 1 + .../ProgressTrackerIndicatorView.swift | 28 ++++++++------- .../View/SwiftUI/ProgressTrackerView.swift | 35 ++++++++++++++++--- .../SwiftUI/ProgressTrackerComponent.swift | 17 +++++++-- .../ProgressTrackerComponentUIViewModel.swift | 2 +- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift index 5944840d7..635e2bcfb 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift @@ -68,6 +68,7 @@ final class ProgressTrackerViewModel -1 else { + self.currentTouchedPageIndex = -1 return } + switch self.viewModel.interactionState { + case .none: () + case .discrete: () + case .continuous: () + case .independent: self.currentPageIndex = self.currentTouchedPageIndex + self.currentTouchedPageIndex = -1 + + } let index = indicators.index(closestTo: value.location) print("ON ENDED \(String(describing: index))") }) @@ -222,6 +243,12 @@ public struct ProgressTrackerView: View { self.viewModel.showDefaultPageNumber = showPageNumber return self } + + /// Set the current interaction state + public func interactionState(_ interactionState: ProgressTrackerInteractionState) -> Self { + self.viewModel.interactionState = interactionState + return self + } } extension CGRect { diff --git a/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift b/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift index 02f315768..9a5d57407 100644 --- a/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift +++ b/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift @@ -25,6 +25,7 @@ struct ProgressTrackerComponent: View { @State private var isDisabled = CheckboxSelectionState.unselected @State private var completedPageIndicator = CheckboxSelectionState.unselected @State private var currentPageIndicator = CheckboxSelectionState.unselected + @State private var interaction = ProgressTrackerInteractionState.none private var numberFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() @@ -67,6 +68,13 @@ struct ProgressTrackerComponent: View { value: self.$orientation ) + EnumSelector( + title: "Interaction", + dialogTitle: "Select an interaction type", + values: ProgressTrackerInteractionState.allCases, + value: self.$interaction + ) + EnumSelector( title: "Content", dialogTitle: "Content Type", @@ -145,6 +153,9 @@ struct ProgressTrackerComponent: View { } } ) + .onChange(of: self.currentPageIndex) { index in + Console.log("Current page \(index)") + } } private func progressTrackerView() -> ProgressTrackerView { @@ -185,7 +196,8 @@ struct ProgressTrackerComponent: View { } if self.completedPageIndicator == .selected { - view = view.completedIndicatorImage(Image(systemName: "checkmark")) + let image: Image? = Image(uiImage: DemoIconography.shared.checkmark) + view = view.completedIndicatorImage(image) } else { view = view.completedIndicatorImage(nil) } @@ -212,9 +224,10 @@ struct ProgressTrackerComponent: View { let image = Image(uiImage: UIImage.image(at: i)) view = view.indicatorImage(image, forIndex: i) } - } + view = view.interactionState(self.interaction) + return view } diff --git a/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIViewModel.swift index 438fff477..30bf1e94b 100644 --- a/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIViewModel.swift @@ -229,7 +229,7 @@ final class ProgressTrackerComponentUIViewModel: ComponentUIViewModel { ] } - lazy var checkmarkImage = UIImage(systemName: "checkmark") + lazy var checkmarkImage = DemoIconography.shared.checkmark // MARK: - Initialization @Published var theme: Theme From 98139f522464dd36a2150932f76847faa04901b6 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 22 Mar 2024 09:50:16 +0100 Subject: [PATCH 074/117] [ProgressTracker#830] Added interaction handler. --- .../View/ProgressTrackerViewModel.swift | 6 + .../ProgressTrackerGestureHandler.swift | 181 ++++++++++++++++++ .../ProgressTrackerHorizontalView.swift | 13 +- .../SwiftUI/ProgressTrackerVerticalView.swift | 1 + .../View/SwiftUI/ProgressTrackerView.swift | 62 +++--- 5 files changed, 231 insertions(+), 32 deletions(-) create mode 100644 core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift index 635e2bcfb..fa179e789 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift @@ -70,6 +70,8 @@ final class ProgressTrackerViewModel Bool { + return self.currentPressedIndicator == index + } + private func updateEnabledIndices() { if !self.isEnabled { for i in (0.. + + init(currentPageIndex: Binding, currentTouchedPageIndex: Binding, + indicators: [CGRect], + frame: CGRect, + disabledIndices: Set + ) { + self._currentPageIndex = currentPageIndex + self._currentTouchedPageIndex = currentTouchedPageIndex + self.indicators = indicators + self.frame = frame + self.disabledIndeces = disabledIndices + } + + func onChanged(location: CGPoint) {} + func onEnded(location: CGPoint) {} + func onCancelled() {} +} + +class ProgressTrackerIndependentGestureHandler: ProgressTrackerGestureHandler { + + override func onChanged(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + let index = self.indicators.index(closestTo: location) + if let index, + self.currentTouchedPageIndex == nil, + !self.disabledIndeces.contains(index) + { + self.currentTouchedPageIndex = index + } + } + + override func onEnded(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + guard let currentTouchedPageIndex else { + return + } + self.currentPageIndex = currentTouchedPageIndex + self.currentTouchedPageIndex = nil + } + + override func onCancelled() { + self.currentTouchedPageIndex = nil + } +} + +class ProgressTrackerDiscreteGestureHandler: ProgressTrackerGestureHandler { + + override func onChanged(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + guard let index = self.indicators.index(closestTo: location) else { return } + + let currentPressedPageIndex: Int? + + if index > self.currentPageIndex { + currentPressedPageIndex = self.currentPageIndex + 1 + } else if index < self.currentPageIndex { + currentPressedPageIndex = self.currentPageIndex - 1 + } else { + currentPressedPageIndex = nil + } + + if let nextIndex = currentPressedPageIndex, self.disabledIndeces.contains(nextIndex) { + return + } + + if self.currentTouchedPageIndex != currentPressedPageIndex { + self.currentTouchedPageIndex = currentPressedPageIndex + } + } + + override func onEnded(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + guard let currentTouchedPageIndex else { return } + + self.currentPageIndex = currentTouchedPageIndex + self.currentTouchedPageIndex = nil + } + + override func onCancelled() { + self.currentTouchedPageIndex = nil + } + +} + +class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { + + override func onChanged(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + guard let index = self.indicators.index(closestTo: location) else { return } + + if let currentTouchedPageIndex { + if index > currentTouchedPageIndex { + self.currentPageIndex = currentTouchedPageIndex + self.currentTouchedPageIndex = currentTouchedPageIndex + 1 + } else if index < currentTouchedPageIndex { + self.currentPageIndex = currentTouchedPageIndex + self.currentTouchedPageIndex = currentTouchedPageIndex - 1 + } + } else { + let currentPressedPageIndex: Int? + + if index > self.currentPageIndex { + currentPressedPageIndex = self.currentPageIndex + 1 + } else if index < self.currentPageIndex { + currentPressedPageIndex = self.currentPageIndex - 1 + } else { + currentPressedPageIndex = nil + } + + if self.currentTouchedPageIndex != currentPressedPageIndex { + self.currentTouchedPageIndex = currentPressedPageIndex + } + } + } + + override func onEnded(location: CGPoint) { + guard self.frame.contains(location) else { + self.currentTouchedPageIndex = nil + return + } + + guard let currentTouchedPageIndex else { return } + + self.currentPageIndex = currentTouchedPageIndex + self.currentTouchedPageIndex = nil + } + + override func onCancelled() { + self.currentTouchedPageIndex = nil + } + +} diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift index 49b3db3bf..1bef98eb9 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift @@ -63,9 +63,7 @@ struct ProgressTrackerHorizontalView: View { private func horizontalLayout() -> some View { HStack(alignment: .top, spacing: self.spacing) { ForEach((0.. some View { - self.indicator(at: index) - if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { - self.label(label, at: index) + VStack(alignment: .center) { + self.indicator(at: index) + if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { + self.label(label, at: index) + } } } @@ -132,6 +132,7 @@ struct ProgressTrackerHorizontalView: View { size: self.size, content: self.viewModel.content.pageContent(atIndex: index)) .selected(self.viewModel.isSelected(at: index)) + .highlighted(self.viewModel.isHighlighted(at: index)) .disabled(!self.viewModel.isEnabled(at: index)) .overlay { GeometryReader { geo in diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift index bb0509823..1c0644b48 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift @@ -139,6 +139,7 @@ struct ProgressTrackerVerticalView: View { size: self.size, content: self.viewModel.content.pageContent(atIndex: index)) .selected(self.viewModel.isSelected(at: index)) + .highlighted(self.viewModel.isHighlighted(at: index)) .disabled(!self.viewModel.isEnabled(at: index)) .overlay { GeometryReader { geo in diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift index 61611b6c8..0d72c45e7 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift @@ -19,8 +19,6 @@ public struct ProgressTrackerView: View { private let variant: ProgressTrackerVariant private let size: ProgressTrackerSize @Binding var currentPageIndex: Int - @State private var currentTouchedPageIndex: Int = -1 -// @State private var indicatorPositions = [Int: CGRect]() //MARK: - Initialization /// Initializer @@ -117,35 +115,15 @@ public struct ProgressTrackerView: View { let indicators = preferences.sorted { $0.key < $1.key }.map(\.value) let frame = bounds ?? .zero + let gestureHandler = self.gestureHandler(frame: frame, indicators: indicators) + return DragGesture(minimumDistance: .zero) .onChanged({ value in - guard frame.contains(value.location) else { return } - let index = indicators.index(closestTo: value.location) - if let index, self.currentTouchedPageIndex == -1 { - switch self.viewModel.interactionState { - case .none: () - case .discrete: () - case .continuous: () - case .independent: - self.currentTouchedPageIndex = index - } - } - print("ON CHANGED \(String(describing: index)) \(value.location)") + gestureHandler.onChanged(location: value.location) }) .onEnded({ value in - guard frame.contains(value.location), self.currentTouchedPageIndex > -1 else { - self.currentTouchedPageIndex = -1 - return } - switch self.viewModel.interactionState { - case .none: () - case .discrete: () - case .continuous: () - case .independent: self.currentPageIndex = self.currentTouchedPageIndex - self.currentTouchedPageIndex = -1 - - } + gestureHandler.onEnded(location: value.location) let index = indicators.index(closestTo: value.location) - print("ON ENDED \(String(describing: index))") }) } @@ -158,6 +136,38 @@ public struct ProgressTrackerView: View { } } + private func gestureHandler(frame: CGRect, indicators: [CGRect]) -> any ProgressTrackerGestureHandling { + + switch self.viewModel.interactionState { + case .none: + return ProgressTrackerNoneGestureHandler() + case .discrete: + return ProgressTrackerDiscreteGestureHandler( + currentPageIndex: self._currentPageIndex, + currentTouchedPageIndex: self.$viewModel.currentPressedIndicator, + indicators: indicators, + frame: frame, + disabledIndices: self.viewModel.disabledIndices + ) + case .continuous: + return ProgressTrackerContinuousGestureHandler( + currentPageIndex: self._currentPageIndex, + currentTouchedPageIndex: self.$viewModel.currentPressedIndicator, + indicators: indicators, + frame: frame, + disabledIndices: self.viewModel.disabledIndices + ) + case .independent: + return ProgressTrackerIndependentGestureHandler( + currentPageIndex: self._currentPageIndex, + currentTouchedPageIndex: self.$viewModel.currentPressedIndicator, + indicators: indicators, + frame: frame, + disabledIndices: self.viewModel.disabledIndices + ) + } + } + // MARK: - Public modifiers /// If use full width is set to true, the horizontal view will try and scale as wide as possible. If it is not true, it will only use as little space as required. public func useFullWidth(_ fullWidth: Bool) -> Self { From 5b887558e545ca2cd5a0dcf122b41085c6da328f Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 27 Mar 2024 12:24:47 +0100 Subject: [PATCH 075/117] [ProgressTracker#830] Fix rebase. --- .../UIKit/Extension/UIControl/UIControl+Extensions.swift | 7 ------- .../ProgressTracker/SwiftUI/ProgressTrackerComponent.swift | 2 +- .../UIKit/ProgressTrackerComponentUIView.swift | 4 ++-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift b/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift index 9fc24e41c..f5676fae7 100644 --- a/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift +++ b/core/Sources/Common/UIKit/Extension/UIControl/UIControl+Extensions.swift @@ -9,16 +9,9 @@ import UIKit extension UIControl { -<<<<<<< HEAD - // Add a default tap gesture recognizer without any action to detect the action/publisher/target action even if the parent view has a gesture recognizer // Why? UIControl action/publisher/target doesn't work if the parent contains a gesture recognizer. // Note: Native UIButton add the same default recognizer to manage this use case. -======= - //Add a default tap gesture recognizer without any action to detect the action/publisher/target action even if the parent view has a gesture recognizer - //Why ? UIControl action/publisher/target doesn't work if the parent contains a gesture recognizer. - //Note: Native UIButton add the same default recognizer to manage this use case. ->>>>>>> 7963ca03 ([ProgressTracker#824] Fix touch when parent has gesture recognizer.) func enableTouch() { let gestureRecognizer = UITapGestureRecognizer() gestureRecognizer.cancelsTouchesInView = false diff --git a/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift b/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift index 9a5d57407..ecc9d7534 100644 --- a/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift +++ b/spark/Demo/Classes/View/Components/ProgressTracker/SwiftUI/ProgressTrackerComponent.swift @@ -196,7 +196,7 @@ struct ProgressTrackerComponent: View { } if self.completedPageIndicator == .selected { - let image: Image? = Image(uiImage: DemoIconography.shared.checkmark) + let image: Image? = Image(uiImage: DemoIconography.shared.checkmark.uiImage) view = view.completedIndicatorImage(image) } else { view = view.completedIndicatorImage(nil) diff --git a/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIView.swift b/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIView.swift index 5f7ee894c..8ecf21783 100644 --- a/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/ProgressTracker/UIKit/ProgressTrackerComponentUIView.swift @@ -172,7 +172,7 @@ final class ProgressTrackerComponentUIView: ComponentUIView { } self.viewModel.$useCompletedPageIndicator.subscribe(in: &self.cancellables) { useImage in - self.componentView.setCompletedIndicatorImage(useImage ? self.viewModel.checkmarkImage : nil) + self.componentView.setCompletedIndicatorImage(useImage ? self.viewModel.checkmarkImage.uiImage : nil) } self.viewModel.$numberOfPages.subscribe(in: &self.cancellables) { numberOfPages in @@ -223,7 +223,7 @@ final class ProgressTrackerComponentUIView: ComponentUIView { self.componentView.showDefaultPageNumber = contentType == .page self.componentView.numberOfPages = numberOfPages - self.componentView.setCompletedIndicatorImage(self.viewModel.checkmarkImage) + self.componentView.setCompletedIndicatorImage(self.viewModel.checkmarkImage.uiImage) switch contentType { case .icon: From 046a08dec5b163a3e6a44895ac9c22216c17654b Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 27 Mar 2024 13:12:32 +0100 Subject: [PATCH 076/117] [ProgressTracker#830] Handle disabled indices in continuous tracking handler. --- .../ProgressTrackerGestureHandler.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift index dbc8ec978..aded67023 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift @@ -138,13 +138,22 @@ class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { guard let index = self.indicators.index(closestTo: location) else { return } if let currentTouchedPageIndex { + let nextPageIndex: Int? if index > currentTouchedPageIndex { - self.currentPageIndex = currentTouchedPageIndex - self.currentTouchedPageIndex = currentTouchedPageIndex + 1 + nextPageIndex = currentTouchedPageIndex + 1 } else if index < currentTouchedPageIndex { + nextPageIndex = currentTouchedPageIndex - 1 + } else { + nextPageIndex = nil + } + + if let nextPageIndex, self.disabledIndeces.contains(nextPageIndex) { + return + } else if let nextPageIndex { self.currentPageIndex = currentTouchedPageIndex - self.currentTouchedPageIndex = currentTouchedPageIndex - 1 + self.currentTouchedPageIndex = nextPageIndex } + } else { let currentPressedPageIndex: Int? @@ -156,7 +165,9 @@ class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { currentPressedPageIndex = nil } - if self.currentTouchedPageIndex != currentPressedPageIndex { + if let currentPressedPageIndex, self.disabledIndeces.contains(currentPressedPageIndex) { + return + } else if self.currentTouchedPageIndex != currentPressedPageIndex { self.currentTouchedPageIndex = currentPressedPageIndex } } From 1bc3edc46bcdd6e24c26f152e68981096f47067d Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 27 Mar 2024 16:53:52 +0100 Subject: [PATCH 077/117] [ProgressTracker#830] Progress tracker touch handler tests. --- ...TrackerContinuousGestureHandlerTests.swift | 154 ++++++++++++++++++ ...ssTrackerDiscreteGestureHandlerTests.swift | 126 ++++++++++++++ .../ProgressTrackerGestureHandler.swift | 16 +- ...rackerIndependentGestureHandlerTests.swift | 147 +++++++++++++++++ 4 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerContinuousGestureHandlerTests.swift create mode 100644 core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerDiscreteGestureHandlerTests.swift create mode 100644 core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerIndependentGestureHandlerTests.swift diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerContinuousGestureHandlerTests.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerContinuousGestureHandlerTests.swift new file mode 100644 index 000000000..7f84cc3ff --- /dev/null +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerContinuousGestureHandlerTests.swift @@ -0,0 +1,154 @@ +// +// ProgressTrackerContinuousGestureHandlerTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 27.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + + +import SwiftUI +import XCTest + +@testable import SparkCore + +final class ProgressTrackerContinuousGestureHandlerTests: XCTestCase { + + var _currentPageIndex: Int = 0 + + lazy var currentPageIndex = Binding( + get: { return self._currentPageIndex }, + set: { self._currentPageIndex = $0 } + ) + + private var _currentTouchedPageIndex: Int? = nil + + lazy var currentTouchedPageIndex = Binding( + get: { return self._currentTouchedPageIndex }, + set: { self._currentTouchedPageIndex = $0 } + ) + + private var indicators = (0...3).map{ CGRect(x: $0 * 40, y: 0, width: 40, height: 40)} + + private var disabledIndices = Set() + + // MARK: - Tests + func test_index_0_is_current_1_may_be_selected() { + // Given + self._currentPageIndex = 0 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 1, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 1, "Current page is not updated") + } + + func test_drag_along_view() { + // Given + self._currentPageIndex = 0 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 1, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onChanged(location: CGPoint(x: 121, y: 0)) + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 2, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 1, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 159, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 2, "Current page is not updated") + } + + func test_index_2_is_current_0_may_be_selected() { + // Given + self._currentPageIndex = 2 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 0, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 1, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 2, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 1, "Current page is not updated") + } + + func test_cant_skip_disabled() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + func test_touch_outside_frame_ignored() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Current touched page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + // MARK: Private helpers + private func sut() -> ProgressTrackerContinuousGestureHandler { + return .init( + currentPageIndex: self.currentPageIndex, + currentTouchedPageIndex: self.currentTouchedPageIndex, + indicators: self.indicators, + frame: CGRect(x: 0, y: 0, width: self.indicators.count * 40, height: 40), + disabledIndices: self.disabledIndices) + } +} diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerDiscreteGestureHandlerTests.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerDiscreteGestureHandlerTests.swift new file mode 100644 index 000000000..5f5ce3d92 --- /dev/null +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerDiscreteGestureHandlerTests.swift @@ -0,0 +1,126 @@ +// +// ProgressTrackerDiscreteGestureHandlerTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 27.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI +import XCTest + +@testable import SparkCore + +final class ProgressTrackerDiscreteGestureHandlerTests: XCTestCase { + + var _currentPageIndex: Int = 0 + + lazy var currentPageIndex = Binding( + get: { return self._currentPageIndex }, + set: { self._currentPageIndex = $0 } + ) + + private var _currentTouchedPageIndex: Int? = nil + + lazy var currentTouchedPageIndex = Binding( + get: { return self._currentTouchedPageIndex }, + set: { self._currentTouchedPageIndex = $0 } + ) + + private var indicators = (0...3).map{ CGRect(x: $0 * 40, y: 0, width: 40, height: 40)} + + private var disabledIndices = Set() + + // MARK: - Tests + func test_index_0_is_current_1_may_be_selected() { + // Given + self._currentPageIndex = 0 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 1, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 1, "Current page is not updated") + } + + func test_index_2_is_current_0_may_be_selected() { + // Given + self._currentPageIndex = 2 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 0, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 1, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 2, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 1, "Current page is not updated") + } + + func test_cant_skip_disabled() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + func test_touch_outside_frame_ignored() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Current touched page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + // MARK: Private helpers + private func sut() -> ProgressTrackerDiscreteGestureHandler { + return .init( + currentPageIndex: self.currentPageIndex, + currentTouchedPageIndex: self.currentTouchedPageIndex, + indicators: self.indicators, + frame: CGRect(x: 0, y: 0, width: self.indicators.count * 40, height: 40), + disabledIndices: self.disabledIndices) + } +} diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift index aded67023..b0fee023a 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerGestureHandler.swift @@ -8,18 +8,22 @@ import SwiftUI +// MARK: - Protocol +/// Touch handlers of the swiftui progress tracker protocol ProgressTrackerGestureHandling { func onChanged(location: CGPoint) func onEnded(location: CGPoint) func onCancelled() } -class ProgressTrackerNoneGestureHandler: ProgressTrackerGestureHandling { +/// A gesture handler that has no actions. +final class ProgressTrackerNoneGestureHandler: ProgressTrackerGestureHandling { func onChanged(location: CGPoint) {} func onEnded(location: CGPoint) {} func onCancelled() {} } +/// An `abstract` gesture handler. class ProgressTrackerGestureHandler: ProgressTrackerGestureHandling { @Binding var currentPageIndex: Int @@ -45,7 +49,8 @@ class ProgressTrackerGestureHandler: ProgressTrackerGestureHandling { func onCancelled() {} } -class ProgressTrackerIndependentGestureHandler: ProgressTrackerGestureHandler { +/// A gesture handler, that let's the user access any page of the page tracker +final class ProgressTrackerIndependentGestureHandler: ProgressTrackerGestureHandler { override func onChanged(location: CGPoint) { guard self.frame.contains(location) else { @@ -80,7 +85,8 @@ class ProgressTrackerIndependentGestureHandler: ProgressTrackerGestureHandler { } } -class ProgressTrackerDiscreteGestureHandler: ProgressTrackerGestureHandler { +/// A gesture handler that only allows access to the direct neighboring pages and one one page change is allowed during one touch handling. +final class ProgressTrackerDiscreteGestureHandler: ProgressTrackerGestureHandler { override func onChanged(location: CGPoint) { guard self.frame.contains(location) else { @@ -127,7 +133,8 @@ class ProgressTrackerDiscreteGestureHandler: ProgressTrackerGestureHandler { } -class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { +/// A gesture handler, that allows the user to swipe across all indicators of the progress tracker and switch from one page to the next in one `drag` gesture. +final class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { override func onChanged(location: CGPoint) { guard self.frame.contains(location) else { @@ -188,5 +195,4 @@ class ProgressTrackerContinuousGestureHandler: ProgressTrackerGestureHandler { override func onCancelled() { self.currentTouchedPageIndex = nil } - } diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerIndependentGestureHandlerTests.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerIndependentGestureHandlerTests.swift new file mode 100644 index 000000000..1d2d1c395 --- /dev/null +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerIndependentGestureHandlerTests.swift @@ -0,0 +1,147 @@ +// +// ProgressTrackerIndependentGestureHandlerTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 27.03.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import SwiftUI +import XCTest + +@testable import SparkCore + +final class ProgressTrackerIndependentGestureHandlerTests: XCTestCase { + + var _currentPageIndex: Int = 0 + + lazy var currentPageIndex = Binding( + get: { return self._currentPageIndex }, + set: { self._currentPageIndex = $0 } + ) + + private var _currentTouchedPageIndex: Int? = nil + + lazy var currentTouchedPageIndex = Binding( + get: { return self._currentTouchedPageIndex }, + set: { self._currentTouchedPageIndex = $0 } + ) + + private var indicators = (0...3).map{ CGRect(x: $0 * 40, y: 0, width: 40, height: 40)} + + private var disabledIndices = Set() + + // MARK: - Tests + func test_index_0_is_current_3_may_be_selected() { + // Given + self._currentPageIndex = 0 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 140, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 3, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 140, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 3, "Current page is not updated") + } + + func test_index_3_is_current_0_may_be_selected() { + // Given + self._currentPageIndex = 3 + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 0, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 0, "Next select page is 1") + XCTAssertEqual(self._currentPageIndex, 3, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 0, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + func test_can_skip_disabled() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertEqual(self._currentTouchedPageIndex, 2, "Current touched page is 2") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 2, "Current page is not updated") + } + + func test_touch_outside_frame_ignored() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(1) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Current touched page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 161, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + func test_disabled_cant_be_selected() { + // Given + self._currentPageIndex = 0 + self.disabledIndices.insert(2) + let sut = self.sut() + + // When + sut.onChanged(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Current touched page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + + // When + sut.onEnded(location: CGPoint(x: 81, y: 0)) + + // Then + XCTAssertNil(self._currentTouchedPageIndex, "Next select page is nil") + XCTAssertEqual(self._currentPageIndex, 0, "Current page is not updated") + } + + // MARK: Private helpers + private func sut() -> ProgressTrackerIndependentGestureHandler { + return .init( + currentPageIndex: self.currentPageIndex, + currentTouchedPageIndex: self.currentTouchedPageIndex, + indicators: self.indicators, + frame: CGRect(x: 0, y: 0, width: self.indicators.count * 40, height: 40), + disabledIndices: self.disabledIndices) + } +} From 55fb04c08eb00fc10ef28b6277c19ec4eb681d26 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 9 Apr 2024 13:51:01 +0200 Subject: [PATCH 078/117] [ProgressTracker#833] Added SwiftUI accessibility traints. --- .../ProgressTrackerHorizontalView.swift | 19 +++++++++++++++ .../SwiftUI/ProgressTrackerVerticalView.swift | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift index 1bef98eb9..88d2127df 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift @@ -64,6 +64,7 @@ struct ProgressTrackerHorizontalView: View { HStack(alignment: .top, spacing: self.spacing) { ForEach((0.. AccessibilityTraits { + + var accessibilityTraits: AccessibilityTraits = AccessibilityTraits() + if self.viewModel.interactionState != .none { + _ = accessibilityTraits.insert(.isButton) + } + if index == self.currentPageIndex { + _ = accessibilityTraits.insert(.isSelected) + } + + return accessibilityTraits + } + @ViewBuilder private func horizontalTracks(preferences: [Int: CGRect]) -> some View { let trackSpacing = self.trackIndicatorSpacing @@ -108,8 +122,13 @@ struct ProgressTrackerHorizontalView: View { private func content(at index: Int) -> some View { VStack(alignment: .center) { self.indicator(at: index) + .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: index)) + .accessibilityValue("\(index)") + if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { self.label(label, at: index) + .accessibilityIdentifier(AccessibilityIdentifier.label(forIndex: index)) + .accessibilityValue("\(index)") } } } diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift index 1c0644b48..8e991ebbd 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift @@ -63,12 +63,26 @@ struct ProgressTrackerVerticalView: View { } //MARK: - Private functions + private func getAccessibilityTraits(index: Int) -> AccessibilityTraits { + + var accessibilityTraits: AccessibilityTraits = AccessibilityTraits() + if self.viewModel.interactionState != .none { + _ = accessibilityTraits.insert(.isButton) + } + if index == self.currentPageIndex { + _ = accessibilityTraits.insert(.isSelected) + } + + return accessibilityTraits + } + @ViewBuilder private func verticalLayout() -> some View { VStack(alignment: .leading, spacing: self.verticalStackSpacing) { ForEach((0.. some View { self.indicator(at: index) + .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: index)) + .accessibilityValue("\(index)") + if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { self.label(label, at: index) + .accessibilityIdentifier(AccessibilityIdentifier.label(forIndex: index)) + .accessibilityValue("\(index)") } } From 3de4f92bacfb28c4f64cf2555b1d4aa800d3eee9 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Thu, 11 Apr 2024 13:13:55 +0200 Subject: [PATCH 079/117] [ProgressTracker833] Set accessibility of button component. --- ...ackerAccessibilityTraitsViewModifier.swift | 45 +++++++++++++++++++ .../ProgressTrackerHorizontalView.swift | 26 ++++------- .../SwiftUI/ProgressTrackerVerticalView.swift | 30 ++++--------- .../View/SwiftUI/ProgressTrackerView.swift | 4 +- 4 files changed, 64 insertions(+), 41 deletions(-) create mode 100644 core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift new file mode 100644 index 000000000..006572d4a --- /dev/null +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift @@ -0,0 +1,45 @@ +// +// ProgressTrackerAccessibilityTraitsViewModifier.swift +// SparkCore +// +// Created by Michael Zimmermann on 11.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation +import SwiftUI + +struct ProgressTrackerAccessibilityTraitsViewModifier: ViewModifier { + typealias AccessibilityIdentifier = ProgressTrackerAccessibilityIdentifier + + private let viewModel: ProgressTrackerViewModel + private let index: Int + + init(viewModel: ProgressTrackerViewModel, index: Int) { + self.viewModel = viewModel + self.index = index + } + + func body(content: Content) -> some View { + return content + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(self.getAccessibilityTraits(index: self.index)) + .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: self.index)) + .accessibilityValue("\(self.index)") + } + + private func getAccessibilityTraits(index: Int) -> AccessibilityTraits { + + var accessibilityTraits: AccessibilityTraits = AccessibilityTraits() + + if self.viewModel.interactionState != .none { + _ = accessibilityTraits.insert(.isButton) + } + if index == self.viewModel.currentPageIndex { + _ = accessibilityTraits.insert(.isSelected) + } + + return accessibilityTraits + } + +} diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift index 88d2127df..8f6ae4ad7 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift @@ -64,7 +64,8 @@ struct ProgressTrackerHorizontalView: View { HStack(alignment: .top, spacing: self.spacing) { ForEach((0.. AccessibilityTraits { - - var accessibilityTraits: AccessibilityTraits = AccessibilityTraits() - if self.viewModel.interactionState != .none { - _ = accessibilityTraits.insert(.isButton) - } - if index == self.currentPageIndex { - _ = accessibilityTraits.insert(.isSelected) - } - - return accessibilityTraits - } - @ViewBuilder private func horizontalTracks(preferences: [Int: CGRect]) -> some View { let trackSpacing = self.trackIndicatorSpacing @@ -122,13 +110,9 @@ struct ProgressTrackerHorizontalView: View { private func content(at index: Int) -> some View { VStack(alignment: .center) { self.indicator(at: index) - .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: index)) - .accessibilityValue("\(index)") if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { self.label(label, at: index) - .accessibilityIdentifier(AccessibilityIdentifier.label(forIndex: index)) - .accessibilityValue("\(index)") } } } @@ -163,6 +147,12 @@ struct ProgressTrackerHorizontalView: View { } } +private extension View { + func accessibilityAttributes(viewModel: ProgressTrackerViewModel, index: Int) -> some View { + return modifier(ProgressTrackerAccessibilityTraitsViewModifier(viewModel: viewModel, index: index)) + } +} + /// Horizontal distance from one point to the other including an offset. private extension CGRect { func xDistance(to other: CGRect, offset: CGFloat = 0) -> CGFloat { diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift index 8e991ebbd..dc19dfcf7 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift @@ -63,26 +63,14 @@ struct ProgressTrackerVerticalView: View { } //MARK: - Private functions - private func getAccessibilityTraits(index: Int) -> AccessibilityTraits { - - var accessibilityTraits: AccessibilityTraits = AccessibilityTraits() - if self.viewModel.interactionState != .none { - _ = accessibilityTraits.insert(.isButton) - } - if index == self.currentPageIndex { - _ = accessibilityTraits.insert(.isSelected) - } - - return accessibilityTraits - } - @ViewBuilder private func verticalLayout() -> some View { VStack(alignment: .leading, spacing: self.verticalStackSpacing) { ForEach((0.. some View { self.indicator(at: index) - .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: index)) - .accessibilityValue("\(index)") if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { self.label(label, at: index) - .accessibilityIdentifier(AccessibilityIdentifier.label(forIndex: index)) - .accessibilityValue("\(index)") } } @@ -175,6 +155,12 @@ struct ProgressTrackerVerticalView: View { } } +private extension View { + func accessibilityAttributes(viewModel: ProgressTrackerViewModel, index: Int) -> some View { + return modifier(ProgressTrackerAccessibilityTraitsViewModifier(viewModel: viewModel, index: index)) + } +} + /// Alignment guide for the label and the indicator. The first line of the label is to be aligned centrally with the indicator. private extension VerticalAlignment { private enum XAlignment : AlignmentID { diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift index 0d72c45e7..48addf32e 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift @@ -96,6 +96,9 @@ public struct ProgressTrackerView: View { //MARK: - Body public var body: some View { self.progressTrackerView + .accessibilityElement(children: .contain) + .accessibilityIdentifier(AccessibilityIdentifier.identifier) + .accessibilityValue("\(self.currentPageIndex)") .isEnabledChanged { isEnabled in self.viewModel.isEnabled = isEnabled } @@ -123,7 +126,6 @@ public struct ProgressTrackerView: View { }) .onEnded({ value in gestureHandler.onEnded(location: value.location) - let index = indicators.index(closestTo: value.location) }) } From e7b336e4e26ec4969a625cc5bb208975e7d354b9 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 12 Apr 2024 19:45:18 +0200 Subject: [PATCH 080/117] [ProgressTracker#833] Use correct label for accessibility. --- .../ProgressTrackerAccessibilityTraitsViewModifier.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift index 006572d4a..12bf069f0 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerAccessibilityTraitsViewModifier.swift @@ -22,7 +22,7 @@ struct ProgressTrackerAccessibilityTraitsViewModifier: ViewModifier { func body(content: Content) -> some View { return content - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .combine) .accessibilityAddTraits(self.getAccessibilityTraits(index: self.index)) .accessibilityIdentifier(AccessibilityIdentifier.indicator(forIndex: self.index)) .accessibilityValue("\(self.index)") From d337762cb7ac84ec757b7faa5a41a042128ca2a4 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Mon, 15 Apr 2024 15:56:48 +0200 Subject: [PATCH 081/117] [ProgressTracker#833] Set indicator label as accessibility label if no label is given. --- .../ProgressTracker/Model/ProgressTrackerContent.swift | 4 ++++ .../View/SwiftUI/ProgressTrackerHorizontalView.swift | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift index 4810ec432..2352dffa7 100644 --- a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift +++ b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift @@ -182,4 +182,8 @@ struct ProgressTrackerContent String? { return self.content[index]?.label } + + func getIndicatorAccessibilityLabel(atIndex index: Int) -> String { + return self.getIndicatorLabel(atIndex: index) ?? "\(index + 1)" + } } diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift index 8f6ae4ad7..5cde7c2eb 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerHorizontalView.swift @@ -109,10 +109,12 @@ struct ProgressTrackerHorizontalView: View { @ViewBuilder private func content(at index: Int) -> some View { VStack(alignment: .center) { - self.indicator(at: index) - if let label = self.viewModel.content.getAttributedLabel(atIndex: index) { + self.indicator(at: index) self.label(label, at: index) + } else { + self.indicator(at: index) + .accessibilityLabel(self.viewModel.content.getIndicatorAccessibilityLabel(atIndex: index)) } } } From 412fd74c4f5695c64b498008e430666071140ffe Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 12 Apr 2024 19:36:14 +0200 Subject: [PATCH 082/117] [ProgressTracker#890] Add accessibility identifiers. --- .../NSLayoutConstraint+Extension.swift | 31 +++++ .../Model/ProgressTrackerContent.swift | 1 + .../View/UIKit/ProgressTrackerUIControl.swift | 117 +++++++++++++++--- .../ProgressTrackerComponentUIViewModel.swift | 2 +- 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/core/Sources/Common/UIKit/GlobalExtension/NSLayoutConstraint/NSLayoutConstraint+Extension.swift b/core/Sources/Common/UIKit/GlobalExtension/NSLayoutConstraint/NSLayoutConstraint+Extension.swift index e277f8bad..1cf1acab1 100644 --- a/core/Sources/Common/UIKit/GlobalExtension/NSLayoutConstraint/NSLayoutConstraint+Extension.swift +++ b/core/Sources/Common/UIKit/GlobalExtension/NSLayoutConstraint/NSLayoutConstraint+Extension.swift @@ -8,6 +8,17 @@ import UIKit +struct EdgeSet: OptionSet { + let rawValue: UInt + + static let top = EdgeSet(rawValue: 1 << 0) + static let trailing = EdgeSet(rawValue: 2 << 0) + static let bottom = EdgeSet(rawValue: 3 << 0) + static let leading = EdgeSet(rawValue: 4 << 0) + + static let all: EdgeSet = [.top, .trailing, .bottom, .leading] +} + extension NSLayoutConstraint { /// Make the view stick to the edges of an other view /// @@ -24,6 +35,26 @@ extension NSLayoutConstraint { ]) } + static func edgeConstraints(from: UIView, to: UIView, insets: UIEdgeInsets = .zero, edge: EdgeSet = .all) -> [NSLayoutConstraint] { + + var constraints = [NSLayoutConstraint]() + if edge.contains(.top) { + constraints.append(from.topAnchor.constraint(equalTo: to.topAnchor, constant: insets.top)) + } + if edge.contains(.leading) { + constraints.append(from.leadingAnchor.constraint(equalTo: to.leadingAnchor, constant: insets.left)) + } + if edge.contains(.bottom) { + constraints.append(from.bottomAnchor.constraint(equalTo: to.bottomAnchor, constant: -insets.bottom)) + } + if edge.contains(.trailing) { + constraints.append(from.trailingAnchor.constraint(equalTo: to.trailingAnchor, constant: -insets.right)) + } + + + return constraints + } + static func center(from: UIView, to: UIView) { NSLayoutConstraint.activate([ from.centerXAnchor.constraint(equalTo: to.centerXAnchor), diff --git a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift index 2352dffa7..90357d33b 100644 --- a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift +++ b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift @@ -47,6 +47,7 @@ struct ProgressTrackerContent() @@ -304,6 +307,17 @@ public final class ProgressTrackerUIControl: UIControl { } // MARK: Private functions + private func createContainerViews(numberOfPages: Int) -> [UIView] { + guard numberOfPages > 0 else { return [] } + + return (0.. [ProgressTrackerIndicatorUIControl] { guard content.numberOfPages > 0 else { return [] } @@ -317,8 +331,9 @@ public final class ProgressTrackerUIControl: UIControl { indicator.translatesAutoresizingMaskIntoConstraints = false indicator.isEnabled = !self.viewModel.disabledIndices.contains(index) indicator.isUserInteractionEnabled = false - indicator.accessibilityIdentifier = AccessibilityIdentifier.indicator(forIndex: index) - indicator.accessibilityValue = "\(index)" +// indicator.accessibilityIdentifier = AccessibilityIdentifier.indicator(forIndex: index) +// indicator.accessibilityValue = "\(index)" + indicator.isAccessibilityElement = false return indicator } } @@ -339,25 +354,31 @@ public final class ProgressTrackerUIControl: UIControl { label.lineBreakMode = .byWordWrapping label.allowsDefaultTighteningForTruncation = true label.isUserInteractionEnabled = false - label.accessibilityIdentifier = AccessibilityIdentifier.label(forIndex: index) - label.accessibilityValue = "\(index)" + label.isAccessibilityElement = true +// label.accessibilityIdentifier = AccessibilityIdentifier.label(forIndex: index) +// label.accessibilityValue = "\(index)" return label } } private func setItemsAccessibilityTraits(disabledIndices: Set, currentPageIndex: Int) { - for (index, label) in self.labels.enumerated() { - self.setItemAccessibilityTraits(view: label, index: index, disabledIndices: disabledIndices, currentPageIndex: currentPageIndex) - } +// for (index, label) in self.labels.enumerated() { +// self.setItemAccessibilityTraits(view: label, index: index, disabledIndices: disabledIndices, currentPageIndex: currentPageIndex) +// } - for (index, indicator) in self.indicatorViews.enumerated() { + for (index, indicator) in self.indicatorContainerViews.enumerated() { self.setItemAccessibilityTraits(view: indicator, index: index, disabledIndices: disabledIndices, currentPageIndex: currentPageIndex) } } private func setItemAccessibilityTraits(view: UIView, index: Int, disabledIndices: Set, currentPageIndex: Int) { + view.accessibilityIdentifier = AccessibilityIdentifier.indicator(forIndex: index) + view.accessibilityValue = "\(index)" + view.isAccessibilityElement = true + view.accessibilityLabel = self.viewModel.content.getAttributedLabel(atIndex: index)?.string ?? self.viewModel.content.getIndicatorLabel(atIndex: index) + if self.interactionState == .none { view.accessibilityTraits.remove(.button) view.accessibilityRespondsToUserInteraction = false @@ -416,11 +437,21 @@ public final class ProgressTrackerUIControl: UIControl { self.labels.removeAllFromSuperView() self.hiddenLabels.removeAllFromSuperView() + self.subviews.forEach { $0.removeFromSuperview() } + + self.indicatorContainerViews.removeAllFromSuperView() + self.trackSpacingConstraints = [] self.labelSpacingConstraints = [] + self.indicatorContainerViews = self.createContainerViews(numberOfPages: content.numberOfPages) + self.indicatorContainerViews.addToSuperView(self) + self.indicatorViews = self.createIndicatorViews(content: content) - self.indicatorViews.addToSuperView(self) +// self.indicatorViews.addToSuperView(self) + for (view, subview) in zip(self.indicatorContainerViews, self.indicatorViews) { + view.addSubview(subview) + } self.indicatorViews[content.currentPageIndex].isSelected = true @@ -428,14 +459,19 @@ public final class ProgressTrackerUIControl: UIControl { self.trackViews.addToSuperView(self) self.labels = self.createLabels(content: content) - self.labels.addToSuperView(self) +// self.labels.addToSuperView(self) + for (view, subview) in zip(self.indicatorContainerViews, self.labels) { + view.addSubview(subview) + } if orientation == .vertical { self.hiddenLabels = self.createHiddenLabels(content: content) for hiddenLabel in hiddenLabels { hiddenLabel.text = " " } - self.hiddenLabels.addToSuperView(self) + for (view, subview) in zip(self.indicatorContainerViews, self.hiddenLabels) { + view.addSubview(subview) + } } } @@ -464,38 +500,40 @@ public final class ProgressTrackerUIControl: UIControl { } self.setItemsAccessibilityTraits(disabledIndices: self.viewModel.disabledIndices, currentPageIndex: self.viewModel.currentPageIndex) + + self.viewCount += 1 } // MARK: Setup supscriptions private func setupSubscriptions() { - self.viewModel.$content.subscribe(in: &self.cancellables) { [weak self] content in + self.viewModel.$content.dropFirst().removeDuplicates().subscribe(in: &self.cancellables) { [weak self] content in self?.didUpdate(content: content) } - self.viewModel.$orientation.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] orientation in + self.viewModel.$orientation.dropFirst().removeDuplicates().subscribe(in: &self.cancellables) { [weak self] orientation in guard let self = self else { return } self.setupView(content: self.viewModel.content, orientation: orientation) } - self.viewModel.$font.removeDuplicates(by: {$0.uiFont == $1.uiFont}).subscribe(in: &self.cancellables) { [weak self] font in + self.viewModel.$font.dropFirst().removeDuplicates(by: {$0.uiFont == $1.uiFont}).subscribe(in: &self.cancellables) { [weak self] font in guard let self = self else { return } for label in self.labels { label.font = font.uiFont } } - self.viewModel.$labelColor.removeDuplicates(by: {$0.uiColor == $1.uiColor}).subscribe(in: &self.cancellables) { [weak self] labelColor in + self.viewModel.$labelColor.dropFirst().removeDuplicates(by: {$0.uiColor == $1.uiColor}).subscribe(in: &self.cancellables) { [weak self] labelColor in guard let self = self else { return } for label in self.labels { label.textColor = labelColor.uiColor } } - self.viewModel.$spacings.removeDuplicates().subscribe(in: &self.cancellables) { [weak self] spacings in + self.viewModel.$spacings.dropFirst().removeDuplicates().subscribe(in: &self.cancellables) { [weak self] spacings in self?.didUpdate(spacings: spacings) } - self.viewModel.$disabledIndices.removeDuplicates().subscribe(in: &self.cancellables) { disabledIndices in + self.viewModel.$disabledIndices.dropFirst().removeDuplicates().subscribe(in: &self.cancellables) { disabledIndices in self.didUpdateDisabledStatus(for: disabledIndices) } } @@ -508,6 +546,27 @@ public final class ProgressTrackerUIControl: UIControl { let numberOfPages = self.indicatorViews.count + if content.hasLabel { + for (indicator, indicatorContainerView) in zip(self.indicatorViews, self.indicatorContainerViews) { + constraints.append(contentsOf: [ + indicator.centerXAnchor.constraint(equalTo: indicatorContainerView.centerXAnchor), + indicator.topAnchor.constraint(equalTo: indicatorContainerView.topAnchor), + indicatorContainerView.widthAnchor.constraint(greaterThanOrEqualTo: indicator.widthAnchor) + ]) + } + for (label, indicatorContainerView) in zip(self.labels, self.indicatorContainerViews) { + constraints.append(contentsOf: [ + label.centerXAnchor.constraint(equalTo: indicatorContainerView.centerXAnchor), + label.bottomAnchor.constraint(equalTo: indicatorContainerView.bottomAnchor), + indicatorContainerView.widthAnchor.constraint(greaterThanOrEqualTo: label.widthAnchor) + ]) + } + } else { + for (indicator, indicatorContainer) in zip(self.indicatorViews, self.indicatorContainerViews) { + constraints.append(contentsOf: NSLayoutConstraint.edgeConstraints(from: indicator, to: indicatorContainer)) + } + } + for i in 1.. Date: Mon, 15 Apr 2024 14:29:38 +0200 Subject: [PATCH 083/117] [ProgressTracker#890] Fixed highlighting, set label to label of indicator or value if no label is set. --- .../Model/ProgressTrackerContent.swift | 3 +- .../Model/ProgressTrackerContentTests.swift | 22 +++++++ ...rogressTrackerAccessibilityUIControl.swift | 20 +++++++ .../View/UIKit/ProgressTrackerUIControl.swift | 60 ++++++++++--------- .../ProgressTrackerComponentUIViewModel.swift | 2 +- 5 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerAccessibilityUIControl.swift diff --git a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift index 90357d33b..64f9de4ad 100644 --- a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift +++ b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContent.swift @@ -47,7 +47,6 @@ struct ProgressTrackerContent String? { + func getIndicatorLabel(atIndex index: Int) -> String? { return self.content[index]?.label } diff --git a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContentTests.swift b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContentTests.swift index 4aecf6044..c03257ef1 100644 --- a/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContentTests.swift +++ b/core/Sources/Components/ProgressTracker/Model/ProgressTrackerContentTests.swift @@ -216,4 +216,26 @@ final class ProgressTrackerContentTests: XCTestCase { // THEN XCTAssertFalse(sut.needsUpdateOfLayout(otherComponent: other)) } + + func test_accessibility_label_with_no_content() { + // GIVEN + let sut = ProgressTrackerContent(numberOfPages: 2, currentPageIndex: 1, showDefaultPageNumber: false) + + // THEN + XCTAssertEqual(sut.getIndicatorAccessibilityLabel(atIndex: 0), "1", "Expected label 0 to be 1") + XCTAssertEqual(sut.getIndicatorAccessibilityLabel(atIndex: 1), "2", "Expected label 1 to be 2") + } + + func test_accessibility_label_with_content() { + // GIVEN + var sut = ProgressTrackerContent(numberOfPages: 2, currentPageIndex: 1, showDefaultPageNumber: false) + + // WHEN + sut.setIndicatorLabel("A", atIndex: 0) + sut.setIndicatorLabel("B", atIndex: 1) + + // THEN + XCTAssertEqual(sut.getIndicatorAccessibilityLabel(atIndex: 0), "A", "Expected accessibility label to be A") + XCTAssertEqual(sut.getIndicatorAccessibilityLabel(atIndex: 1), "B", "Expected accessibility label to be B") + } } diff --git a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerAccessibilityUIControl.swift b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerAccessibilityUIControl.swift new file mode 100644 index 000000000..4eebf0040 --- /dev/null +++ b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerAccessibilityUIControl.swift @@ -0,0 +1,20 @@ +// +// ProgressTrackerAccessibilityUIControl.swift +// Spark +// +// Created by Michael Zimmermann on 15.04.24. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +final class ProgressTrackerAccessibilityUIControl: UIControl { + + override var isHighlighted: Bool { + didSet { + self.subviews + .compactMap{$0 as? UIControl} + .forEach { $0.isHighlighted = self.isHighlighted } + } + } +} diff --git a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift index abb3bb570..4415a32a8 100644 --- a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift +++ b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift @@ -133,7 +133,7 @@ public final class ProgressTrackerUIControl: UIControl { private lazy var labels = [UILabel]() private lazy var hiddenLabels = [UILabel]() private lazy var trackViews = [ProgressTrackerTrackUIView]() - private lazy var indicatorContainerViews = [UIView]() + private lazy var indicatorContainerViews = [UIControl]() var viewCount = 0 @@ -255,7 +255,7 @@ public final class ProgressTrackerUIControl: UIControl { public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let touchHandler = self.interactionState.touchHandler( currentPageIndex: self.currentPageIndex, - indicatorViews: self.indicatorViews) + indicatorViews: self.indicatorContainerViews) self.touchHandler = touchHandler touchHandler.currentPagePublisher.subscribe(in: &self.cancellables) { [weak self] index in @@ -307,13 +307,14 @@ public final class ProgressTrackerUIControl: UIControl { } // MARK: Private functions - private func createContainerViews(numberOfPages: Int) -> [UIView] { + private func createContainerViews(numberOfPages: Int) -> [UIControl] { guard numberOfPages > 0 else { return [] } return (0.., currentPageIndex: Int) { -// for (index, label) in self.labels.enumerated() { -// self.setItemAccessibilityTraits(view: label, index: index, disabledIndices: disabledIndices, currentPageIndex: currentPageIndex) -// } + + if self.viewModel.content.hasLabel { + let buttons = zip(self.indicatorViews, self.labels) + for (indicatorContainerView, button) in zip(indicatorContainerViews, buttons) { + indicatorContainerView.accessibilityElements = [button.0, button.1] + indicatorContainerView.shouldGroupAccessibilityChildren = true + indicatorContainerView.isAccessibilityElement = true + + indicatorContainerView.accessibilityLabel = button.1.accessibilityLabel ?? button.0.accessibilityLabel + } + } else { + for (indicatorContainerView, indicatorView) in zip(self.indicatorContainerViews, self.indicatorViews) { + indicatorContainerView.accessibilityElements = [indicatorView] + indicatorContainerView.shouldGroupAccessibilityChildren = true + indicatorContainerView.isAccessibilityElement = true + + indicatorContainerView.accessibilityLabel = indicatorView.accessibilityLabel + } + } for (index, indicator) in self.indicatorContainerViews.enumerated() { self.setItemAccessibilityTraits(view: indicator, index: index, disabledIndices: disabledIndices, currentPageIndex: currentPageIndex) @@ -377,7 +393,6 @@ public final class ProgressTrackerUIControl: UIControl { view.accessibilityIdentifier = AccessibilityIdentifier.indicator(forIndex: index) view.accessibilityValue = "\(index)" view.isAccessibilityElement = true - view.accessibilityLabel = self.viewModel.content.getAttributedLabel(atIndex: index)?.string ?? self.viewModel.content.getIndicatorLabel(atIndex: index) if self.interactionState == .none { view.accessibilityTraits.remove(.button) @@ -432,15 +447,8 @@ public final class ProgressTrackerUIControl: UIControl { NSLayoutConstraint.deactivate(self.labelSpacingConstraints) NSLayoutConstraint.deactivate(self.trackSpacingConstraints) - self.indicatorViews.removeAllFromSuperView() - self.trackViews.removeAllFromSuperView() - self.labels.removeAllFromSuperView() - self.hiddenLabels.removeAllFromSuperView() - self.subviews.forEach { $0.removeFromSuperview() } - self.indicatorContainerViews.removeAllFromSuperView() - self.trackSpacingConstraints = [] self.labelSpacingConstraints = [] @@ -448,7 +456,7 @@ public final class ProgressTrackerUIControl: UIControl { self.indicatorContainerViews.addToSuperView(self) self.indicatorViews = self.createIndicatorViews(content: content) -// self.indicatorViews.addToSuperView(self) + for (view, subview) in zip(self.indicatorContainerViews, self.indicatorViews) { view.addSubview(subview) } @@ -459,7 +467,7 @@ public final class ProgressTrackerUIControl: UIControl { self.trackViews.addToSuperView(self) self.labels = self.createLabels(content: content) -// self.labels.addToSuperView(self) + for (view, subview) in zip(self.indicatorContainerViews, self.labels) { view.addSubview(subview) } @@ -708,10 +716,12 @@ public final class ProgressTrackerUIControl: UIControl { for i in 0.. Date: Mon, 15 Apr 2024 16:38:00 +0200 Subject: [PATCH 084/117] [ProgressTracker#890] Added accessibilityContainerType. --- .../ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift index 4415a32a8..b6ea750cc 100644 --- a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift +++ b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift @@ -241,6 +241,7 @@ public final class ProgressTrackerUIControl: UIControl { self.enableTouch() self.addPanGestureToPreventCancelTracking() self.isUserInteractionEnabled = false + self.accessibilityContainerType = .semanticGroup } public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From a353f92956ef2897d773f89795045aff63210584 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 16 Apr 2024 17:01:06 +0200 Subject: [PATCH 085/117] [ProgressTracker#855] Updated disabled state. --- .../View/ProgressTrackerViewModel.swift | 8 +++++++- .../View/ProgressTrackerViewModelTests.swift | 8 ++++---- .../View/SwiftUI/ProgressTrackerView.swift | 11 +++++------ .../View/UIKit/ProgressTrackerUIControl.swift | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift index fa179e789..e7c1be68c 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift @@ -28,7 +28,7 @@ final class ProgressTrackerViewModel Self { + self.isEnabled = isEnabled + return self + } + func setIsEnabled(isEnabled: Bool, forIndex index: Int) { if isEnabled { self.disabledIndices.remove(index) diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift index a20435dd0..b3b779573 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift @@ -155,7 +155,7 @@ final class ProgressTrackerViewModelTests: XCTestCase { } // WHEN - sut.isEnabled = false + sut.isEnabled(false) // THEN XCTAssertEqual(sut.disabledIndices.count, 4, "Expected all indices to be disabled") @@ -170,7 +170,7 @@ final class ProgressTrackerViewModelTests: XCTestCase { let sut = sut(orientation: .vertical, numberOfPages: 4) // WHEN - sut.isEnabled = false + sut.isEnabled(false) sut.setIsEnabled(isEnabled: true, forIndex: 0) // THEN @@ -193,8 +193,8 @@ final class ProgressTrackerViewModelTests: XCTestCase { let sut = sut(orientation: .vertical, numberOfPages: 4) // WHEN - sut.isEnabled = false - sut.isEnabled = true + sut.isEnabled(false) + sut.isEnabled(true) // THEN XCTAssertEqual(sut.disabledIndices.count, 0) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift index 48addf32e..9ddc24049 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift @@ -19,6 +19,7 @@ public struct ProgressTrackerView: View { private let variant: ProgressTrackerVariant private let size: ProgressTrackerSize @Binding var currentPageIndex: Int + @Environment(\.isEnabled) private var isEnabled: Bool //MARK: - Initialization /// Initializer @@ -99,9 +100,6 @@ public struct ProgressTrackerView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier(AccessibilityIdentifier.identifier) .accessibilityValue("\(self.currentPageIndex)") - .isEnabledChanged { isEnabled in - self.viewModel.isEnabled = isEnabled - } .backgroundPreferenceValue(ProgressTrackerSizePreferences.self) { preferences in if self.viewModel.interactionState != .none { GeometryReader { geometry in @@ -131,10 +129,11 @@ public struct ProgressTrackerView: View { @ViewBuilder private var progressTrackerView: some View { - if self.viewModel.orientation == .horizontal { - ProgressTrackerHorizontalView(intent: self.intent, variant: self.variant, size: self.size, currentPageIndex: self.$currentPageIndex, viewModel: self.viewModel) + let viewModel = self.viewModel.isEnabled(self.isEnabled) + if viewModel.orientation == .horizontal { + ProgressTrackerHorizontalView(intent: self.intent, variant: self.variant, size: self.size, currentPageIndex: self.$currentPageIndex, viewModel: viewModel) } else { - ProgressTrackerVerticalView(intent: self.intent, variant: self.variant, size: self.size, currentPageIndex: self.$currentPageIndex, viewModel: self.viewModel) + ProgressTrackerVerticalView(intent: self.intent, variant: self.variant, size: self.size, currentPageIndex: self.$currentPageIndex, viewModel: viewModel) } } diff --git a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift index b6ea750cc..a4fb085e1 100644 --- a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift +++ b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift @@ -65,7 +65,7 @@ public final class ProgressTrackerUIControl: UIControl { return self.viewModel.isEnabled } set { - self.viewModel.isEnabled = newValue + self.viewModel.isEnabled(newValue) if newValue { self.accessibilityTraits.remove(.notEnabled) } else { From 851283c90cc6309ac8f57a9364aeff8449ff1dff Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 17 Apr 2024 10:08:53 +0200 Subject: [PATCH 086/117] [ProgressTracker#895] Renamed func isEnabled. --- .../ProgressTracker/View/ProgressTrackerViewModel.swift | 2 +- .../View/ProgressTrackerViewModelTests.swift | 8 ++++---- .../View/SwiftUI/ProgressTrackerView.swift | 2 +- .../View/UIKit/ProgressTrackerUIControl.swift | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift index e7c1be68c..aef1582cd 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModel.swift @@ -119,7 +119,7 @@ final class ProgressTrackerViewModel Self { + func setIsEnabled(_ isEnabled: Bool) -> Self { self.isEnabled = isEnabled return self } diff --git a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift index b3b779573..7108764b5 100644 --- a/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift +++ b/core/Sources/Components/ProgressTracker/View/ProgressTrackerViewModelTests.swift @@ -155,7 +155,7 @@ final class ProgressTrackerViewModelTests: XCTestCase { } // WHEN - sut.isEnabled(false) + sut.setIsEnabled(false) // THEN XCTAssertEqual(sut.disabledIndices.count, 4, "Expected all indices to be disabled") @@ -170,7 +170,7 @@ final class ProgressTrackerViewModelTests: XCTestCase { let sut = sut(orientation: .vertical, numberOfPages: 4) // WHEN - sut.isEnabled(false) + sut.setIsEnabled(false) sut.setIsEnabled(isEnabled: true, forIndex: 0) // THEN @@ -193,8 +193,8 @@ final class ProgressTrackerViewModelTests: XCTestCase { let sut = sut(orientation: .vertical, numberOfPages: 4) // WHEN - sut.isEnabled(false) - sut.isEnabled(true) + sut.setIsEnabled(false) + sut.setIsEnabled(true) // THEN XCTAssertEqual(sut.disabledIndices.count, 0) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift index 9ddc24049..1a0382de4 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerView.swift @@ -129,7 +129,7 @@ public struct ProgressTrackerView: View { @ViewBuilder private var progressTrackerView: some View { - let viewModel = self.viewModel.isEnabled(self.isEnabled) + let viewModel = self.viewModel.setIsEnabled(self.isEnabled) if viewModel.orientation == .horizontal { ProgressTrackerHorizontalView(intent: self.intent, variant: self.variant, size: self.size, currentPageIndex: self.$currentPageIndex, viewModel: viewModel) } else { diff --git a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift index a4fb085e1..cff22fa45 100644 --- a/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift +++ b/core/Sources/Components/ProgressTracker/View/UIKit/ProgressTrackerUIControl.swift @@ -65,7 +65,7 @@ public final class ProgressTrackerUIControl: UIControl { return self.viewModel.isEnabled } set { - self.viewModel.isEnabled(newValue) + self.viewModel.setIsEnabled(newValue) if newValue { self.accessibilityTraits.remove(.notEnabled) } else { From 6d66e9de9be2400b64e6042c62df71c4e009171e Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 19 Apr 2024 14:37:51 +0200 Subject: [PATCH 087/117] [ProgressTracker#906] Fix touch area and label sizing in vertical model. --- .../View/SwiftUI/ProgressTrackerVerticalView.swift | 3 ++- .../ProgressTracker/View/SwiftUI/ProgressTrackerView.swift | 2 +- .../ProgressTracker/SwiftUI/ProgressTrackerComponent.swift | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift index dc19dfcf7..7b1d6c35f 100644 --- a/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift +++ b/core/Sources/Components/ProgressTracker/View/SwiftUI/ProgressTrackerVerticalView.swift @@ -67,7 +67,7 @@ struct ProgressTrackerVerticalView: View { private func verticalLayout() -> some View { VStack(alignment: .leading, spacing: self.verticalStackSpacing) { ForEach((0.. Date: Fri, 3 May 2024 16:12:05 +0200 Subject: [PATCH 088/117] [Formfield] Add alignment toggle to formfield on demo project --- .../SwiftUI/FormFieldComponentView.swift | 22 ++++++++---- .../UIKit/FormFieldComponentUIView.swift | 28 +++++++++++++++ .../UIKit/FormFieldComponentUIViewModel.swift | 35 +++++++++++++++++-- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift index 0915f9f8c..3e07abf1c 100644 --- a/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift +++ b/spark/Demo/Classes/View/Components/FormField/SwiftUI/FormFieldComponentView.swift @@ -21,6 +21,7 @@ struct FormFieldComponentView: View { @State private var descriptionStyle: FormFieldTextStyle = .text @State private var isEnabled = CheckboxSelectionState.selected @State private var isTitleRequired = CheckboxSelectionState.unselected + @State private var isTrailingAlignment = CheckboxSelectionState.unselected @State private var checkboxGroupItems: [any CheckboxGroupItemProtocol] = [ CheckboxGroupItemDefault(title: "Checkbox 1", id: "1", selectionState: .unselected, isEnabled: true), @@ -83,6 +84,14 @@ struct FormFieldComponentView: View { isEnabled: true, selectionState: self.$isTitleRequired ) + + CheckboxView( + text: "Change component alignment", + checkedImage: DemoIconography.shared.checkmark.image, + theme: theme, + isEnabled: true, + selectionState: self.$isTrailingAlignment + ) }, integration: { FormFieldView( @@ -144,6 +153,7 @@ struct FormFieldComponentView: View { CheckboxView( text: "Hello World", checkedImage: DemoIconography.shared.checkmark.image, + alignment: self.isTrailingAlignment == .selected ? .right : .left, theme: self.theme, intent: .success, selectionState: self.$checkboxSelected @@ -154,7 +164,7 @@ struct FormFieldComponentView: View { CheckboxGroupView( checkedImage: DemoIconography.shared.checkmark.image, items: self.$checkboxGroupItems, - alignment: .left, + alignment: self.isTrailingAlignment == .selected ? .right : .left, theme: self.theme, accessibilityIdentifierPrefix: "checkbox-group" ) @@ -164,7 +174,7 @@ struct FormFieldComponentView: View { checkedImage: DemoIconography.shared.checkmark.image, items: self.$checkboxGroupItems, layout: .horizontal, - alignment: .left, + alignment: self.isTrailingAlignment == .selected ? .right : .left, theme: self.theme, intent: .support, accessibilityIdentifierPrefix: "checkbox-group" @@ -175,7 +185,7 @@ struct FormFieldComponentView: View { checkedImage: DemoIconography.shared.checkmark.image, items: self.$scrollableCheckboxGroupItems, layout: .horizontal, - alignment: .left, + alignment: self.isTrailingAlignment == .selected ? .right : .left, theme: self.theme, intent: .support, accessibilityIdentifierPrefix: "checkbox-group" @@ -188,7 +198,7 @@ struct FormFieldComponentView: View { items: [ RadioButtonItem(id: 0, label: "Radio Button 1") ], - labelAlignment: .trailing + labelAlignment: self.isTrailingAlignment == .selected ? .leading : .trailing ) case .verticalRadioButton: RadioButtonGroupView( @@ -199,7 +209,7 @@ struct FormFieldComponentView: View { RadioButtonItem(id: 0, label: "Radio Button 1"), RadioButtonItem(id: 1, label: "Radio Button 2"), ], - labelAlignment: .leading + labelAlignment: self.isTrailingAlignment == .selected ? .leading : .trailing ) case .horizontalRadioButton: RadioButtonGroupView( @@ -210,7 +220,7 @@ struct FormFieldComponentView: View { RadioButtonItem(id: 0, label: "Radio Button 1"), RadioButtonItem(id: 1, label: "Radio Button 2"), ], - labelAlignment: .trailing, + labelAlignment: self.isTrailingAlignment == .selected ? .leading : .trailing, groupLayout: .horizontal ) case .textField: diff --git a/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift index f7ddac28a..7469236e1 100644 --- a/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIView.swift @@ -130,6 +130,34 @@ final class FormFieldComponentUIView: ComponentUIView { guard let self = self else { return } self.componentView.isTitleRequired = isTitleRequired } + + self.viewModel.$isTrailingAlignment.subscribe(in: &self.cancellables) { [weak self] isTrailingAlignment in + guard let self = self else { return } + + switch self.viewModel.componentStyle { + case .singleCheckbox: + (self.componentView.component as? CheckboxUIView)?.alignment = isTrailingAlignment ? .right : .left + case .verticalCheckbox: + (self.componentView.component as? CheckboxGroupUIView)?.alignment = isTrailingAlignment ? .right : .left + case .horizontalCheckbox: + (self.componentView.component as? CheckboxGroupUIView)?.alignment = isTrailingAlignment ? .right : .left + case .horizontalScrollableCheckbox: + (self.componentView.component as? CheckboxGroupUIView)?.alignment = isTrailingAlignment ? .right : .left + case .singleRadioButton: + (self.componentView.component as? RadioButtonUIView)?.labelAlignment = isTrailingAlignment ? .leading : .trailing + case .verticalRadioButton: + (self.componentView.component as? RadioButtonUIGroupView)?.labelAlignment = isTrailingAlignment ? .leading : .trailing + case .horizontalRadioButton: + (self.componentView.component as? RadioButtonUIGroupView)?.labelAlignment = isTrailingAlignment ? .leading : .trailing + default: + break + } + } + + self.viewModel.$containerViewAlignment.subscribe(in: &self.cancellables) { [weak self] containerViewAlignment in + guard let self = self else { return } + self.integrationStackViewAlignment = containerViewAlignment ? .fill : .leading + } } // MARK: - Create View diff --git a/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift index 9d02d2e3a..740b6df37 100644 --- a/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/FormField/UIKit/FormFieldComponentUIViewModel.swift @@ -101,6 +101,21 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { target: (source: self, action: #selector(self.isRequiredChanged(_:)))) }() + lazy var alignmentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Change component alignment", + type: .checkbox(title: "", isOn: self.isTrailingAlignment), + target: (source: self, action: #selector(self.isAlignmentChanged(_:)))) + }() + + lazy var containerViewAlignmentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Fill screen for right alinment", + type: .checkbox(title: "", isOn: self.containerViewAlignment), + target: (source: self, action: #selector(self.isContainerViewAlignmentChanged)) + ) + }() + // MARK: - Default Properties var themes = ThemeCellModel.themes let text: String = "Agreement" @@ -130,6 +145,8 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { @Published var componentStyle: FormFieldComponentStyle @Published var isEnabled: Bool @Published var isTitleRequired: Bool + @Published var isTrailingAlignment: Bool + @Published var containerViewAlignment: Bool init( theme: Theme, @@ -138,7 +155,9 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { descriptionStyle: FormFieldTextStyle = .text, componentStyle: FormFieldComponentStyle = .singleCheckbox, isEnabled: Bool = true, - isTitleRequired: Bool = false + isTitleRequired: Bool = false, + isTrailingAlignment: Bool = false, + containerViewAlignment: Bool = false ) { self.theme = theme self.feedbackState = feedbackState @@ -147,6 +166,8 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { self.componentStyle = componentStyle self.isEnabled = isEnabled self.isTitleRequired = isTitleRequired + self.isTrailingAlignment = isTrailingAlignment + self.containerViewAlignment = containerViewAlignment super.init(identifier: "FormField") self.configurationViewModel = .init(itemsViewModel: [ @@ -156,7 +177,9 @@ final class FormFieldComponentUIViewModel: ComponentUIViewModel { self.descriptionStyleConfigurationItemViewModel, self.componentStyleConfigurationItemViewModel, self.disableConfigurationItemViewModel, - self.isRequiredConfigurationItemViewModel + self.isRequiredConfigurationItemViewModel, + self.alignmentConfigurationItemViewModel, + self.containerViewAlignmentConfigurationItemViewModel ]) } } @@ -191,6 +214,14 @@ extension FormFieldComponentUIViewModel { @objc func isRequiredChanged(_ isSelected: Any?) { self.isTitleRequired = isTrue(isSelected) } + + @objc func isAlignmentChanged(_ isSelected: Any?) { + self.isTrailingAlignment = isTrue(isSelected) + } + + @objc func isContainerViewAlignmentChanged(_ isSelected: Any?) { + self.containerViewAlignment = isTrue(isSelected) + } } // MARK: - Enum From b3dbfb3b00f0bddcab5734fbc912c68418e60d03 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Fri, 3 May 2024 16:17:05 +0200 Subject: [PATCH 089/117] [Formfield#782] Fix uikit formfield stackview alignment --- .../Components/FormField/View/UIKit/FormFieldUIView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift index 441f302fe..2b56b3ef7 100644 --- a/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift +++ b/core/Sources/Components/FormField/View/UIKit/FormFieldUIView.swift @@ -35,7 +35,7 @@ public final class FormFieldUIView: UIControl { let stackView = UIStackView(arrangedSubviews: [self.titleLabel, self.descriptionLabel]) stackView.axis = .vertical stackView.spacing = self.spacing - stackView.alignment = .leading + stackView.alignment = .fill stackView.translatesAutoresizingMaskIntoConstraints = false return stackView }() From 6ef0e390e55724a1a2434efbb5845dc6e5989e73 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 7 May 2024 17:05:15 +0200 Subject: [PATCH 090/117] [Formfield] Change asterisk opacity --- .../Components/FormField/UseCase/FormFieldColorsUseCase.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift index 959c25d1f..f58c8175a 100644 --- a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCase.swift @@ -21,13 +21,13 @@ struct FormFieldColorsUseCase: FormFieldColorsUseCaseable { return FormFieldColors( title: theme.colors.base.onSurface, description: theme.colors.base.onSurface.opacity(theme.dims.dim1), - asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim1) ) case .error: return FormFieldColors( title: theme.colors.base.onSurface, description: theme.colors.feedback.error, - asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim1) ) } } From cae69e155ba25fa455bb22b7c7fdbd97673133c5 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 7 May 2024 17:06:47 +0200 Subject: [PATCH 091/117] [Formfield] Fix tests for asterisk opacity --- .../FormField/UseCase/FormFieldColorsUseCaseTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift index 23c8ec570..58d8ef550 100644 --- a/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift +++ b/core/Sources/Components/FormField/UseCase/FormFieldColorsUseCaseTests.swift @@ -38,13 +38,13 @@ final class FormFieldColorsUseCaseTests: XCTestCase { expectedFormfieldColor = FormFieldColors( title: theme.colors.base.onSurface, description: theme.colors.base.onSurface.opacity(theme.dims.dim1), - asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim1) ) case .error: expectedFormfieldColor = FormFieldColors( title: theme.colors.base.onSurface, description: theme.colors.feedback.error, - asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim3) + asterisk: theme.colors.base.onSurface.opacity(theme.dims.dim1) ) } From 51e85b6b8f3621b0d48ac184e09c541c73409d5a Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 15 May 2024 13:11:39 +0200 Subject: [PATCH 092/117] [CheckboxGroup#939] Fix layout update issue after change alignment --- .../Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift index b1579293b..a893ffae9 100644 --- a/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift +++ b/core/Sources/Components/Checkbox/View/UIKit/CheckboxGroupUIView.swift @@ -357,6 +357,7 @@ extension CheckboxGroupUIView { private func updateAlignment() { self.checkboxes.forEach { $0.alignment = self.alignment } + self.layoutIfNeeded() } private func updateIntent() { From 8cdc103fae61cb67f59c7cb7992c18a3c731e381 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 30 Apr 2024 13:51:44 +0200 Subject: [PATCH 093/117] [RadioChip#1989] remove chip component safe area guide --- core/Sources/Components/Chip/View/UIKit/ChipUIView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/Sources/Components/Chip/View/UIKit/ChipUIView.swift b/core/Sources/Components/Chip/View/UIKit/ChipUIView.swift index b9a552967..b27c00989 100644 --- a/core/Sources/Components/Chip/View/UIKit/ChipUIView.swift +++ b/core/Sources/Components/Chip/View/UIKit/ChipUIView.swift @@ -217,6 +217,7 @@ public final class ChipUIView: UIControl { stackView.axis = .horizontal stackView.alignment = .center stackView.isLayoutMarginsRelativeArrangement = true + stackView.insetsLayoutMarginsFromSafeArea = false stackView.translatesAutoresizingMaskIntoConstraints = false return stackView }() @@ -449,11 +450,11 @@ public final class ChipUIView: UIControl { ] let stackConstraints = [ - self.stackView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor), - self.stackView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor), + self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), heightConstraint, - self.stackView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor), - self.stackView.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor) + self.stackView.topAnchor.constraint(equalTo: self.topAnchor), + self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) ] NSLayoutConstraint.activate(stackConstraints) From 2a272b8e2fb72411f47708d8f83e055ea1c66eab Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Tue, 30 Apr 2024 13:52:24 +0200 Subject: [PATCH 094/117] [RadioChip#1989] remove radio button component safe area guide --- .../View/UIKit/RadioButtonUIGroupView.swift | 30 +++++++++---------- .../View/UIKit/RadioButtonUIView.swift | 8 ++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIGroupView.swift b/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIGroupView.swift index 8d0d442ca..6086a8a27 100644 --- a/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIGroupView.swift +++ b/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIGroupView.swift @@ -401,21 +401,21 @@ public final class RadioButtonUIGroupView: U let labelViewTopConstraint = self.textLabel.topAnchor.constraint( equalTo: self.toggleView.topAnchor, constant: self.textLabelTopSpacing) let toggleViewTopConstraint = self.toggleView.topAnchor.constraint( - equalTo: self.safeAreaLayoutGuide.topAnchor, constant: -(self.haloWidth)) + equalTo: self.topAnchor, constant: -(self.haloWidth)) let bottomViewConstraint = self.textLabel.bottomAnchor.constraint( - lessThanOrEqualTo: self.safeAreaLayoutGuide.bottomAnchor, constant: 0) + lessThanOrEqualTo: self.bottomAnchor, constant: 0) let labelPositionConstraints = calculatePositionConstraints() @@ -437,12 +437,12 @@ public final class RadioButtonUIView: U self.textLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor) ] } else { - let toggleViewTrailingConstraint = self.toggleView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor, constant: self.haloWidth) + let toggleViewTrailingConstraint = self.toggleView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: self.haloWidth) self.toggleViewTrailingConstraint = toggleViewTrailingConstraint return [ toggleViewTrailingConstraint, - self.textLabel.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor), + self.textLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor), ] } } From 76ac90966fb5b2eaab783ce7b01b9adc79ec4c11 Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 8 May 2024 14:00:10 +0200 Subject: [PATCH 095/117] [RadioButton#2101] Fix radiobutton text position according checkbox. --- .../Components/RadioButton/View/UIKit/RadioButtonUIView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIView.swift b/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIView.swift index fbfe66fb2..3835cf5fd 100644 --- a/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIView.swift +++ b/core/Sources/Components/RadioButton/View/UIKit/RadioButtonUIView.swift @@ -450,10 +450,10 @@ public final class RadioButtonUIView: U private func calculateToggleViewSpacingConstraint() -> NSLayoutConstraint { if self.viewModel.alignment == .trailing { return self.toggleView.trailingAnchor.constraint( - equalTo: self.textLabel.leadingAnchor, constant: -self.spacing) + equalTo: self.textLabel.leadingAnchor, constant: -self.spacing + self.haloWidth) } else { return self.textLabel.trailingAnchor.constraint( - lessThanOrEqualTo: self.toggleView.leadingAnchor, constant: -self.spacing) + lessThanOrEqualTo: self.toggleView.leadingAnchor, constant: -self.spacing + self.haloWidth) } } From 255e6290aa3580e9f32dc0e67fc54ea19ae4c6ed Mon Sep 17 00:00:00 2001 From: Alican Aycil Date: Wed, 8 May 2024 14:56:02 +0200 Subject: [PATCH 096/117] [Radiobutton#918] Fix horizontal radiobutton group bottom padding --- .../RadioButton/View/SwiftUI/RadioButtonGroupView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Sources/Components/RadioButton/View/SwiftUI/RadioButtonGroupView.swift b/core/Sources/Components/RadioButton/View/SwiftUI/RadioButtonGroupView.swift index cc52f7e0d..5b218a1f8 100644 --- a/core/Sources/Components/RadioButton/View/SwiftUI/RadioButtonGroupView.swift +++ b/core/Sources/Components/RadioButton/View/SwiftUI/RadioButtonGroupView.swift @@ -160,7 +160,7 @@ public struct RadioButtonGroupView) -> CGFloat { - if item.id == items.last?.id { + if self.groupLayout == .horizontal || item.id == items.last?.id { return 0 } else { return self.viewModel.spacing From b439f97f959db7b274294672981e298b3dbf3454 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 7 May 2024 17:25:36 +0200 Subject: [PATCH 097/117] [ProgressTracker#924] Corrected accessibility identifier. --- .../ProgressTrackerAccessibilityIdentifier.swift | 2 +- .../ProgressTrackerAccessibilityIdentifierTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifier.swift b/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifier.swift index 6bcad7516..ae8551ddd 100644 --- a/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifier.swift +++ b/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifier.swift @@ -12,7 +12,7 @@ import Foundation public enum ProgressTrackerAccessibilityIdentifier { /// The general identifier for the control - public static let identifier = "progress-tracker" + public static let identifier = "spark-progress-tracker" public static let indicator = "\(Self.identifier)-indicator" diff --git a/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifierTests.swift b/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifierTests.swift index da6d78dd3..99a2fd591 100644 --- a/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifierTests.swift +++ b/core/Sources/Components/ProgressTracker/AccessibilityIdentifier/ProgressTrackerAccessibilityIdentifierTests.swift @@ -12,10 +12,10 @@ import XCTest final class ProgressTrackerAccessibilityIdentifierTests: XCTestCase { func test_indicator_identifier() { - XCTAssertEqual(ProgressTrackerAccessibilityIdentifier.indicator(forIndex: 99), "progress-tracker-indicator-99") + XCTAssertEqual(ProgressTrackerAccessibilityIdentifier.indicator(forIndex: 99), "spark-progress-tracker-indicator-99") } func test_label_identifier() { - XCTAssertEqual(ProgressTrackerAccessibilityIdentifier.label(forIndex: 99), "progress-tracker-label-99") + XCTAssertEqual(ProgressTrackerAccessibilityIdentifier.label(forIndex: 99), "spark-progress-tracker-label-99") } } From da18efaaa4e6403d40dd8bc5de3731a3adf54316 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 16 Apr 2024 16:45:52 +0200 Subject: [PATCH 098/117] [RatingInput] Update disabled state, --- .../Rating/View/RatingDisplayViewModel.swift | 13 ++++- .../Rating/View/SwiftUI/RatingInputView.swift | 53 ++++++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift index cbef29d24..216db3e5c 100644 --- a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift +++ b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift @@ -9,9 +9,13 @@ import Combine import Foundation +protocol Disableable { + func setDisabled(_ disabled: Bool) -> Self +} + /// A view model for the rating display. -final class RatingDisplayViewModel: ObservableObject { - +final class RatingDisplayViewModel: Disableable, ObservableObject { + /// The current theme of which colors and sizes are dependent. var theme: Theme { didSet { @@ -108,6 +112,11 @@ final class RatingDisplayViewModel: ObservableObject { state: self.ratingState) } + func setDisabled(_ disabled: Bool) -> Self { + self.updateState(isEnabled: !disabled) + return self + } + // MARK: - Private functions private func updateRatingValue() { self.ratingValue = self.count.ratingValue(self.rating) diff --git a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift index 83d37d1c3..0fae8c4ec 100644 --- a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift +++ b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift @@ -8,15 +8,12 @@ import SwiftUI -/// A SwiftUI native rating input component. public struct RatingInputView: View { - - // MARK: - Private variables @ObservedObject private var viewModel: RatingDisplayViewModel - @State private var displayRating: CGFloat - @Binding private var rating: CGFloat - @ScaledMetric private var scaleFactor: CGFloat = 1.0 - private let configuration: StarConfiguration + @Environment(\.isEnabled) private var isEnabled: Bool + private var rating: Binding + private var configuration: StarConfiguration + private var intent: RatingIntent // MARK: - Initialization /// Create a rating display view with the following parameters @@ -31,8 +28,8 @@ public struct RatingInputView: View { rating: Binding, configuration: StarConfiguration = .default ) { - self._rating = rating - self._displayRating = State(initialValue: rating.wrappedValue) + self.rating = rating + self.intent = intent self.configuration = configuration self.viewModel = RatingDisplayViewModel( theme: theme, @@ -41,8 +38,41 @@ public struct RatingInputView: View { count: .five) } - // MARK: - View public var body: some View { + RatingInputInternalView(viewModel: viewModel.setDisabled(!self.isEnabled), rating: self.rating) + } +} + +/// A SwiftUI native rating input component. +struct RatingInputInternalView: View { + + // MARK: - Private variables + @ObservedObject private var viewModel: RatingDisplayViewModel + @State private var displayRating: CGFloat + @Binding private var rating: CGFloat + @ScaledMetric private var scaleFactor: CGFloat = 1.0 + private let configuration: StarConfiguration + + // MARK: - Initialization + /// Create a rating display view with the following parameters + /// - Parameters: + /// - theme: The current theme + /// - intent: The intent to define the colors + /// - rating: A binding containg the rating value. This should be a value within the range 0...5 + /// - configuration: A configuration of the star. A default value is defined. + init( + viewModel: RatingDisplayViewModel, + rating: Binding, + configuration: StarConfiguration = .default + ) { + self._rating = rating + self._displayRating = State(initialValue: rating.wrappedValue) + self.configuration = configuration + self.viewModel = viewModel + } + + // MARK: - View + var body: some View { let size = self.viewModel.ratingSize.height * self.scaleFactor let lineWidth = self.viewModel.ratingSize.borderWidth * self.scaleFactor let spacing = self.viewModel.ratingSize.spacing * self.scaleFactor @@ -67,9 +97,6 @@ public struct RatingInputView: View { .accessibilityIdentifier("\(RatingInputAccessibilityIdentifier.identifier)-\(index)") } } - .isEnabledChanged { isEnabled in - self.viewModel.updateState(isEnabled: isEnabled) - } .compositingGroup() .opacity(colors.opacity) .gesture(self.dragGesture(viewRect: viewRect)) From 91c312fc36588389ba3f3478a5b517a9fa3e5684 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 16 Apr 2024 17:09:55 +0200 Subject: [PATCH 099/117] [RatingInput#894] Updated setting disabled state. --- .../Rating/View/RatingDisplayViewModel.swift | 10 +++----- .../Rating/View/SwiftUI/RatingInputView.swift | 24 ++++++++++--------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift index 216db3e5c..dee124300 100644 --- a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift +++ b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift @@ -9,12 +9,8 @@ import Combine import Foundation -protocol Disableable { - func setDisabled(_ disabled: Bool) -> Self -} - /// A view model for the rating display. -final class RatingDisplayViewModel: Disableable, ObservableObject { +final class RatingDisplayViewModel: ObservableObject { /// The current theme of which colors and sizes are dependent. var theme: Theme { @@ -112,8 +108,8 @@ final class RatingDisplayViewModel: Disableable, ObservableObject { state: self.ratingState) } - func setDisabled(_ disabled: Bool) -> Self { - self.updateState(isEnabled: !disabled) + func isEnabled(_ enabled: Bool) -> Self { + self.updateState(isEnabled: enabled) return self } diff --git a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift index 0fae8c4ec..683cd1978 100644 --- a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift +++ b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift @@ -8,7 +8,9 @@ import SwiftUI +/// A SwiftUI native rating input component. public struct RatingInputView: View { + // MARK: - Private variables @ObservedObject private var viewModel: RatingDisplayViewModel @Environment(\.isEnabled) private var isEnabled: Bool private var rating: Binding @@ -38,12 +40,20 @@ public struct RatingInputView: View { count: .five) } + // MARK: - View public var body: some View { - RatingInputInternalView(viewModel: viewModel.setDisabled(!self.isEnabled), rating: self.rating) + RatingInputInternalView(viewModel: viewModel.isEnabled(self.isEnabled), rating: self.rating) + } + + // MARK: - Internal functions + /// This function is just exposed for testing + internal func highlighted(_ isHiglighed: Bool) -> Self { + self.viewModel.updateState(isPressed: isHiglighed) + return self } } -/// A SwiftUI native rating input component. +// MARK: - Internal Rating Input struct RatingInputInternalView: View { // MARK: - Private variables @@ -56,8 +66,7 @@ struct RatingInputInternalView: View { // MARK: - Initialization /// Create a rating display view with the following parameters /// - Parameters: - /// - theme: The current theme - /// - intent: The intent to define the colors + /// - viewModel: The view model of the view. /// - rating: A binding containg the rating value. This should be a value within the range 0...5 /// - configuration: A configuration of the star. A default value is defined. init( @@ -105,13 +114,6 @@ struct RatingInputInternalView: View { .accessibilityValue("\(self.displayRating)") } - // MARK: - Internal functions - /// This function is just exposed for testing - internal func highlighted(_ isHiglighed: Bool) -> Self { - self.viewModel.updateState(isPressed: isHiglighed) - return self - } - // MARK: - Private functions private func dragGesture(viewRect: CGRect) -> some Gesture { DragGesture(minimumDistance: 0.0) From 748c4d0c640ca67d4f48d91bcf3fe9bc6a136032 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 17 Apr 2024 10:22:13 +0200 Subject: [PATCH 100/117] [RatingInput#894] Rename function isEnabled.. --- .../Components/Rating/View/RatingDisplayViewModel.swift | 8 +++----- .../Components/Rating/View/SwiftUI/RatingInputView.swift | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift index dee124300..c9e9ab8a0 100644 --- a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift +++ b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift @@ -98,18 +98,16 @@ final class RatingDisplayViewModel: ObservableObject { state: self.ratingState) } - func updateState(isEnabled: Bool) { - guard self.ratingState.isEnabled != isEnabled else { return } + @discardableResult + func updateState(isEnabled: Bool) -> Self { + guard self.ratingState.isEnabled != isEnabled else { return self } self.ratingState.isEnabled = isEnabled self.colors = self.colorsUseCase.execute( theme: self.theme, intent: self.intent, state: self.ratingState) - } - func isEnabled(_ enabled: Bool) -> Self { - self.updateState(isEnabled: enabled) return self } diff --git a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift index 683cd1978..b31778594 100644 --- a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift +++ b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift @@ -42,7 +42,7 @@ public struct RatingInputView: View { // MARK: - View public var body: some View { - RatingInputInternalView(viewModel: viewModel.isEnabled(self.isEnabled), rating: self.rating) + RatingInputInternalView(viewModel: viewModel.updateState(isEnabled: self.isEnabled), rating: self.rating, configuration: self.configuration) } // MARK: - Internal functions @@ -68,11 +68,11 @@ struct RatingInputInternalView: View { /// - Parameters: /// - viewModel: The view model of the view. /// - rating: A binding containg the rating value. This should be a value within the range 0...5 - /// - configuration: A configuration of the star. A default value is defined. + /// - configuration: A configuration of the star init( viewModel: RatingDisplayViewModel, rating: Binding, - configuration: StarConfiguration = .default + configuration: StarConfiguration ) { self._rating = rating self._displayRating = State(initialValue: rating.wrappedValue) From a34985757952509368c0a78b1545b4b10aecee85 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Mon, 6 May 2024 10:57:18 +0200 Subject: [PATCH 101/117] [Chip#922] Updated icon and label size. --- core/Sources/Components/Chip/Enum/ChipConstants.swift | 2 +- core/Sources/Components/Chip/View/ChipViewModel.swift | 10 ++++++++-- .../Components/Chip/View/ChipViewModelTests.swift | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/core/Sources/Components/Chip/Enum/ChipConstants.swift b/core/Sources/Components/Chip/Enum/ChipConstants.swift index 6f8ad5de7..f41e2ceac 100644 --- a/core/Sources/Components/Chip/Enum/ChipConstants.swift +++ b/core/Sources/Components/Chip/Enum/ChipConstants.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - Constants enum ChipConstants { - static let imageSize: CGFloat = 13.33 + static let imageSize: CGFloat = 16 static let height: CGFloat = 32 static let borderWidth: CGFloat = 1 static let dashLength: CGFloat = 1.9 diff --git a/core/Sources/Components/Chip/View/ChipViewModel.swift b/core/Sources/Components/Chip/View/ChipViewModel.swift index 9d49c00c9..878f81f38 100644 --- a/core/Sources/Components/Chip/View/ChipViewModel.swift +++ b/core/Sources/Components/Chip/View/ChipViewModel.swift @@ -90,7 +90,7 @@ class ChipViewModel: ObservableObject { self.spacing = self.theme.layout.spacing.small self.padding = self.theme.layout.spacing.medium self.borderRadius = self.theme.border.radius.medium - self.font = self.theme.typography.body2 + self.font = self.theme.bodyFont self.isIconLeading = alignment.isIconLeading } @@ -132,7 +132,7 @@ class ChipViewModel: ObservableObject { self.spacing = self.theme.layout.spacing.small self.padding = self.theme.layout.spacing.medium self.borderRadius = self.theme.border.radius.medium - self.font = self.theme.typography.body2 + self.font = self.theme.bodyFont } private func variantDidUpdate() { @@ -144,6 +144,12 @@ class ChipViewModel: ObservableObject { } } +private extension Theme { + var bodyFont: TypographyFontToken { + return self.typography.body1 + } +} + private extension ChipVariant { var isBordered: Bool { return self == .dashed || self == .outlined diff --git a/core/Sources/Components/Chip/View/ChipViewModelTests.swift b/core/Sources/Components/Chip/View/ChipViewModelTests.swift index 7542dff25..b2c98d36e 100644 --- a/core/Sources/Components/Chip/View/ChipViewModelTests.swift +++ b/core/Sources/Components/Chip/View/ChipViewModelTests.swift @@ -197,7 +197,7 @@ final class ChipViewModelTests: XCTestCase { // When let newTheme = ThemeGeneratedMock.mocked() let typography = TypographyGeneratedMock.mocked() - typography.body2 = TypographyFontTokenGeneratedMock.mocked(.title) + typography.body1 = TypographyFontTokenGeneratedMock.mocked(.title) newTheme.typography = typography self.sut.set(theme: newTheme) From 7e25a846e9b4c3b09d411fd65507528e2b20764d Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Thu, 18 Apr 2024 14:43:00 +0200 Subject: [PATCH 102/117] [Tabs#899] Change handling of disabling tabs. --- .../Tab/View/SwiftUI/TabItemView.swift | 40 +----- .../SwiftUI/TabItemViewSnapshotTests.swift | 135 ++++++++++++------ .../Tab/View/SwiftUI/TabSingleItem.swift | 39 +++-- .../Components/Tab/View/SwiftUI/TabView.swift | 9 +- .../Tab/View/UIKit/TabItemUIView.swift | 8 +- .../Components/Tab/View/UIKit/TabUIView.swift | 2 +- .../Tab/ViewModel/TabItemViewModel.swift | 26 +++- .../Tab/ViewModel/TabItemViewModelTests.swift | 26 +++- .../Tab/ViewModel/TabViewModel.swift | 26 ++-- .../Tab/ViewModel/TabViewModelTests.swift | 12 +- 10 files changed, 202 insertions(+), 121 deletions(-) diff --git a/core/Sources/Components/Tab/View/SwiftUI/TabItemView.swift b/core/Sources/Components/Tab/View/SwiftUI/TabItemView.swift index 02fd9afa4..98fe19595 100644 --- a/core/Sources/Components/Tab/View/SwiftUI/TabItemView.swift +++ b/core/Sources/Components/Tab/View/SwiftUI/TabItemView.swift @@ -9,7 +9,7 @@ import SwiftUI /// A single tab item used on the tab view. -public struct TabItemView: View { +struct TabItemView: View { // MARK: - Private Variables @ObservedObject private var viewModel: TabItemViewModel @@ -39,36 +39,9 @@ public struct TabItemView: View { // MARK: Initialization /// Initializer /// - Parameters: - /// - theme: The current theme. - /// - intent: The intent, the default is `basic`. - /// - size: The tab size, the default is `md`. - /// - content: The content of the tab. - /// - apportionsSegmentWidthsByContent: Determins if the tab is to be as wide as it's content, or equally spaced. + /// - viewModel: The view model of the tab item. /// - tapAction: the action triggered by tapping on the tab. - public init( - theme: Theme, - intent: TabIntent = .basic, - size: TabSize = .md, - content: TabItemContent, - apportionsSegmentWidthsByContent: Bool = false, - isSelected: Bool = false, - tapAction: @escaping () -> Void - ) { - let viewModel = TabItemViewModel( - theme: theme, - intent: intent, - tabSize: size, - content: content, - apportionsSegmentWidthsByContent: apportionsSegmentWidthsByContent - ) - viewModel.isSelected = isSelected - - self.init(viewModel: viewModel, - tapAction: tapAction - ) - } - - internal init( + init( viewModel: TabItemViewModel, tapAction: @escaping () -> Void ) { @@ -77,7 +50,7 @@ public struct TabItemView: View { } // MARK: - View - public var body: some View { + var body: some View { Button( action: { self.tapAction() @@ -89,9 +62,6 @@ public struct TabItemView: View { }) .opacity(self.viewModel.tabStateAttributes.colors.opacity) .buttonStyle(PressedButtonStyle(isPressed: self.$viewModel.isPressed, animationDuration: 0.1)) - .isEnabledChanged { isEnabled in - self.viewModel.isEnabled = isEnabled - } } // MARK: Private Functions @@ -162,7 +132,7 @@ public struct TabItemView: View { /// Set the tab as selected public func selected(_ selected: Bool) -> Self { - self.viewModel.isSelected = selected + self.viewModel.updateState(isSelected: selected) return self } } diff --git a/core/Sources/Components/Tab/View/SwiftUI/TabItemViewSnapshotTests.swift b/core/Sources/Components/Tab/View/SwiftUI/TabItemViewSnapshotTests.swift index 864939e2d..77799fff0 100644 --- a/core/Sources/Components/Tab/View/SwiftUI/TabItemViewSnapshotTests.swift +++ b/core/Sources/Components/Tab/View/SwiftUI/TabItemViewSnapshotTests.swift @@ -28,103 +28,152 @@ final class TabItemViewSnapshotTests: SwiftUIComponentSnapshotTestCase { // MARK: - Tests func test_tab_icon_and_title_and_badge() throws { + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .main, + content: .init( + icon: Image(systemName: "paperplane"), + title: "Label") + ) let sut = TabItemView( - theme: self.theme, - intent: .main, - content: .init( - icon: Image(systemName: "paperplane"), - title: "Label")) {} + viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(false) .badge(self.badge) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.medium]) + //THEN + assertSnapshot(matching: sut, modes: [.light, .dark], sizes: [.medium]) } func test_selected_tab_with_intent_main() throws { - let sut = TabItemView( - theme: self.theme, - intent: .main, - content: .init(title: "Label")) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .main, + content: .init(title: "Label") + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .selected(true) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.medium]) + //THEN + assertSnapshot(matching: sut, modes: [.light, .dark], sizes: [.medium]) } func test_with_badge_only() throws { - - let sut = TabItemView( - theme: self.theme, - intent: .main, - content: .init()) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .main, + content: .init() + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .badge(self.badge) .selected(true) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.medium]) + //THEN + assertSnapshot(matching: sut, modes: [.light, .dark], sizes: [.medium]) } func test_with_label_only() throws { - let sut = TabItemView( - theme: self.theme, - intent: .main, - content: .init(title: "Label")) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .main, + content: .init(title: "Label") + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.small, .medium, .large, .extraLarge]) + //THEN + assertSnapshot(matching: sut, modes: [.light, .dark], sizes: [.small, .medium, .large, .extraLarge]) } func test_with_icon_only() throws { + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .main, + content: .init(icon: Image(systemName: "paperplane")) + ) + let sut = TabItemView( - theme: self.theme, - intent: .main, - content: .init(icon: Image(systemName: "paperplane"))) {} + viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.medium]) + //THEN + assertSnapshot(matching: sut, modes: [.light, .dark], sizes: [.medium]) } func test_with_label_and_badge() throws { - let sut = TabItemView( - theme: self.theme, - intent: .basic, - content: .init(title: "Label")) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .basic, + content: .init(title: "Label") + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .badge(self.badge) .selected(true) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.small, .medium, .large, .extraLarge]) + //THEN + assertSnapshot(matching: sut, modes: [.dark, .light], sizes: [.small, .medium, .large, .extraLarge]) } func test_with_icon_and_label() throws { - let sut = TabItemView( - theme: self.theme, - intent: .basic, - content: .init( - icon: Image(systemName: "paperplane"), - title: "Label")) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .basic, + content: .init( + icon: Image(systemName: "paperplane"), + title: "Label") + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(false) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.large]) + //THEN + assertSnapshot(matching: sut, modes: [.dark, .light], sizes: [.large]) } func test_with_icon_and_badge() throws { - let sut = TabItemView( - theme: self.theme, - intent: .basic, - content: .init(icon: Image(systemName: "paperplane"))) {} + //GIVEN + let viewModel: TabItemViewModel = + .init( + theme: theme, + intent: .basic, + content: .init( + icon: Image(systemName: "paperplane")) + ) + + let sut = TabItemView(viewModel: viewModel, tapAction: {}) .apportionsSegmentWidthsByContent(true) .badge(self.badge) .background(.systemBackground) - assertSnapshotInDarkAndLight(matching: sut, sizes: [.extraSmall]) + //THEN + assertSnapshot(matching: sut, modes: [.dark, .light], sizes: [.extraSmall]) } } diff --git a/core/Sources/Components/Tab/View/SwiftUI/TabSingleItem.swift b/core/Sources/Components/Tab/View/SwiftUI/TabSingleItem.swift index 9076de03c..f5de6fbef 100644 --- a/core/Sources/Components/Tab/View/SwiftUI/TabSingleItem.swift +++ b/core/Sources/Components/Tab/View/SwiftUI/TabSingleItem.swift @@ -10,30 +10,41 @@ import Foundation import SwiftUI struct TabSingleItem: View { - @ObservedObject var viewModel: TabViewModel let intent: TabIntent let content: TabItemContent let proxy: ScrollViewProxy @Binding var selectedIndex: Int let index: Int + @ObservedObject private var itemViewModel: TabItemViewModel + + init(viewModel: TabViewModel, intent: TabIntent, content: TabItemContent, proxy: ScrollViewProxy, selectedIndex: Binding, index: Int) { + self.intent = intent + self.content = content + self.proxy = proxy + self._selectedIndex = selectedIndex + self.index = index + + self.itemViewModel = TabItemViewModel( + theme: viewModel.theme, + intent: intent, + tabSize: viewModel.tabSize, + content: content, + apportionsSegmentWidthsByContent: viewModel.apportionsSegmentWidthsByContent + ) + .updateState(isSelected: selectedIndex.wrappedValue == index) + .updateState(isEnabled: viewModel.isTabEnabled(index: index)) + } + var body: some View { - TabItemView( - theme: self.viewModel.theme, - intent: self.intent, - size: self.viewModel.tabSize, - content: self.content, - apportionsSegmentWidthsByContent: self.viewModel.apportionsSegmentWidthsByContent, - isSelected: self.selectedIndex == self.index - ) { - self.selectedIndex = index + TabItemView(viewModel: itemViewModel) { + self.selectedIndex = self.index withAnimation{ - self.proxy.scrollTo(content.id) + self.proxy.scrollTo(self.content.id) } } - .disabled(self.viewModel.disabledTabs[index]) - .id(content.id) + .disabled(!self.itemViewModel.isEnabled) + .id(self.content.id) .accessibilityIdentifier("\(TabAccessibilityIdentifier.tabItem)_\(index)") - } } diff --git a/core/Sources/Components/Tab/View/SwiftUI/TabView.swift b/core/Sources/Components/Tab/View/SwiftUI/TabView.swift index ac3f60752..7353537ff 100644 --- a/core/Sources/Components/Tab/View/SwiftUI/TabView.swift +++ b/core/Sources/Components/Tab/View/SwiftUI/TabView.swift @@ -13,6 +13,7 @@ public struct TabView: View { private let intent: TabIntent @ObservedObject private var viewModel: TabViewModel @Binding private var selectedIndex: Int + @Environment(\.isEnabled) private var isEnabled: Bool // MARK: - Initialization /// Initializer @@ -79,10 +80,12 @@ public struct TabView: View { // MARK: - View public var body: some View { - if self.viewModel.apportionsSegmentWidthsByContent { - TabApportionsSizeView(viewModel: self.viewModel, intent: self.intent, selectedIndex: self.$selectedIndex) + let viewModel = self.viewModel.setIsEnabled(self.isEnabled) + + if viewModel.apportionsSegmentWidthsByContent { + TabApportionsSizeView(viewModel: viewModel, intent: self.intent, selectedIndex: self.$selectedIndex) } else { - TabEqualSizeView(viewModel: self.viewModel, intent: self.intent, selectedIndex: self.$selectedIndex) + TabEqualSizeView(viewModel: viewModel, intent: self.intent, selectedIndex: self.$selectedIndex) } } diff --git a/core/Sources/Components/Tab/View/UIKit/TabItemUIView.swift b/core/Sources/Components/Tab/View/UIKit/TabItemUIView.swift index 923bd6e8b..58526e753 100644 --- a/core/Sources/Components/Tab/View/UIKit/TabItemUIView.swift +++ b/core/Sources/Components/Tab/View/UIKit/TabItemUIView.swift @@ -66,8 +66,7 @@ public final class TabItemUIView: UIControl { return self.viewModel.isSelected } set { - guard newValue != self.viewModel.isSelected else { return } - self.viewModel.isSelected = newValue + self.viewModel.updateState(isSelected: newValue) } } @@ -242,7 +241,7 @@ public final class TabItemUIView: UIControl { return self.viewModel.isPressed } set { - self.viewModel.isPressed = newValue + self.viewModel.updateState(isPressed: newValue) } } @@ -255,8 +254,7 @@ public final class TabItemUIView: UIControl { return self.viewModel.isEnabled } set { - guard newValue != self.viewModel.isEnabled else { return } - self.viewModel.isEnabled = newValue + self.viewModel.updateState(isEnabled: newValue) } } diff --git a/core/Sources/Components/Tab/View/UIKit/TabUIView.swift b/core/Sources/Components/Tab/View/UIKit/TabUIView.swift index 5d54cc0a3..eced6bd82 100644 --- a/core/Sources/Components/Tab/View/UIKit/TabUIView.swift +++ b/core/Sources/Components/Tab/View/UIKit/TabUIView.swift @@ -91,7 +91,7 @@ public final class TabUIView: UIControl { /// Disable each segement of the tab public override var isEnabled: Bool { didSet { - self.viewModel.isEnabled = self.isEnabled + self.viewModel.setIsEnabled(self.isEnabled) self.segments.forEach{ $0.isEnabled = self.isEnabled } } } diff --git a/core/Sources/Components/Tab/ViewModel/TabItemViewModel.swift b/core/Sources/Components/Tab/ViewModel/TabItemViewModel.swift index a119a7ac4..5a77ca7ff 100644 --- a/core/Sources/Components/Tab/ViewModel/TabItemViewModel.swift +++ b/core/Sources/Components/Tab/ViewModel/TabItemViewModel.swift @@ -47,17 +47,16 @@ final class TabItemViewModel: ObservableObject where Content: TitleCont } - var isEnabled: Bool { + private (set) var isEnabled: Bool { get { self.tabState.isEnabled } set { - guard self.tabState.isEnabled != newValue else { return } self.tabState = self.tabState.update(\.isEnabled, value: newValue) } } - var isSelected: Bool { + private (set) var isSelected: Bool { get { self.tabState.isSelected } @@ -118,6 +117,27 @@ final class TabItemViewModel: ObservableObject where Content: TitleCont ) } + @discardableResult + func updateState(isEnabled: Bool) -> Self { + guard self.isEnabled != isEnabled else { return self } + self.isEnabled = isEnabled + return self + } + + @discardableResult + func updateState(isSelected: Bool) -> Self { + guard self.isSelected != isSelected else { return self } + self.isSelected = isSelected + return self + } + + @discardableResult + func updateState(isPressed: Bool) -> Self { + guard self.isPressed != isPressed else { return self } + self.isPressed = isPressed + return self + } + // MARK: - Private functions private func updateStateAttributes() { self.tabStateAttributes = self.tabGetStateAttributesUseCase.execute( diff --git a/core/Sources/Components/Tab/ViewModel/TabItemViewModelTests.swift b/core/Sources/Components/Tab/ViewModel/TabItemViewModelTests.swift index de5fc405a..fee72eb43 100644 --- a/core/Sources/Components/Tab/ViewModel/TabItemViewModelTests.swift +++ b/core/Sources/Components/Tab/ViewModel/TabItemViewModelTests.swift @@ -117,7 +117,7 @@ final class TabItemViewModelTests: XCTestCase { .store(in: &self.cancellables) // When - sut.isSelected = true + sut.updateState(isSelected: true) // Then wait(for: [expectation], timeout: 0.1) @@ -133,7 +133,7 @@ final class TabItemViewModelTests: XCTestCase { .store(in: &self.cancellables) // When - sut.isSelected = false + sut.updateState(isSelected: false) // Then wait(for: [expectation], timeout: 0.1) @@ -172,7 +172,7 @@ final class TabItemViewModelTests: XCTestCase { .store(in: &self.cancellables) // When - sut.isEnabled = false + sut.updateState(isEnabled: false) // Then wait(for: [expectation], timeout: 0.1) @@ -275,6 +275,26 @@ final class TabItemViewModelTests: XCTestCase { // Then wait(for: [expectation], timeout: 0.1) } + + func test_attributes_changed_when_pressed() { + // Given + let sut = self.sut(size: .md, title: "Hello") + + let expectation = expectation(description: "wait for attributes") + expectation.expectedFulfillmentCount = 2 + + sut.$tabStateAttributes.sink { attributes in + expectation.fulfill() + } + .store(in: &self.cancellables) + + // When + sut.updateState(isPressed: true) + + // Then + wait(for: [expectation], timeout: 0.1) + XCTAssertEqual(self.tabGetStateAttributesUseCase.executeWithThemeAndIntentAndStateAndTabSizeAndHasTitleReceivedArguments?.tabSize, .md) + } } // MARK: - Helper diff --git a/core/Sources/Components/Tab/ViewModel/TabViewModel.swift b/core/Sources/Components/Tab/ViewModel/TabViewModel.swift index c78f0a632..3ffdf94ad 100644 --- a/core/Sources/Components/Tab/ViewModel/TabViewModel.swift +++ b/core/Sources/Components/Tab/ViewModel/TabViewModel.swift @@ -24,13 +24,9 @@ final class TabViewModel: ObservableObject { // The whole tab is regarded as enabled, if all tabs are enabled. // When set, each tab will be disabled or enabled. // To disable a single tab, use the function `disableTab`. - var isEnabled: Bool { - get { - return self.disabledTabs.reduce(true) { return $0 && !$1 } - } - set { - self.tabsAttributes = self.useCase.execute(theme: theme, size: self.tabSize, isEnabled: newValue) - self.disabledTabs = self.disabledTabs.map { _ in return !newValue } + private (set) var isEnabled: Bool { + didSet { + self.tabsAttributes = self.useCase.execute(theme: theme, size: self.tabSize, isEnabled: self.isEnabled) } } @@ -66,12 +62,26 @@ final class TabViewModel: ObservableObject { self.content = content self.disabledTabs = content.map{ _ in return false } self.tabsAttributes = useCase.execute(theme: theme, size: tabSize, isEnabled: true) + self.isEnabled = true } // Disable or enable a single tab. func disableTab(_ disabled: Bool, index: Int) { guard index < self.content.count else { return } - + guard self.disabledTabs[index] != disabled else { return } + self.disabledTabs[index] = disabled } + + func isTabEnabled(index: Int) -> Bool { + return !self.disabledTabs[index] && self.isEnabled + } + + @discardableResult + func setIsEnabled(_ isEnabled: Bool) -> Self { + guard self.isEnabled != isEnabled else { return self } + + self.isEnabled = isEnabled + return self + } } diff --git a/core/Sources/Components/Tab/ViewModel/TabViewModelTests.swift b/core/Sources/Components/Tab/ViewModel/TabViewModelTests.swift index d93ef8c41..a82e9d466 100644 --- a/core/Sources/Components/Tab/ViewModel/TabViewModelTests.swift +++ b/core/Sources/Components/Tab/ViewModel/TabViewModelTests.swift @@ -125,7 +125,7 @@ final class TabViewModelTests: XCTestCase { expect.fulfill() } - sut.isEnabled = false + sut.setIsEnabled(false) // Then wait(for: [expect], timeout: 0.1) @@ -152,10 +152,9 @@ final class TabViewModelTests: XCTestCase { useCase: self.useCase ) - sut.isEnabled = true + sut.setIsEnabled(true) XCTAssertEqual(sut.disabledTabs, [false]) - } func test_disable() { @@ -178,9 +177,9 @@ final class TabViewModelTests: XCTestCase { useCase: self.useCase ) - sut.isEnabled = false + sut.setIsEnabled(false) - XCTAssertEqual(sut.disabledTabs, [true]) + XCTAssertFalse(sut.isTabEnabled(index: 0)) } func test_disable_single_tab() { @@ -206,6 +205,7 @@ final class TabViewModelTests: XCTestCase { sut.disableTab(true, index: 0) XCTAssertEqual(sut.disabledTabs, [true], "Expect tab to be disabled") - XCTAssertEqual(sut.isEnabled, false, "Expect tab control not to be enabled") + XCTAssertTrue(sut.isEnabled, "Expect tab control be enabled") + XCTAssertFalse(sut.isTabEnabled(index: 0), "Expected single tab not to be enabled") } } From 5f233bf108ed08fa0e0e4781a9a8dc7e8348699b Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 14 May 2024 12:13:06 +0200 Subject: [PATCH 103/117] [RatingInput] Accessibility. Manually picked changes from branch . --- .../Rating/View/SwiftUI/RatingInputView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift index b31778594..f73488072 100644 --- a/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift +++ b/core/Sources/Components/Rating/View/SwiftUI/RatingInputView.swift @@ -111,7 +111,21 @@ struct RatingInputInternalView: View { .gesture(self.dragGesture(viewRect: viewRect)) .frame(width: width, height: size) .accessibilityIdentifier(RatingInputAccessibilityIdentifier.identifier) - .accessibilityValue("\(self.displayRating)") + .accessibilityElement() + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + guard self.displayRating <= CGFloat(self.viewModel.count.maxIndex) else { break } + self.displayRating += 1 + case .decrement: + guard self.displayRating > 1 else { break } + self.displayRating -= 1 + @unknown default: + break + } + self.rating = self.displayRating + } + .accessibilityValue(self.displayRating.description) } // MARK: - Private functions From bc37d275efeec7c6febea3f30dbc03b1059a10f6 Mon Sep 17 00:00:00 2001 From: Xavier Daleau Date: Mon, 19 Feb 2024 14:48:49 +0100 Subject: [PATCH 104/117] [Accessibility] Improve SwiftUI rating display --- .../Components/Rating/View/SwiftUI/RatingDisplayView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/Sources/Components/Rating/View/SwiftUI/RatingDisplayView.swift b/core/Sources/Components/Rating/View/SwiftUI/RatingDisplayView.swift index b47f2b187..5bf80da9d 100644 --- a/core/Sources/Components/Rating/View/SwiftUI/RatingDisplayView.swift +++ b/core/Sources/Components/Rating/View/SwiftUI/RatingDisplayView.swift @@ -56,6 +56,8 @@ public struct RatingDisplayView: View { public var body: some View { self.stars() .accessibilityIdentifier(RatingDisplayAccessibilityIdentifier.identifier) + .accessibilityElement() + .accessibilityValue("\(self.viewModel.ratingValue.description) / \(self.viewModel.count.rawValue)") } @ViewBuilder From 12fe0a9b4b655d9efc93b72e4ab708d6d5579cb4 Mon Sep 17 00:00:00 2001 From: Xavier Daleau Date: Mon, 19 Feb 2024 14:59:59 +0100 Subject: [PATCH 105/117] [Accessibility] Improve UIKit rating display --- .../Rating/View/UIKit/RatingDisplayUIView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift b/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift index 74418ada8..c9dae3507 100644 --- a/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift +++ b/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift @@ -149,6 +149,7 @@ public class RatingDisplayUIView: UIView { super.init(frame: .zero) self.setupView() self.setupSubscriptions() + self.setUpAccessibility() } required init?(coder: NSCoder) { @@ -218,12 +219,22 @@ public class RatingDisplayUIView: UIView { } } + private func setUpAccessibility() { + self.isAccessibilityElement = true + self.updateAccessibilityValue() + } + + private func updateAccessibilityValue() { + self.accessibilityValue = "\(self.rating.description) / \(self.viewModel.count.rawValue)" + } + private func didUpdate(rating: CGFloat) { var currentRating = rating for view in self.ratingStarViews { view.rating = currentRating currentRating -= 1 } + self.updateAccessibilityValue() } private func didUpdate(colors: RatingColors) { From 7be2e128f64fb70143fd52e2122e513bdded33fc Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 17 May 2024 14:51:31 +0200 Subject: [PATCH 106/117] [Dim#941] Make Dim.none public. --- core/Sources/Theming/Content/Dims/Dims.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Sources/Theming/Content/Dims/Dims.swift b/core/Sources/Theming/Content/Dims/Dims.swift index 86b835dd8..1b9b49d91 100644 --- a/core/Sources/Theming/Content/Dims/Dims.swift +++ b/core/Sources/Theming/Content/Dims/Dims.swift @@ -17,7 +17,7 @@ public protocol Dims { var dim5: CGFloat { get } } -extension Dims { +public extension Dims { /// None corresponding to 1.0 value var none: CGFloat { return 1.0 From cc4bb351727e16831741100187f9ec706e4b6137 Mon Sep 17 00:00:00 2001 From: Robin Lemaire Date: Thu, 25 Apr 2024 11:18:47 +0200 Subject: [PATCH 107/117] [Button-909] SwiftUI: Fix tap area --- .../Internal/ButtonContainerView.swift | 20 ++++++++++--------- .../SwiftUI/Public/Button/ButtonView.swift | 2 -- .../SwiftUI/Public/Icon/IconButtonView.swift | 1 - .../Button/SwiftUI/ButtonComponentView.swift | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift b/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift index 0cc159f84..668df6604 100644 --- a/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift +++ b/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift @@ -52,19 +52,21 @@ struct ButtonContainerView Date: Thu, 25 Apr 2024 17:01:45 +0200 Subject: [PATCH 108/117] [Button-909] UIKit: Remove vertical spacing --- .../EdgeInsets/EdgeInsets+Extension.swift | 2 +- .../ButtonSpacings+ExtensionTests.swift | 2 -- .../Internal/Spacings/ButtonSpacings.swift | 1 - .../ButtonGetSpacingsUseCase.swift | 1 - .../ButtonGetSpacingsUseCaseTests.swift | 3 --- .../SwiftUI/Public/Button/ButtonView.swift | 3 --- .../View/UIKit/Button/ButtonUIView.swift | 19 +++++-------------- 7 files changed, 6 insertions(+), 25 deletions(-) diff --git a/core/Sources/Common/SwiftUI/Extension/EdgeInsets/EdgeInsets+Extension.swift b/core/Sources/Common/SwiftUI/Extension/EdgeInsets/EdgeInsets+Extension.swift index 9da266a9d..4970ab147 100644 --- a/core/Sources/Common/SwiftUI/Extension/EdgeInsets/EdgeInsets+Extension.swift +++ b/core/Sources/Common/SwiftUI/Extension/EdgeInsets/EdgeInsets+Extension.swift @@ -23,7 +23,7 @@ extension EdgeInsets { /// - Parameters: /// - vertical: horizontal inset value use to set left and right insets. /// - horizontal: horizontal inset value use to set left and right insets. - init(vertical: CGFloat, horizontal: CGFloat) { + init(vertical: CGFloat = 0, horizontal: CGFloat = 0) { self = .init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal) } } diff --git a/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings+ExtensionTests.swift b/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings+ExtensionTests.swift index 1ef929dec..4ee5e3da7 100644 --- a/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings+ExtensionTests.swift +++ b/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings+ExtensionTests.swift @@ -14,12 +14,10 @@ extension ButtonSpacings { // MARK: - Properties static func mocked( - verticalSpacing: CGFloat = 10, horizontalSpacing: CGFloat = 11, horizontalPadding: CGFloat = 12 ) -> Self { return .init( - verticalSpacing: verticalSpacing, horizontalSpacing: horizontalSpacing, horizontalPadding: horizontalPadding ) diff --git a/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings.swift b/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings.swift index 38e9bd874..804f5dfbe 100644 --- a/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings.swift +++ b/core/Sources/Components/Button/Properties/Internal/Spacings/ButtonSpacings.swift @@ -12,7 +12,6 @@ struct ButtonSpacings: Equatable { // MARK: - Properties - let verticalSpacing: CGFloat let horizontalSpacing: CGFloat let horizontalPadding: CGFloat } diff --git a/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCase.swift b/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCase.swift index d72cf81b7..a0163a5c0 100644 --- a/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCase.swift +++ b/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCase.swift @@ -20,7 +20,6 @@ struct ButtonGetSpacingsUseCase: ButtonGetSpacingsUseCaseable { func execute(spacing: LayoutSpacing) -> ButtonSpacings { return .init( - verticalSpacing: spacing.medium, horizontalSpacing: spacing.large, horizontalPadding: spacing.medium ) diff --git a/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCaseTests.swift b/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCaseTests.swift index a311879a4..679443e9e 100644 --- a/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCaseTests.swift +++ b/core/Sources/Components/Button/UseCase/GetSpacings/ButtonGetSpacingsUseCaseTests.swift @@ -26,9 +26,6 @@ final class ButtonGetSpacingsUseCaseTests: XCTestCase { ) // THEN - XCTAssertEqual(spacings.verticalSpacing, - spacingMock.medium, - "Wrong verticalSpacing value") XCTAssertEqual(spacings.horizontalSpacing, spacingMock.large, "Wrong horizontalSpacing value") diff --git a/core/Sources/Components/Button/View/SwiftUI/Public/Button/ButtonView.swift b/core/Sources/Components/Button/View/SwiftUI/Public/Button/ButtonView.swift index f3e1b9a96..1cc131d5a 100644 --- a/core/Sources/Components/Button/View/SwiftUI/Public/Button/ButtonView.swift +++ b/core/Sources/Components/Button/View/SwiftUI/Public/Button/ButtonView.swift @@ -15,7 +15,6 @@ public struct ButtonView: View { @ObservedObject private var viewModel: ButtonSUIViewModel - @ScaledMetric private var verticalSpacing: CGFloat @ScaledMetric private var horizontalSpacing: CGFloat @ScaledMetric private var horizontalPadding: CGFloat @@ -53,7 +52,6 @@ public struct ButtonView: View { // ** // Scaled Metric - self._verticalSpacing = .init(wrappedValue: viewModel.spacings?.verticalSpacing ?? .zero) self._horizontalSpacing = .init(wrappedValue: viewModel.spacings?.horizontalSpacing ?? .zero) self._horizontalPadding = .init(wrappedValue: viewModel.spacings?.horizontalPadding ?? .zero) // ** @@ -67,7 +65,6 @@ public struct ButtonView: View { ButtonContainerView( viewModel: self.viewModel, padding: .init( - vertical: self.verticalSpacing, horizontal: self.horizontalSpacing ), action: self.action diff --git a/core/Sources/Components/Button/View/UIKit/Button/ButtonUIView.swift b/core/Sources/Components/Button/View/UIKit/Button/ButtonUIView.swift index 732deb8c0..fb8175f04 100644 --- a/core/Sources/Components/Button/View/UIKit/Button/ButtonUIView.swift +++ b/core/Sources/Components/Button/View/UIKit/Button/ButtonUIView.swift @@ -120,10 +120,7 @@ public final class ButtonUIView: ButtonMainUIView { private let viewModel: ButtonViewModel private var contentStackViewLeadingConstraint: NSLayoutConstraint? - private var contentStackViewTopConstraint: NSLayoutConstraint? - private var contentStackViewBottomConstraint: NSLayoutConstraint? - @ScaledUIMetric private var verticalSpacing: CGFloat = 0 @ScaledUIMetric private var horizontalSpacing: CGFloat = 0 @ScaledUIMetric private var horizontalPadding: CGFloat = 0 @@ -199,15 +196,15 @@ public final class ButtonUIView: ButtonMainUIView { self.contentStackView.translatesAutoresizingMaskIntoConstraints = false self.contentStackViewLeadingConstraint = self.contentStackView.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor) - self.contentStackViewTopConstraint = self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor) + let contentStackViewTopConstraint = self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor) let contentStackViewCenterXAnchor = self.contentStackView.centerXAnchor.constraint(equalTo: self.centerXAnchor) - self.contentStackViewBottomConstraint = self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + let contentStackViewBottomConstraint = self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) NSLayoutConstraint.activate([ self.contentStackViewLeadingConstraint, - self.contentStackViewTopConstraint, + contentStackViewTopConstraint, contentStackViewCenterXAnchor, - self.contentStackViewBottomConstraint, + contentStackViewBottomConstraint, ].compactMap({ $0 })) } @@ -283,12 +280,10 @@ public final class ButtonUIView: ButtonMainUIView { private func updateSpacings() { // Reload spacing only if value changed - let verticalSpacing = self._verticalSpacing.wrappedValue let horizontalSpacing = self._horizontalSpacing.wrappedValue let horizontalPadding = self._horizontalPadding.wrappedValue - if verticalSpacing != self.contentStackViewTopConstraint?.constant || - horizontalSpacing != self.contentStackViewLeadingConstraint?.constant || + if horizontalSpacing != self.contentStackViewLeadingConstraint?.constant || horizontalPadding != self.contentStackView.spacing { let isAnimated = self.isAnimated && !self.firstContentStackViewAnimation @@ -300,8 +295,6 @@ public final class ButtonUIView: ButtonMainUIView { self.firstContentStackViewAnimation = false self.contentStackViewLeadingConstraint?.constant = horizontalSpacing - self.contentStackViewTopConstraint?.constant = verticalSpacing - self.contentStackViewBottomConstraint?.constant = -verticalSpacing self.contentStackView.updateConstraintsIfNeeded() self.contentStackView.spacing = horizontalPadding @@ -312,7 +305,6 @@ public final class ButtonUIView: ButtonMainUIView { // MARK: - Data Did Update private func spacingsDidUpdate(_ spacings: ButtonSpacings) { - self.verticalSpacing = spacings.verticalSpacing self.horizontalSpacing = spacings.horizontalSpacing self.horizontalPadding = spacings.horizontalPadding @@ -383,7 +375,6 @@ public final class ButtonUIView: ButtonMainUIView { super.traitCollectionDidChange(previousTraitCollection) // Update spacings - self._verticalSpacing.update(traitCollection: self.traitCollection) self._horizontalSpacing.update(traitCollection: self.traitCollection) self._horizontalPadding.update(traitCollection: self.traitCollection) self.updateSpacings() From 4cd78f25cbb93f6a220cbf6aad0f1021cdf1ea80 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 24 May 2024 13:58:57 +0200 Subject: [PATCH 109/117] [Chip#950] Remove if modifier. --- .../Chip/View/SwiftUI/ChipView.swift | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/core/Sources/Components/Chip/View/SwiftUI/ChipView.swift b/core/Sources/Components/Chip/View/SwiftUI/ChipView.swift index 8b00989a4..f91afb51b 100644 --- a/core/Sources/Components/Chip/View/SwiftUI/ChipView.swift +++ b/core/Sources/Components/Chip/View/SwiftUI/ChipView.swift @@ -121,27 +121,37 @@ public struct ChipView: View { // MARK: - View public var body: some View { - Button(action: self.action ?? {}) { - self.content() + if (self.action == nil) { + self.borderedChipView().buttonStyle(NoButtonStyle()) + } else { + self.borderedChipView().buttonStyle(PressedButtonStyle(isPressed: self.$viewModel.isPressed)) } - .frame(height: self.height) - .background(self.viewModel.colors.background.color) - .if(self.viewModel.isBordered) { view in - view.chipBorder(width: self.borderWidth, + } + + @ViewBuilder + private func borderedChipView() -> some View { + if self.viewModel.isBordered { + self.chipView().chipBorder(width: self.borderWidth, radius: self.borderRadius, dashLength: self.borderDashLength(), colorToken: self.viewModel.colors.border) + } else { + self.chipView() } + } + + @ViewBuilder + private func chipView() -> some View { + Button(action: self.action ?? {}) { + self.content() + } + .frame(height: self.height) + .background(self.viewModel.colors.background.color) .opacity(self.viewModel.colors.opacity) .cornerRadius(self.borderRadius) .isEnabledChanged { isEnabled in self.viewModel.isEnabled = isEnabled } - .if(self.action == nil) { content in - content.buttonStyle(NoButtonStyle()) - } else: { content in - content.buttonStyle(PressedButtonStyle(isPressed: self.$viewModel.isPressed)) - } .accessibilityIdentifier(ChipAccessibilityIdentifier.identifier) } From 80207dcc996421490634eafb15ba495faeb3e801 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 24 May 2024 14:13:24 +0200 Subject: [PATCH 110/117] [Switch#950] Remove if modifier. --- .../Switch/View/SwiftUI/SwitchView.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/core/Sources/Components/Switch/View/SwiftUI/SwitchView.swift b/core/Sources/Components/Switch/View/SwiftUI/SwitchView.swift index cf305b661..9119e9975 100644 --- a/core/Sources/Components/Switch/View/SwiftUI/SwitchView.swift +++ b/core/Sources/Components/Switch/View/SwiftUI/SwitchView.swift @@ -258,19 +258,26 @@ private extension Text { private extension Image { + @ViewBuilder func applyStyle( isForOnImage: Bool, viewModel: SwitchViewModel ) -> some View { + if isForOnImage { + self.applyImageStyle(viewModel: viewModel) + .opacity(viewModel.toggleDotImagesState?.onImageOpacity ?? 0) + } else { + self.applyImageStyle(viewModel: viewModel) + .opacity(viewModel.toggleDotImagesState?.offImageOpacity ?? 0) + } + } + + func applyImageStyle(viewModel: SwitchViewModel) -> some View { self.resizable() .aspectRatio(contentMode: .fit) .foregroundColor(viewModel.toggleDotForegroundColorToken?.color) - .if(isForOnImage) { - $0.opacity(viewModel.toggleDotImagesState?.onImageOpacity ?? 0) - } else: { - $0.opacity(viewModel.toggleDotImagesState?.offImageOpacity ?? 0) - } .accessibilityIdentifier(SwitchAccessibilityIdentifier.toggleDotImageView) + } } From 0d4c14b35a6361bfb070ae53221de0f1e07f9e3b Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Fri, 24 May 2024 16:57:13 +0200 Subject: [PATCH 111/117] [ProgressBar#951] Remove if modifier. --- .../Public/ProgressBarDoubleView.swift | 46 ++++++++++++++----- .../View/SwiftUI/Public/ProgressBarView.swift | 24 +++++++--- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarDoubleView.swift b/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarDoubleView.swift index d64f0c3fc..b78179c6c 100644 --- a/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarDoubleView.swift +++ b/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarDoubleView.swift @@ -56,21 +56,43 @@ public struct ProgressBarDoubleView: View { trackBackgroundColor: self.viewModel.colors?.trackBackgroundColorToken, indicatorView: { // Bottom Indicator - RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) - .fill(self.viewModel.colors?.bottomIndicatorBackgroundColorToken) - .if(self.viewModel.isValidIndicatorValue(self.bottomValue)) { view in - view.proportionalWidth(from: self.bottomValue) - } - .accessibilityIdentifier(AccessibilityIdentifier.bottomIndicatorView) + self.bottomIndicator() // Top Indicator - RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) - .fill(self.viewModel.colors?.indicatorBackgroundColorToken) - .if(self.viewModel.isValidIndicatorValue(self.topValue)) { view in - view.proportionalWidth(from: self.topValue) - } - .accessibilityIdentifier(AccessibilityIdentifier.indicatorView) + self.topIndicator() } ) } + + @ViewBuilder + private func bottomIndicator() -> some View { + if self.viewModel.isValidIndicatorValue(self.bottomValue) { + self.bottomRectangle().proportionalWidth(from: self.bottomValue) + } else { + self.bottomRectangle() + } + } + + private func bottomRectangle() -> some View { + RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) + .fill(self.viewModel.colors?.bottomIndicatorBackgroundColorToken) + .accessibilityIdentifier(AccessibilityIdentifier.bottomIndicatorView) + } + + @ViewBuilder + private func topIndicator() -> some View { + if self.viewModel.isValidIndicatorValue(self.topValue) { + self.topRectangle().proportionalWidth(from: self.topValue) + } else { + self.topRectangle() + } + } + + private func topRectangle() -> some View { + RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) + .fill(self.viewModel.colors?.indicatorBackgroundColorToken) + .accessibilityIdentifier(AccessibilityIdentifier.indicatorView) + + } + } diff --git a/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarView.swift b/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarView.swift index 947eb6f1d..2b45ac5ce 100644 --- a/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarView.swift +++ b/core/Sources/Components/ProgressBar/View/SwiftUI/Public/ProgressBarView.swift @@ -51,13 +51,25 @@ public struct ProgressBarView: View { trackCornerRadius: self.viewModel.cornerRadius, trackBackgroundColor: self.viewModel.colors?.trackBackgroundColorToken, indicatorView: { - RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) - .fill(self.viewModel.colors?.indicatorBackgroundColorToken) - .if(self.viewModel.isValidIndicatorValue(self.value)) { view in - view.proportionalWidth(from: self.value) - } - .accessibilityIdentifier(AccessibilityIdentifier.indicatorView) + self.indicatorView() } ) } + + @ViewBuilder + private func indicatorView() -> some View { + if self.viewModel.isValidIndicatorValue(self.value) { + self.indicatorRectangle() + .proportionalWidth(from: self.value) + } else { + self.indicatorRectangle() + } + } + + @ViewBuilder + private func indicatorRectangle() -> some View { + RoundedRectangle(cornerRadius: self.viewModel.cornerRadius ?? 0) + .fill(self.viewModel.colors?.indicatorBackgroundColorToken) + .accessibilityIdentifier(AccessibilityIdentifier.indicatorView) + } } From 97cae4aefb98b0a2be6363e9720bd075b14d468a Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Mon, 27 May 2024 13:23:13 +0200 Subject: [PATCH 112/117] [CheckBox#949] Remove if view modifier. --- .../View/SwiftUI/CheckboxGroupView.swift | 15 +++-- .../Checkbox/View/SwiftUI/CheckboxView.swift | 60 ++++++++++++------- .../SwiftUI/CheckboxViewSnapshotTests.swift | 23 ++++--- 3 files changed, 64 insertions(+), 34 deletions(-) diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift index 41fc056df..a7be7b18a 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxGroupView.swift @@ -97,7 +97,17 @@ public struct CheckboxGroupView: View { .accessibilityIdentifier("\(self.viewModel.accessibilityIdentifierPrefix).\(CheckboxAccessibilityIdentifier.checkboxGroup)") } + @ViewBuilder private func makeHStackView() -> some View { + if self.isScrollableHStack { + self.makeScrollHStackView() + } else { + self.makeDefaultHStackView() + } + } + + @ViewBuilder + private func makeScrollHStackView() -> some View { ScrollView (.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: self.spacingLarge) { self.makeContentView(maxWidth: self.viewWidth) @@ -114,11 +124,6 @@ public struct CheckboxGroupView: View { .padding(checkboxSelectedBorderWidth) } .padding(-checkboxSelectedBorderWidth) - .if(!self.isScrollableHStack) { _ in - makeDefaultHStackView() - } else: { view in - view - } } @ViewBuilder diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift index 68a6d3d2c..927671c6d 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxView.swift @@ -112,25 +112,19 @@ public struct CheckboxView: View { @ViewBuilder private var checkboxView: some View { - let tintColor = self.viewModel.colors.tintColor.color + if self.selectionState == .selected { + self.checkbox().accessibilityAddTraits(.isSelected) + } else { + self.checkbox() + } + } + + @ViewBuilder + private func checkbox() -> some View { let iconColor = self.viewModel.colors.iconColor.color - let borderColor = self.viewModel.colors.borderColor.color + ZStack { - RoundedRectangle(cornerRadius: self.checkboxBorderRadius) - .if(self.selectionState == .selected || self.selectionState == .indeterminate) { - $0.fill(tintColor) - } else: { - $0.strokeBorder(borderColor, lineWidth: self.checkboxBorderWidth) - } - .frame(width: self.checkboxSize, height: self.checkboxSize) - .if(self.isPressed && self.viewModel.isEnabled) { - $0.overlay( - RoundedRectangle(cornerRadius: self.checkboxBorderRadius) - .inset(by: -self.checkboxSelectedBorderWidth / 2) - .stroke(self.viewModel.colors.pressedBorderColor.color, lineWidth: self.checkboxSelectedBorderWidth) - .animation(.easeInOut(duration: 0.1), value: self.isPressed) - ) - } + self.stateFullCheckboxRectangle() switch self.selectionState { case .selected: @@ -148,13 +142,39 @@ public struct CheckboxView: View { .frame(width: self.checkboxIndeterminateWidth, height: self.checkboxIndeterminateHeight) } } - .if(self.selectionState == .selected) { - $0.accessibilityAddTraits(.isSelected) - } .id(Identifier.checkbox.rawValue) .matchedGeometryEffect(id: Identifier.checkbox.rawValue, in: self.namespace) } + @ViewBuilder + private func stateFullCheckboxRectangle() -> some View { + if self.isPressed && self.viewModel.isEnabled { + self.checkboxRectangle() + .overlay( + RoundedRectangle(cornerRadius: self.checkboxBorderRadius) + .inset(by: -self.checkboxSelectedBorderWidth / 2) + .stroke(self.viewModel.colors.pressedBorderColor.color, lineWidth: self.checkboxSelectedBorderWidth) + .animation(.easeInOut(duration: 0.1), value: self.isPressed) + ) + } else { + self.checkboxRectangle() + } + } + + @ViewBuilder + private func checkboxRectangle() -> some View { + let tintColor = self.viewModel.colors.tintColor.color + let borderColor = self.viewModel.colors.borderColor.color + + RoundedRectangle(cornerRadius: self.checkboxBorderRadius) + .if(self.selectionState == .selected || self.selectionState == .indeterminate) { + $0.fill(tintColor) + } else: { + $0.strokeBorder(borderColor, lineWidth: self.checkboxBorderWidth) + } + .frame(width: self.checkboxSize, height: self.checkboxSize) + } + @ViewBuilder private var contentView: some View { HStack(spacing: 0) { diff --git a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxViewSnapshotTests.swift b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxViewSnapshotTests.swift index f4feaacec..24dde3055 100644 --- a/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxViewSnapshotTests.swift +++ b/core/Sources/Components/Checkbox/View/SwiftUI/CheckboxViewSnapshotTests.swift @@ -38,7 +38,7 @@ final class CheckboxViewSnapshotTests: SwiftUIComponentSnapshotTestCase { for configuration in configurations { self.selectionState = configuration.selectionState - let view = CheckboxView( + let checkboxView = CheckboxView( text: configuration.text, checkedImage: Image(uiImage: configuration.image), alignment: configuration.alignment, @@ -48,14 +48,8 @@ final class CheckboxViewSnapshotTests: SwiftUIComponentSnapshotTestCase { selectionState: self._selectionState ) .background(Color.systemBackground) - .if(configuration.text != "Hello World") { view in - VStack { - view - } - .frame(width: UIScreen.main.bounds.width) - } else: { view in - view.fixedSize() - } + + let view = self.view(text: configuration.text, checkbox: checkboxView) self.assertSnapshot( matching: view, @@ -66,4 +60,15 @@ final class CheckboxViewSnapshotTests: SwiftUIComponentSnapshotTestCase { } } } + + func view(text: String, checkbox: some View) -> AnyView { + if text != "Hello World" { + let view = VStack { checkbox } + .frame(width: UIScreen.main.bounds.width) + return AnyView(view) + } else { + return AnyView(checkbox.fixedSize()) + } + + } } From f0ce64f0364d7b551d36cead15ebc3af6778a38e Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 29 May 2024 11:44:07 +0200 Subject: [PATCH 113/117] [Bug#963] Fixed addon containers not hidden at init --- .../TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift index fa3d22a46..4ac659935 100644 --- a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -102,6 +102,8 @@ public final class TextFieldAddonsUIView: UIControl { ]) self.setupSeparators() + self.setLeftAddon(nil) + self.setRightAddon(nil) } private func setupSeparators() { From 39b1ba2f2b8cc56d14d1b5c14ac080f47b0cecbf Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 29 May 2024 11:48:40 +0200 Subject: [PATCH 114/117] [Bug#964] Fixed layout issue with addons taking priority over textfield --- .../Addons/View/UIKit/TextFieldAddonsUIView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift index fa3d22a46..1de2816ac 100644 --- a/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift +++ b/core/Sources/Components/TextField/Addons/View/UIKit/TextFieldAddonsUIView.swift @@ -90,6 +90,8 @@ public final class TextFieldAddonsUIView: UIControl { self.clipsToBounds = true self.addSubview(self.stackView) + self.textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.stackView.translatesAutoresizingMaskIntoConstraints = false // Adding constant padding to set borders outline instead of inline self.leadingConstraint = self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: self.borderWidth) @@ -219,9 +221,10 @@ public final class TextFieldAddonsUIView: UIControl { } if let leftAddon { self.leftAddonContainer.addSubview(leftAddon) + leftAddon.setContentHuggingPriority(.defaultHigh, for: .horizontal) leftAddon.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - leftAddon.trailingAnchor.constraint(lessThanOrEqualTo: self.leftSeparatorView.leadingAnchor, constant: withPadding ? -self.viewModel.leftSpacing : 0), + leftAddon.trailingAnchor.constraint(equalTo: self.leftSeparatorView.leadingAnchor, constant: withPadding ? -self.viewModel.leftSpacing : 0), leftAddon.centerXAnchor.constraint(equalTo: self.leftAddonContainer.centerXAnchor, constant: -self.borderWidth / 2.0), leftAddon.centerYAnchor.constraint(equalTo: self.leftAddonContainer.centerYAnchor) ]) @@ -233,7 +236,7 @@ public final class TextFieldAddonsUIView: UIControl { /// Set the textfield's right addon /// - Parameters: - /// - leftAddon: the view to be set as rightAddon + /// - rightAddon: the view to be set as rightAddon /// - withPadding: adds a padding on the addon if `true`, default is `false` public func setRightAddon(_ rightAddon: UIView? = nil, withPadding: Bool = false) { if let oldValue = self.rightAddon, oldValue.isDescendant(of: self.rightAddonContainer) { @@ -241,9 +244,10 @@ public final class TextFieldAddonsUIView: UIControl { } if let rightAddon { self.rightAddonContainer.addSubview(rightAddon) + rightAddon.setContentHuggingPriority(.defaultHigh, for: .horizontal) rightAddon.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - rightAddon.leadingAnchor.constraint(greaterThanOrEqualTo: self.rightSeparatorView.trailingAnchor, constant: withPadding ? self.viewModel.rightSpacing : 0), + rightAddon.leadingAnchor.constraint(equalTo: self.rightSeparatorView.trailingAnchor, constant: withPadding ? self.viewModel.rightSpacing : 0), rightAddon.centerXAnchor.constraint(equalTo: self.rightAddonContainer.centerXAnchor, constant: self.borderWidth / 2.0), rightAddon.centerYAnchor.constraint(equalTo: self.rightAddonContainer.centerYAnchor) ]) From 0e427c74cd81216106d7364443c4c2a71f1e4a9c Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Wed, 29 May 2024 14:23:16 +0200 Subject: [PATCH 115/117] [RadioButton#968] Fix color. --- .../UseCases/RadioButtonGetColorsUseCase.swift | 14 +------------- .../RadioButtonGetColorsUseCaseTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCase.swift b/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCase.swift index b04ef0644..7eb1a3c30 100644 --- a/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCase.swift +++ b/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCase.swift @@ -53,19 +53,7 @@ private extension SparkCore.Colors { func buttonColor( intent: RadioButtonIntent, isSelected: Bool) -> any ColorToken { - return isSelected ? self.selectedColor(intent: intent) : self.outlineColor(intent: intent) - } - - private func outlineColor(intent: RadioButtonIntent) -> any ColorToken { - switch intent { - case .danger: - return self.feedback.error - case .alert: - return self.feedback.alert - case .success: - return self.feedback.success - default: return self.base.outline - } + return isSelected ? self.selectedColor(intent: intent) : self.base.outline } private func selectedColor(intent: RadioButtonIntent) -> any ColorToken { diff --git a/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCaseTests.swift b/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCaseTests.swift index 4fa8ec82e..f017b9af6 100644 --- a/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCaseTests.swift +++ b/core/Sources/Components/RadioButton/UseCases/RadioButtonGetColorsUseCaseTests.swift @@ -54,7 +54,7 @@ final class RadioButtonGetColorsUseCaseTests: XCTestCase { // Given let colors = self.theme.colors let expectedColors = RadioButtonColors( - button: colors.feedback.error, + button: colors.base.outline, label: colors.base.onBackground, halo: colors.feedback.errorContainer, fill: ColorTokenDefault.clear, @@ -96,7 +96,7 @@ final class RadioButtonGetColorsUseCaseTests: XCTestCase { // Given let colors = self.theme.colors let expectedColors = RadioButtonColors( - button: colors.feedback.alert, + button: colors.base.outline, label: colors.base.onBackground, halo: colors.feedback.alertContainer, fill: ColorTokenDefault.clear, @@ -117,7 +117,7 @@ final class RadioButtonGetColorsUseCaseTests: XCTestCase { // Given let colors = self.theme.colors let expectedColors = RadioButtonColors( - button: colors.feedback.success, + button: colors.base.outline, label: colors.base.onBackground, halo: colors.feedback.successContainer, fill: ColorTokenDefault.clear, From 66a0b0a6659f7551db404c31416ee5dc4991e90c Mon Sep 17 00:00:00 2001 From: Robin Lemaire Date: Wed, 29 May 2024 17:03:54 +0200 Subject: [PATCH 116/117] [Button-972] Fix button animation --- .../Button/View/SwiftUI/Internal/ButtonContainerView.swift | 1 - .../Button/View/SwiftUI/Public/Button/ButtonView.swift | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift b/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift index 668df6604..9112efc6e 100644 --- a/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift +++ b/core/Sources/Components/Button/View/SwiftUI/Internal/ButtonContainerView.swift @@ -62,7 +62,6 @@ struct ButtonContainerView Date: Wed, 29 May 2024 15:20:22 +0200 Subject: [PATCH 117/117] [Tag#1939] Use default text font of tag if not overwritten by attributed text. --- .../Components/Tag/View/UIKit/TagUIView.swift | 13 +++++-------- .../Classes/View/Components/Tag/TagContent.swift | 2 +- .../Tag/UIKit/TagComponentUIViewModel.swift | 12 +++++------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/core/Sources/Components/Tag/View/UIKit/TagUIView.swift b/core/Sources/Components/Tag/View/UIKit/TagUIView.swift index 4ce1cc436..ba5b6e493 100644 --- a/core/Sources/Components/Tag/View/UIKit/TagUIView.swift +++ b/core/Sources/Components/Tag/View/UIKit/TagUIView.swift @@ -335,21 +335,18 @@ public final class TagUIView: UIView { } private func reloadTextLabel() { + self.reloadTextStyle() if let attributedText = self.attributedText { self.textLabel.attributedText = attributedText } else { self.textLabel.text = self.text - self.reloadTextStyle() } self.textLabel.isHidden = (self.text == nil && self.attributedText == nil) } private func reloadTextStyle() { - // Change the style only if the text is displayed and not the attributedText - if self._attributedText == nil { - self.textLabel.font = self.theme.typography.captionHighlight.uiFont - self.textLabel.textColor = self.colors.foregroundColor.uiColor - } + self.textLabel.font = self.theme.typography.captionHighlight.uiFont + self.textLabel.textColor = self.colors.foregroundColor.uiColor } private func reloadUIFromTheme() { @@ -364,7 +361,7 @@ public final class TagUIView: UIView { self.setMasksToBounds(true) // Subviews - self.reloadTextStyle() + self.reloadTextLabel() } private func reloadUIFromColors() { @@ -374,7 +371,7 @@ public final class TagUIView: UIView { // Subviews self.iconImageView.tintColor = self.colors.foregroundColor.uiColor - self.reloadTextStyle() + self.reloadTextLabel() } private func reloadUIFromSize() { diff --git a/spark/Demo/Classes/View/Components/Tag/TagContent.swift b/spark/Demo/Classes/View/Components/Tag/TagContent.swift index f4d14756d..641efb7d2 100644 --- a/spark/Demo/Classes/View/Components/Tag/TagContent.swift +++ b/spark/Demo/Classes/View/Components/Tag/TagContent.swift @@ -31,7 +31,7 @@ enum TagContent: CaseIterable { var text: String { switch self { case .attributedText, .iconAndAttributedText: - return "This is a AT Tag" + return "This is an AT Tag" case .longText, .longAttributedText, .iconAndLongText, .iconAndLongAttributedText: return "This is a Tag with a very very very very very long long long long width" default: diff --git a/spark/Demo/Classes/View/Components/Tag/UIKit/TagComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Tag/UIKit/TagComponentUIViewModel.swift index eb18dae39..77478dfb6 100644 --- a/spark/Demo/Classes/View/Components/Tag/UIKit/TagComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Tag/UIKit/TagComponentUIViewModel.swift @@ -92,13 +92,11 @@ final class TagComponentUIViewModel: ComponentUIViewModel { let image: UIImage = UIImage(named: "alert") ?? UIImage() func attributeText(_ text: String) -> NSAttributedString { - let attributeString = NSMutableAttributedString( - string: text, - attributes: [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: UIColor.red - ] - ) + let attributeString = NSMutableAttributedString(string: text) + let range = NSRange(location: text.count / 3, length: text.count / 3) + + attributeString.addAttribute(NSAttributedString.Key.font, value: UIFont.boldSystemFont(ofSize: 14), range: range) + attributeString.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.red, range: range) return attributeString }