Creating a Reusable Full Screen SwiftUI View

A repository containing an example Xcode project for this article is available for download from GitHub.

Recently, I was trying to achieve a very common layout in SwiftUI that I have done in UIKit and AutoLayout a number of times in the past. A screen displaying a header image, some text of varying lengths, and a button. For blocks of text that don’t take up the entire screen, the button is pinned to the bottom of the screen. But for longer blocks it should be at the bottom of the scrolled content.

My first step was to create the basic DetailView layout . The SwiftUI necessary to create the view was fairly straightforward and the code is shown below.

struct DetailView: View {
	var body: some View {
		ScrollView {
			VStack {
				Image("DetailBanner")
					.resizable()
					.scaledToFit()
					.padding(.bottom, 20.0)
				VStack {
					Text("Heading")
						.frame(maxWidth: .infinity, alignment: .leading)
						.padding(.bottom, 16.0)
						.font(.title)
					Text(bodyText)
						.frame(maxWidth: .infinity, alignment: .leading)
						.font(.body)
					Spacer(minLength: 32.0)
					Button {
					} label: {
						Text("Click to continue")
							.frame(maxWidth: .infinity)
					}
					.buttonStyle(.borderedProminent)
					.padding(.horizontal)
				}.padding(.horizontal, 32.0)
			}
		}
	}
}

This got me the basic view and, as long as the bodyText is long enough to push the button off-screen, the resulting behavior is correct. But if bodyText is a shorter paragraph, the button doesn’t pin to the bottom of the screen.

I’d expected that the Spacer(minLength:32) between the Text and the Button would have expanded to push the button to the bottom of the screen if the text didn’t fit the screen. But it didn’t.

It turns out I need to use a GeometryReader wrapped around the ScrollView. A GeometryReader is defined as:

A container view that defines its content as a function of its own size and coordinate space.

Using a GeometryReader you can set the frames of child views relative to its size. In this case, the size of the GeometryReader is the full screen because it is the outermost structure in the view.

Once the size of the screen is known, you set the .frame(minHeight:) of the inner VStack to the height of the GeometryReader. The implementation of the DetailView now looks like the listing below.

struct DetailView: View {
	var body: some View {
		GeometryReader { fullView in
			ScrollView {
				VStack {
					Image("DetailBanner")
						.resizable()
						.scaledToFit()
						.padding(.bottom, 20.0)
					VStack {
						Text("Heading")
							.frame(maxWidth: .infinity, alignment: .leading)
							.padding(.bottom, 16.0)
							.font(.title)
						Text(bodyText)
							.frame(maxWidth: .infinity, alignment: .leading)
							.font(.body)
						Spacer(minLength: 32)
						Button {
						} label: {
							Text("Click to continue")
								.frame(maxWidth: .infinity)
						}
						.buttonStyle(.borderedProminent)
						.padding(.horizontal)
					}.padding(.horizontal, 32.0)
				}.frame(minHeight: fullView.size.height)
			}
		}
	}
}

This gives the desired result: when the body of the text is short, the button is at the bottom of the screen. And when the paragraph text is much longer than a screen, it scrolls nicely with the button after the text.

But it’s not very reusable as it sits. You could copy and paste that ScrollView/VStack code every time you need to accomplish the same thing, but that’s not very efficient and it's prone to copy and paste errors.

Making it Reusable

The solution is to create a reusable View component, which I’ve called FullScreenScrollView. The goal is to factor out the GeometryReader and ScrollView in such a way that you can use this view to wrap your individual user interface designs in a reusable container. Structurally, I'm envisioning something like the code below.

struct FullScreenScrollView: View {
	var body: some View {
		GeometryReader { fullView in
			ScrollView {
				VStack {
					// my content needs to live h
				}.frame(minHeight: fullView.size.height)
			}
		}
	}
}

But what isn’t obvious is how to actually get the DetailViews to display within that component. Conceptually, this is what I want.

var body: some View {
    FullScreenScrollView {
        Image(named: "DetailBanner")
        Text(bodyText)
        Spacer()
        Button("Button Label")
    }
}

Introducing a ViewBuilder

This is where using @ViewBuilder comes into play. ViewBuilder is a parameter attribute that allows you to specify the contents of a closure that you can include as content within your view. (SwiftLee has a great article on the topic.)

By adding an initializer with the ViewBuilder parameter attribute, you can wrap your views bracketed within the FullScreenScrollViewwhen you use it elsewhere. The content property of the init function is a trailing closure, so you can declare it outside of the initialization of a FullScreenScrollView.

By adding a content property and the init function using the @ViewBuilder on the content parameter, this becomes the entirety of the FullScreenScrollView.swift implementation.

struct FullScreenScrollView<Content: View>: View {
	let content: Content

	init(@ViewBuilder _ content: () -> Content) {
		self.content = content()
	}

	var body: some View {
		GeometryReader { fullView in
			ScrollView {
				VStack {
					content
				}.frame(minHeight: fullView.size.height)
			}
		}
	}
}

Now the FullScreenScrollView view can be reused anywhere that it is needed and it removes that pesky GeometryReader / VStack from the point-of-use implementation.

That makes the final DetailView code much cleaner.

struct DetailView: View {
	var body: some View {
		FullScreenScrollView() {
			Image("DetailBanner")
				.resizable()
				.scaledToFit()
				.padding(.bottom, 20.0)
			VStack {
				Text("Heading")
					.frame(maxWidth: .infinity, alignment: .leading)
					.padding(.bottom, 16.0)
					.font(.title)
				Text(bodyText)
					.frame(maxWidth: .infinity, alignment: .leading)
					.font(.body)
				Spacer(minLength: 20)
				Button {
				} label: {
					Text("Click to continue")
						.frame(maxWidth: .infinity)
				}
				.buttonStyle(.borderedProminent)
				.padding(.horizontal)
			}.padding(.horizontal, 32.0)
		}
	}
}

Just drop FullscreenScrollView.swift into your project and you should be good to go!