Custom Selector in SwiftUI with Animations

Aetheraurelia
14 min readJul 11, 2023

--

In this tutorial, we will explore the process of building a custom selector in SwiftUI that is highly customizable and adaptable across different devices.

A selector is typically a user interface component used to choose or toggle between different options or modes.

Our custom selector will offer flexibility in appearance and behavior, allowing developers to customize its design and adapt it to various screen sizes. Throughout the implementation, we will utilize key SwiftUI components such as GeometryReader to enable highlights for the active selection, as well as @AppStorage to save the state of our selection, and we’ll create a simple custom button too

By the end of this tutorial, you will have a solid understanding of how to create a basic custom selector in SwiftUI, empowering you to build engaging and responsive user interfaces tailored to your specific application requirements.

Checkout the source code on github

Implementation details

Setting up our enum

In the process of creating a custom selector, we utilize an enum called AnimationMode to represent different animation modes available in our selector. Our animation modes are just examples here, you could add whatever you’d like!

An enum provides a clear and self-explanatory way to define and represent a set of related values. It allows us to express the available animation modes in a structured manner, making the code more readable and maintainable.

enum AnimationMode: Int, CaseIterable {
case enabled
case reduced
case disabled
case extra

var imageName: String {
switch self {
case .disabled:
return "figure.stand" // SF Symbol name for disabled mode
case .reduced:
return "figure.walk" // SF Symbol name for reduced mode
case .enabled:
return "figure.run" // SF Symbol name for enabled mode
case .extra:
return "cube" // SF Symbol name for extra mode
}
}
var title: String {
switch self {
case .disabled:
return "Disabled" // Title for disabled mode
case .reduced:
return "Reduced" // Title for reduced mode
case .enabled:
return "Enabled" // Title for enabled mode
case .extra:
return "Extra" // Title for extra mode
}
}
}

This code snippet defines an enum called AnimationMode that conforms to the CaseIterable protocol, allowing iteration over all its cases.

  1. The enum is declared with an Int raw value type, allowing each case to be associated with a specific numerical value. Using an Int raw value type in the enum declaration allows us to associate each case with a specific numerical value.
  2. This enables us to utilize features like AppStorage to store and retrieve the selected animation mode using the associated raw value.
  3. The cases are defined as .enabled, .reduced, .disabled, and .extra, representing different animation modes.
  4. The imageName property returns a corresponding SF Symbol name based on each animation mode case using a switch statement. This property provides the SF Symbol name for each mode
  5. The title computed property returns a descriptive title for each animation mode case using a switch statement. This property provides the title or label associated with each mode, making it easier to display the mode name in the user interface.

Building our selector

Let’s set up our view

struct AnimationsView: View {
@AppStorage("animationMode") private var animationsMode: AnimationMode = .enabled
@Environment(\.colorScheme) var colorScheme
let color = Color.indigo // Replace with your desired color

var body: some View {
VStack {

}
}
}

Within the AnimationsView, we utilize the @AppStorage property wrapper to seamlessly store and retrieve the selected animation mode value

To adapt the appearance of our view based on the device’s color scheme, we access the current color scheme using @Environtment(\.colorScheme)

We define the color constant as the desired color for our view, initialising it with Color.indigo

You can customize this color by replacing it with the color of your choice

Now let’s display all our modes

struct AnimationsView: View {
@AppStorage("animationModeKey") private var animationsMode: AnimationMode = .enabled
@Environment(\.colorScheme) var colorScheme
let color = Color.indigo // Replace with your desired color

var body: some View {
VStack {
HStack(spacing: 0) {
ForEach(AnimationMode.allCases.indices, id: \.self) { index in
let mode = AnimationMode.allCases[index]
Button {

} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 2)
.padding(12)
.background {
Color(.systemBackground)
.opacity(0.6)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.primary.opacity(colorScheme == .dark ? 0.15 : 0.08), lineWidth: 1.2))
}
.padding(.horizontal, 25)
}
}
}

We use an HStack to horizontally stack the buttons representing different animation modes. The spacing parameter controls the spacing between the buttons.

We iterate over the indices of the AnimationMode enum using AnimationMode.allCases.indices

We’re using the index of each case to handle our dividers later on, we’ll be able to make our picker scalable to more enum cases this way. The id: \.self part in the ForEach loop is used to identify each element in the loop’s data source.

let mode = AnimationMode.allCases[index]

Button {

} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)

Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}

The line let mode = AnimationMode.allCases[index] is used to retrieve the mode for each iteration in the ForEachloop. This allows us to easily access it’s properties, like the icon Image(systemName: mode.imageName) or the title Text(mode.title)

We’ll create a simple button with no action for now, and display our icon and title

Using .frame(maxWidth: .infinity) will make our button label take up as much space as possible, you can try this out by removing some enum cases, you should see the buttons update in your preview, and they should automatically take up maximum equal space!

Let’s add our dividers

let makeDivider = index < AnimationMode.allCases.count - 1

First let’s make our condition for dividers, we don’t want the last mode to generate a divider

Now let’s add the dividers

if makeDivider {
Divider()
.frame(width: 0, height: 55)
}

We set our width to 0 so that they don’t interfere with the layout, and the height can be as we desire

We’ll add this code within our HStack

HStack(spacing: 0) {
ForEach(AnimationMode.allCases.indices, id: \.self) { index in
let mode = AnimationMode.allCases[index]
let makeDivider = index < AnimationMode.allCases.count - 1

Button {
} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)

Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}

if makeDivider {
Divider()
.frame(width: 0, height: 55)
}
}
}

What does it looks like if we don’t make the condition?

Finishing Button Logic

Button {

} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}

First, let’s highlight the text when it’s selected

.foregroundStyle(mode == animationsMode ? color : .primary)

This condition asks if the mode in the sleep is the current one selected, and if so, make the text color our color variable

Now we just need to add the action!

Button {
animationsMode = mode
}

This completes the functionality, now when a button is pressed, the selection updates

Button {
animationsMode = mode
} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}
.foregroundStyle(mode == animationsMode ? color : .primary)

To keep contrast in the example gifs, I’ve opted to remove the foregroundStyling in the later examples, but it will be included in the source code, and you can just keep it

Custom styling for the buttons

struct BouncyButton: ButtonStyle {
public func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
.scaleEffect(x: configuration.isPressed ? 0.95 : 1.0, y: configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}

To implement the BouncyButton style, we need to conform to the ButtonStyle protocol and implement the required makeBody(configuration:) function. This function receives a configuration parameter, which contains information about the current state of the button.

Inside the makeBody function, we modify the appearance of the button’s label using the configuration.label property. By applying the scaleEffect modifier to the label, we create a scaling animation that gives a bouncy effect to the button.

The scaleEffect modifier takes two parameters: x and y, which control the horizontal and vertical scaling factors of the label. We use the configuration.isPressed property to determine whether the button is currently pressed or not.

We can apply this to our button

Button {
animationsMode = mode
} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}
.buttonStyle(BouncyButton())
.foregroundStyle(mode == animationsMode ? color : .primary)

You can adjust the effect by changing values in scale effect, and changing the animation

A quick tap has a minimal effect, longer taps are more noticeable

You could add a quick flash effect like native buttons to make it more noticeable

struct BouncyButton: ButtonStyle {
public func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
.scaleEffect(x: configuration.isPressed ? 0.95 : 1.0, y: configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
.opacity(configuration.isPressed ? 0.5 : 1)
}
}

Almost there! Let’s make sure your at the same stage, you should have this so far

enum AnimationMode: Int, CaseIterable {
case enabled
case reduced
case disabled
case extra

var imageName: String {
switch self {
case .disabled:
return "figure.stand"
case .reduced:
return "figure.walk"
case .enabled:
return "figure.run"
case .extra:
return "cube"
}}
var title: String {
switch self {
case .disabled:
return "Disabled"
case .reduced:
return "Reduced"
case .enabled:
return "Enabled"
case .extra:
return "Extra"
}}
}
struct AnimationsView: View {
@AppStorage("animationModeKey") private var animationsMode: AnimationMode = .enabled
@Environment(\.colorScheme) var colorScheme
let color = Color.indigo // Replace with your desired color
var body: some View {
VStack {
HStack(spacing: 0) {
ForEach(AnimationMode.allCases.indices, id: \.self) { index in
let mode = AnimationMode.allCases[index]
let makeDivider = index < AnimationMode.allCases.count - 1
Button {
} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
}
.buttonStyle(BouncyButton())
if makeDivider {
Divider()
.frame(width: 0, height: 55)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 2)

.padding(12)
.background {
Color(.systemBackground)
.opacity(0.6)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.primary.opacity(colorScheme == .dark ? 0.15 : 0.08), lineWidth: 1.2))
}
.padding(.horizontal, 25)
.animation(.smooth, value: animationsMode)
}
}
}
struct BouncyButton: ButtonStyle {
public func makeBody(configuration: Self.Configuration) -> some View {
return configuration.label
.scaleEffect(x: configuration.isPressed ? 0.95 : 1.0, y: configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
.opacity(configuration.isPressed ? 0.5 : 1)
}
}

Adding Selection Indication

I recommend checking the provided source code on github to check if you’ve done this right

Between

.padding(.horizontal, 2)

.padding(12)

I’m going to add another background

.background {
GeometryReader { proxy in

}
}

We introduce the GeometryReader as the background for our view. The GeometryReader allows us to access the size and position of the parent view, providing the necessary information for dynamic sizing and positioning of the background

.background {
GeometryReader { proxy in
// Apply the desired background color with opacity
color.opacity(0.1)
// Clip the background to a rounded rectangle shape
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

We apply the desired background color with an opacity of 0.1 to the GeometryReader

The color represents the color to use for the background (we defined this earlier)

We add the .clipShape(RoundedRectangle(cornerRadius: 10)) modifier to the background color. This clips the background to a rounded rectangle shape with a corner radius of 10. You can adjust the cornerRadius value to achieve the desired rounded corner effect.

.background {
GeometryReader { proxy in
let caseCount = AnimationMode.allCases.count
color.opacity(0.1)
.clipShape(RoundedRectangle(cornerRadius: 10))
// Set the width of the background based on the available space
.frame(width: proxy.size.width / CGFloat(caseCount))
}
}

In this stage, we calculate the width of each background segment based on the available space. By using the .frame(width: proxy.size.width / CGFloat(caseCount)) modifier, we divide the available width equally among the cases, ensuring each segment has a consistent width.

The caseCount variable represents the total number of cases in the AnimationMode enum.

So for 4 cases, a width of 400 for example, the width of the selector would be 100

.background {
GeometryReader { proxy in
let caseCount = AnimationMode.allCases.count
color.opacity(0.1)
.clipShape(RoundedRectangle(cornerRadius: 10))
.frame(width: proxy.size.width / CGFloat(caseCount))
// Offset the background horizontally based on the selected animation mode
.offset(x: proxy.size.width / CGFloat(caseCount) * CGFloat(animationsMode.rawValue))
}
}

Here, we add the .offset(x: proxy.size.width / CGFloat(caseCount) * CGFloat(animationsMode.rawValue))modifier to the background.

This offsets the background horizontally based on the selected animation mode. The animationsMode.rawValuerepresents the index of the selected animation mode within the enum, allowing us to calculate the appropriate horizontal offset.

To further explain how this works, when the first item in selected, the index of that item (or more accurately the rawValue) is 0, so no offset is applied, by default the background go to the left

When we select the next one, the index is 1, so now an offset will be applied, this offset is the equivalent to the width of 1 mode, so it will move away from the 1st mode to the 2nd, since the offset is now moving it

Here the width of each is 79.75, we can see that the offset is always the width × index

  • 79.75 × 1 = 79.75
  • 79.75 × 2 = 159.5
  • 79.75 × 3 = 239.25

Almost there!

Hiding Dividers

If the divider is next to a currently selected mode, we want it to hide.

Each divider shares an index with the mode next to it. So if the currently selected mode has the same index as a divider, it needs to hide.

if makeDivider {
if !(index == animationsMode.rawValue) {
Divider()
.frame(width: 0, height: 55)
}
}

Here, the divider shows if the index is not equal to the currently selected mode (indicated by animationsMode)

It works! But we also need to hide the one before the selection too

Using the same logic, we just take index and add 1 for our comparison.

(index + 1) == animationsMode.rawValue

Using || we can ask if either of the conditions are true

if makeDivider {
if !(index == animationsMode.rawValue || (index + 1) == animationsMode.rawValue ) {
Divider()
.frame(width: 0, height: 55)
}
}

We can see now the dividers hide on both sides!

Finally lets adjust animations for the dividers appearing and disappearing

In SwiftUI, the .transition() modifier is used to apply animations when a view appears or disappears. The .asymmetric(…) variant of the modifier allows you to specify different animations for the insertion (when a view appears) and removal (when a view disappears) phases.

.transition(.asymmetric(insertion: .opacity.animation(.linear(duration: 0.1).delay(0.15)), removal: .opacity.animation(.linear(duration: 0.1))))

.asymmetric(...): This is the main part of the modifier that specifies the asymmetric transition. It takes two parameters: insertion and removal, which define the animations for the insertion and removal phases, respectively.

insertion: .opacity.animation(.linear(duration: 0.1).delay(0.15)): This parameter specifies the animation to be used when a view appears (insertion phase).

  • In this example, the animation is defined using the .opacity modifier, which controls the opacity (transparency) of the view.
  • The .animation(…) modifier is chained to specify the animation’s properties.
  • Here, .linear(duration: 0.1) sets a linear animation with a duration of 0.1 seconds, and .delay(0.15) adds a delay of 0.15 seconds before the animation starts.
  • The purpose of the insertion animation with a delay is to ensure that the hidden divider remains invisible until the selector has passed over it.
  • By adding the delay, the hidden divider only starts fading in (increasing opacity) after 0.15 seconds, allowing the selector to move past it without revealing the divider.

removal: .opacity.animation(.linear(duration: 0.1)): This parameter specifies the animation to be used when a view disappears (removal phase).

  • Similar to the insertion animation, it uses the .opacity modifier to control the opacity of the view.
  • The .animation(…) modifier sets the animation properties, where .linear(duration: 0.1) defines a linear animation with a duration of 0.1 seconds.
  • The removal animation with an instant duration is applied to hide the divider immediately when the selector moves away from it.
  • Since the removal animation does not have any delay, the hidden divider instantly fades out (decreases opacity) as soon as the selector moves away, preventing the divider from being visible underneath the selector.

Adding .contentShape

We’re adding the .contentShape(Rectangle()) modifier to the button in order to define the tappable area for the button.

By default, SwiftUI uses the layout bounding box as the tappable area

Adding the .contentShape(Rectangle()) modifier ensures that the entire visible area of the button’s content becomes tappable. In this case, it allows users to tap anywhere within the button’s content, including the image and text (and any blank space), to trigger the button action. This improves the user experience by providing a larger interaction area and making it more intuitive for users to interact with the button.

Add the .contentShape(Rectangle()) modifier to the button. This modifier allows you to define the shape of the tappable area for the button. In this case, we want to use a rectangular shape:

Button {
animationsMode = mode
} label: {
VStack(spacing: 7) {
Image(systemName: mode.imageName)
.font(.title2)
Text(mode.title)
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(8)
.padding(.vertical, 13)
.contentShape(Rectangle()) // Add this line
}
.buttonStyle(BouncyButton())

That’s it! By adding the .contentShape(Rectangle()) modifier to the button, you have defined the rectangular shape as the tappable area for the button. This means that users can tap anywhere within the bounds of the button’s content to trigger the button action.

In this vertical example (which you can find on github) you should be able to see that despite clicking on the blank space next to the button, the click still registers

Wrapping up

In this tutorial, we successfully built a custom selector in SwiftUI that is highly customizable and adaptable.

We covered several important concepts and techniques, including working with enums, using the @AppStorage property wrapper for persistence, implementing custom button styles, and utilizing the GeometryReader for dynamic sizing and positioning.

By following the step-by-step instructions and code examples, you learned how to create a custom selector that displays different animation modes. The selector allows users to choose their preferred animation mode by tapping on the corresponding button. You can easily customize this to your liking

We also added interactive and visually appealing effects to the buttons using the BouncyButton custom button style.

To enhance the selector’s visual presentation, we added a background color that dynamically adjusts its width based on the available space. We also implemented dividers between the buttons, ensuring that dividers hide when they are adjacent to the selected animation mode.

By combining these techniques, you now have the knowledge and skills to create your own custom selectors and interactive components in SwiftUI. You can apply these concepts to build versatile and engaging user interfaces for your SwiftUI projects.

Remember, SwiftUI offers a powerful and intuitive framework for creating modern and interactive app interfaces. By leveraging its features and incorporating your creativity, you can design delightful user experiences that seamlessly adapt to different devices and screen sizes.

Congratulations on completing this tutorial, and happy SwiftUI coding!

--

--