Sheets en SwiftUI que se ajustan automáticamente a su contenido
Arturo Rivas Arias
📐 Las sheet de SwiftUI funcionan muy bien cuando aceptas los tamaños que el sistema propone. El problema aparece cuando tu interfaz no encaja en niguno de esos tamaños. Una vista pequeña queda flotando dentro de un sheet demasiado alto. Una vista algo más grande puede quedar cortada. Y una pantalla que solo necesita presentar una confirmación breve termina ocupando gran parte de la pantalla sin necesidad.
Desde iOS 16, SwiftUI permite controlar la altura de una hoja con presentationDetents, usando valores como .medium, .large, .height(...) o .fraction(...). Es una mejora enorme frente al comportamiento inicial de los sheets, pero sigue teniendo una limitación importante: no existe un detent nativo que diga simplemente “mide el contenido y usa esa altura”.
Y ahí está el matiz importante. SwiftUI sí puede presentar un sheet a una altura fija, y también tiene APIs que pueden medir una vista. Lo que no ofrece de forma directa es el puente entre ambas. Si conseguimos leer la altura real del contenido y aplicársela al modificador .height(...), podemos construir un sheet que se ajuste siempre a la altura del contenido.
🧩 La idea base consiste en observar el tamaño de la vista a presentar y guardar su altura en un @State local. Después, esa altura se transforma en un PresentationDetent.height. Así el sheet deja de depender de valores genéricos como .medium o una magic number para definir una altura y pasa a depender de lo que realmente ocupa su contenido.
import SwiftUI
private struct FittingSheetModifier: ViewModifier {
let extraDetents: Set<PresentationDetent>
@State private var measuredHeight: CGFloat = 1
func body(content: Content) -> some View {
content
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
measuredHeight = max(1, newHeight)
}
.presentationDetents(
Set([.height(measuredHeight)]).union(extraDetents)
)
}
}
extension View {
func fittingSheetDetents(
extraDetents: Set<PresentationDetent> = []
) -> some View {
modifier(FittingSheetModifier(extraDetents: extraDetents))
}
}
⚠️ El valor inicial no debería ser cero. Una altura 0 puede provocar un primer cálculo del layout extraño o una aimación poco natural, ya que el sheet necesita un tamaño válido desde el incio. Usar por ejemplo 1 como valor temporal evita ese problema hasta que SwiftUI mide el contenido real.
Con este modificador, obtenermos una interfaz limpia, sencilla y escalable. La vista que se presenta no necesita conocer nada sobre el mecanismo interno de medición. Solo define su contenido, y el sheet se encarga de adaptarse a su altura.
struct CheckoutView: View {
@State private var showsSummary = falses
var body: some View {
Button("Ver resumen") {
showsSummary = true
}
.sheet(isPresented: $showsSummary) {
PaymentSummaryView()
.fittingSheetDetents()
}
}
}
struct PaymentSummaryView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Resumen del pago")
.font(.title2.bold())
Text("Plan Pro")
Text("Facturación mensual")
Text("Total: 12,99 €")
.font(.headline)
Button("Confirmar") {
// Confirmar operación
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
}
✅ Este patrón encaja especialmente bien en sheets de contenido breve: confirmaciones, filtros compactos, selectores, acciones contextuales, formularios pequeños o resúmenes antes de ejecutar una acción. En todos esos casos, .medium suele ser demasiado grande y .large directamente rompe la intención de mantener compacta la interfaz.
También puedes permitir que el usuario siga expandiendo la sheet. Esto es útil cuando el contenido normalmente es compacto, pero puede crecer en ciertos estados: por ejemplo, cuando se despliega una sección avanzada, aparece una explicación adicional o se muestra una lista de errores.
.sheet(isPresented: $showsSummary) {
PaymentSummaryView()
.fittingSheetDetents(extraDetents: [.medium, .large])
}
🕹️ En este caso, el sheet arranca ajustado al contenido, pero el usuario puede arrastrarlo hasta otros tamaños soportados. No estás eligiendo entre una experiencia automática y una experiencia flexible: puedes combinar ambas.
Hay un detalle importante: si el contenido cambia de altura después de presentarse, la medición también cambia. Eso significa que el sheet puede reajustarse automáticamente cuando aparece contenido condicional.
struct DiscountDetailView: View {
@State private var showsConditions = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Cupón aplicado")
.font(.title3.bold())
Text("Se ha aplicado un descuento del 15% a tu próxima factura.")
Button(showsConditions ? "Ocultar condiciones" : "Ver condiciones") {
withAnimation {
showsConditions.toggle()
}
}
if showsConditions {
Text("El descuento solo se aplica a renovaciones mensuales y no es acumulable con otras promociones activas.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(24)
.fittingSheetDetents(extraDetents: [.medium])
}
}
🎛️ Este tipo de solución parece irrelevatne, pero cambia bastante la percepción de calidad de una interfaz. Un sheet que ocupa exactamente lo que necesita encaja mucho mejor que uno con espacio adicional o en que es necesario hacer scroll para ver toda la información. La pantalla principal se oculta sólo lo necesario, el usuario entiende mejor que está ante una acción contextual y el sistema mantiene esa sensación de componente nativo.
Ahora bien, no conviene usar este patrón para todo. Si el sheet contiene una lista larga, una pantalla de edición compleja o una navegación interna, normalmente es mejor usar .medium, .large o directamente una presentación grande. Ajustar la altura al contenido tiene sentido cuando el contenido tiene una altura razonablemente controlada.
// ✅ Buen candidato
.fittingSheetDetents()
// ✅ Buen candidato con expansión opcional
.fittingSheetDetents(extraDetents: [.medium])
// ⚠️ Mejor pensarlo dos veces si el contenido puede crecer demasiado
.fittingSheetDetents(extraDetents: [.large])
🧠 También merece la pena pensar en accesibilidad. Con tamaños de texto grandes, una vista que normalmente ocupa poco puede crecer mucho. Si el sheet solo ofrece una altura exacta y el contenido no puede desplazarse, podrías terminar con elementos fuera de pantalla o con una experiencia incómoda. Para contenido que pueda crecer por Dynamic Type, añadir un ScrollView interno o permitir .large como detent adicional suele ser una decisión más segura.
struct AccessibleSummarySheet: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Cambios importantes")
.font(.title2.bold())
Text("Hemos actualizado las condiciones del servicio para simplificar la facturación y mejorar la transparencia de los cargos recurrentes.")
Button("Entendido") {
// Cerrar sheet desde el entorno o desde el estado del padre
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
.fittingSheetDetents(extraDetents: [.large])
}
}
🔍 Otro punto delicado es evitar bucles de layout. Medir una vista, guardar la altura en @State y aplicar esa altura a la presentación provoca una nueva actualización. Normalmente SwiftUI estabiliza el layout sin problema, pero si el contenido cambia de tamaño como reacción directa a la altura del sheet, puedes crear un bucle de refresco difícil de depurar. La regla práctica es sencilla: mide el contenido, pero no hagas que el contenido dependa de forma agresiva de la altura que estás midiendo.
Esa es la parte más importante del patrón. No se trata solo de ahorrar unas líneas de código, sino de expresar mejor el diseño. .presentationDetents([.medium, .large]) habla de tamaños de sistema. .fittingSheetDetents() habla de intención de producto: esta presentación es contextual, compacta y debe ocupar lo justo.
🚀 SwiftUI suele brillar cuando convertimos comportamientos repetidos en modificadores pequeños y declarativos. Un sheet autoajustable es un buen ejemplo: combina medición de layout, estado local y una API nativa de presentación para resolver una fricción muy concreta sin abandonar el modelo reactivo de SwiftUI.