Matthew's Dev Blog

Sized-to-fit SwiftUI bottom sheet

Using detents to control the height of a bottom sheet

With Xcode 14, Apple introduced a PresentationDetent struct into SwiftUI, to control the height of a bottom sheet. Possible values are:

  • .large to show a full-height sheet
  • .medium to show a half-height sheet
  • .custom<D>(D.Type) to provide a custom type which specifies the height
  • .fraction(CGFloat) to specify a fraction of the available height
  • .height(CGFloat) for a fixed height

These are useful, but most of the time we want a sheet which is sized for its content.

Sizing a bottom-view to fit its content

  • first get the height of the sheet contents, and put that value into a Preference
  • listen for that preference in the presenting view
  • store the height in a @State value in the presenting view
  • use that state value in a fixed-height detent

Let's start with these two views:

struct ContentView: View {
	@State var presentSheet: Bool = false

	var body: some View {
		Button("Tap me") {
			self.presentSheet.toggle()
		}
		.sheet(isPresented: self.$presentSheet) {
			BottomView()
		}
	}
}

struct BottomView: View {
	var body: some View {
		VStack {
			Text("Hello")
			Text("World")
		}
	}
}

How to size the bottom-view correctly

I guess that most SwiftUI projects have a ViewModifier which captures the height of a view. Here's mine:

struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat?

    static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
		guard let nextValue = nextValue() else { return }
        value = nextValue
    }
}

private struct ReadHeightModifier: ViewModifier {
    private var sizeView: some View {
        GeometryReader { geometry in
            Color.clear.preference(key: HeightPreferenceKey.self, value: geometry.size.height)
        }
    }

    func body(content: Content) -> some View {
        content.background(sizeView)
    }
}

extension View {
    func readHeight() -> some View {
        self
            .modifier(ReadHeightModifier())
    }
}

Remember that preference values propagate up the view hierarchy, from child to parent.

It's all wired-up like this:

struct ContentView: View {
	@State var presentSheet: Bool = false
	@State var detentHeight: CGFloat = 0
	
	var body: some View {
		Button("Tap me") {
			self.presentSheet.toggle()
		}
		.sheet(isPresented: self.$presentSheet) {
			BottomView()
				.readHeight()
				.onPreferenceChange(HeightPreferenceKey.self) { height in
					if let height {
						self.detentHeight = height
					}
				}
				.presentationDetents([.height(self.detentHeight)])
		}
	}
}

And yes, it works great with dynamic type!

Sized-to-fit bottom sheet

Note about presentationDragIndicator

If you intent to show a drag indicator, using the .presentationDragIndicator(.visible) modifier, then you'll also need to add a little top padding to bottom-sheet content:

struct BottomView: View {
	var body: some View {
		VStack {
			Text("Hello")
			Text("World")
		}
		.padding(.top)
	}
}
Tagged with:

First published 27 November 2022