Skip to content

Commit 26b1ec1

Browse files
Merge pull request #35 from componentskit/UKCountdown
UKCountdown
2 parents 04c51b6 + 78baf60 commit 26b1ec1

File tree

6 files changed

+327
-6
lines changed

6 files changed

+327
-6
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CountdownPreview.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ struct CountdownPreview: View {
77

88
var body: some View {
99
VStack {
10+
PreviewWrapper(title: "UIKit") {
11+
UKCountdown(model: self.model)
12+
.preview
13+
}
1014
PreviewWrapper(title: "SwiftUI") {
1115
SUCountdown(model: self.model)
1216
}

Sources/ComponentsKit/Countdown/Localization/UnitsLocalization.swift

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,20 @@ import Foundation
22

33
// MARK: - UnitsLocalization
44

5+
/// A structure that provides localized representations of time units (seconds, minutes, hours, days).
56
public struct UnitsLocalization: Equatable {
7+
/// A structure that represents the localized short and long forms of a single time unit.
68
public struct UnitItemLocalization: Equatable {
9+
/// The short-form representation of the time unit (e.g., "s" for seconds).
710
public let short: String
11+
/// The long-form representation of the time unit (e.g., "Seconds").
812
public let long: String
913

14+
/// Initializes a new `UnitItemLocalization` with specified short and long forms.
15+
///
16+
/// - Parameters:
17+
/// - short: The short-form representation of the time unit.
18+
/// - long: The long-form representation of the time unit.
1019
public init(short: String, long: String) {
1120
self.short = short
1221
self.long = long
@@ -15,14 +24,33 @@ public struct UnitsLocalization: Equatable {
1524

1625
// MARK: - Properties
1726

27+
/// The localized representation for seconds.
1828
public let seconds: UnitItemLocalization
29+
30+
/// The localized representation for minutes.
1931
public let minutes: UnitItemLocalization
32+
33+
/// The localized representation for hours.
2034
public let hours: UnitItemLocalization
21-
public let days: UnitItemLocalization
2235

23-
// MARK: - Initializer
36+
/// The localized representation for days.
37+
public let days: UnitItemLocalization
2438

25-
public init(seconds: UnitItemLocalization, minutes: UnitItemLocalization, hours: UnitItemLocalization, days: UnitItemLocalization) {
39+
// MARK: - Initialization
40+
41+
/// Initializes a new `UnitsLocalization` with localized representations for all time units.
42+
///
43+
/// - Parameters:
44+
/// - seconds: The localization for seconds.
45+
/// - minutes: The localization for minutes.
46+
/// - hours: The localization for hours.
47+
/// - days: The localization for days.
48+
public init(
49+
seconds: UnitItemLocalization,
50+
minutes: UnitItemLocalization,
51+
hours: UnitItemLocalization,
52+
days: UnitItemLocalization
53+
) {
2654
self.seconds = seconds
2755
self.minutes = minutes
2856
self.hours = hours

Sources/ComponentsKit/Countdown/Models/CountdownVM.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ extension CountdownVM {
9090
)
9191
return foregroundColor
9292
}
93+
var colonColor: UniversalColor {
94+
return self.foregroundColor.withOpacity(0.5)
95+
}
9396
var height: CGFloat {
9497
return switch self.size {
9598
case .small: 45
@@ -220,3 +223,21 @@ extension CountdownVM {
220223
return (widths.max() ?? self.defaultMinWidth) + self.horizontalPadding * 2
221224
}
222225
}
226+
227+
// MARK: - UIKit Helpers
228+
229+
extension CountdownVM {
230+
var isColumnLabelVisible: Bool {
231+
switch self.style {
232+
case .plain:
233+
return true
234+
case .light:
235+
return false
236+
}
237+
}
238+
239+
func shouldUpdateHeight(_ oldModel: Self) -> Bool {
240+
return self.style != oldModel.style
241+
|| self.height != oldModel.height
242+
}
243+
}

Sources/ComponentsKit/Countdown/SUCountdown.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,12 @@ public struct SUCountdown: View {
8181
return Text(attributedString)
8282
.multilineTextAlignment(.center)
8383
.frame(width: self.timeWidth)
84-
.monospacedDigit()
8584
}
8685

8786
private var colonView: some View {
8887
Text(":")
8988
.font(self.model.preferredFont.font)
90-
.foregroundColor(.gray)
89+
.foregroundColor(self.model.colonColor.color(for: self.colorScheme))
9190
}
9291

9392
private func lightStyledTime(
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import AutoLayout
2+
import Combine
3+
import UIKit
4+
5+
/// A UIKit component that displays a countdown.
6+
public class UKCountdown: UIView, UKComponent {
7+
// MARK: - Public Properties
8+
9+
/// A model that defines the appearance properties.
10+
public var model: CountdownVM {
11+
didSet {
12+
self.update(oldValue)
13+
}
14+
}
15+
16+
/// The main container stack view containing all time labels and colon labels.
17+
public let stackView = UIStackView()
18+
19+
/// A label showing the number of days remaining.
20+
public let daysLabel = UILabel()
21+
22+
/// A label showing the number of hours remaining.
23+
public let hoursLabel = UILabel()
24+
25+
/// A label showing the number of minutes remaining.
26+
public let minutesLabel = UILabel()
27+
28+
/// A label showing the number of seconds remaining.
29+
public let secondsLabel = UILabel()
30+
31+
/// An array of colon labels used as separators between the time segments (days/hours/minutes/seconds).
32+
public let colonLabels: [UILabel] = [
33+
UILabel(),
34+
UILabel(),
35+
UILabel()
36+
]
37+
38+
// MARK: - Private Properties
39+
40+
/// Constraints specifically applied to the "days" label.
41+
private var daysConstraints = LayoutConstraints()
42+
43+
private let manager = CountdownManager()
44+
45+
private var cancellables: Set<AnyCancellable> = []
46+
47+
// MARK: - Initialization
48+
49+
/// Initializer.
50+
/// - Parameters:
51+
/// - model: A model that defines the appearance properties.
52+
public init(model: CountdownVM) {
53+
self.model = model
54+
55+
super.init(frame: .zero)
56+
57+
self.setup()
58+
self.style()
59+
self.layout()
60+
}
61+
62+
required init?(coder: NSCoder) {
63+
fatalError("init(coder:) has not been implemented")
64+
}
65+
66+
deinit {
67+
self.manager.stop()
68+
self.cancellables.forEach {
69+
$0.cancel()
70+
}
71+
}
72+
73+
// MARK: - Setup
74+
75+
private func setup() {
76+
self.addSubview(self.stackView)
77+
78+
self.stackView.addArrangedSubview(self.daysLabel)
79+
self.stackView.addArrangedSubview(self.colonLabels[0])
80+
self.stackView.addArrangedSubview(self.hoursLabel)
81+
self.stackView.addArrangedSubview(self.colonLabels[1])
82+
self.stackView.addArrangedSubview(self.minutesLabel)
83+
self.stackView.addArrangedSubview(self.colonLabels[2])
84+
self.stackView.addArrangedSubview(self.secondsLabel)
85+
86+
self.setupSubscriptions()
87+
self.manager.start(until: self.model.until)
88+
}
89+
90+
private func setupSubscriptions() {
91+
self.manager.$days
92+
.sink { [weak self] newValue in
93+
guard let self else { return }
94+
self.daysLabel.attributedText = self.model.timeText(value: newValue, unit: .days)
95+
}
96+
.store(in: &self.cancellables)
97+
98+
self.manager.$hours
99+
.sink { [weak self] newValue in
100+
guard let self else { return }
101+
self.hoursLabel.attributedText = self.model.timeText(value: newValue, unit: .hours)
102+
}
103+
.store(in: &self.cancellables)
104+
105+
self.manager.$minutes
106+
.sink { [weak self] newValue in
107+
guard let self else { return }
108+
self.minutesLabel.attributedText = self.model.timeText(value: newValue, unit: .minutes)
109+
}
110+
.store(in: &self.cancellables)
111+
112+
self.manager.$seconds
113+
.sink { [weak self] newValue in
114+
guard let self else { return }
115+
self.secondsLabel.attributedText = self.model.timeText(value: newValue, unit: .seconds)
116+
}
117+
.store(in: &self.cancellables)
118+
}
119+
120+
// MARK: - Style
121+
122+
private func style() {
123+
Self.Style.mainView(self, model: self.model)
124+
Self.Style.stackView(self.stackView, model: self.model)
125+
126+
Self.Style.timeLabel(self.daysLabel, model: self.model)
127+
Self.Style.timeLabel(self.hoursLabel, model: self.model)
128+
Self.Style.timeLabel(self.minutesLabel, model: self.model)
129+
Self.Style.timeLabel(self.secondsLabel, model: self.model)
130+
131+
self.colonLabels.forEach {
132+
Self.Style.colonLabel($0, model: self.model)
133+
}
134+
135+
self.daysLabel.attributedText = self.model.timeText(value: self.manager.days, unit: .days)
136+
self.hoursLabel.attributedText = self.model.timeText(value: self.manager.hours, unit: .hours)
137+
self.minutesLabel.attributedText = self.model.timeText(value: self.manager.minutes, unit: .minutes)
138+
self.secondsLabel.attributedText = self.model.timeText(value: self.manager.seconds, unit: .seconds)
139+
}
140+
141+
// MARK: - Layout
142+
143+
private func layout() {
144+
self.stackView.centerVertically()
145+
self.stackView.centerHorizontally()
146+
147+
self.stackView.topAnchor.constraint(
148+
greaterThanOrEqualTo: self.topAnchor
149+
).isActive = true
150+
self.stackView.bottomAnchor.constraint(
151+
lessThanOrEqualTo: self.bottomAnchor
152+
).isActive = true
153+
self.stackView.leadingAnchor.constraint(
154+
greaterThanOrEqualTo: self.leadingAnchor
155+
).isActive = true
156+
self.stackView.trailingAnchor.constraint(
157+
lessThanOrEqualTo: self.trailingAnchor
158+
).isActive = true
159+
160+
self.daysConstraints.width = self.daysLabel.widthAnchor.constraint(
161+
equalToConstant: self.model.defaultMinWidth
162+
)
163+
self.daysConstraints.width?.isActive = true
164+
165+
self.daysConstraints.height = self.daysLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: self.model.height)
166+
self.daysConstraints.height?.isActive = true
167+
168+
self.hoursLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true
169+
self.hoursLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true
170+
171+
self.minutesLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true
172+
self.minutesLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true
173+
174+
self.secondsLabel.widthAnchor.constraint(equalTo: self.daysLabel.widthAnchor).isActive = true
175+
self.secondsLabel.heightAnchor.constraint(equalTo: self.daysLabel.heightAnchor).isActive = true
176+
177+
switch self.model.style {
178+
case .plain:
179+
self.daysConstraints.height?.isActive = false
180+
self.daysConstraints.width?.constant = self.model.timeWidth(manager: self.manager)
181+
case .light:
182+
self.daysConstraints.width?.constant = max(
183+
self.model.timeWidth(manager: self.manager),
184+
self.model.lightBackgroundMinWidth
185+
)
186+
}
187+
}
188+
189+
// MARK: - Update
190+
191+
public func update(_ oldModel: CountdownVM) {
192+
guard self.model != oldModel else { return }
193+
194+
if self.model.until != oldModel.until {
195+
self.manager.stop()
196+
self.manager.start(until: self.model.until)
197+
}
198+
199+
if self.model.shouldUpdateHeight(oldModel) {
200+
switch self.model.style {
201+
case .plain:
202+
self.daysConstraints.height?.isActive = false
203+
case .light:
204+
self.daysConstraints.height?.isActive = true
205+
self.daysConstraints.height?.constant = self.model.height
206+
}
207+
}
208+
209+
if self.model.shouldRecalculateWidth(oldModel) {
210+
let newWidth = self.model.timeWidth(manager: self.manager)
211+
switch self.model.style {
212+
case .plain:
213+
self.daysConstraints.width?.constant = newWidth
214+
case .light:
215+
self.daysConstraints.width?.constant = max(newWidth, self.model.lightBackgroundMinWidth)
216+
}
217+
}
218+
219+
self.style()
220+
221+
self.layoutIfNeeded()
222+
}
223+
}
224+
225+
// MARK: - Style Helpers
226+
227+
extension UKCountdown {
228+
fileprivate enum Style {
229+
static func mainView(_ view: UIView, model: CountdownVM) {
230+
view.backgroundColor = .clear
231+
}
232+
233+
static func stackView(_ stackView: UIStackView, model: CountdownVM) {
234+
stackView.axis = .horizontal
235+
stackView.alignment = .top
236+
stackView.spacing = model.spacing
237+
}
238+
239+
static func timeLabel(_ label: UILabel, model: CountdownVM) {
240+
switch model.style {
241+
case .plain:
242+
label.backgroundColor = .clear
243+
label.layer.cornerRadius = 0
244+
case .light:
245+
label.backgroundColor = model.backgroundColor.uiColor
246+
label.layer.cornerRadius = 8
247+
label.clipsToBounds = true
248+
}
249+
label.font = model.preferredFont.uiFont
250+
label.textColor = model.foregroundColor.uiColor
251+
label.textAlignment = .center
252+
label.numberOfLines = 0
253+
label.lineBreakMode = .byClipping
254+
}
255+
256+
static func colonLabel(_ label: UILabel, model: CountdownVM) {
257+
label.text = ":"
258+
label.font = model.preferredFont.uiFont
259+
label.textColor = model.colonColor.uiColor
260+
label.textAlignment = .center
261+
label.isVisible = model.isColumnLabelVisible
262+
}
263+
}
264+
}

Sources/ComponentsKit/Helpers/UIView+Helpers.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import UIKit
33
extension UIView {
44
/// Whether the view is visible.
55
var isVisible: Bool {
6-
return !self.isHidden
6+
get {
7+
return !self.isHidden
8+
}
9+
set {
10+
self.isHidden = !newValue
11+
}
712
}
813
}

0 commit comments

Comments
 (0)