diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift new file mode 100644 index 00000000..b611a8b9 --- /dev/null +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift @@ -0,0 +1,52 @@ +import ComponentsKit +import SwiftUI +import UIKit + +struct CountdownPreview: View { + @State private var model = CountdownVM() + + var body: some View { + VStack { + PreviewWrapper(title: "UIKit") { + UKCountdown(model: self.model) + .preview + } + PreviewWrapper(title: "SwiftUI") { + SUCountdown(model: self.model) + } + Form { + ComponentOptionalColorPicker(selection: self.$model.color) + FontPicker(selection: self.$model.font) + Picker("Locale", selection: self.$model.locale) { + Text("Current").tag(Locale.current) + Text("EN").tag(Locale(identifier: "en")) + Text("ES").tag(Locale(identifier: "es")) + Text("FR").tag(Locale(identifier: "fr")) + Text("DE").tag(Locale(identifier: "de")) + Text("ZH").tag(Locale(identifier: "zh")) + Text("JA").tag(Locale(identifier: "ja")) + Text("RU").tag(Locale(identifier: "ru")) + Text("AR").tag(Locale(identifier: "ar")) + Text("HI").tag(Locale(identifier: "hi")) + Text("PT").tag(Locale(identifier: "pt")) + } + SizePicker(selection: self.$model.size) + Picker("Style", selection: self.$model.style) { + Text("Plain").tag(CountdownVM.Style.plain) + Text("Light").tag(CountdownVM.Style.light) + } + Picker("Units Style", selection: self.$model.unitsStyle) { + Text("None").tag(CountdownVM.UnitsStyle.hidden) + Text("Bottom").tag(CountdownVM.UnitsStyle.bottom) + Text("Trailing").tag(CountdownVM.UnitsStyle.trailing) + } + DatePicker("Until Date", selection: self.$model.until, in: Date()..., displayedComponents: [.date, .hourAndMinute]) + .datePickerStyle(.compact) + } + } + } +} + +#Preview { + CountdownPreview() +} diff --git a/Examples/DemosApp/DemosApp/Core/App.swift b/Examples/DemosApp/DemosApp/Core/App.swift index de547a4d..938b9b8e 100644 --- a/Examples/DemosApp/DemosApp/Core/App.swift +++ b/Examples/DemosApp/DemosApp/Core/App.swift @@ -12,6 +12,9 @@ struct App: View { NavigationLinkWithTitle("Checkbox") { CheckboxPreview() } + NavigationLinkWithTitle("Countdown") { + CountdownPreview() + } NavigationLinkWithTitle("Divider") { DividerPreview() } diff --git a/Sources/ComponentsKit/Countdown/Helpers/CountdownHelpers.swift b/Sources/ComponentsKit/Countdown/Helpers/CountdownHelpers.swift new file mode 100644 index 00000000..29b27cd3 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Helpers/CountdownHelpers.swift @@ -0,0 +1,15 @@ +import Foundation + +enum CountdownHelpers { + enum Unit { + case days + case hours + case minutes + case seconds + } + + enum UnitLength { + case short + case long + } +} diff --git a/Sources/ComponentsKit/Countdown/Helpers/CountdownWidthCalculator.swift b/Sources/ComponentsKit/Countdown/Helpers/CountdownWidthCalculator.swift new file mode 100644 index 00000000..7487cb30 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Helpers/CountdownWidthCalculator.swift @@ -0,0 +1,24 @@ +import UIKit + +struct CountdownWidthCalculator { + private static let label = UILabel() + + private init() {} + + static func preferredWidth( + for attributedText: NSAttributedString, + model: CountdownVM + ) -> CGFloat { + self.style(label, with: model) + label.attributedText = attributedText + + let targetSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: model.height) + let estimatedSize = label.sizeThatFits(targetSize) + + return estimatedSize.width + } + + private static func style(_ label: UILabel, with model: CountdownVM) { + label.numberOfLines = 0 + } +} diff --git a/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift b/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift new file mode 100644 index 00000000..b51343fd --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift @@ -0,0 +1,154 @@ +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 + } + } + + // 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 + + /// The localized representation for days. + public let 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 + self.days = days + } +} + +// MARK: - Localizations + +extension UnitsLocalization { + static let defaultLocalizations: [Locale: UnitsLocalization] = [ + // English (en) + Locale(identifier: "en"): UnitsLocalization( + seconds: .init(short: "s", long: "Seconds"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Hours"), + days: .init(short: "d", long: "Days") + ), + + // Spanish (es) + Locale(identifier: "es"): UnitsLocalization( + seconds: .init(short: "s", long: "Segundos"), + minutes: .init(short: "m", long: "Minutos"), + hours: .init(short: "h", long: "Horas"), + days: .init(short: "d", long: "Días") + ), + + // French (fr) + Locale(identifier: "fr"): UnitsLocalization( + seconds: .init(short: "s", long: "Secondes"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Heures"), + days: .init(short: "j", long: "Jours") + ), + + // German (de) + Locale(identifier: "de"): UnitsLocalization( + seconds: .init(short: "s", long: "Sekunden"), + minutes: .init(short: "m", long: "Minuten"), + hours: .init(short: "h", long: "Stunden"), + days: .init(short: "t", long: "Tage") + ), + + // Chinese (zh) + Locale(identifier: "zh"): UnitsLocalization( + seconds: .init(short: "秒", long: "秒"), + minutes: .init(short: "分", long: "分钟"), + hours: .init(short: "时", long: "小时"), + days: .init(short: "天", long: "天") + ), + + // Japanese (ja) + Locale(identifier: "ja"): UnitsLocalization( + seconds: .init(short: "秒", long: "秒"), + minutes: .init(short: "分", long: "分"), + hours: .init(short: "時", long: "時間"), + days: .init(short: "日", long: "日") + ), + + // Russian (ru) + Locale(identifier: "ru"): UnitsLocalization( + seconds: .init(short: "с", long: "Секунд"), + minutes: .init(short: "м", long: "Минут"), + hours: .init(short: "ч", long: "Часов"), + days: .init(short: "д", long: "Дней") + ), + + // Arabic (ar) + Locale(identifier: "ar"): UnitsLocalization( + seconds: .init(short: "ث", long: "ثوانٍ"), + minutes: .init(short: "د", long: "دقائق"), + hours: .init(short: "س", long: "ساعات"), + days: .init(short: "ي", long: "أيام") + ), + + // Hindi (hi) + Locale(identifier: "hi"): UnitsLocalization( + seconds: .init(short: "से", long: "सेकंड"), + minutes: .init(short: "मि", long: "मिनट"), + hours: .init(short: "घं", long: "घंटे"), + days: .init(short: "दि", long: "दिन") + ), + + // Portuguese (pt) + Locale(identifier: "pt"): UnitsLocalization( + seconds: .init(short: "s", long: "Segundos"), + minutes: .init(short: "m", long: "Minutos"), + hours: .init(short: "h", long: "Horas"), + days: .init(short: "d", long: "Dias") + ) + ] + + static var localizationFallback: UnitsLocalization { + return UnitsLocalization( + seconds: .init(short: "s", long: "Seconds"), + minutes: .init(short: "m", long: "Minutes"), + hours: .init(short: "h", long: "Hours"), + days: .init(short: "d", long: "Days") + ) + } +} diff --git a/Sources/ComponentsKit/Countdown/Manager/CountdownManager.swift b/Sources/ComponentsKit/Countdown/Manager/CountdownManager.swift new file mode 100644 index 00000000..9303fb7b --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Manager/CountdownManager.swift @@ -0,0 +1,50 @@ +import SwiftUI + +class CountdownManager: ObservableObject { + // MARK: - Published Properties + + @Published var days: Int = 0 + @Published var hours: Int = 0 + @Published var minutes: Int = 0 + @Published var seconds: Int = 0 + + // MARK: - Properties + + private var timer: Timer? + private var until: Date? + + // MARK: - Methods + + func start(until: Date) { + self.until = until + self.updateUnitValues() + self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + self?.updateUnitValues() + } + } + + func stop() { + self.timer?.invalidate() + self.timer = nil + } + + private func updateUnitValues() { + guard let until = self.until else { return } + + let now = Date() + let calendar = Calendar.current + let components = calendar.dateComponents( + [.day, .hour, .minute, .second], + from: now, + to: until + ) + self.days = max(0, components.day ?? 0) + self.hours = max(0, components.hour ?? 0) + self.minutes = max(0, components.minute ?? 0) + self.seconds = max(0, components.second ?? 0) + + if now >= until { + self.stop() + } + } +} diff --git a/Sources/ComponentsKit/Countdown/Models/CountdownStyle.swift b/Sources/ComponentsKit/Countdown/Models/CountdownStyle.swift new file mode 100644 index 00000000..3f2dcad3 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Models/CountdownStyle.swift @@ -0,0 +1,16 @@ +import Foundation + +extension CountdownVM { + /// Defines the visual styles for the countdown component. + public enum Style: Equatable { + case plain + case light + } + + /// Defines the units style for the countdown component. + public enum UnitsStyle: Equatable { + case hidden + case bottom + case trailing + } +} diff --git a/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift b/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift new file mode 100644 index 00000000..d3cb2854 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/Models/CountdownVM.swift @@ -0,0 +1,243 @@ +import SwiftUI + +/// A model that defines the appearance properties for a countdown component. +public struct CountdownVM: ComponentVM { + /// The font used for displaying the countdown numbers and units. + public var font: UniversalFont? + + /// The color of the countdown. + public var color: ComponentColor? + + /// The predefined size of the countdown. + /// + /// Defaults to `.medium`. + public var size: ComponentSize = .medium + + /// The visual style of the countdown component. + /// + /// Defaults to `.light`. + public var style: Style = .light + + /// The visual style of the units. + /// + /// Defaults to `.bottom`. + public var unitsStyle: UnitsStyle = .bottom + + /// The target date until which the countdown runs. + public var until: Date = Date().addingTimeInterval(3600 * 85) + + /// The locale used for localizing the countdown. + public var locale: Locale = .current + + /// A dictionary containing localized representations of time units (days, hours, minutes, seconds) for various locales. + /// + /// This property can be used to override the default localizations for supported languages or to add + /// localizations for unsupported languages. By default, the library provides strings for the following locales: + /// - English ("en") + /// - Spanish ("es") + /// - French ("fr") + /// - German ("de") + /// - Chinese ("zh") + /// - Japanese ("ja") + /// - Russian ("ru") + /// - Arabic ("ar") + /// - Hindi ("hi") + /// - Portuguese ("pt") + public var localization: [Locale: UnitsLocalization] = [:] + + /// Initializes a new instance of `CountdownVM` with default values. + public init() {} +} + +// MARK: - Shared Helpers + +extension CountdownVM { + var preferredFont: UniversalFont { + if let font = self.font { + return font + } + + switch self.size { + case .small: + return UniversalFont.Component.small + case .medium: + return UniversalFont.Component.medium + case .large: + return UniversalFont.Component.large + } + } + var unitFontSize: CGFloat { + let preferredFontSize = self.preferredFont.uiFont.pointSize + return preferredFontSize * 0.6 + } + var unitFont: UniversalFont { + return self.preferredFont.withSize(self.unitFontSize) + } + var backgroundColor: UniversalColor { + if let color { + return color.main.withOpacity(0.15) + } else { + return .init( + light: .rgba(r: 244, g: 244, b: 245, a: 1.0), + dark: .rgba(r: 39, g: 39, b: 42, a: 1.0) + ) + } + } + var foregroundColor: UniversalColor { + let foregroundColor = self.color?.main ?? .init( + light: .rgba(r: 0, g: 0, b: 0, a: 1.0), + dark: .rgba(r: 255, g: 255, b: 255, a: 1.0) + ) + return foregroundColor + } + var colonColor: UniversalColor { + return self.foregroundColor.withOpacity(0.5) + } + var height: CGFloat { + return switch self.size { + case .small: 45 + case .medium: 55 + case .large: 60 + } + } + var defaultMinWidth: CGFloat { + return switch self.size { + case .small: 40 + case .medium: 50 + case .large: 55 + } + } + var lightBackgroundMinWidth: CGFloat { + return switch self.size { + case .small: 45 + case .medium: 55 + case .large: 60 + } + } + var horizontalPadding: CGFloat { + return 4 + } + var spacing: CGFloat { + switch self.style { + case .light: + return 10 + case .plain: + return 6 + } + } +} + +extension CountdownVM { + func localizedUnit( + _ unit: CountdownHelpers.Unit, + length: CountdownHelpers.UnitLength + ) -> String { + let localization = self.localization[self.locale] + ?? UnitsLocalization.defaultLocalizations[self.locale] + ?? UnitsLocalization.localizationFallback + + switch (unit, length) { + case (.days, .long): + return localization.days.long + case (.days, .short): + return localization.days.short + + case (.hours, .long): + return localization.hours.long + case (.hours, .short): + return localization.hours.short + + case (.minutes, .long): + return localization.minutes.long + case (.minutes, .short): + return localization.minutes.short + + case (.seconds, .long): + return localization.seconds.long + case (.seconds, .short): + return localization.seconds.short + } + } + + func timeText( + value: Int, + unit: CountdownHelpers.Unit + ) -> NSAttributedString { + let mainTextAttributes: [NSAttributedString.Key: Any] = [ + .font: self.preferredFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + + let formattedValue = String(format: "%02d", value) + let result = NSMutableAttributedString(string: formattedValue, attributes: mainTextAttributes) + + switch self.unitsStyle { + case .hidden: + return result + + case .trailing: + let localized = self.localizedUnit(unit, length: .short) + let trailingString = " " + localized + let trailingAttributes: [NSAttributedString.Key: Any] = [ + .font: self.preferredFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + result.append(NSAttributedString(string: trailingString, attributes: trailingAttributes)) + return result + + case .bottom: + let localized = self.localizedUnit(unit, length: .long) + let bottomString = "\n" + localized + let bottomAttributes: [NSAttributedString.Key: Any] = [ + .font: self.unitFont.uiFont, + .foregroundColor: self.foregroundColor.uiColor + ] + result.append(NSAttributedString(string: bottomString, attributes: bottomAttributes)) + return result + } + } +} + +extension CountdownVM { + func shouldRecalculateWidth(_ oldModel: Self) -> Bool { + return self.unitsStyle != oldModel.unitsStyle + || self.style != oldModel.style + || self.preferredFont != oldModel.preferredFont + || self.size != oldModel.size + || self.locale != oldModel.locale + } + + func timeWidth(manager: CountdownManager) -> CGFloat { + let values: [(Int, CountdownHelpers.Unit)] = [ + (manager.days, .days), + (manager.hours, .hours), + (manager.minutes, .minutes), + (manager.seconds, .seconds) + ] + + let widths = values.map { value, unit -> CGFloat in + let attributedString = self.timeText(value: value, unit: unit) + return CountdownWidthCalculator.preferredWidth(for: attributedString, model: self) + } + + 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 new file mode 100644 index 00000000..4d1d03d9 --- /dev/null +++ b/Sources/ComponentsKit/Countdown/SUCountdown.swift @@ -0,0 +1,103 @@ +import SwiftUI + +/// A SwiftUI component that displays a countdown. +public struct SUCountdown: View { + // MARK: - Properties + + /// A model that defines the appearance properties. + public var model: CountdownVM + + @State private var timeWidth: CGFloat = 70 + + /// The countdown manager handling the countdown logic. + @StateObject private var manager = CountdownManager() + + @Environment(\.colorScheme) private var colorScheme + + // MARK: - Initializer + + /// Initializer. + /// - Parameters: + /// - model: A model that defines the appearance properties. + public init(model: CountdownVM = .init()) { + self.model = model + } + + // MARK: - Body + + public var body: some View { + HStack(alignment: .top, spacing: self.model.spacing) { + switch (self.model.style, self.model.unitsStyle) { + case (.plain, .bottom): + self.styledTime(value: self.manager.days, unit: .days) + colonView + self.styledTime(value: self.manager.hours, unit: .hours) + colonView + self.styledTime(value: self.manager.minutes, unit: .minutes) + colonView + self.styledTime(value: self.manager.seconds, unit: .seconds) + + case (.plain, .hidden), (.plain, .trailing): + self.styledTime(value: self.manager.days, unit: .days) + self.colonView + self.styledTime(value: self.manager.hours, unit: .hours) + self.colonView + self.styledTime(value: self.manager.minutes, unit: .minutes) + self.colonView + self.styledTime(value: self.manager.seconds, unit: .seconds) + + case (.light, _): + self.lightStyledTime(value: self.manager.days, unit: .days) + self.lightStyledTime(value: self.manager.hours, unit: .hours) + self.lightStyledTime(value: self.manager.minutes, unit: .minutes) + self.lightStyledTime(value: self.manager.seconds, unit: .seconds) + } + } + .onAppear { + self.manager.start(until: self.model.until) + self.timeWidth = self.model.timeWidth(manager: self.manager) + } + .onChange(of: self.model.until) { newDate in + self.manager.stop() + self.manager.start(until: newDate) + } + .onChange(of: self.model) { newValue in + if newValue.shouldRecalculateWidth(self.model) { + self.timeWidth = newValue.timeWidth(manager: self.manager) + } + } + .onDisappear { + self.manager.stop() + } + } + + // MARK: - Subviews + + private func styledTime( + value: Int, + unit: CountdownHelpers.Unit + ) -> some View { + let attributedString = AttributedString(self.model.timeText(value: value, unit: unit)) + return Text(attributedString) + .multilineTextAlignment(.center) + .frame(width: self.timeWidth) + } + + private var colonView: some View { + Text(":") + .font(self.model.preferredFont.font) + .foregroundColor(self.model.colonColor.color(for: self.colorScheme)) + } + + private func lightStyledTime( + value: Int, + unit: CountdownHelpers.Unit + ) -> some View { + return self.styledTime(value: value, unit: unit) + .frame(minHeight: self.model.height) + .frame(minWidth: self.model.lightBackgroundMinWidth) + .background(RoundedRectangle(cornerRadius: 8) + .fill(self.model.backgroundColor.color(for: self.colorScheme)) + ) + } +} 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 + } } }