diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift index 883ec190..b611a8b9 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift @@ -7,6 +7,10 @@ struct CountdownPreview: View { var body: some View { VStack { + PreviewWrapper(title: "UIKit") { + UKCountdown(model: self.model) + .preview + } PreviewWrapper(title: "SwiftUI") { SUCountdown(model: self.model) } diff --git a/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift b/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift index 1efabfd9..b51343fd 100644 --- a/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift +++ b/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift @@ -2,11 +2,20 @@ import Foundation // MARK: - UnitsLocalization +/// A structure that provides localized representations of time units (seconds, minutes, hours, days). public struct UnitsLocalization: Equatable { + /// A structure that represents the localized short and long forms of a single time unit. public struct UnitItemLocalization: Equatable { + /// The short-form representation of the time unit (e.g., "s" for seconds). public let short: String + /// The long-form representation of the time unit (e.g., "Seconds"). public let long: String + /// Initializes a new `UnitItemLocalization` with specified short and long forms. + /// + /// - Parameters: + /// - short: The short-form representation of the time unit. + /// - long: The long-form representation of the time unit. public init(short: String, long: String) { self.short = short self.long = long @@ -15,14 +24,33 @@ public struct UnitsLocalization: Equatable { // MARK: - Properties + /// The localized representation for seconds. public let seconds: UnitItemLocalization + + /// The localized representation for minutes. public let minutes: UnitItemLocalization + + /// The localized representation for hours. public let hours: UnitItemLocalization - public let days: UnitItemLocalization - // MARK: - Initializer + /// The localized representation for days. + public let days: UnitItemLocalization - public init(seconds: UnitItemLocalization, minutes: UnitItemLocalization, hours: UnitItemLocalization, days: UnitItemLocalization) { + // MARK: - Initialization + + /// Initializes a new `UnitsLocalization` with localized representations for all time units. + /// + /// - Parameters: + /// - seconds: The localization for seconds. + /// - minutes: The localization for minutes. + /// - hours: The localization for hours. + /// - days: The localization for days. + public init( + seconds: UnitItemLocalization, + minutes: UnitItemLocalization, + hours: UnitItemLocalization, + days: UnitItemLocalization + ) { self.seconds = seconds self.minutes = minutes self.hours = hours diff --git a/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift b/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift index 8769cc3a..d3cb2854 100644 --- a/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift +++ b/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift @@ -90,6 +90,9 @@ extension CountdownVM { ) return foregroundColor } + var colonColor: UniversalColor { + return self.foregroundColor.withOpacity(0.5) + } var height: CGFloat { return switch self.size { case .small: 45 @@ -220,3 +223,21 @@ extension CountdownVM { return (widths.max() ?? self.defaultMinWidth) + self.horizontalPadding * 2 } } + +// MARK: - UIKit Helpers + +extension CountdownVM { + var isColumnLabelVisible: Bool { + switch self.style { + case .plain: + return true + case .light: + return false + } + } + + func shouldUpdateHeight(_ oldModel: Self) -> Bool { + return self.style != oldModel.style + || self.height != oldModel.height + } +} diff --git a/Sources/ComponentsKit/Countdown/SUCountdown.swift b/Sources/ComponentsKit/Countdown/SUCountdown.swift index f164fd79..4d1d03d9 100644 --- a/Sources/ComponentsKit/Countdown/SUCountdown.swift +++ b/Sources/ComponentsKit/Countdown/SUCountdown.swift @@ -81,13 +81,12 @@ public struct SUCountdown: View { return Text(attributedString) .multilineTextAlignment(.center) .frame(width: self.timeWidth) - .monospacedDigit() } private var colonView: some View { Text(":") .font(self.model.preferredFont.font) - .foregroundColor(.gray) + .foregroundColor(self.model.colonColor.color(for: self.colorScheme)) } private func lightStyledTime( diff --git a/Sources/ComponentsKit/Countdown/UKCountdown.swift b/Sources/ComponentsKit/Countdown/UKCountdown.swift new file mode 100644 index 00000000..982d3fb0 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/UKCountdown.swift @@ -0,0 +1,264 @@ +import AutoLayout +import Combine +import UIKit + +/// A UIKit component that displays a countdown. +public class UKCountdown: UIView, UKComponent { + // MARK: - Public Properties + + /// A model that defines the appearance properties. + public var model: CountdownVM { + didSet { + self.update(oldValue) + } + } + + /// The main container stack view containing all time labels and colon labels. + public let stackView = UIStackView() + + /// A label showing the number of days remaining. + public let daysLabel = UILabel() + + /// A label showing the number of hours remaining. + public let hoursLabel = UILabel() + + /// A label showing the number of minutes remaining. + public let minutesLabel = UILabel() + + /// A label showing the number of seconds remaining. + public let secondsLabel = UILabel() + + /// An array of colon labels used as separators between the time segments (days/hours/minutes/seconds). + public let colonLabels: [UILabel] = [ + UILabel(), + UILabel(), + UILabel() + ] + + // MARK: - Private Properties + + /// Constraints specifically applied to the "days" label. + private var daysConstraints = LayoutConstraints() + + private let manager = CountdownManager() + + private var cancellables: Set = [] + + // MARK: - Initialization + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: CountdownVM) { + self.model = model + + super.init(frame: .zero) + + self.setup() + self.style() + self.layout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.manager.stop() + self.cancellables.forEach { + $0.cancel() + } + } + + // MARK: - Setup + + private func setup() { + self.addSubview(self.stackView) + + self.stackView.addArrangedSubview(self.daysLabel) + self.stackView.addArrangedSubview(self.colonLabels[0]) + self.stackView.addArrangedSubview(self.hoursLabel) + self.stackView.addArrangedSubview(self.colonLabels[1]) + self.stackView.addArrangedSubview(self.minutesLabel) + self.stackView.addArrangedSubview(self.colonLabels[2]) + self.stackView.addArrangedSubview(self.secondsLabel) + + self.setupSubscriptions() + self.manager.start(until: self.model.until) + } + + private func setupSubscriptions() { + self.manager.$days + .sink { [weak self] newValue in + guard let self else { return } + self.daysLabel.attributedText = self.model.timeText(value: newValue, unit: .days) + } + .store(in: &self.cancellables) + + self.manager.$hours + .sink { [weak self] newValue in + guard let self else { return } + self.hoursLabel.attributedText = self.model.timeText(value: newValue, unit: .hours) + } + .store(in: &self.cancellables) + + self.manager.$minutes + .sink { [weak self] newValue in + guard let self else { return } + self.minutesLabel.attributedText = self.model.timeText(value: newValue, unit: .minutes) + } + .store(in: &self.cancellables) + + self.manager.$seconds + .sink { [weak self] newValue in + guard let self else { return } + self.secondsLabel.attributedText = self.model.timeText(value: newValue, unit: .seconds) + } + .store(in: &self.cancellables) + } + + // MARK: - Style + + private func style() { + Self.Style.mainView(self, model: self.model) + Self.Style.stackView(self.stackView, model: self.model) + + Self.Style.timeLabel(self.daysLabel, model: self.model) + Self.Style.timeLabel(self.hoursLabel, model: self.model) + Self.Style.timeLabel(self.minutesLabel, model: self.model) + Self.Style.timeLabel(self.secondsLabel, model: self.model) + + self.colonLabels.forEach { + Self.Style.colonLabel($0, model: self.model) + } + + self.daysLabel.attributedText = self.model.timeText(value: self.manager.days, unit: .days) + self.hoursLabel.attributedText = self.model.timeText(value: self.manager.hours, unit: .hours) + self.minutesLabel.attributedText = self.model.timeText(value: self.manager.minutes, unit: .minutes) + self.secondsLabel.attributedText = self.model.timeText(value: self.manager.seconds, unit: .seconds) + } + + // MARK: - Layout + + private func layout() { + self.stackView.centerVertically() + self.stackView.centerHorizontally() + + self.stackView.topAnchor.constraint( + greaterThanOrEqualTo: self.topAnchor + ).isActive = true + self.stackView.bottomAnchor.constraint( + lessThanOrEqualTo: self.bottomAnchor + ).isActive = true + self.stackView.leadingAnchor.constraint( + greaterThanOrEqualTo: self.leadingAnchor + ).isActive = true + self.stackView.trailingAnchor.constraint( + lessThanOrEqualTo: self.trailingAnchor + ).isActive = true + + self.daysConstraints.width = self.daysLabel.widthAnchor.constraint( + equalToConstant: self.model.defaultMinWidth + ) + self.daysConstraints.width?.isActive = true + + self.daysConstraints.height = self.daysLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: self.model.height) + self.daysConstraints.height?.isActive = true + + self.hoursLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.hoursLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + self.minutesLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.minutesLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + self.secondsLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true + self.secondsLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true + + switch self.model.style { + case .plain: + self.daysConstraints.height?.isActive = false + self.daysConstraints.width?.constant = self.model.timeWidth(manager: self.manager) + case .light: + self.daysConstraints.width?.constant = max( + self.model.timeWidth(manager: self.manager), + self.model.lightBackgroundMinWidth + ) + } + } + + // MARK: - Update + + public func update(_ oldModel: CountdownVM) { + guard self.model != oldModel else { return } + + if self.model.until != oldModel.until { + self.manager.stop() + self.manager.start(until: self.model.until) + } + + if self.model.shouldUpdateHeight(oldModel) { + switch self.model.style { + case .plain: + self.daysConstraints.height?.isActive = false + case .light: + self.daysConstraints.height?.isActive = true + self.daysConstraints.height?.constant = self.model.height + } + } + + if self.model.shouldRecalculateWidth(oldModel) { + let newWidth = self.model.timeWidth(manager: self.manager) + switch self.model.style { + case .plain: + self.daysConstraints.width?.constant = newWidth + case .light: + self.daysConstraints.width?.constant = max(newWidth, self.model.lightBackgroundMinWidth) + } + } + + self.style() + + self.layoutIfNeeded() + } +} + +// MARK: - Style Helpers + +extension UKCountdown { + fileprivate enum Style { + static func mainView(_ view: UIView, model: CountdownVM) { + view.backgroundColor = .clear + } + + static func stackView(_ stackView: UIStackView, model: CountdownVM) { + stackView.axis = .horizontal + stackView.alignment = .top + stackView.spacing = model.spacing + } + + static func timeLabel(_ label: UILabel, model: CountdownVM) { + switch model.style { + case .plain: + label.backgroundColor = .clear + label.layer.cornerRadius = 0 + case .light: + label.backgroundColor = model.backgroundColor.uiColor + label.layer.cornerRadius = 8 + label.clipsToBounds = true + } + label.font = model.preferredFont.uiFont + label.textColor = model.foregroundColor.uiColor + label.textAlignment = .center + label.numberOfLines = 0 + label.lineBreakMode = .byClipping + } + + static func colonLabel(_ label: UILabel, model: CountdownVM) { + label.text = ":" + label.font = model.preferredFont.uiFont + label.textColor = model.colonColor.uiColor + label.textAlignment = .center + label.isVisible = model.isColumnLabelVisible + } + } +} diff --git a/Sources/ComponentsKit/Helpers/UIView+Helpers.swift b/Sources/ComponentsKit/Helpers/UIView+Helpers.swift index 6ec15b4b..0e27d42c 100644 --- a/Sources/ComponentsKit/Helpers/UIView+Helpers.swift +++ b/Sources/ComponentsKit/Helpers/UIView+Helpers.swift @@ -3,6 +3,11 @@ import UIKit extension UIView { /// Whether the view is visible. var isVisible: Bool { - return !self.isHidden + get { + return !self.isHidden + } + set { + self.isHidden = !newValue + } } }