From 60b94c47ffe08079e50a25f2f5e3d9fea0ff9c1d Mon Sep 17 00:00:00 2001 From: Robin Lemaire Date: Thu, 26 Oct 2023 14:21:43 +0200 Subject: [PATCH] [Refacto-557] Button: Add common Control management --- .../PropertyState/ControlPropertyState.swift | 23 + .../ControlPropertyStateTests.swift | 27 ++ .../ControlPropertyStates.swift | 81 ++++ .../ControlPropertyStatesTests.swift | 407 ++++++++++++++++++ .../Common/Control/State/ControlState.swift | 21 + .../Common/Control/Status/ControlStatus.swift | 28 ++ .../Control/Status/ControlStatusTests.swift | 49 +++ .../UIView/UIControlStateImageView.swift | 55 +++ .../Control/UIView/UIControlStateLabel.swift | 151 +++++++ 9 files changed, 842 insertions(+) create mode 100644 core/Sources/Common/Control/PropertyState/ControlPropertyState.swift create mode 100644 core/Sources/Common/Control/PropertyState/ControlPropertyStateTests.swift create mode 100644 core/Sources/Common/Control/PropertyStates/ControlPropertyStates.swift create mode 100644 core/Sources/Common/Control/PropertyStates/ControlPropertyStatesTests.swift create mode 100644 core/Sources/Common/Control/State/ControlState.swift create mode 100644 core/Sources/Common/Control/Status/ControlStatus.swift create mode 100644 core/Sources/Common/Control/Status/ControlStatusTests.swift create mode 100644 core/Sources/Common/Control/UIView/UIControlStateImageView.swift create mode 100644 core/Sources/Common/Control/UIView/UIControlStateLabel.swift diff --git a/core/Sources/Common/Control/PropertyState/ControlPropertyState.swift b/core/Sources/Common/Control/PropertyState/ControlPropertyState.swift new file mode 100644 index 000000000..66c77a68a --- /dev/null +++ b/core/Sources/Common/Control/PropertyState/ControlPropertyState.swift @@ -0,0 +1,23 @@ +// +// ControlPropertyState.swift +// SparkCore +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +/// Contains the dynamic property for a ControlState. +final class ControlPropertyState { + + // MARK: - Properties + + var value: T? + private let state: ControlState + + // MARK: - Initialization + + /// Init the object with a state. The value is nil by default. + init(for state: ControlState) { + self.state = state + } +} diff --git a/core/Sources/Common/Control/PropertyState/ControlPropertyStateTests.swift b/core/Sources/Common/Control/PropertyState/ControlPropertyStateTests.swift new file mode 100644 index 000000000..a0edb27aa --- /dev/null +++ b/core/Sources/Common/Control/PropertyState/ControlPropertyStateTests.swift @@ -0,0 +1,27 @@ +// +// ControlPropertyStateTests.swift +// SparkCoreUnitTests +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +@testable import SparkCore + +final class ControlPropertyStateTests: XCTestCase { + + // MARK: - Tests + + func test_default_value() { + // GIVEN / WHEN + let state = ControlPropertyState(for: .normal) + + // THEN + XCTAssertNil( + state.value, + "Wrong value. Should be nil" + ) + } +} diff --git a/core/Sources/Common/Control/PropertyStates/ControlPropertyStates.swift b/core/Sources/Common/Control/PropertyStates/ControlPropertyStates.swift new file mode 100644 index 000000000..640543d2b --- /dev/null +++ b/core/Sources/Common/Control/PropertyStates/ControlPropertyStates.swift @@ -0,0 +1,81 @@ +// +// ControlPropertyStates.swift +// SparkCore +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +/// Manage all the states for a dynamic property. +final class ControlPropertyStates { + + // MARK: - Type Alias + + private typealias PropertyState = ControlPropertyState + + // MARK: - Properties + + private var normalState = PropertyState(for: .normal) + private var highlightedState = PropertyState(for: .highlighted) + private var disabledState = PropertyState(for: .disabled) + private var selectedState = PropertyState(for: .selected) + + // MARK: - Setter + + /// Set the new value for a state. + /// - Parameters: + /// - value: the new value + /// - state: the state for the new value + func setValue(_ value: PropertyType?, for state: ControlState) { + let propertyState: PropertyState + + switch state { + case .normal: + propertyState = self.normalState + case .highlighted: + propertyState = self.highlightedState + case .disabled: + propertyState = self.disabledState + case .selected: + propertyState = self.selectedState + } + + propertyState.value = value + } + + // MARK: - Getter + + /// Get the value for a state. + /// - Parameters: + /// - state: the state of the value + func value(forState state: ControlState) -> PropertyType? { + switch state { + case .normal: return self.normalState.value + case .highlighted: return self.highlightedState.value + case .disabled: return self.disabledState.value + case .selected: return self.selectedState.value + } + } + + /// Get the value for the status of the control. + /// - Parameters: + /// - state: the status of the control + func value(forStatus status: ControlStatus) -> PropertyType? { + // isHighlighted has the highest priority, + // then isDisabled, + // then isSelected, + // and if there is no matching case, we always return the normal value. + + if status.isHighlighted, let value = self.highlightedState.value { + return value + } else if status.isDisabled, let value = self.disabledState.value { + return value + } else if status.isSelected, let value = self.selectedState.value { + return value + } else { + return self.normalState.value + } + } +} diff --git a/core/Sources/Common/Control/PropertyStates/ControlPropertyStatesTests.swift b/core/Sources/Common/Control/PropertyStates/ControlPropertyStatesTests.swift new file mode 100644 index 000000000..56bb4cb57 --- /dev/null +++ b/core/Sources/Common/Control/PropertyStates/ControlPropertyStatesTests.swift @@ -0,0 +1,407 @@ +// +// ControlPropertyStatesTests.swift +// SparkCoreUnitTests +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +@testable import SparkCore + +final class ControlPropertyStatesTests: XCTestCase { + + // MARK: - Value for States - Tests + + func test_value_for_all_states_when_value_is_set() { + // GIVEN + let expectedValue = "Value" + + let states = ControlState.allCases + + for state in states { + let states = ControlPropertyStates() + states.setValue(expectedValue, for: state) + + // WHEN + let value = states.value(forState: state) + + // THEN + XCTAssertEqual( + value, + expectedValue, + "Wrong value for the .\(state) state" + ) + } + } + + func test_value_for_all_states_when_value_is_nil() { + // GIVEN + let states = ControlState.allCases + + for state in states { + let states = ControlPropertyStates() + states.setValue(nil, for: state) + + // WHEN + let value = states.value(forState: state) + + // THEN + XCTAssertNil( + value, + "The value should be nil for the .\(state) state" + ) + } + } + + // MARK: - Value for Status - Tests + + func test_all_values_when_status_isHighlighted() { + // GIVEN + let normalStateValue = "normal" + let highlightedValue = "highlighted" + + let states = ControlPropertyStates() + + // Set value for normal state (default state) + states.setValue(normalStateValue, for: .normal) + + // ** + // WHEN + // Test with .highlighted value and true isHighlighted status. + + var status = ControlStatus(isHighlighted: true) + states.setValue(highlightedValue, for: .highlighted) + var value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + highlightedValue, + "Wrong value WHEN status isHighlighted AND set .highlighted state value" + ) + // ** + + // ** + // WHEN + // Test without .highlighted value and true isHighlighted status. + + status = ControlStatus(isHighlighted: true) + states.setValue(nil, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status isHighlighted AND nil .highlighted state value" + ) + // ** + + // ** + // WHEN + // Test with .highlighted value and false isHighlighted status. + + status = .init(isHighlighted: false) + + states.setValue(highlightedValue, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status !isHighlighted AND set .highlighted state value" + ) + // ** + } + + func test_all_values_when_status_isDisabled() { + // GIVEN + let normalStateValue = "normal" + let disabledValue = "disabled" + let highlightedValue = "highlighted" + + let states = ControlPropertyStates() + + // Set value for normal state (default state) + states.setValue(normalStateValue, for: .normal) + + // ** + // WHEN + // Test with .disabled value and true isDisabled status. + + var status = ControlStatus(isDisabled: true) + states.setValue(disabledValue, for: .disabled) + var value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + disabledValue, + "Wrong value WHEN status isDisabled AND set .disabled state value" + ) + // ** + + // ** + // WHEN + // Test without .disabled value and true isDisabled status. + + status = ControlStatus(isDisabled: true) + states.setValue(nil, for: .disabled) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status isDisabled AND nil .disabled state value" + ) + // ** + + // ** + // WHEN + // Test with .disabled value and false isDisabled status. + + status = .init(isDisabled: false) + + states.setValue(disabledValue, for: .disabled) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status !isDisabled AND set .disabled state value" + ) + // ** + + // ** + // WHEN + // Test with .disabled value and true isDisabled status. + // AND with value for .highlighted and isHighlighted status is true + + status = .init(isHighlighted: true, isDisabled: true) + + states.setValue(disabledValue, for: .disabled) + states.setValue(highlightedValue, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + highlightedValue, + "Wrong value WHEN status isDisabled and isHighlighted AND set .disabled and .highlighted state value" + ) + // ** + + // ** + // WHEN + // Test with .disabled value and true isDisabled status. + // AND without value for .highlighted and isHighlighted status is true + + status = .init(isHighlighted: true, isDisabled: true) + + states.setValue(disabledValue, for: .disabled) + states.setValue(nil, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + disabledValue, + "Wrong value WHEN status isDisabled and isHighlighted AND set .disabled and nil .highlighted state value" + ) + // ** + } + + func test_all_values_when_status_isSelected() { + // GIVEN + let normalStateValue = "normal" + let selectedValue = "selected" + let disabledValue = "disabled" + let highlightedValue = "highlighted" + + let states = ControlPropertyStates() + + // Set value for normal state (default state) + states.setValue(normalStateValue, for: .normal) + + var status = ControlStatus(isSelected: true) + + // ** + // WHEN + // Test with .selected value and true isSelected status. + + states.setValue(selectedValue, for: .selected) + var value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + selectedValue, + "Wrong value WHEN status isSelected AND set .selected state value" + ) + // ** + + // ** + // WHEN + // Test without .selected value and true isSelected status. + + states.setValue(nil, for: .selected) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status isSelected AND nil .selected state value" + ) + // ** + + // ** + // WHEN + // Test with .selected value and false isSelected status. + + status = .init(isSelected: false) + + states.setValue(selectedValue, for: .selected) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN status !isSelected AND set .selected state value" + ) + // ** + + // ** + // WHEN + // Test with .selected value and true isSelected status. + // AND with value for .highlighted and isHighlighted status is true + + status = .init(isHighlighted: true, isSelected: true) + + states.setValue(selectedValue, for: .selected) + states.setValue(highlightedValue, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + highlightedValue, + "Wrong value WHEN status isSelected and isHighlighted AND set .selected and .highlighted state value" + ) + // ** + + // ** + // WHEN + // Test with .selected value and true isSelected status. + // AND without value for .highlighted and isHighlighted status is true + + status = .init(isHighlighted: true, isSelected: true) + + states.setValue(selectedValue, for: .selected) + states.setValue(nil, for: .highlighted) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + selectedValue, + "Wrong value WHEN status isSelected and isHighlighted AND set .selected and nil .highlighted state value" + ) + // ** + + // ** + // WHEN + // Test with .selected value and true isSelected status. + // AND with value for .disabled and isDisabled status is true + + status = .init(isDisabled: true, isSelected: true) + + states.setValue(selectedValue, for: .selected) + states.setValue(disabledValue, for: .disabled) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + disabledValue, + "Wrong value WHEN status isSelected and isDisabled AND set .selected and .disabled state value" + ) + // ** + + // ** + // WHEN + // Test with .selected value and true isSelected status. + // AND without value for .disabled and isDisabled status is true + + status = .init(isDisabled: true, isSelected: true) + + states.setValue(selectedValue, for: .selected) + states.setValue(nil, for: .disabled) + value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + selectedValue, + "Wrong value WHEN status isSelected and isDisabled AND set .selected and nil .disabled state value" + ) + // ** + } + + func test_all_values_when_status_isNormal() { + // GIVEN + let normalStateValue = "normal" + + let states = ControlPropertyStates() + + // ** + // WHEN + // Test with .normal value and all false properties on status. + + var status = ControlStatus() + states.setValue(normalStateValue, for: .normal) + var value = states.value(forStatus: status) + + // THEN + XCTAssertEqual( + value, + normalStateValue, + "Wrong value WHEN all status properties are false AND set .normal state value" + ) + // ** + + // ** + // WHEN + // Test without .normal value and all false properties on status. + + status = ControlStatus() + states.setValue(nil, for: .normal) + value = states.value(forStatus: status) + + // THEN + XCTAssertNil( + value, + "Wrong value WHEN all status properties are false AND nil .normal state value" + ) + // ** + } +} + +// MARK: - Extension + +private extension ControlStatus { + + init(isHighlighted: Bool = false, isDisabled: Bool = false, isSelected: Bool = false) { + self.init( + isHighlighted: isHighlighted, + isEnabled: !isDisabled, + isSelected: isSelected + ) + } +} diff --git a/core/Sources/Common/Control/State/ControlState.swift b/core/Sources/Common/Control/State/ControlState.swift new file mode 100644 index 000000000..dc5638704 --- /dev/null +++ b/core/Sources/Common/Control/State/ControlState.swift @@ -0,0 +1,21 @@ +// +// ControlState.swift +// SparkCore +// +// Created by robin.lemaire on 23/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +/// Constants describing the state of a Spark control. +public enum ControlState: CaseIterable { + /// The normal, or default, state of a control where the control is enabled but neither selected nor highlighted. + case normal + /// The highlighted state of a control. + case highlighted + /// The disabled state of a control. + case disabled + /// The selected state of a control. + case selected +} diff --git a/core/Sources/Common/Control/Status/ControlStatus.swift b/core/Sources/Common/Control/Status/ControlStatus.swift new file mode 100644 index 000000000..b09b3f936 --- /dev/null +++ b/core/Sources/Common/Control/Status/ControlStatus.swift @@ -0,0 +1,28 @@ +// +// ControlStatus.swift +// SparkCore +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +/// The current status of the control: highlighted or not, disabled or not and selected or not. +struct ControlStatus { + + // MARK: - Properties + + /// A Boolean value indicating whether the control draws a highlight. + let isHighlighted: Bool + /// A Boolean value indicating whether the control is in the disabled state. + let isDisabled: Bool + /// A Boolean value indicating whether the control is in the selected state. + let isSelected: Bool + + // MARK: - Initialization + + init(isHighlighted: Bool, isEnabled: Bool, isSelected: Bool) { + self.isHighlighted = isHighlighted + self.isDisabled = !isEnabled + self.isSelected = isSelected + } +} diff --git a/core/Sources/Common/Control/Status/ControlStatusTests.swift b/core/Sources/Common/Control/Status/ControlStatusTests.swift new file mode 100644 index 000000000..d7457c5ad --- /dev/null +++ b/core/Sources/Common/Control/Status/ControlStatusTests.swift @@ -0,0 +1,49 @@ +// +// ControlStatusTests.swift +// SparkCoreUnitTests +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +import SwiftUI +@testable import SparkCore + +final class ControlStatusTests: XCTestCase { + + // MARK: - Tests + + func test_init() { + // GIVEN + let givenIsHighlighted = true + let givenIsEnabled = true + let givenIsSelected = true + + // WHEN + let status = ControlStatus( + isHighlighted: givenIsHighlighted, + isEnabled: givenIsEnabled, + isSelected: givenIsSelected + ) + + // THEN + XCTAssertEqual( + status.isHighlighted, + givenIsHighlighted, + "Wrong isHighlighted" + ) + + XCTAssertEqual( + status.isDisabled, + !givenIsEnabled, + "Wrong isDisabled" + ) + + XCTAssertEqual( + status.isSelected, + givenIsSelected, + "Wrong isSelected" + ) + } +} diff --git a/core/Sources/Common/Control/UIView/UIControlStateImageView.swift b/core/Sources/Common/Control/UIView/UIControlStateImageView.swift new file mode 100644 index 000000000..8da3d72e0 --- /dev/null +++ b/core/Sources/Common/Control/UIView/UIControlStateImageView.swift @@ -0,0 +1,55 @@ +// +// UIControlStateImageView.swift +// SparkCore +// +// Created by robin.lemaire on 25/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import UIKit + +/// The custom UIImageView which set the correct image from the state of the UIControl. +/// Must be used only on UIControl. +final class UIControlStateImageView: UIImageView { + + // MARK: - Properties + + private let imageStates = ControlPropertyStates() + + // MARK: - Setter & Getter + + /// The image for a state. + /// - parameter state: state of the image + func image(for state: ControlState) -> UIImage? { + return self.imageStates.value(forState: state) + } + + /// Set the image for a state. + /// - parameter image: new image + /// - parameter state: state of the image + /// - parameter control: the parent control + func setImage( + _ image: UIImage?, + for state: ControlState, + on control: UIControl + ) { + self.imageStates.setValue(image, for: state) + self.updateContent(from: control) + } + + // MARK: - Update UI + + /// Update the image for a parent control state. + /// - parameter control: the parent control + func updateContent(from control: UIControl) { + // Create the status from the control + let status = ControlStatus( + isHighlighted: control.isHighlighted, + isEnabled: control.isEnabled, + isSelected: control.isSelected + ) + + // Set the image from states + self.image = self.imageStates.value(forStatus: status) + } +} diff --git a/core/Sources/Common/Control/UIView/UIControlStateLabel.swift b/core/Sources/Common/Control/UIView/UIControlStateLabel.swift new file mode 100644 index 000000000..3310e2934 --- /dev/null +++ b/core/Sources/Common/Control/UIView/UIControlStateLabel.swift @@ -0,0 +1,151 @@ +// +// UIControlStateLabel.swift +// SparkCore +// +// Created by robin.lemaire on 23/10/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import UIKit + +/// The custom UILabel which set the correct text or attributedText from the state of the UIControl. +/// Must be used only on UIControl. +final class UIControlStateLabel: UILabel { + + // MARK: - Properties + + private let textStates = ControlPropertyStates() + private let attributedTextStates = ControlPropertyStates() + private let textTypesStates = ControlPropertyStates() + + private var storedTextFont: UIFont? + private var storedTextColor: UIColor? + + // MARK: - Override Properties + + override var font: UIFont! { + get { + return super.font + } + set { + // We need to store this value to put it back when a new text will be set (and not an attributedText) + self.storedTextFont = newValue + + // Set the font only if the display text is not an attributedText. + if !self.isAttributedDisplayed { + super.font = newValue + } + } + } + + override var textColor: UIColor! { + get { + return super.textColor + } + set { + // We need to store this value to put it back when a new text will be set (and not an attributedText) + self.storedTextColor = newValue + + // Set the color only if the display text is not an attributedText + if !self.isAttributedDisplayed { + super.textColor = newValue + } + } + } + + // MARK: - Setter & Getter + + /// The text for a state. + /// - parameter state: state of the text + func text(for state: ControlState) -> String? { + return self.textStates.value(forState: state) + } + + /// Set the text for a state. + /// - parameter text: new text + /// - parameter state: state of the text + /// - parameter control: the parent control + func setText( + _ text: String?, + for state: ControlState, + on control: UIControl + ) { + self.textStates.setValue(text, for: state) + self.textTypesStates.setValue( + text != nil ? .text : DisplayedTextType.none, + for: state + ) + self.updateContent(from: control) + } + + /// The attributedText for a state. + /// - parameter state: state of the attributedText + func attributedText(for state: ControlState) -> NSAttributedString? { + return self.attributedTextStates.value(forState: state) + } + + /// Set the attributedText of the button for a state. + /// - parameter attributedText: new attributedText of the button + /// - parameter state: state of the attributedText + func setAttributedText( + _ attributedText: NSAttributedString?, + for state: ControlState, + on control: UIControl + ) { + self.attributedTextStates.setValue(attributedText, for: state) + self.textTypesStates.setValue( + attributedText != nil ? .attributedText : DisplayedTextType.none, + for: state + ) + self.updateContent(from: control) + } + + // MARK: - Update UI + + /// Update the label (text or attributed) for a parent control state. + /// - parameter control: the parent control + func updateContent(from control: UIControl) { + // Create the status from the control + let status = ControlStatus( + isHighlighted: control.isHighlighted, + isEnabled: control.isEnabled, + isSelected: control.isSelected + ) + + // Get the current textType from status + let textType = textTypesStates.value(forStatus: status) + + // Reset attributedText & text + self.attributedText = nil + self.text = nil + + // Set the text or the attributedText from textType and states + switch textType { + case .text: + self.text = self.textStates.value(forStatus: status) + if let storedTextFont = self.storedTextFont { + self.font = storedTextFont + } + if let storedTextColor = self.storedTextColor { + self.textColor = storedTextColor + } + case .attributedText: + self.attributedText = self.attributedTextStates.value(forStatus: status) + default: + break + } + } + + // MARK: - Helpers + + /// The attributedText is displayed or not. + private var isAttributedDisplayed: Bool { + // There is an attributedText ? + guard let attributedText = self.attributedText else { + return false + } + + // The attributedText contains attributes ? + return !attributedText.attributes(at: 0, effectiveRange: nil).isEmpty + } +}