Dynamic Height Tray
A sheet exactly as tall as its content, which grows and shrinks as one step of a flow gives way to the next.

- Sheets
- iOS 18.0+
- intermediate
- Origin
- Inspired by The trays of the Family wallet app, written from scratch
- Found by
- tray · self sizing sheet · dynamic height · bottom sheet · steps · fit content · detent · bandeja · hoja autoajustable · altura dinámica · hoja inferior · pasos · ajustar al contenido · detent
Description
Section titled “Description”Presents a sheet that is exactly as tall as its content, and that grows or shrinks with it when the content changes, as when one step of a flow gives way to the next.
Change the content inside withAnimation and the tray follows it. The content is measured at the height it asks for, so give a list too long for the screen a frame of its own.
struct DynamicHeightTrayDemo: View { private enum Step { case options, phrase, confirm, done }
private let words = ["river", "amber", "quiet", "orbit", "maple", "candle", "harbor", "velvet", "summit", "ember", "willow", "north"]
@State private var isPresented = false @State private var step = Step.options @State private var hasWallet = true @State private var hidesSmallBalances = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 14) { Image(systemName: "wallet.bifold.fill") .font(.system(size: 52)) .foregroundStyle(.tint) .accessibilityHidden(true) Text(hasWallet ? "Main Wallet" : "No wallet") .font(.title2.bold())
Button(hasWallet ? "Manage Wallet" : "Add Wallet") { guard hasWallet else { hasWallet = true return } step = .options isPresented = true } .buttonStyle(.borderedProminent) .controlSize(.large) .padding(.top, 8) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) .dynamicHeightTray(isPresented: $isPresented) { VStack(alignment: .leading, spacing: 16) { switch step { case .options: options case .phrase: phrase case .confirm: confirm case .done: done } } .padding(.horizontal, 24) .padding(.top, 32) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .leading) } }
private func go(to next: Step) { withAnimation(reduceMotion ? nil : .snappy(duration: 0.35)) { step = next } }
@ViewBuilder private var options: some View { Text("Manage Wallet") .font(.title3.bold())
row("View Recovery Phrase", symbol: "key.fill") { go(to: .phrase) } row(hidesSmallBalances ? "Show Small Balances" : "Hide Small Balances", symbol: hidesSmallBalances ? "eye.fill" : "eye.slash.fill") { hidesSmallBalances.toggle() } row("Remove Wallet", symbol: "trash.fill", role: .destructive) { go(to: .confirm) } }
@ViewBuilder private var phrase: some View { Text("Recovery Phrase") .font(.title3.bold())
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) { ForEach(Array(words.enumerated()), id: \.offset) { index, word in Text("\(index + 1). \(word)") .font(.callout.monospaced()) .lineLimit(1) .minimumScaleFactor(0.7) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(.fill.quaternary, in: .rect(cornerRadius: 12)) } }
Button("Back") { go(to: .options) } .buttonStyle(.bordered) .controlSize(.large) .frame(maxWidth: .infinity) }
@ViewBuilder private var confirm: some View { Text("Remove this wallet?") .font(.title3.bold()) Text("You will need its recovery phrase to add it again.") .font(.subheadline) .foregroundStyle(.secondary)
HStack(spacing: 12) { Button("Cancel") { go(to: .options) } .buttonStyle(.bordered)
Button("Remove", role: .destructive) { hasWallet = false go(to: .done) } .buttonStyle(.borderedProminent) } .controlSize(.large) .frame(maxWidth: .infinity) }
@ViewBuilder private var done: some View { Label("Wallet removed", systemImage: "checkmark.circle.fill") .font(.title3.bold()) .foregroundStyle(.green) .frame(maxWidth: .infinity) .padding(.vertical, 8)
Button("Done") { isPresented = false } .buttonStyle(.borderedProminent) .controlSize(.large) .frame(maxWidth: .infinity) }
private func row(_ title: String, symbol: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { Button(role: role, action: action) { Label(title, systemImage: symbol) .font(.body.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 14) .padding(.horizontal, 16) .background(.fill.quaternary, in: .rect(cornerRadius: 14)) } .buttonStyle(.plain) .foregroundStyle(role == .destructive ? AnyShapeStyle(.red) : AnyShapeStyle(.primary)) }}API reference
Section titled “API reference”dynamicHeightTray(isPresented:content:)
| Parameter | Type | Default |
|---|---|---|
isPresented | Binding<Bool> | Required |
content | @escaping () -> Tray | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum DynamicHeightTrayLogic { /// The height the tray takes for content that is `content` tall: never under `minimum` nor /// over `maximum`, and a whole number of points, so a measure that is off by a fraction /// does not start another round of layout. static func height(content: Double, minimum: Double = 80, maximum: Double) -> Double { guard content.isFinite, maximum.isFinite else { return minimum } return min(max(content.rounded(.up), minimum), max(maximum, minimum)) }}
extension View { /// Presents a sheet that is exactly as tall as its content, and that grows or shrinks with /// it when the content changes, as when one step of a flow gives way to the next. /// /// Change the content inside `withAnimation` and the tray follows it. The content is measured /// at the height it asks for, so give a list too long for the screen a frame of its own. func dynamicHeightTray<Tray: View>(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> Tray) -> some View { modifier(DynamicHeightTrayModifier(isPresented: isPresented, tray: content)) }}
private struct DynamicHeightTrayModifier<Tray: View>: ViewModifier { @Binding var isPresented: Bool @ViewBuilder var tray: () -> Tray
/// The share of the screen the tray may take at most. private let maximumShare = 0.9
@State private var detents: Set<PresentationDetent> = [.height(240)] @State private var detent = PresentationDetent.height(240) @State private var available: CGFloat = 800
func body(content: Content) -> some View { content .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { available = $0 } .sheet(isPresented: $isPresented) { tray() .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measured in // A sheet animates from one detent to another, but not a detent that // changes its height. So the new height joins the old one, the sheet // moves to it, and the old one goes once it has arrived. let fitted = PresentationDetent.height(DynamicHeightTrayLogic.height(content: measured, maximum: available * maximumShare)) detents.insert(fitted) detent = fitted } .frame(maxHeight: .infinity, alignment: .top) .presentationDetents(detents, selection: $detent) .presentationDragIndicator(.visible) .task(id: detent) { try? await Task.sleep(for: .seconds(0.6)) if !Task.isCancelled { detents = [detent] } } } }}
// MARK: - Demo
struct DynamicHeightTrayDemo: View { private enum Step { case options, phrase, confirm, done }
private let words = ["river", "amber", "quiet", "orbit", "maple", "candle", "harbor", "velvet", "summit", "ember", "willow", "north"]
@State private var isPresented = false @State private var step = Step.options @State private var hasWallet = true @State private var hidesSmallBalances = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 14) { Image(systemName: "wallet.bifold.fill") .font(.system(size: 52)) .foregroundStyle(.tint) .accessibilityHidden(true) Text(hasWallet ? "Main Wallet" : "No wallet") .font(.title2.bold())
Button(hasWallet ? "Manage Wallet" : "Add Wallet") { guard hasWallet else { hasWallet = true return } step = .options isPresented = true } .buttonStyle(.borderedProminent) .controlSize(.large) .padding(.top, 8) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) .dynamicHeightTray(isPresented: $isPresented) { VStack(alignment: .leading, spacing: 16) { switch step { case .options: options case .phrase: phrase case .confirm: confirm case .done: done } } .padding(.horizontal, 24) .padding(.top, 32) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .leading) } }
private func go(to next: Step) { withAnimation(reduceMotion ? nil : .snappy(duration: 0.35)) { step = next } }
@ViewBuilder private var options: some View { Text("Manage Wallet") .font(.title3.bold())
row("View Recovery Phrase", symbol: "key.fill") { go(to: .phrase) } row(hidesSmallBalances ? "Show Small Balances" : "Hide Small Balances", symbol: hidesSmallBalances ? "eye.fill" : "eye.slash.fill") { hidesSmallBalances.toggle() } row("Remove Wallet", symbol: "trash.fill", role: .destructive) { go(to: .confirm) } }
@ViewBuilder private var phrase: some View { Text("Recovery Phrase") .font(.title3.bold())
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) { ForEach(Array(words.enumerated()), id: \.offset) { index, word in Text("\(index + 1). \(word)") .font(.callout.monospaced()) .lineLimit(1) .minimumScaleFactor(0.7) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(.fill.quaternary, in: .rect(cornerRadius: 12)) } }
Button("Back") { go(to: .options) } .buttonStyle(.bordered) .controlSize(.large) .frame(maxWidth: .infinity) }
@ViewBuilder private var confirm: some View { Text("Remove this wallet?") .font(.title3.bold()) Text("You will need its recovery phrase to add it again.") .font(.subheadline) .foregroundStyle(.secondary)
HStack(spacing: 12) { Button("Cancel") { go(to: .options) } .buttonStyle(.bordered)
Button("Remove", role: .destructive) { hasWallet = false go(to: .done) } .buttonStyle(.borderedProminent) } .controlSize(.large) .frame(maxWidth: .infinity) }
@ViewBuilder private var done: some View { Label("Wallet removed", systemImage: "checkmark.circle.fill") .font(.title3.bold()) .foregroundStyle(.green) .frame(maxWidth: .infinity) .padding(.vertical, 8)
Button("Done") { isPresented = false } .buttonStyle(.borderedProminent) .controlSize(.large) .frame(maxWidth: .infinity) }
private func row(_ title: String, symbol: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { Button(role: role, action: action) { Label(title, systemImage: symbol) .font(.body.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 14) .padding(.horizontal, 16) .background(.fill.quaternary, in: .rect(cornerRadius: 14)) } .buttonStyle(.plain) .foregroundStyle(role == .destructive ? AnyShapeStyle(.red) : AnyShapeStyle(.primary)) }}
#Preview { DynamicHeightTrayDemo()}