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.

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.

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

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

11. Practice Exercise

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.