How to Build a Custom Component in SwiftUI

Learn how to create and integrate custom components into your applications for a more personalized user experience.”

Waseem
11 min readMar 2, 2023

In the world of app development, it is not uncommon to find yourself needing to create custom components that are not included in the standard library. Whether it’s a button with a unique design, a customized text field, or a new type of chart, creating your own components can be a great way to add unique features to your app and stand out from the crowd.

In this article, we will explore how to build a custom SwiftUI component. We will walk you through the code and explain each piece, so that you can follow along and create your own custom components.

Real-Life Applications

Before we dive into the code, let’s discuss some real-life applications of custom components. Imagine that you are working on a fitness app that allows users to track their workouts. You want to create a button that displays a loading spinner when the user taps it, indicating that their workout is being saved to the server. However, you also want to customize the button’s appearance to fit the theme of your app.

This is where a custom component comes in handy. By building your own button component, you can customize its appearance and behavior to meet the needs of your app. You can add a loading spinner, change the button’s colors, and more.

So, grab your favorite coding beverage, and let’s get started on creating a custom component in SwiftUI!

Understanding the Code

Before we start building a custom component, let’s first understand the code we are about to write We will have three structs in our code, PrimaryButtonStyleCustom, CircularProgress, and ContentView.

The PrimaryButtonStyleCustom struct conforms to the ButtonStyle protocol, which defines the appearance and interaction behavior of a button. This struct takes several parameters such as disabled, image, showLoader, btnBg, btnFg, cornerRadius, buttonHeight, and textColor. These parameters can be used to customize the appearance of the button.

The makeBody function of PrimaryButtonStyleCustom struct creates the visual representation of the button. It is used to build the custom button that will be shown on the screen. In this function, we first check if the showLoader parameter is true. If it is true, we display a CircularProgress view, otherwise, we display an Image view and a label.

The CircularProgress struct is used to display a circular progress view. It animates the circle by rotating it and trimming it to give the illusion of progress.

The ContentView struct defines the user interface for the main view of our application. It contains a button that is styled using the PrimaryButtonStyleCustom struct. When the button is clicked, it sets the isLoading state variable to true, simulating a loading state. After a delay of 4 seconds, the isLoading state variable is set back to false, simulating the end of the loading state.

Now, let’s take a look at the code and see how we can build our own custom SwiftUI component.

Define the Button Style

Create a new struct called “PrimaryButtonStyleCustom” that conforms to the “ButtonStyle” protocol. This protocol provides a way to customize the appearance and behavior of a button.

The “PrimaryButtonStyleCustom” struct has several properties that including disabled, image, showLoader, btnBg, btnFg, cornerRadius, buttonHeight, and textColor.

  • disabled: A boolean value that indicates whether the button should be disabled or not.
  • image: An optional string that specifies the image to be displayed on the button.
  • showLoader: A boolean binding that indicates whether a progress indicator should be displayed on the button or not.
  • btnBg: The background color of the button.
  • btnFg: The foreground color of the button.
  • cornerRadius: The corner radius of the button.
  • buttonHeight: The height of the button.
  • textColor: The color of the button text.

The init method is used to initialize the properties of the struct. It takes several parameters, which have default values if not specified. The disabled parameter determines if the button is disabled, the image parameter specifies the image to be displayed on the button (if any), the showIndicator parameter is a binding Boolean value that is used to show/hide a progress indicator, the btnBg parameter sets the background color of the button, the btnFg parameter sets the text color of the button, the cornerRadius parameter sets the corner radius of the button, the buttonHeight parameter sets the height of the button, and the textColor parameter sets the color of the text displayed on the button.

public struct PrimaryButtonStyleCustom: ButtonStyle {
private var disabled: Bool
private var image: String?
@Binding private var showLoader: Bool
private var btnBg: Color
private var btnFg: Color
private var cornerRadius: CGFloat
private var buttonHeight: CGFloat
private var textColor: Color

public init(disabled: Bool = false, image: String? = nil, showIndicatore: Binding<Bool> = .constant(false), btnBg: Color = .accentColor, btnFg: Color = .white, cornerRadius: CGFloat = 0, buttonHeight: CGFloat = 0, textColor: Color = .black) {
self.disabled = disabled
self.image = image
self._showLoader = showIndicatore
self.btnBg = btnBg
self.btnFg = btnFg
self.cornerRadius = cornerRadius
self.textColor = textColor
self.buttonHeight = buttonHeight
}

The initializer of the struct takes in all these properties as arguments and sets them as instance variables. The default values for these properties are provided as well.

Define the Button Body

Next, we need to define the body of the button. In the “makeBody” method, we define the appearance and behavior of the button.

public func makeBody(configuration: Configuration) -> some View {
HStack {
if showLoader {
CircularProgress()
} else {
if image != nil {
Image(image ?? "")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 13.33)
}
configuration.label
}
}
.font(.callout)
.foregroundColor(textColor)
.frame(height: buttonHeight)
.frame(minWidth: 0, maxWidth: .infinity)
.background(disabled ? Color.gray : btnBg)
.cornerRadius(cornerRadius)
.scaleEffect(disabled ? 1 : configuration.isPressed ? 0.95 : 1)
}

The makeBody(configuration:) method is where the actual button is created. It receives a Configuration parameter that holds information about the button’s state and properties, such as the label and whether the button is currently pressed.

Inside the makeBody(configuration:) method, we start by creating an HStack that contains either the circular progress indicator or the button label and image. The CircularProgress() view is displayed if the showLoader property is true, which means that the button is currently in a loading state. Otherwise, the label and image are displayed.

The if image != nil statement checks whether an image string has been passed as a parameter to the PrimaryButtonStyleCustom initializer. If an image string exists, an Image view is created and set to the specified image. The resizable() modifier allows the image to be scaled to fit within its parent container, while the aspectRatio(contentMode: .fit) modifier maintains the aspect ratio of the image. Finally, the frame(height: 13.33) modifier sets the height of the image to a fixed value of 13.33 points.

Next, the configuration.label property is added to the HStack. This displays the button’s text label, which was passed as a parameter to the Button view’s initializer.

After defining the button’s content, we apply various modifiers to customize the button’s appearance and behavior. The font(.callout) modifier sets the font to callout size, while the foregroundColor(textColor) modifier sets the color of the button’s text to the specified textColor.

The frame(height: buttonHeight) modifier sets the height of the button to the value specified by the buttonHeight parameter. The frame(minWidth: 0, maxWidth: .infinity) modifier allows the button to expand to fill its parent container’s width.

The background(disabled ? Color.gray : btnBg) modifier sets the background color of the button to either gray or the btnBg color, depending on whether the button is currently disabled or not.

The cornerRadius(cornerRadius) modifier sets the button’s corner radius to the specified value.

Finally, the scaleEffect(disabled ? 1 : configuration.isPressed ? 0.95 : 1) modifier applies a scaling effect to the button based on its state. If the button is disabled, it is not scaled. If it is currently pressed, it is scaled down to 0.95 of its original size to give the user visual feedback.

Complete PrimaryButtonStyleCustom code:

public struct PrimaryButtonStyleCustom: ButtonStyle {
private var disabled: Bool
private var image: String?
@Binding private var showLoader: Bool
private var btnBg: Color
private var btnFg: Color
private var cornerRadius: CGFloat
private var buttonHeight: CGFloat
private var textColor: Color

public init(disabled: Bool = false, image: String? = nil, showIndicatore: Binding<Bool> = .constant(false), btnBg: Color = .accentColor, btnFg: Color = .white, cornerRadius: CGFloat = 0, buttonHeight: CGFloat = 0, textColor: Color = .black) {
self.disabled = disabled
self.image = image
self._showLoader = showIndicatore
self.btnBg = btnBg
self.btnFg = btnFg
self.cornerRadius = cornerRadius
self.textColor = textColor
self.buttonHeight = buttonHeight
}

public func makeBody(configuration: Configuration) -> some View {
HStack {
if showLoader {
CircularProgress()
} else {
if image != nil {
Image(image ?? "")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 13.33)
}
configuration.label
}
}
.font(.callout)
.foregroundColor(textColor)
.frame(height: buttonHeight)
.frame(minWidth: 0, maxWidth: .infinity)
.background(disabled ? Color.gray : btnBg)
.cornerRadius(cornerRadius)
.scaleEffect(disabled ? 1 : configuration.isPressed ? 0.95 : 1)
}
}

Creating the CircularProgress view

The CircularProgress view is responsible for displaying a loading spinner when the isLoading variable is set to true. The spinner is made up of two circles that rotate and fill up at different intervals, creating the effect of a loading spinner.

Let’s break down the code:

public struct CircularProgress: View {

This declares a CircularProgress struct that conforms to the View protocol, indicating that it is a view that can be displayed in a SwiftUI interface.

    @State private var isCircleRotation = true
@State private var animationStart = false
@State private var animationEnd = false

These are three state properties that will be used to control the animation of the loading indicator.

public var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 4)
.fill(Color.white)
.frame(width: 20, height: 20)

This sets up the first circle that will be the background of the loading indicator. It is a Circle shape with a stroke that has a lineWidth of 4 and a fill color of white. The frame of this circle is set to be 20 by 20 points.

Circle()
.trim(from: animationStart ? 1/3 : 1/9, to: animationEnd ? 2/5 : 1)
.stroke(lineWidth: 4)
.rotationEffect(.degrees(isCircleRotation ? 360 : 0))
.frame(width: 20, height: 20)
.foregroundColor(.accentColor)

This sets up the second circle that will be the actual loading indicator. It is also a Circle shape with a stroke that has a lineWidth of 4. The trim modifier is used to animate the arc of the circle from a starting point to an ending point. The starting point is determined by the animationStart state property and the ending point is determined by the animationEnd state property. The rotationEffect modifier is used to rotate the circle depending on the value of the isCircleRotation state property. The frame of this circle is also set to be 20 by 20 points. The foregroundColor modifier is used to set the color of the circle to the accent color of the app.

.onAppear {
withAnimation(Animation
.linear(duration: 1)
.repeatForever(autoreverses: false)) {
self.isCircleRotation.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(0.5)
.repeatForever(autoreverses: true)) {
self.animationStart.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(1)
.repeatForever(autoreverses: true)) {
self.animationEnd.toggle()
}
}

This uses the onAppear modifier to start the animation when the view appears on the screen. The withAnimation function is used to specify the animations that will occur.

The first animation uses the withAnimation function to animate the isCircleRotation state property. It creates a linear animation with a duration of 1 second and repeats it forever without reversing the animation. This means that the isCircleRotation property toggles every second, causing the circle to spin continuously.

The second animation animates the animationStart state property. It creates a linear animation with a duration of 1 second, a delay of 0.5 seconds, and repeats it forever with autoreverses set to true. This means that the animationStart property toggles every 1.5 seconds, causing the circle to appear to fill up gradually from one end.

The third animation animates the animationEnd state property. It creates a linear animation with a duration of 1 second, a delay of 1 second, and repeats it forever with autoreverses set to true. This means that the animationEnd property toggles every 2 seconds, causing the circle to appear to fill up gradually from the other end.

Together, these animations give the impression of a circular progress indicator that spins continuously while gradually filling up and then emptying out. The use of the withAnimation function ensures that all of the state property changes are animated smoothly.

Complete CircularProgress code:

public struct CircularProgress: View {
@State private var isCircleRotation = true
@State private var animationStart = false
@State private var animationEnd = false

public var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 4)
.fill(Color.white)
.frame(width: 20, height: 20)

Circle()
.trim(from: animationStart ? 1/3 : 1/9, to: animationEnd ? 2/5 : 1)
.stroke(lineWidth: 4)
.rotationEffect(.degrees(isCircleRotation ? 360 : 0))
.frame(width: 20, height: 20)
.foregroundColor(.accentColor)
.onAppear {
withAnimation(Animation
.linear(duration: 1)
.repeatForever(autoreverses: false)) {
self.isCircleRotation.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(0.5)
.repeatForever(autoreverses: true)) {
self.animationStart.toggle()
}
withAnimation(Animation
.linear(duration: 1)
.delay(1)
.repeatForever(autoreverses: true)) {
self.animationEnd.toggle()
}
}
}
}
}

Using the custom button in ContentView

Finally, we can use our custom button in our ContentView by calling PrimaryButtonStyleCustom and passing in the necessary parameters.

This code will defines a ContentView struct, which conforms to the View protocol. The ContentView contains a VStack that includes a single Button element.

@State private var isLoading = false

This line declares a state property variable named isLoading with an initial value of false. State properties are used in SwiftUI to track the state of the view, which can be changed dynamically as the user interacts with the view.

Button {
self.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.isLoading = false
}
} label: {
Text("Log in")
.font(.callout)
}

This is a SwiftUI Button element that triggers an action when it is tapped. When the button is tapped, it sets the isLoading state to true, which will trigger the CircularProgress animation. Fir example we have used DispatchQueue with delay of 4 seconds to mimic the loading time, After a delay of 4 seconds, it sets the isLoading state back to false, which will stop the CircularProgress animation.

.buttonStyle(PrimaryButtonStyleCustom(showIndicatore: $isLoading, cornerRadius: 10, buttonHeight: 45, textColor: .white))

This line sets the button style to PrimaryButtonStyleCustom, which is a custom button style defined elsewhere in the code. The showIndicatore parameter is bound to the isLoading state, which determines whether or not the CircularProgress animation is displayed. Other parameters set the corner radius, button height, and text color of the button.

.padding()

This line adds padding to the VStack. The padding ensures that the button is not too close to the edges of the screen.

Complete ContentView code:

struct ContentView: View {
@State private var isLoading = false
var body: some View {
VStack {
Button {
self.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.isLoading = false
}
} label: {
Text("Log in")
.font(.callout)
}
.buttonStyle(PrimaryButtonStyleCustom(showIndicatore: $isLoading, cornerRadius: 10, buttonHeight: 45, textColor: .white))
}
.padding()
}
}

Overall, this code creates a button that shows a loading animation when tapped. The animation is displayed using a custom button style that shows a CircularProgress view, and it is triggered by changing the isLoading state.

Conclusion

That’s it! You now have a custom SwiftUI component that can be used in your apps. Custom components are a great way to build reusable code that can save time and effort in the long run. By creating custom components, you can ensure that your app has a consistent look and feel throughout, which can be a big plus for user experience.

In this article, we learned how to create a custom SwiftUI button style and a circular progress indicator that can be used as a loading animation. We went through the code step-by-step to understand how each part of the code contributes to the overall functionality of the component.

If you want to learn more about SwiftUI and building custom components, there are many resources available online. Apple’s documentation is a great place to start, and there are many tutorials and code samples available on sites like GitHub and Stack Overflow.

I hope you found this tutorial helpful in building your own custom SwiftUI component. Good luck with your app development!

You can download the project from here 👉 Download me

Also if you have something and you want me to write on that topic then feel free to drop in the comments down below

Thanks for your time, If you found this article useful, please click the 👏 button, follow for more articles like this and share to help others find it!

--

--

Waseem

As a creative software developer, I design and build user-friendly solutions that make a difference. Join me on a journey of curiosity and innovation!