Easy Steps to Create Custom Radio Button in SwiftUI
Hi fellas, welcome back to another SwiftUI Component series article. In this article, we’re going to make a sleek Radio Button component. While it might seem like a radio button is rare in mobile apps, they do exist, and adding them to your UI library might bring a touch of sophistication to your user interfaces. A radio button is a UI component typically employed in various scenarios, enhancing user interactions in applications that demand a clear and concise selection process, such as forms, preference settings, multiple-choice questions like the cover of this article, or any situation where users need to choose a single option from a list.
Now, you might be wondering, why bother with the radio button when there are other options available? Well, the beauty of the radio button lies in its simplicity and clarity. It provides users with a straightforward way to make a single selection, reducing the cognitive load and ensuring a smooth user experience.
Without further ado, let’s go to the code. We’ll walk through the steps to create a stylish and functional Radio Button component in SwiftUI. So, open our ̶f̶a̶v̶o̶r̶i̶t̶e̶ IDE, yup XCode, and let’s start coding!
1. Create a radio button view
We kick off with the RadioButton
structure, the core of our component.
struct RadioButton: View {
var body: some View {
HStack(alignment: .top, spacing: 10) {
circleView
labelView
}
}
}
extension RadioButton: View {
@ViewBuilder var labelView: some View {
Text("Placeholder")
}
@ViewBuilder var circleView: some View {
Circle()
.fill(Color.blue) // Inner circle color
.padding(4)
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 1)
) // Circle outline
.frame(width: 20, height: 20)
}
}
Looking at the code snippet above, you can see that creating a basic radio button view only requires a few lines of code. Below I included a preview of the code to illustrate just how easy it is to build a radio button component in SwiftUI
2. Add functionality to the component
To make the component usable, we must add some essential functionality. This includes determining which button is currently active and ensuring the component reacts appropriately to user interactions.
struct RadioButton: View {
@Binding private var isSelected: Bool
private let label: String
init(isSelected: Binding<Bool>, label: String = "") {
self._isSelected = isSelected
self.label = label
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
circleView
labelView
}
.contentShape(Rectangle())
.onTapGesture { isSelected = true }
}
}
private extension RadioButton {
@ViewBuilder var labelView: some View {
if !label.isEmpty { // Show label if label is not empty
Text(label)
}
}
@ViewBuilder var circleView: some View {
Circle()
.fill(innerCircleColor) // Inner circle color
.padding(4)
.overlay(
Circle()
.stroke(outlineColor, lineWidth: 1)
) // Circle outline
.frame(width: 20, height: 20)
}
}
private extension RadioButton {
var innerCircleColor: Color {
return isSelected ? Color.blue : Color.clear
}
var outlineColor: Color {
return isSelected ? Color.blue : Color.gray
}
}
We’re building on what we’ve done so far. Look at the picture above — now our radio button can be in two states: selected and unselected.
And now look at the picture above — we can see the interaction in action. When the user taps the unselected button, it becomes selected. Pretty cool, huh?
But we’re not stopping there. Let’s keep going. We’re going to make our radio button even better by adding more features and functionalities to make it more interesting for users.
3. Create an initializer for multiple options
A radio button is commonly utilized in scenarios involving multiple options with a single selection. However, based on our current implementation, it appears that the radio button behaves as if it only supports a single option, and once selected, there’s no way to unselect it. In reality, the radio button component typically functions this way — once you choose an option, you can’t unselect it; you can only change to a different option. Let’s align our radio button component with this conventional behavior for a more intuitive user experience.
extension RadioButton {
// Our current initializer
init(isSelected: Binding<Bool>, label: String = "") {
self._isSelected = isSelected
self.label = label
}
// To support multiple options
init<V: Hashable>(tag: V, selection: Binding<V?>, label: String = "") {
self._isSelected = Binding(
get: { selection.wrappedValue == tag },
set: { _ in selection.wrappedValue = tag }
)
self.label = label
}
}
The first initializer is the existing one, which takes a Binding<Bool>
and label string
. This initializer is suitable for a scenario where there are only two states: selected or unselected and for a single option, hence we can simply use the boolean.
The second initializer is designed to support multiple options. The generic type V
is used to allow flexibility in the type of data the radio button can represent (e.g. enums, strings, integers). Making it conform to Hashable
is necessary because we are using it as a key in a binding setup. The Hashable
conformance enables Swift to compare and differentiate between different options efficiently. The selection
is a bound variable that causes the radio button to become selected when theselection
is equal to the tag. It is pretty similar to some of the SwifUI APIs (e.g NavigationLink
). And for the label, I think it’s pretty straight forward :D
Here is an example of the implementation:
enum Option {
case a
case b
case c
}
struct ContentView: View {
@State var selectedOption: Option? = nil
var body: some View {
VStack {
RadioButton(tag: .a, selection: $SelectedOption, label: "Option A")
RadioButton(tag: .b, selection: $SelectedOption, label: "Option B")
RadioButton(tag: .c, selection: $SelectedOption, label: "Option C")
}
}
}
And voila, it works like a charm! Now, when it comes to multiple options, our radio button can function flawlessly.
4. (Optional) Create a disable state
We can choose to make our radio button even more useful. We’ll add the ability to handle situations where some options should be disabled. This means our radio button can be adapted to various scenarios, making it more versatile and user-friendly.
struct RadioButton: View {
@Binding private var isSelected: Bool
private let label: String
private var isDisabled: Bool = false
init(isSelected: Binding<Bool>, label: String = "") {
self._isSelected = isSelected
self.label = label
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
circleView
labelView
}
.contentShape(Rectangle())
.onTapGesture { isSelected = true }
.disabled(isDisabled)
}
}
private extension RadioButton {
@ViewBuilder var labelView: some View {
if !label.isEmpty { // Show label if label is not empty
Text(label)
.foregroundColor(labelColor)
}
}
@ViewBuilder var circleView: some View {
Circle()
.fill(innerCircleColor) // Inner circle color
.padding(4)
.overlay(
Circle()
.stroke(outlineColor, lineWidth: 1)
) // Circle outline
.frame(width: 20, height: 20)
}
}
private extension RadioButton {
var innerCircleColor: Color {
guard isSelected else { return Color.clear }
if isDisabled { return Color.gray.opacity(0.6) }
return Color.blue
}
var outlineColor: Color {
if isDisabled { return Color.gray.opacity(0.6) }
return isSelected ? Color.blue : Color.gray
}
var labelColor: Color {
return isDisabled ? Color.gray.opacity(0.6) : Color.black
}
}
extension RadioButton {
func disabled(_ value: Bool) -> Self {
var view = self
view.isDisabled = value
return view
}
}
As you can see from the above code, we have just added the isDisabled
property to handle the disabled state of the RadioButton
. When set to true
the radio button appears in a disabled state, visually indicating that it is not interactive because we also have adjusted our innerCircleColor
, outlineColor
, and labelColor
.
And now, we have introduced the disabled
function. This function enables dynamic adjustment of the disabled state for RadioButton
instance. While SwiftUI provides disabled
view modifier that restricts user interaction with the component, it only affects interactivity. In addition to preventing user interaction, we also need to ensure a visual reflection of the disabled state. That is why we need to implement our own disabled
function.
And this is how we implement it:
enum Option {
case a
case b
case c
}
struct ContentView: View {
@State var selectedOption: Option? = nil
var body: some View {
VStack {
RadioButton(tag: .a, selection: $SelectedOption, label: "Option A")
RadioButton(tag: .b, selection: $SelectedOption, label: "Option B")
RadioButton(tag: .c, selection: $SelectedOption, label: "Option C")
.disabled(true)
}
}
}
So easy, right?
5. (Optional) Add animation for the final touch
For the finishing touch, let’s enhance our component’s visual appeal by introducing an animation to it. Good news, with the latest iOS 17 update, Apple has introduced a new SwiftUI API that makes animating our views so easy — the keyframeAnimator
. For more details on how to implement this animation, you can find a comprehensive guide in my exploration of this API here.
struct AnimationProperties {
var scaleValue: CGFloat = 1.0
}
struct RadioButton: View {
@Binding private var isSelected: Bool
private let label: String
private var isDisabled: Bool = false
@State private var animate: Bool = false
init(isSelected: Binding<Bool>, label: String = "") {
self._isSelected = isSelected
self.label = label
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
circleView
labelView
}
.contentShape(Rectangle())
.onTapGesture { isSelected = true }
.disabled(isDisabled)
}
}
private extension RadioButton {
//...
@ViewBuilder var circleView: some View {
Circle()
.fill(innerCircleColor) // Inner circle color
.animation(.easeInOut(duration: 0.15), value: isSelected)
.padding(4)
.overlay(
Circle()
.stroke(outlineColor, lineWidth: 1)
) // Circle outline
.frame(width: 20, height: 20)
.keyframeAnimator(
initialValue: AnimationProperties(), trigger: animate,
content: { content, value in
content
.scaleEffect(value.scaleValue)
},
keyframes: { _ in
KeyframeTrack(\.scaleValue) {
CubicKeyframe(0.9, duration: 0.05)
CubicKeyframe(1.10, duration: 0.15)
CubicKeyframe(1, duration: 0.25)
}
})
.onChange(of: isSelected) { _, newValue in
if newValue == true {
animate.toggle()
}
}
}
}
//...
Keyframe-based animation provides the flexibility to animate individual properties independently. To use the keyframe animator, we need to create a struct that holds the properties we want to animate. In our implementation above, we only have a single property,scaleValue
, as our goal is to animate the component by changing its size when the user taps it. We have also created a new animate
property, which will be used as a trigger whenever the user taps the button. We use this because we want the radio button to undergo scaling animation only when the state becomes selected. Additionally, we have added easeInOut
animation just below .fill
view modifier to make the transition smoother.
Here’s the result of the implementation:
So smooth~
Closing
As we wrap up our journey in creating the radio button component, we have seen different ways to make it and it is actually easy to build it from scratch. The most common method involves using images or SF Symbols, but there’s a catch — it lacks flexibility, especially when it comes to adding animations. By following the approach shared here, we gain total control over our button. We can tweak it however we like.
Think of this as a starting point. There’s room for improvement, like adding new states such as an error state, or enhancing the functionality.
I welcome your thoughts and feedback in the comment section. If you have specific ideas or suggestions for future SwiftUI components, drop a comment below. Your claps mean a lot. Thanks for joining me on this SwiftUI journey!