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?
2. What does the @ViewBuilder attribute enable in custom views?
3. What is the primary purpose of PreferenceKey in SwiftUI?
4. Which method must be implemented when conforming to PreferenceKey?
Flashcards
Question
What is a custom view in SwiftUI?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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