Skip to content
intermediate Phase 7 · Advanced UI

Custom Views & ViewModifiers

Create reusable components with custom ViewModifiers, preference keys, and view composition.

50m
3 problems
Topic Progress 0%

Building Reusable Components

Why Custom Views Matter

In SwiftUI, every view is a struct that conforms to the View protocol. As your app grows, you will find yourself repeating the same layout patterns and styling across multiple screens. Custom views solve this by encapsulating reusable UI components into self-contained building blocks.

Defining a Custom View

A custom view is simply a struct that conforms to View and implements the body property. The body property returns some View, which is the view's content.

struct UserAvatar: View {
    let name: String
    let imageURL: URL?
    var size: CGFloat = 48
    
    var body: some View {
        VStack(spacing: 4) {
            AsyncImage(url: imageURL) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                case .failure:
                    Image(systemName: "person.circle.fill")
                        .foregroundColor(.gray)
                default:
                    ProgressView()
                }
            }
            .frame(width: size, height: size)
            .clipShape(Circle())
            .overlay(Circle().stroke(Color.blue, lineWidth: 2))
            
            Text(name)
                .font(.caption)
                .lineLimit(1)
        }
    }
}

// Usage
UserAvatar(name: "Alice", imageURL: user.profileImage, size: 64)

Using ViewBuilder for Dynamic Content

When your custom view needs to accept content from the parent, use the @ViewBuilder attribute. This allows you to pass closures that return multiple views.

struct CardView<Content: View>: View {
    let title: String
    @ViewBuilder let content: () -> Content
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title)
                .font(.headline)
                .foregroundColor(.primary)
            
            content()
        }
        .padding()
        .background(
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemBackground))
                .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
        )
    }
}

// Usage with content builder
CardView(title: "Profile") {
    Text("Email: user@example.com")
    Text("Phone: (555) 123-4567")
    Button("Edit Profile") { /* action */ }
}

State and Binding in Custom Views

Custom views should manage their own state when appropriate, and expose bindings when the parent needs to control state.

struct ToggleCard: View {
    let title: String
    @Binding var isOn: Bool
    @State private var isExpanded = false
    
    var body: some View {
        VStack {
            HStack {
                Text(title)
                Spacer()
                Toggle("", isOn: $isOn)
                    .labelsHidden()
            }
            .onTapGesture {
                withAnimation(.spring()) {
                    isExpanded.toggle()
                }
            }
            
            if isExpanded {
                Text("Additional details here...")
                    .transition(.opacity.combined(with: .move(edge: .top)))
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .cornerRadius(10)
    }
}

Designing for Reusability

Good custom views follow these principles: keep the interface small, use sensible defaults for parameters, and avoid embedding business logic. The view should be a pure presentation layer component that receives data and renders UI.

When creating component libraries, organize views by feature or by UI element type. Consider using extensions to add convenience initializers or computed properties that simplify usage at the call site.

ViewModifier Protocol

Understanding ViewModifier

The ViewModifier protocol allows you to encapsulate a set of view modifications into a reusable unit. Instead of chaining multiple modifiers directly on views, you can group them into a modifier and apply it consistently.

Implementing ViewModifier

To create a custom modifier, define a struct that conforms to ViewModifier and implement the body(content:) method. The content parameter represents the view being modified.

struct PrimaryButtonStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.headline)
            .foregroundColor(.white)
            .padding(.horizontal, 24)
            .padding(.vertical, 12)
            .background(
                LinearGradient(
                    colors: [.blue, .blue.opacity(0.8)],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
            .cornerRadius(12)
            .shadow(color: .blue.opacity(0.3), radius: 8, x: 0, y: 4)
    }
}

// Applying the modifier
Button("Sign Up") { }
    .modifier(PrimaryButtonStyle())

// Or using an extension for cleaner syntax
extension View {
    func primaryButton() -> some View {
        modifier(PrimaryButtonStyle())
    }
}

Button("Sign Up") { }
    .primaryButton()

Parameterized Modifiers

Modifiers can accept parameters to make them more flexible. This is useful when you need variations of the same styling pattern.

struct CardModifier: ViewModifier {
    let backgroundColor: Color
    let cornerRadius: CGFloat
    let shadow: Bool
    
    init(
        background: Color = Color(.systemBackground),
        cornerRadius: CGFloat = 12,
        shadow: Bool = true
    ) {
        self.backgroundColor = background
        self.cornerRadius = cornerRadius
        self.shadow = shadow
    }
    
    func body(content: Content) -> some View {
        content
            .padding()
            .background(backgroundColor)
            .cornerRadius(cornerRadius)
            .if(shadow) { view in
                view.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
            }
    }
}

// Conditional modifier extension
extension View {
    @ViewBuilder
    func `if`<Transform: View>(
        _ condition: Bool,
        transform: (Self) -> Transform
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

ViewModifier vs Extension on View

Both approaches achieve reusability, but they serve different purposes. ViewModifier is better when you need a complete transformation of a view, including its content. Extensions on View are better for adding individual modifiers that chain naturally.

Use ViewModifier when:

  • You are creating a design system component
  • The modifier involves complex layout changes
  • You want to encapsulate multiple related modifiers

Use View extension when:

  • You are adding a single, simple modifier
  • The modifier is parameterized and composable
  • You want to maintain the natural modifier chaining syntax

Preference Keys

What Are Preference Keys?

Preference keys allow child views to communicate data up to their parent views. This is essential for building complex layouts where child views need to report measurements, selections, or other state to ancestors without tight coupling.

Defining a PreferenceKey

A preference key must conform to the PreferenceKey protocol and implement a reduce method that combines values from multiple child views.

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

Using Preferences in Practice

Child views set preference values, and parent views read them. This is commonly used for dynamic sizing, custom navigation bar modifications, and scroll tracking.

struct AdaptiveContainerView<Content: View>: View {
    @State private var childHeight: CGFloat = 0
    @ViewBuilder let content: () -> Content
    
    var body: some View {
        ScrollView {
            content()
                .background(
                    GeometryReader { geo in
                        Color.clear
                            .preference(
                                key: HeightPreferenceKey.self,
                                value: geo.size.height
                            )
                    }
                )
                .onPreferenceChange(HeightPreferenceKey.self) { height in
                    childHeight = height
                }
        }
        .frame(height: min(childHeight, UIScreen.main.bounds.height * 0.8))
    }
}

ScrollViewReader and Preference Keys

Preference keys are particularly useful for scroll-based interactions. You can track which view is currently visible and update UI accordingly.

struct SectionTrackingView: View {
    @State private var visibleSection: String = ""
    
    var body: some View {
        VStack {
            Text("Current: \(visibleSection)")
                .font(.headline)
                .padding()
            
            ScrollView {
                ForEach(sections) { section in
                    SectionHeader(title: section.title)
                        .background(
                            GeometryReader { geo in
                                Color.clear
                                    .preference(
                                        key: VisibleSectionKey.self,
                                        value: [
                                            section.title: geo.frame(in: .global).minY
                                        ]
                                    )
                            }
                        )
                }
            }
            .onPreferenceChange(VisibleSectionKey.self) { values in
                let screenCenter = UIScreen.main.bounds.height / 2
                visibleSection = values
                    .min(by: { abs($0.value - screenCenter) < abs($1.value - screenCenter) })?
                    .key ?? ""
            }
        }
    }
}

Advanced: Multiple Preference Keys

Complex views often use multiple preference keys simultaneously. Each key handles a different aspect of child-to-parent communication. This pattern is used extensively in navigation bar customization, where children set title, background color, and bar buttons independently.

Performance Considerations

Preference keys trigger view updates when their values change. Keep values lightweight and avoid frequent unnecessary updates. Use onPreferenceChange sparingly and consider debouncing rapid changes in scroll-based preferences.

Quiz

1. What protocol must a struct conform to in order to be used as a reusable view modifier?

Question 1 options

2. What does the @ViewBuilder attribute enable in custom views?

Question 2 options

3. What is the primary purpose of PreferenceKey in SwiftUI?

Question 3 options

4. Which method must be implemented when conforming to PreferenceKey?

Question 4 options

Flashcards

Question

What is a custom view in SwiftUI?

Answer

A struct conforming to the View protocol that encapsulates reusable UI components with a body property returning some View.

Question

What does ViewModifier allow you to do?

Answer

Encapsulate a set of view modifications into a reusable unit that can be applied consistently across multiple views.

Question

How do child views communicate data to parents in SwiftUI?

Answer

Using PreferenceKey - child views set preference values and parent views read them using onPreferenceChange.

Question

When should you use ViewModifier over a View extension?

Answer

ViewModifier is better for complex, complete transformations of views. View extensions are better for single, simple modifiers.

Revision Notes

Key Takeaways

  • 1. Custom views are structs conforming to View that encapsulate reusable UI
  • 2. ViewModifier groups multiple modifiers into a reusable unit
  • 3. PreferenceKeys enable child-to-parent data flow
  • 4. Use @ViewBuilder for content that accepts multiple views
  • 5. Parameterized modifiers increase flexibility and reusability

Interview Tips

  • Explain the difference between ViewModifier and View extension approaches
  • Discuss when to use PreferenceKey vs EnvironmentObject for parent-child communication
  • Describe how to build a component library using custom views and modifiers

Cheat Sheet

Custom Views & ViewModifiers Quick Reference

  • Custom View: struct conforming to View with body property
  • ViewModifier: struct conforming to ViewModifier with body(content:) method
  • Apply modifier: .modifier(MyModifier()) or extension .myModifier()
  • PreferenceKey: allows child-to-parent communication
  • @ViewBuilder: enables multi-view content closures
  • Use extensions on View for cleaner modifier syntax