Skip to content

Commit b766f90

Browse files
Merge pull request #27 from componentskit/RadioGroup-UIKit
UKRadioGroup
2 parents 40875fc + 1e8e8d6 commit b766f90

File tree

5 files changed

+423
-14
lines changed

5 files changed

+423
-14
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/RadioGroupPreview.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import SwiftUI
33
import UIKit
44

55
struct RadioGroupPreview: View {
6-
@State private var selectedId: String? = nil
6+
@State private var selectedId: String?
77
@State private var model: RadioGroupVM<String> = {
88
var model = RadioGroupVM<String>()
99
model.items = [
@@ -19,18 +19,28 @@ struct RadioGroupPreview: View {
1919
]
2020
return model
2121
}()
22-
22+
2323
var body: some View {
2424
VStack {
25+
PreviewWrapper(title: "UIKit") {
26+
UKComponentPreview(model: self.model) {
27+
UKRadioGroup(model: self.model)
28+
}
29+
}
2530
PreviewWrapper(title: "SwiftUI") {
2631
SURadioGroup(selectedId: $selectedId, model: self.model)
2732
}
2833
Form {
2934
AnimationScalePicker(selection: self.$model.animationScale)
35+
UniversalColorPicker(title: "Color", selection: self.$model.color)
3036
Toggle("Enabled", isOn: self.$model.isEnabled)
3137
FontPicker(selection: self.$model.font)
3238
SizePicker(selection: self.$model.size)
33-
UniversalColorPicker(title: "Color", selection: self.$model.color)
39+
Picker("Spacing", selection: self.$model.spacing) {
40+
Text("8px").tag(CGFloat(8))
41+
Text("10px").tag(CGFloat(10))
42+
Text("14px").tag(CGFloat(14))
43+
}
3444
}
3545
}
3646
}

Sources/ComponentsKit/RadioGroup/Models/RadioGroupVM.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Foundation
2+
import UIKit
23

34
/// A model that defines the appearance of a radio group component.
4-
public struct RadioGroupVM<ID: Hashable> {
5+
public struct RadioGroupVM<ID: Hashable>: ComponentVM {
56
/// The scaling factor for the button's press animation, with a value between 0 and 1.
67
///
78
/// Defaults to `.medium`.
@@ -38,6 +39,11 @@ public struct RadioGroupVM<ID: Hashable> {
3839
/// Defaults to `.medium`.
3940
public var size: ComponentSize = .medium
4041

42+
/// The spacing between radio items.
43+
///
44+
/// Defaults to `10`.
45+
public var spacing: CGFloat = 10
46+
4147
/// Initializes a new instance of `RadioGroupVM` with default values.
4248
public init() {}
4349
}
@@ -103,23 +109,34 @@ extension RadioGroupVM {
103109
// MARK: - Appearance
104110

105111
extension RadioGroupVM {
106-
func radioItemColor(for item: RadioItemVM<ID>, selectedId: ID?) -> UniversalColor {
107-
let isSelected = item.id == selectedId
112+
func isItemEnabled(_ item: RadioItemVM<ID>) -> Bool {
113+
return item.isEnabled && self.isEnabled
114+
}
115+
116+
func radioItemColor(for item: RadioItemVM<ID>, isSelected: Bool) -> UniversalColor {
108117
let defaultColor = UniversalColor.universal(.uiColor(.lightGray))
109118
let color = isSelected ? self.color : defaultColor
110-
return (item.isEnabled && self.isEnabled)
119+
return self.isItemEnabled(item)
111120
? color
112121
: color.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity)
113122
}
114123

115-
func textColor(for item: RadioItemVM<ID>, selectedId: ID?) -> UniversalColor {
124+
func textColor(for item: RadioItemVM<ID>) -> UniversalColor {
116125
let baseColor = Palette.Text.primary
117-
return (item.isEnabled && self.isEnabled)
126+
return self.isItemEnabled(item)
118127
? baseColor
119128
: baseColor.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity)
120129
}
121130
}
122131

132+
// MARK: - UIKit Helpers
133+
134+
extension RadioGroupVM {
135+
func shouldUpdateLayout(_ oldModel: RadioGroupVM<ID>) -> Bool {
136+
return self.items != oldModel.items || self.size != oldModel.size
137+
}
138+
}
139+
123140
// MARK: - Validation
124141

125142
extension RadioGroupVM {

Sources/ComponentsKit/RadioGroup/SURadioGroup.swift renamed to Sources/ComponentsKit/RadioGroup/SwiftUI/SURadioGroup.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,20 @@ public struct SURadioGroup<ID: Hashable>: View {
3030
// MARK: Body
3131

3232
public var body: some View {
33-
VStack(alignment: .leading, spacing: 8) {
33+
VStack(alignment: .leading, spacing: self.model.spacing) {
3434
ForEach(self.model.items) { item in
35-
HStack {
35+
HStack(spacing: 8) {
3636
ZStack {
3737
Circle()
3838
.strokeBorder(
39-
self.model.radioItemColor(for: item, selectedId: self.selectedId).color(for: self.colorScheme),
39+
self.model.radioItemColor(for: item, isSelected: self.selectedId == item.id).color(for: self.colorScheme),
4040
lineWidth: self.model.lineWidth
4141
)
4242
.frame(width: self.model.circleSize, height: self.model.circleSize)
4343
if self.selectedId == item.id {
4444
Circle()
4545
.fill(
46-
self.model.radioItemColor(for: item, selectedId: self.selectedId).color(for: self.colorScheme)
46+
self.model.radioItemColor(for: item, isSelected: true).color(for: self.colorScheme)
4747
)
4848
.frame(width: self.model.innerCircleSize, height: self.model.innerCircleSize)
4949
.transition(.scale)
@@ -54,7 +54,7 @@ public struct SURadioGroup<ID: Hashable>: View {
5454
Text(item.title)
5555
.font(self.model.preferredFont(for: item.id).font)
5656
.foregroundColor(
57-
self.model.textColor(for: item, selectedId: self.selectedId).color(for: self.colorScheme)
57+
self.model.textColor(for: item).color(for: self.colorScheme)
5858
)
5959
}
6060
.gesture(
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import AutoLayout
2+
import UIKit
3+
4+
/// A view representing a single radio button item in a radio group.
5+
public class RadioGroupItemView<ID: Hashable>: UIView {
6+
// MARK: Properties
7+
8+
/// A view that represents an outer circle and contains an inner circle.
9+
public let radioView = UIView()
10+
/// A view that represents an inner circle in the radio button.
11+
public let innerCircle = UIView()
12+
/// A label that displays the title from the model.
13+
public let titleLabel = UILabel()
14+
15+
let itemVM: RadioItemVM<ID>
16+
var groupVM: RadioGroupVM<ID> {
17+
didSet {
18+
self.update(oldValue)
19+
}
20+
}
21+
var isSelected: Bool {
22+
didSet {
23+
guard isSelected != oldValue else { return }
24+
if self.isSelected {
25+
self.select()
26+
} else {
27+
self.deselect()
28+
}
29+
}
30+
}
31+
32+
// MARK: Initialization
33+
34+
init(
35+
isSelected: Bool,
36+
groupVM: RadioGroupVM<ID>,
37+
itemVM: RadioItemVM<ID>
38+
) {
39+
self.groupVM = groupVM
40+
self.itemVM = itemVM
41+
self.isSelected = isSelected
42+
43+
super.init(frame: .zero)
44+
45+
self.setup()
46+
self.style()
47+
self.layout()
48+
}
49+
50+
required init?(coder: NSCoder) {
51+
fatalError("init(coder:) has not been implemented")
52+
}
53+
54+
// MARK: Setup
55+
56+
private func setup() {
57+
self.addSubview(self.radioView)
58+
self.radioView.addSubview(self.innerCircle)
59+
self.addSubview(self.titleLabel)
60+
}
61+
62+
// MARK: Style
63+
64+
private func style() {
65+
Self.Style.mainView(
66+
self,
67+
itemVM: self.itemVM,
68+
groupVM: self.groupVM
69+
)
70+
Self.Style.radioView(
71+
self.radioView,
72+
itemVM: self.itemVM,
73+
groupVM: self.groupVM,
74+
isSelected: self.isSelected
75+
)
76+
Self.Style.innerCircle(
77+
self.innerCircle,
78+
itemVM: self.itemVM,
79+
groupVM: self.groupVM,
80+
isSelected: self.isSelected
81+
)
82+
Self.Style.titleLabel(
83+
self.titleLabel,
84+
itemVM: self.itemVM,
85+
groupVM: self.groupVM
86+
)
87+
}
88+
89+
// MARK: Layout
90+
91+
private func layout() {
92+
self.radioView.size(self.groupVM.circleSize)
93+
self.radioView.leading()
94+
self.radioView.centerVertically()
95+
self.radioView.topAnchor.constraint(greaterThanOrEqualTo: self.topAnchor).isActive = true
96+
self.radioView.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor).isActive = true
97+
98+
self.innerCircle.size(self.groupVM.innerCircleSize)
99+
self.innerCircle.center(in: self.radioView)
100+
101+
self.titleLabel.after(self.radioView, padding: 8)
102+
self.titleLabel.trailing()
103+
self.titleLabel.centerVertically()
104+
self.titleLabel.topAnchor.constraint(greaterThanOrEqualTo: self.topAnchor).isActive = true
105+
self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor).isActive = true
106+
}
107+
108+
// MARK: Update
109+
110+
func update(_ oldModel: RadioGroupVM<ID>) {
111+
self.style()
112+
}
113+
114+
// MARK: Selection
115+
116+
private func select() {
117+
self.radioView.layer.borderColor = self.groupVM.radioItemColor(
118+
for: self.itemVM,
119+
isSelected: true
120+
).uiColor.cgColor
121+
self.innerCircle.backgroundColor = self.groupVM.radioItemColor(
122+
for: self.itemVM,
123+
isSelected: true
124+
).uiColor
125+
126+
UIView.animate(
127+
withDuration: 0.2,
128+
delay: 0.0,
129+
options: [.curveEaseOut],
130+
animations: {
131+
self.innerCircle.transform = CGAffineTransform.identity
132+
self.innerCircle.alpha = 1
133+
},
134+
completion: nil
135+
)
136+
}
137+
138+
private func deselect() {
139+
self.radioView.layer.borderColor = self.groupVM.radioItemColor(
140+
for: self.itemVM,
141+
isSelected: false
142+
).uiColor.cgColor
143+
144+
UIView.animate(
145+
withDuration: 0.2,
146+
delay: 0.0,
147+
options: [.curveEaseOut],
148+
animations: {
149+
self.innerCircle.transform = .init(scaleX: 0.1, y: 0.1)
150+
self.innerCircle.alpha = 0
151+
},
152+
completion: nil
153+
)
154+
}
155+
}
156+
157+
// MARK: - Style Helpers
158+
159+
extension RadioGroupItemView {
160+
fileprivate enum Style {
161+
static func mainView(
162+
_ view: UIView,
163+
itemVM: RadioItemVM<ID>,
164+
groupVM: RadioGroupVM<ID>
165+
) {
166+
view.isUserInteractionEnabled = groupVM.isItemEnabled(itemVM)
167+
}
168+
169+
static func radioView(
170+
_ view: UIView,
171+
itemVM: RadioItemVM<ID>,
172+
groupVM: RadioGroupVM<ID>,
173+
isSelected: Bool
174+
) {
175+
view.layer.cornerRadius = groupVM.circleSize / 2
176+
view.layer.borderWidth = groupVM.lineWidth
177+
view.layer.borderColor = groupVM.radioItemColor(for: itemVM, isSelected: isSelected).uiColor.cgColor
178+
view.backgroundColor = .clear
179+
}
180+
181+
static func innerCircle(
182+
_ view: UIView,
183+
itemVM: RadioItemVM<ID>,
184+
groupVM: RadioGroupVM<ID>,
185+
isSelected: Bool
186+
) {
187+
view.layer.cornerRadius = groupVM.innerCircleSize / 2
188+
view.backgroundColor = groupVM.radioItemColor(for: itemVM, isSelected: isSelected).uiColor
189+
view.alpha = isSelected ? 1 : 0
190+
view.transform = isSelected ? .identity : .init(scaleX: 0.1, y: 0.1)
191+
}
192+
193+
static func titleLabel(
194+
_ label: UILabel,
195+
itemVM: RadioItemVM<ID>,
196+
groupVM: RadioGroupVM<ID>
197+
) {
198+
label.text = itemVM.title
199+
label.font = groupVM.preferredFont(for: itemVM.id).uiFont
200+
label.textColor = groupVM.textColor(for: itemVM).uiColor
201+
label.numberOfLines = 0
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)