Skip to content

Commit a4bb4e8

Browse files
Merge pull request #26 from componentskit/RadioGroup-SwiftUI
RadioGroup SwiftUI
2 parents 9dfaf81 + 0225a8d commit a4bb4e8

File tree

10 files changed

+751
-10
lines changed

10 files changed

+751
-10
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import ComponentsKit
22
import SwiftUI
33

4+
// MARK: - AnimationScalePicker
5+
6+
struct AnimationScalePicker: View {
7+
@Binding var selection: AnimationScale
8+
9+
var body: some View {
10+
Picker("Animation Scale", selection: self.$selection) {
11+
Text("None").tag(AnimationScale.none)
12+
Text("Small").tag(AnimationScale.small)
13+
Text("Medium").tag(AnimationScale.medium)
14+
Text("Large").tag(AnimationScale.large)
15+
Text("Custom: 0.9").tag(AnimationScale.custom(0.9))
16+
}
17+
}
18+
}
19+
420
// MARK: - AutocapitalizationPicker
521

622
struct AutocapitalizationPicker: View {

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,7 @@ struct ButtonPreview: View {
1818
SUButton(model: self.model)
1919
}
2020
Form {
21-
Picker("Animation Scale", selection: self.$model.animationScale) {
22-
Text("None").tag(AnimationScale.none)
23-
Text("Small").tag(AnimationScale.small)
24-
Text("Medium").tag(AnimationScale.medium)
25-
Text("Large").tag(AnimationScale.large)
26-
Text("Custom: 0.9").tag(AnimationScale.custom(0.9))
27-
}
21+
AnimationScalePicker(selection: self.$model.animationScale)
2822
ComponentColorPicker(selection: self.$model.color)
2923
CornerRadiusPicker(selection: self.$model.cornerRadius) {
3024
Text("Custom: 20px").tag(ComponentRadius.custom(20))
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import ComponentsKit
2+
import SwiftUI
3+
import UIKit
4+
5+
struct RadioGroupPreview: View {
6+
@State private var selectedId: String?
7+
@State private var model: RadioGroupVM<String> = {
8+
var model = RadioGroupVM<String>()
9+
model.items = [
10+
RadioItemVM(id: "option1") { item in
11+
item.title = "Option 1"
12+
},
13+
RadioItemVM(id: "option2") { item in
14+
item.title = "Option 2"
15+
},
16+
RadioItemVM(id: "option3") { item in
17+
item.title = "Option 3"
18+
}
19+
]
20+
return model
21+
}()
22+
23+
var body: some View {
24+
VStack {
25+
PreviewWrapper(title: "UIKit") {
26+
UKComponentPreview(model: self.model) {
27+
UKRadioGroup(model: self.model)
28+
}
29+
}
30+
PreviewWrapper(title: "SwiftUI") {
31+
SURadioGroup(selectedId: $selectedId, model: self.model)
32+
}
33+
Form {
34+
AnimationScalePicker(selection: self.$model.animationScale)
35+
UniversalColorPicker(title: "Color", selection: self.$model.color)
36+
Toggle("Enabled", isOn: self.$model.isEnabled)
37+
FontPicker(selection: self.$model.font)
38+
SizePicker(selection: self.$model.size)
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+
}
44+
}
45+
}
46+
}
47+
}
48+
49+
#Preview {
50+
RadioGroupPreview()
51+
}

Examples/DemosApp/DemosApp/Core/App.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ struct App: View {
2121
NavigationLinkWithTitle("Loading") {
2222
LoadingPreview()
2323
}
24+
NavigationLinkWithTitle("Radio Group") {
25+
RadioGroupPreview()
26+
}
2427
NavigationLinkWithTitle("Segmented Control") {
2528
SegmentedControlPreview()
2629
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import Foundation
2+
import UIKit
3+
4+
/// A model that defines the appearance of a radio group component.
5+
public struct RadioGroupVM<ID: Hashable>: ComponentVM {
6+
/// The scaling factor for the button's press animation, with a value between 0 and 1.
7+
///
8+
/// Defaults to `.medium`.
9+
public var animationScale: AnimationScale = .medium
10+
11+
/// The color of the selected radio button.
12+
public var color: UniversalColor = .primary
13+
14+
/// The font used for the radio items' titles.
15+
public var font: UniversalFont?
16+
17+
/// A Boolean value indicating whether the radio group is enabled or disabled.
18+
///
19+
/// Defaults to `true`.
20+
public var isEnabled: Bool = true
21+
22+
/// An array of items representing the options in the radio group.
23+
///
24+
/// Must contain at least one item, and all items must have unique identifiers.
25+
public var items: [RadioItemVM<ID>] = [] {
26+
didSet {
27+
guard self.items.isNotEmpty else {
28+
assertionFailure("Array of items must contain at least one item.")
29+
return
30+
}
31+
if let duplicatedId {
32+
assertionFailure("Items must have unique ids! Duplicated id: \(duplicatedId)")
33+
}
34+
}
35+
}
36+
37+
/// The predefined size of the radio buttons.
38+
///
39+
/// Defaults to `.medium`.
40+
public var size: ComponentSize = .medium
41+
42+
/// The spacing between radio items.
43+
///
44+
/// Defaults to `10`.
45+
public var spacing: CGFloat = 10
46+
47+
/// Initializes a new instance of `RadioGroupVM` with default values.
48+
public init() {}
49+
}
50+
51+
// MARK: - Shared Helpers
52+
53+
extension RadioGroupVM {
54+
var circleSize: CGFloat {
55+
switch self.size {
56+
case .small:
57+
return 16
58+
case .medium:
59+
return 20
60+
case .large:
61+
return 24
62+
}
63+
}
64+
65+
var innerCircleSize: CGFloat {
66+
switch self.size {
67+
case .small:
68+
return 10
69+
case .medium:
70+
return 12
71+
case .large:
72+
return 14
73+
}
74+
}
75+
76+
var lineWidth: CGFloat {
77+
switch self.size {
78+
case .small:
79+
return 1.5
80+
case .medium:
81+
return 2.0
82+
case .large:
83+
return 2.0
84+
}
85+
}
86+
87+
func preferredFont(for id: ID) -> UniversalFont {
88+
if let itemFont = self.item(for: id)?.font {
89+
return itemFont
90+
} else if let font = self.font {
91+
return font
92+
}
93+
94+
switch self.size {
95+
case .small:
96+
return UniversalFont.Component.small
97+
case .medium:
98+
return UniversalFont.Component.medium
99+
case .large:
100+
return UniversalFont.Component.large
101+
}
102+
}
103+
104+
func item(for id: ID) -> RadioItemVM<ID>? {
105+
return self.items.first(where: { $0.id == id })
106+
}
107+
}
108+
109+
// MARK: - Appearance
110+
111+
extension RadioGroupVM {
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 {
117+
let defaultColor = UniversalColor.universal(.uiColor(.lightGray))
118+
let color = isSelected ? self.color : defaultColor
119+
return self.isItemEnabled(item)
120+
? color
121+
: color.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity)
122+
}
123+
124+
func textColor(for item: RadioItemVM<ID>) -> UniversalColor {
125+
let baseColor = Palette.Text.primary
126+
return self.isItemEnabled(item)
127+
? baseColor
128+
: baseColor.withOpacity(ComponentsKitConfig.shared.layout.disabledOpacity)
129+
}
130+
}
131+
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+
140+
// MARK: - Validation
141+
142+
extension RadioGroupVM {
143+
/// Checks for duplicated item identifiers in the radio group.
144+
private var duplicatedId: ID? {
145+
var set: Set<ID> = []
146+
for item in self.items {
147+
if set.contains(item.id) {
148+
return item.id
149+
}
150+
set.insert(item.id)
151+
}
152+
return nil
153+
}
154+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
3+
/// A model that defines the appearance properties for an item in a radio group.
4+
public struct RadioItemVM<ID: Hashable> {
5+
/// The unique identifier for the radio item.
6+
public var id: ID
7+
8+
/// The text displayed next to the radio button.
9+
public var title: String = ""
10+
11+
/// The font used for the item's title.
12+
public var font: UniversalFont?
13+
14+
/// A Boolean value indicating whether the item is enabled or disabled.
15+
///
16+
/// Defaults to `true`.
17+
public var isEnabled: Bool = true
18+
19+
/// Initializes a new instance of `RadioItemVM` with the specified identifier.
20+
///
21+
/// - Parameter id: The unique identifier for the radio item.
22+
public init(id: ID) {
23+
self.id = id
24+
}
25+
26+
/// Initializes a new instance of `RadioItemVM` with a closure for custom configuration.
27+
///
28+
/// - Parameters:
29+
/// - id: The unique identifier for the radio item.
30+
/// - transform: A closure that allows for custom configuration of the model's properties.
31+
public init(id: ID, _ transform: (_ value: inout Self) -> Void) {
32+
var defaultValue = Self(id: id)
33+
transform(&defaultValue)
34+
self = defaultValue
35+
}
36+
}
37+
38+
extension RadioItemVM: Equatable, Identifiable {}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import SwiftUI
2+
3+
/// A SwiftUI component that displays a group of radio buttons, allowing users to select one option from multiple choices.
4+
public struct SURadioGroup<ID: Hashable>: View {
5+
// MARK: Properties
6+
7+
/// A model that defines the appearance properties.
8+
public var model: RadioGroupVM<ID>
9+
10+
/// A Binding value to control the selected identifier.
11+
@Binding public var selectedId: ID?
12+
13+
@State private var viewSizes: [ID: CGSize] = [:]
14+
@Environment(\.colorScheme) private var colorScheme
15+
@State private var tappingId: ID?
16+
17+
// MARK: Initialization
18+
19+
/// Initializer.
20+
/// - Parameters:
21+
/// - selectedId: A binding to the selected identifier.
22+
/// - model: A model that defines the appearance properties.
23+
public init(
24+
selectedId: Binding<ID?>,
25+
model: RadioGroupVM<ID>
26+
) {
27+
self._selectedId = selectedId
28+
self.model = model
29+
}
30+
31+
// MARK: Body
32+
33+
public var body: some View {
34+
VStack(alignment: .leading, spacing: self.model.spacing) {
35+
ForEach(self.model.items) { item in
36+
HStack(spacing: 8) {
37+
ZStack {
38+
Circle()
39+
.strokeBorder(
40+
self.model.radioItemColor(for: item, isSelected: self.selectedId == item.id).color(for: self.colorScheme),
41+
lineWidth: self.model.lineWidth
42+
)
43+
.frame(width: self.model.circleSize, height: self.model.circleSize)
44+
if self.selectedId == item.id {
45+
Circle()
46+
.fill(
47+
self.model.radioItemColor(for: item, isSelected: true).color(for: self.colorScheme)
48+
)
49+
.frame(width: self.model.innerCircleSize, height: self.model.innerCircleSize)
50+
.transition(.scale)
51+
}
52+
}
53+
.animation(.easeOut(duration: 0.2), value: self.selectedId)
54+
.scaleEffect(self.tappingId == item.id ? self.model.animationScale.value : 1.0)
55+
Text(item.title)
56+
.font(self.model.preferredFont(for: item.id).font)
57+
.foregroundColor(
58+
self.model.textColor(for: item).color(for: self.colorScheme)
59+
)
60+
}
61+
.background(
62+
GeometryReader { proxy in
63+
Color.clear
64+
.onAppear {
65+
self.viewSizes[item.id] = proxy.size
66+
}
67+
.onChange(of: proxy.size) { value in
68+
self.viewSizes[item.id] = value
69+
}
70+
}
71+
)
72+
.simultaneousGesture(
73+
DragGesture(minimumDistance: 0)
74+
.onChanged { _ in
75+
self.tappingId = item.id
76+
}
77+
.onEnded { gesture in
78+
self.tappingId = nil
79+
80+
if let size = self.viewSizes[item.id],
81+
CGRect(origin: .zero, size: size).contains(gesture.location) {
82+
self.selectedId = item.id
83+
}
84+
}
85+
)
86+
.disabled(!item.isEnabled || !self.model.isEnabled)
87+
}
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)