Advanced SwiftUI: Modifiers, Custom Layout, Animation and Charts

Advanced iOS Development: Concurrency, Architecture, Security and Release · Advanced UI and Navigation · 8 min read · Updated

Reusable parts

  1. You start with a plain view and a chain of modifiers you keep repeating.
  2. You move the chain into a ViewModifier, written once.
  3. A small extension gives it a short name such as card().
  4. Every screen uses the same call, so one change updates the whole app.

Once you can build screens, the next skill is building parts: small, reusable pieces that give a whole app one consistent look and make each screen shorter. This lesson shows the SwiftUI tools for that, then two features that make apps feel polished: shared-element animation and charts. All of them need iOS 16 or newer, and most need nothing beyond what you already know.

ViewModifier: reuse a style

If you repeat the same chain of modifiers, such as padding, background, corners and shadow, wrap it in a ViewModifier. Add a small extension so the call reads like a built-in modifier.

import SwiftUI

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.background, in: RoundedRectangle(cornerRadius: 16))
            .shadow(color: .black.opacity(0.1), radius: 6, y: 2)
    }
}

extension View {
    func card() -> some View { modifier(CardStyle()) }
}

// use
Text("Hello").card()

ViewBuilder: containers that accept views

A view that takes other views as its content uses a `@ViewBuilder` property. This is how VStack { ... } works, and you can write your own.

struct TitledCard<Content: View>: View {
    let title: String
    @ViewBuilder var content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title).font(.headline)
            content
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .card()
    }
}

// use
TitledCard(title: "This month") {
    Text("Food: 120")
    Text("Travel: 60")
}

A custom Layout

The Layout protocol (iOS 16 and up) lets you decide exactly how children are measured and placed, when stacks and grids are not enough. You write two methods: one that reports the total size, and one that places each child. This example makes every child as wide as the widest one.

struct EqualWidthHStack: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let width = sizes.map(\.width).max() ?? 0
        let height = sizes.map(\.height).max() ?? 0
        return CGSize(width: width * CGFloat(subviews.count), height: height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let width = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
        for (index, subview) in subviews.enumerated() {
            subview.place(
                at: CGPoint(x: bounds.minX + CGFloat(index) * width, y: bounds.minY),
                proposal: ProposedViewSize(width: width, height: bounds.height)
            )
        }
    }
}

// use
EqualWidthHStack {
    Button("OK") {}
    Button("Cancel, maybe later") {}
}

matchedGeometryEffect: shared-element animation

matchedGeometryEffect tells SwiftUI that two views in different states are *the same thing*, so it moves and resizes one into the other. The classic use is a selection highlight that slides between tabs.

struct PeriodPicker: View {
    @Namespace private var namespace
    @State private var selected = 0
    private let titles = ["Day", "Week", "Month"]

    var body: some View {
        HStack {
            ForEach(titles.indices, id: \.self) { index in
                Text(titles[index])
                    .padding(.horizontal, 14)
                    .padding(.vertical, 8)
                    .background {
                        if selected == index {
                            Capsule()
                                .fill(.blue.opacity(0.2))
                                .matchedGeometryEffect(id: "highlight", in: namespace)
                        }
                    }
                    .onTapGesture {
                        withAnimation(.easeInOut) { selected = index }
                    }
            }
        }
    }
}

Only one capsule exists at a time, with the same id and namespace, so when selected changes SwiftUI animates it from the old place to the new one. The same idea can grow a thumbnail into a full-screen image.

Charts

Apple's Charts framework draws bar, line, area and other charts from your data in a few lines, with accessibility and Dark Mode built in. Each data point becomes a *mark*, and you say which values map to which axis.

import Charts
import SwiftUI

struct CategoryTotal: Identifiable {
    let id = UUID()
    let name: String
    let amount: Double
}

struct SpendingChart: View {
    let totals = [
        CategoryTotal(name: "Food", amount: 120),
        CategoryTotal(name: "Travel", amount: 60),
        CategoryTotal(name: "Fun", amount: 45)
    ]

    var body: some View {
        Chart(totals) { item in
            BarMark(
                x: .value("Category", item.name),
                y: .value("Amount", item.amount)
            )
            .foregroundStyle(by: .value("Category", item.name))
        }
        .frame(height: 220)
    }
}

Performance habits for SwiftUI

  • Cheap bodies. The body runs often. Do not sort big arrays, format many dates or decode JSON inside it. Compute once, and keep the result in state or the view model.
  • Stable identity. Give ForEach a stable id. An id that changes each time makes SwiftUI throw away and rebuild rows.
  • Lazy containers. Use LazyVStack or List for long content, and an ordinary VStack for a few views.
  • Small views. When one value changes, only the views that read it are redrawn. Splitting a big body into small views keeps redraws small.
  • Measure, do not guess. Xcode has a SwiftUI template in Instruments that shows how often views update.

Accessibility and Dark Mode in reusable parts

The great advantage of reusable parts is that you fix a problem once. Build accessibility into them from the start. In CardStyle, use semantic colours such as .background and .primary, so Dark Mode works without extra code. In a custom control, add .accessibilityLabel("...") and, where it makes sense, .accessibilityAddTraits(.isButton). Text that uses .font(.body) or another text style grows when people choose larger text in Settings, so avoid fixed point sizes. Preview your parts in several settings at once, for example Dark Mode with the largest text size, using the Xcode preview variants. Ten minutes spent here saves many support emails later, and it is a point interviewers like to hear.

Common mistakes

  • Copying the same five modifiers on every screen instead of making a ViewModifier.
  • A matchedGeometryEffect where the same id appears twice at once, which confuses the animation.
  • Using GeometryReader for everything. It takes all the space it is given, and often a simpler tool is better.

Your task

  1. Make CardStyle and TitledCard and use them on two screens of your app.
  2. Write EqualWidthHStack and compare it with a normal HStack of two buttons with different text lengths.
  3. Build the PeriodPicker and check that the highlight slides.
  4. Draw the spending chart from your own expense data, grouped by category.

How to check it worked

  • Changing CardStyle in one place changes every card.
  • The highlight moves smoothly between Day, Week and Month.
  • You can name three habits that keep a SwiftUI screen fast.

Frequently asked questions

When should I write a custom ViewModifier?

When you repeat the same chain of modifiers in several places. Put it in a ViewModifier and add a View extension so the call is short and the style lives in one place.

What is matchedGeometryEffect used for?

It tells SwiftUI that two views in different states are the same element, so it animates one into the other. A sliding tab highlight is a common example.

Which iOS version do the Charts framework and the Layout protocol need?

Both arrived in iOS 16, so they work if your minimum version is iOS 16 or newer.