SwiftUI Custom Views: Build Reusable, Composable UI Components
SwiftUI custom views let you turn repeated interface code into reusable building blocks. Instead of writing the same layout and styling inline across multiple screens, you can package it into a view that takes data and renders itself consistently.
Quick answer: A SwiftUI custom view is usually a struct that conforms to View and returns other views from its body. You use custom views to make UI easier to read, reuse, test, and maintain.
Difficulty: Beginner
Helpful to know first: You'll understand this better if you know basic Swift structs, how properties store data, and the idea that SwiftUI builds interfaces from smaller views.
1. What Is SwiftUI Custom Views?
A custom view is a reusable UI component you define yourself in SwiftUI. It can be as small as a labeled icon or as large as a complete card, form row, or screen section.
- It is usually a Swift struct that conforms to View.
- Its body describes what should appear on screen.
- It can accept input through properties like String, Bool, and Binding.
- It can be reused anywhere in your app just like a built-in SwiftUI view.
Custom views are not a separate feature from SwiftUI itself. They are the normal way SwiftUI encourages you to build interfaces: small pieces composed into larger pieces.
2. Why SwiftUI Custom Views Matter
Custom views matter because real apps repeat patterns. You may need the same styled button, profile row, info card, or empty-state message across several screens. A custom view keeps that UI in one place so changes are easier to make.
They also improve readability. A screen made of well-named custom views is much easier to scan than one huge body full of nested stacks, modifiers, and conditionals.
They are especially useful when you want to separate what a screen contains from how each part is built. That separation makes code more maintainable as the interface grows.
3. Basic Syntax or Core Idea
A SwiftUI custom view is typically a struct with stored properties and a computed body property. The body returns one or more views wrapped in a single container.
Minimal custom view
This example shows the smallest useful pattern: a reusable title view that takes a string and renders it.
import SwiftUI
struct SectionTitleView: View {
let title: String
var body: some View {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
}
}
The title property holds input from the parent view. The body uses that value to create the final interface.
Using the custom view
Once defined, you can place the view anywhere in another SwiftUI view.
struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SectionTitleView(title: "Profile")
SectionTitleView(title: "Settings")
}
.padding()
}
}
This is the core idea of SwiftUI composition: break the UI into small parts and assemble them into larger layouts.
4. Step-by-Step Examples
Example 1: A reusable badge
A badge is a good first custom view because it is small, self-contained, and reused often.
struct BadgeView: View {
let text: String
var body: some View {
Text(text)
.font(.caption.bold())
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.12))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
}You can use this badge in any screen that needs a compact label such as New, Premium, or Draft.
Example 2: A profile row with an image and text
This version combines several built-in views into one reusable row.
struct ProfileRowView: View {
let name: String
let role: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "person.crop.circle")
.font(.largeTitle)
.foregroundStyle(.secondary)
VStack(alignment: .leading) {
Text(name)
.font(.headline)
Text(role)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}This pattern is useful when the same row appears in a list, detail screen, and settings page.
Example 3: A view that accepts a binding
Some custom views do not just display data; they also change it. In those cases, pass a Binding so the child view can edit state owned by the parent.
struct FavoriteToggleView: View {
@Binding var isFavorite: Bool
var body: some View {
Button(action: {
isFavorite.toggle()
}) {
Label(
isFavorite ? "Saved" : "Save",
systemImage: isFavorite ? "heart.fill" : "heart"
)
}
}
}Because the property is a binding, the view can change the parent’s state without owning that state itself.
Example 4: A view with internal helper subviews
Sometimes a custom view is easier to manage when you split it into smaller private pieces. This keeps the main body readable.
struct StatsCardView: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
metric
}
.padding()
.background(RoundedRectangle(cornerRadius: 16).fill(Color.gray.opacity(0.12)))
}
private var header: some View {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
private var metric: some View {
Text(value)
.font(.title.bold())
}
}This approach keeps related layout logic together without forcing everything into a single long view builder.
5. Practical Use Cases
Use custom views when you need a reusable piece of interface that appears in more than one place or needs a clear name.
- Reusable buttons with the same style and spacing across the app.
- List rows that repeat in several views but show different data.
- Cards for products, users, articles, or statistics.
- Empty-state screens with an icon, message, and action button.
- Form sections with consistent labels, hints, and controls.
- Header blocks that group a title, subtitle, and action.
If a layout is only used once and is very small, a separate custom view may be unnecessary. If it helps make the parent view readable, though, extracting it is usually worth it.
6. Common Mistakes
Mistake 1: Putting too much UI inside one body
Beginners often keep adding stacks, modifiers, and conditions to one huge body. The code still works, but it becomes difficult to read and change.
Problem: A large inline layout is harder to reuse and makes it easy to repeat styling mistakes in multiple places.
struct ContentView: View {
var body: some View {
VStack {
HStack {
Image(systemName: "star.fill")
Text("Featured")
}
.padding()
.background(Color.yellow.opacity(0.2))
HStack {
Image(systemName: "star.fill")
Text("Recommended")
}
.padding()
.background(Color.yellow.opacity(0.2))
}
}
}Fix: Extract the repeated UI into a custom view and pass in only the changing data.
struct BadgeRowView: View {
let symbol: String
let title: String
var body: some View {
HStack {
Image(systemName: symbol)
Text(title)
}
.padding()
.background(Color.yellow.opacity(0.2))
}
}The corrected version works better because one reusable view replaces duplicated layout code.
Mistake 2: Trying to mutate a plain stored property in a child view
A child view cannot directly change a value passed into it unless that value is a binding or internal state. This is a very common confusion when building interactive custom views.
Problem: The child view receives a constant value, so trying to toggle it causes a compile-time error about mutating an immutable property.
struct FavoriteButton: View {
let isFavorite: Bool
var body: some View {
Button("Toggle") {
isFavorite.toggle()
}
}
}Fix: Use @Binding when the view should edit state owned by a parent.
struct FavoriteButton: View {
@Binding var isFavorite: Bool
var body: some View {
Button("Toggle") {
isFavorite.toggle()
}
}
}The corrected version works because the child view edits a shared source of truth instead of a read-only copy.
Mistake 3: Forgetting that body must return one view value
SwiftUI view builders allow multiple child views, but the overall body still needs a single view result. If you write statements in the wrong place or return unrelated branches without a container, the compiler can complain.
Problem: Two sibling views written directly in body without a container do not form a valid single return value.
struct TitleAndSubtitleView: View {
var body: some View {
Text("Title")
Text("Subtitle")
}
}Fix: Wrap the children in a container such as VStack or HStack.
struct TitleAndSubtitleView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Title")
Text("Subtitle")
}
}
}The corrected version works because the container makes the whole block a single view hierarchy.
7. Best Practices
Practice 1: Pass data in, do not hardcode it
Custom views are most useful when they receive their content from the parent. Hardcoding text and images makes the view less reusable.
struct InfoRowView: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
}
}
}This version can represent many kinds of rows because the content is supplied from outside.
Practice 2: Use clear names that describe the UI role
A name like ProfileHeaderView is much more helpful than MyView. The view’s name should explain what it does in the interface, not how it happens to be implemented.
struct ProfileHeaderView: View {
let name: String
let subtitle: String
var body: some View {
VStack(alignment: .leading) {
Text(name)
Text(subtitle)
}
}
}A descriptive name reduces guesswork when the view is reused later in a larger codebase.
Practice 3: Keep layout and behavior close together, but not tangled
If a custom view includes tap handling, toggles, or state changes, keep the interaction logic near the relevant UI. If it starts growing too large, split it into smaller child views.
struct ExpandablePanelView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading) {
Button(isExpanded ? "Hide details" : "Show details") {
isExpanded.toggle()
}
if isExpanded {
Text("Extra content appears here.")
}
}
}
}This approach is readable because the state and the UI that depends on it live in the same place.
8. Limitations and Edge Cases
- Custom views are value types, so they are recreated often as SwiftUI updates the interface.
- A view should be cheap to build; expensive work belongs in models, loaders, or tasks, not directly inside body.
- State inside a custom view is local to that view instance. If you need shared app-wide data, pass it in or use an appropriate shared data model.
- Some layouts behave differently on small screens, large text sizes, or different device orientations, so test custom views in multiple size classes.
- A custom view is not automatically interactive. You still need explicit controls, gestures, or bindings if the view should respond to user input.
- Preview success does not guarantee runtime success; a custom view can look fine in previews but still break when fed real app data.
One common surprise is that a custom view may appear to “lose” its state if it is recreated with different inputs. That usually means the state belongs elsewhere and should be moved up the hierarchy.
9. Practical Mini Project
Here is a small but complete example: a reusable notification card that can be shown in a list, on a home screen, or in a detail page.
import SwiftUI
struct NotificationCardView: View {
let title: String
let message: String
let systemImage: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title2)
.foregroundStyle(.white)
.padding(10)
.background(Circle().fill(Color.blue))
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(RoundedRectangle(cornerRadius: 16).fill(Color.gray.opacity(0.12)))
}
}
struct ContentView: View {
var body: some View {
VStack(spacing: 16) {
NotificationCardView(
title: "Backup Complete",
message: "Your files were saved successfully.",
systemImage: "checkmark.seal.fill"
)
NotificationCardView(
title: "New Message",
message: "You have a message from Jordan.",
systemImage: "message.fill"
)
}
.padding()
}
}This example shows the full workflow: define a reusable custom view once, then reuse it with different inputs. The parent remains clean, and the child view owns the presentation details.
10. Key Points
- SwiftUI custom views are reusable view structs that conform to View.
- They make screens easier to read by extracting repeated UI into named components.
- Use plain stored properties for input and @Binding when the child must edit parent state.
- Break large layouts into smaller views when the parent body becomes difficult to scan.
- Custom views should stay focused on presentation, while data loading and heavy logic usually belong elsewhere.
11. Practice Exercise
- Create a reusable ProductPriceView that shows a product name, price, and currency symbol.
- Use it twice in a parent ContentView with different values.
- Add a small visual style such as padding, rounded corners, or a background color.
Expected output: Two product price cards appear on screen, each showing different product details but using the same layout.
Hint: Start with three stored properties and a VStack inside the custom view. Then reuse the same component in a parent view.
import SwiftUI
struct ProductPriceView: View {
let name: String
let price: String
let currencySymbol: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(name)
.font(.headline)
Text("\(currencySymbol)\(price)")
.font(.title3.bold())
}
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(Color.gray.opacity(0.1)))
}
}
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
ProductPriceView(name: "Coffee", price: "4.99", currencySymbol: "$")
ProductPriceView(name: "Sandwich", price: "7.50", currencySymbol: "$")
}
.padding()
}
}If your solution shows two styled rows and reuses one custom component, you have the right idea.
12. Final Summary
SwiftUI custom views are one of the most important parts of building maintainable SwiftUI apps. They let you take repeated interface code and turn it into reusable pieces with meaningful names and clear input.
By passing data into a custom view, using @Binding only when a child needs to edit shared state, and splitting large layouts into smaller subviews, you keep your code organized and easier to evolve. That approach scales much better than keeping every layout inline inside one large screen.
As you continue learning SwiftUI, practice extracting repeated UI into custom views early. The more you compose small views well, the easier it becomes to build larger screens without losing clarity.