Adding a Bottom Sheet or Partial Modal in SwiftUI

Chase
5 min readMay 29, 2024

--

The sheet modifier in SwiftUI is a powerful tool. Did you know that you are able to easily control the height of the sheet? Let’s learn together how to build one in SwiftUI.

The text of modal above the screenshots of the modals that we will create in this tutorial

Before we get started, please take a couple of seconds to follow me and 👏 clap for the article so that we can help more people learn about this useful content.

Initial Setup

Before we learn about the various options for the sheet modifier, we will need something to display, which brings us to the ModalView. This view has a blue background easily allows us see exactly where the sheet is, compared to the main view. We have also wrapped our Text in a ScrollView allowing this view to work well for users with larger accessibility font sizes. We have also wrapped our view in a navigation stack with a dismiss button in the toolbar. Adding the dismiss button in the toolbar allows our view to be dismissed even if it is running on a phone in landscape orientation. If you are like me when I started writing this tutorial, you may not be aware that displaying a modal on a phone in landscape disables the ability for a user to swipe down to dismiss a sheet, which is why it is crucial to always give the user a way to easily dismiss your view (for reference as to what this looks like in an app see the image below the code block). Adding the dismiss button in the toolbar also means that it will stay out of way of the content in our view, for even the largest accessible font sizes.

//  ModalView.swift
import SwiftUI

struct ModalView: View {
@Environment(\.dismiss) var dismiss

var body: some View {
NavigationStack {
ZStack {
Color.blue.ignoresSafeArea(edges: .all)

ScrollView {
Text("Hello from the modal view!")
.bold()
.foregroundStyle(.white)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.tint(.white)
}
}
}
}
}

#Preview {
ModalView()
}
A screenshot of the modal being displayed in both portrait and landscape, demonstrating the need to add a dismiss button to your view.
A side by side comparison of the fractional modal displayed in both portrait and landscape demonstrating the reason behind why we should add a dismiss button button to every modal.

Building a partial sheet with presentationDetents

We will cover several different options for building a sheet, though you will usually only need one for any given view in your app, because we really only need one sheet displayed at a time, I have decided to keep each option separate so that you can easily use the one that best fits your use case.

The key to making a partial sheet in SwiftUI is the presentationDetents modifier. This modifier allows us to pass in several options (most of which are broken down in the code below) that will ensure we can customize our app to look exactly how we want it to look. One thing to note for the examples below, the heights that are described in the comments are for portrait orientation on an iPhone since landscape orientation causes the modal to be displayed full screen.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State var isShowingMediumModal = false
@State var isShowingLargeModal = false
@State var isShowingFractionalModal = false
@State var isShowingCustomHeightModal = false
@State var isShowingMultipleHeightModal = false
@State var isShowingDynamicDetent = false

var body: some View {
List {
Button("Medium Modal") {
isShowingMediumModal.toggle()
}
Button("Large Modal") {
isShowingLargeModal.toggle()
}
Button("Fractional Modal") {
isShowingFractionalModal.toggle()
}
Button("Custom Height Modal") {
isShowingCustomHeightModal.toggle()
}
Button("Multiple Height Modal") {
isShowingMultipleHeightModal.toggle()
}
Button("Dynamic Height Modal") {
isShowingDynamicDetent.toggle()
}
}
.sheet(isPresented: $isShowingMediumModal, content: {
ModalView()
// this gives us a modal that takes up about half the height of the screen
.presentationDetents([.medium])
})
.sheet(isPresented: $isShowingLargeModal, content: {
ModalView()
// this gives us a modal that takes the same amount of space as an unmodified sheet
.presentationDetents([.large])
})
.sheet(isPresented: $isShowingFractionalModal, content: {
ModalView()
// this gives us a modal that takes up 20% of the screens height
.presentationDetents([.fraction(0.2)])
})
.sheet(isPresented: $isShowingCustomHeightModal, content: {
ModalView()
// this gives us a modal that takes up exactly 100 pixels of height
.presentationDetents([.height(100)])
})
.sheet(isPresented: $isShowingMultipleHeightModal, content: {
ModalView()
// This gives us a modal that starts at 100 pixels tall
// and displays a grab bar at the top of the modal.
// The grab bar will allow the user to pull the modal up to the medium height,
// and then up to the full height or back down.
// Passing multiple size options is great for accessibility and usability of a partial modal
.presentationDetents([.height(100), .medium, .large])
})
.sheet(isPresented: $isShowingDynamicDetent, content: {
ModalView()
// This is a custom option,
// be sure to check out the code block below this section to learn more
.presentationDetents([.dynamicDetent])
})
}
}

#Preview {
ContentView()
}

Changing the height dynamically

In most cases you will want to rely on building views to ensure they work well with any accessible font size. However, on some occasions, you may need to limit the height of your view (possibly to match that pixel perfect design) but still want to support the accessibility font sizes. To create a fixed size modal that still supports larger fonts, we can create a custom presentation detent. In the code below, we are adjusting the height based on the font size from the users device, and ensuring that it fits well on the iPhone 15 Pro that we are testing with. However, the downside to this approach is that not all users have the exact same size device and Apple is constantly making new devices. This means that while our design below works for the iPhone 15 Pro, it may not work for the iPhone SE, or a new smaller Apple device. This approach is more fragile since we are locking our view to a fixed size, therefore be cautious when using this approach and ensure it is the right approach for your app on the devices that your users expect and that you haven’t left out any accessibility concerns.

//  DynamicDetentHeight.swift
import SwiftUI

struct DynamicDetentHeight: CustomPresentationDetent {
static func height(in context: Context) -> CGFloat? {
switch context.dynamicTypeSize {
case .accessibility1:
return 120
case .accessibility2:
return 140
case .accessibility3:
return 160
case .accessibility4:
return 180
case .accessibility5:
return 200
default:
return 100
}
}
}

extension PresentationDetent {
static let dynamicDetent = Self.custom(DynamicDetentHeight.self)
}

If you got value from this article, please consider following me, 👏 clapping for this article, or sharing it to help others more easily find it.

If you have any questions on the topic or know of another way to accomplish the same task, feel free to respond to the post or share it with a friend and get their opinion on it. If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@jpmtech. If you want to see apps that have been built with native mobile development, you can check out my apps here: https://jpmtech.io/apps. Thank you for taking the time to check out my work!

--

--