How to Create and Combine SwiftUI Views Without Getting Lost in Deep Nesting and Complex Layouts

11 min read Original article ↗

As you create increasingly complex SwiftUI views, you may feel your code is turning into a tangled mess of nested stacks, layout view modifiers, and conditionals.

In this article, we’ll explore how to leverage SwiftUI’s full toolkit—beyond just stacks—to build configurable views. You’ll learn to use built-in specialized views, view styles, and view builders for idiomatic code that’s easier to customize.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Table of contents

  1. The standard approach of passing data to custom SwiftUI views through memberwise initializers
  2. As user interface complexity grows, views become cluttered with nested stacks and conditionals
  3. Removing nested stacks and layout modifiers by using specific built-in SwiftUI views
  4. Using styles to customize the appearance of built-in SwiftUI views
  5. Creating custom view styles to change the layout structure of built-in SwiftUI views
  6. Using view builders to pass content to container views declaratively

The standard approach of passing data to custom SwiftUI views through memberwise initializers

When creating SwiftUI views, the most natural approach is to pass data using stored properties that get reflected into the view’s memberwise initializer.

However, in specifically complex views, cramming everything into stored properties creates structural problems.

Let’s take, as an example, a view showing the stops in an itinerary, as you would show in a navigation or a public transport app.

The standard approach would be to define a view with a few stored properties and, in the body, use stacks and layout view modifiers to arrange and format the passed data.

struct Stop: View {
	let title: String
	let systemImage: String

	var body: some View {
		HStack(spacing: 16) {
			Image(systemName: systemImage)
				.foregroundStyle(.tint)
				.font(.title2)
			Text(title)
		}
	}
}

struct ItineraryView: View {
	var body: some View {
		List {
			Stop(title: "Your location", systemImage: "circle.circle.fill")
			Stop(title: "Walk 10 minutes", systemImage: "figure.walk")
			Stop(title: "Marnixplein", systemImage: "train.side.front.car")
			Stop(title: "Elandsgracht", systemImage: "mappin.and.ellipse.circle")
		}
		.listStyle(.plain)
	}
}

#Preview {
	ItineraryView()
}

This seems straightforward at first, but it’s already displaying some issues. The icons are not vertically centered as we would like, which would require additional layout code.

As user interface complexity grows, views become cluttered with nested stacks and conditionals

The problems start piling up when each row needs to display different content. If we follow the same approach, the view’s stored properties multiply, and we need to introduce optionals and conditional binding.

struct Stop: View {
	let title: String
	let systemImage: String
	// These stored properties must be optional to remove them from
	// initializers when they are not needed.
	var time: String?
	var action: (() -> Void)?

	var body: some View {
		// Displaying the accessory views on the right requires
		// nested stacks and spacers.
		HStack {
			HStack(spacing: 16) {
				// ...
			}
			Spacer()
			// Each optional property requires optional binding.
			if let time {
				Text(time)
			}
			if let action {
				Button(action: action) {
					Image(systemName: "map")
				}
			}
		}
	}
}

struct ItineraryView: View {
	var body: some View {
		List {
			Stop(title: "Your location", systemImage: "circle.circle.fill")
			Stop(title: "Walk 10 minutes", systemImage: "figure.walk", action: {})
			Stop(title: "Marnixplein", systemImage: "train.side.front.car")
			Stop(
				title: "Elandsgracht",
				systemImage: "mappin.and.ellipse.circle",
				time: "12:07"
			)
		}
		.listStyle(.plain)
	}
}

This approach is not intrinsically wrong. It is not a mistake to use nested stacks, spacers, and layout view modifiers. However, it is time-consuming to deal with SwiftUI’s layout process and determine the correct modifiers and spacing to align the views on the screen perfectly.

Moreover, the above approach becomes untenable when each row requires further customization, increasing the number of the view’s stored properties, the nesting of stacks, and the number of conditionals in its body.

Some developers might pass a whole model type to the view to reduce the number of stored properties. However, that would not solve the view’s structural problems, and it introduces other issues.

Removing nested stacks and layout modifiers by using specific built-in SwiftUI views

SwiftUI provides specialized views that handle the most common layout and styling automatically.

Instead of defaulting to stacks, always check the SwiftUI documentation for alternatives. These reduce nesting and make your code more declarative, easy to configure, and ultimately reusable.

The Label view removes the need to use an HStack with Image and Text every time you need to combine text and images. Moreover, it inherits its style from its container, eliminating the need for additional view modifiers.

struct Stop: View {
	let title: String
	let systemImage: String
	var time: String?
	var action: (() -> Void)?

	var body: some View {
		HStack {
			Label(title, systemImage: systemImage)
			Spacer()
			// ...
		}
	}
}

In a List, the images of a label have the proper size, are vertically aligned along their central axis, and are tinted automatically, eliminating the need for additional view modifiers and guesswork.

Content paired with a label is also common enough for SwiftUI to provide a built-in view.

LabeledContent is ideal for settings or information rows, where a label is placed on one side and content on the other. It adapts to contexts such as Form or List without requiring extra layout code.

struct Stop: View {
	let title: String
	let systemImage: String
	var time: String?
	var action: (() -> Void)?

	var body: some View {
		LabeledContent {
			if let time {
				Text(time)
			}
			if let action {
				Button(action: action) {
					Image(systemName: "map")
				}
			}
		} label: {
			Label(title, systemImage: systemImage)
		}
	}
}

In this case, it might seem we didn’t gain anything as the HStack was merely replaced by a LabeledContent view.

However, we already had some improvements. The Spacer is gone, and the LabeledContent adds the standard styling to a row’s content in a List

Moreover, LabeledContent adapts to its container, ensuring it displays appropriately when used in other contexts and platforms, such as on macOS. The other benefits of using LabeledContent will become more evident below.

Using styles to customize the appearance of built-in SwiftUI views

The map button still does not appear as we intended. Luckily, we can easily customize it.

struct MapButton: View {
	let action: () -> Void

	var body: some View {
		Button(action: action) {
			Image(systemName: "map")
		}
		.buttonStyle(.borderedProminent)
		.buttonBorderShape(.circle)
	}
}

#Preview {
	List {
		LabeledContent {
			MapButton(action: {})
		} label: {
			Text("Hello, World!")
		}
	}
	.listStyle(.plain)
}

But how does that work exactly?

The buttonStyle(_:) view modifier takes a ButtonStyle value, which is then passed to the Button type through the environment.

We don’t need to customize our button further, but we can use the same concept elsewhere. Styles are supported by many of the built-in SwiftUI views.

Creating custom view styles to change the layout structure of built-in SwiftUI views

To create the tags for public transport lines, we can use another built-in view. The GroupBox view provides a solid background with rounded corners, so we don’t need to mess with extra view modifiers.

struct TransportTag: View {
	let line: String
	let systemImage: String

	var body: some View {
		GroupBox {
			Label(line, systemImage: systemImage)
		}
		.backgroundStyle(.tint.quinary)
	}
}

#Preview {
	List {
		LabeledContent {
			TransportTag(line: "5", systemImage: "tram")
		} label: {
			Text("Hello, World!")
		}
	}
	.listStyle(.plain)
}

The tag, however, is too large and has excessive space between the image and the number because of the Label view.

There is no built-in label style that meets our needs, but we can build our own, creating a structure that conforms to the LabelStyle protocol. This is where we use stacks and other explicit layout views and modifiers to change a label’s appearance.

struct PublicTransportLabelStyle: LabelStyle {
	func makeBody(configuration: Configuration) -> some View {
		HStack(spacing: 4) {
			configuration.icon
			configuration.title
		}
		.padding(-4)
		.frame(height: 4)
	}
}

extension LabelStyle where Self == PublicTransportLabelStyle {
	static var publicTransport: PublicTransportLabelStyle { .init() }
}

struct TransportTag: View {
	let line: String
	let systemImage: String

	var body: some View {
		GroupBox {
			Label(line, systemImage: systemImage)
				.labelStyle(.publicTransport)
		}
		.backgroundStyle(.tint.quinary)
	}
}

The LabeledContent view displays its content vertically, but we want our public transport tags displayed horizontally. We can also change that, creating a custom style structure that conforms to the LabeledContentStyle protocol.

struct InlineLabeledContentStyle: LabeledContentStyle {
	func makeBody(configuration: Configuration) -> some View {
		HStack {
			configuration.label
			Spacer()
			configuration.content
				.foregroundStyle(.secondary)
		}
	}
}

extension LabeledContentStyle where Self == InlineLabeledContentStyle {
	static var inline: InlineLabeledContentStyle { .init() }
}

#Preview {
	List {
		LabeledContent {
			TransportTag(line: "5", systemImage: "tram")
			TransportTag(line: "7", systemImage: "bus")
		} label: {
			Text("Hello, World!")
		}
	}
	.listStyle(.plain)
	.labeledContentStyle(.inline)
}

Since styles are passed through the environment, we can even apply them to any view that uses Label and LabeledContent internally, even when we don’t have access to the view’s source code.

Moreover, you can easily apply styles conditionally, depending on external factors such as containers and platforms. This is how, for example, a List affects the aspects of the Label and LabeledContent views.

Using view builders to pass content to container views declaratively

View styles and the SwiftUI environment allow us to achieve a high degree of customization. However, they don’t address all our problems.

Our Stop view still relies on multiple stored properties and conditional statements to decide which accessory to display. What we need is the ability to pass views as parameters to other views.

The solution is right in front of us. The Label, LabeledContent, and GroupBox views we have used above, like stacks and other containers, all accept other views through their initializers using view builders.

We can do the same in our Stop view.

struct Stop<Accessory: View>: View {
	let title: String
	let systemImage: String
	@ViewBuilder let accessory: () -> Accessory

	var body: some View {
		LabeledContent {
			accessory()
		} label: {
			Label(title, systemImage: systemImage)
		}
	}
}
Note

Since the caller defines the return type of a view builder, you must express it using a Swift generic with a View type constraint.

Since not all stops in our ItineraryView require an accessory, we could make the accessory optional. However, that creates some compilation problems in some calls.

The alternative is to provide a concrete default value in a custom initializer.

extension Stop where Accessory == EmptyView {
	init(title: String, systemImage: String) {
		self.title = title
		self.systemImage = systemImage
		self.accessory = { EmptyView() }
	}
}

Thanks to built-in SwiftUI views, view styles, and view builders, our code views are now highly customizable, eliminating the need for extra stored properties and conditional statements.

struct ItineraryView: View {
	var body: some View {
		List {
			Stop(title: "Your location", systemImage: "circle.circle.fill")
			Stop(title: "Walk 10 minutes", systemImage: "figure.walk") {
				MapButton(action: {})
				}
			Stop(title: "Marnixplein", systemImage: "train.side.front.car") {
				TransportTag(line: "5", systemImage: "tram")
				TransportTag(line: "7", systemImage: "bus")
				}
			.labeledContentStyle(.inline)
			Stop(title: "Elandsgracht", systemImage: "mappin.and.ellipse.circle") {
				Text("12:17")
				}
		}
		.listStyle(.plain)
	}
}

Conclusions

Instead of merely relying on standard Swift features like memberwise initializers and conditional statements, you can make your SwiftUI views more configurable and reusable by embracing all the tools offered by the framework.

  • Built-in specialized views remove the need for nested stacks and layout modifiers. 
  • View styles enable you to customize the structure of any built-in view without requiring access to its source code.
  • View builders reduce the number of stored properties and parameters in memberwise initializers, allowing you to declare the content of your custom views from outside the type.

These are only a few of the tools you can use to improve the structure of your SwiftUI apps. To go further, you need to adopt standard architectural patterns. I explain how in my free guide, which you can access below.

SwiftUI App Architecture: Design Patterns and Best Practices

It's easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.

GET THE FREE BOOK NOW