Para que las etiquetas cambiantes de los menús desplegables se reorganicen suavemente, mide cada control en su tamaño ideal, colócalos con un algoritmo de ajuste compartido y anima los cambios de selección en el contenedor. Este tutorial crea un pequeño panel de control para macOS con opciones sintéticas.

Separar el ajuste de la animación

La tarea del lector es concreta: crear un panel de escritorio donde elegir una etiqueta más larga en un menú desplegable pueda mover los controles vecinos a otra fila. Los controles deben conservar su identidad mientras cambian de posición, y el panel debe comunicar su nueva altura a la interfaz que lo rodea.

Hay dos responsabilidades distintas. Un Layout personalizado calcula los tamaños y las posiciones de destino. Una animación indica a SwiftUI cómo pasar de la geometría anterior a la nueva. Apple documenta Layout como el punto de extensión para distribuciones que requieren un comportamiento más complejo que el que ofrecen los contenedores integrados. Sus dos métodos obligatorios informan del tamaño del contenedor y colocan sus subvistas. Layout

En este ejemplo, cada menú desplegable es un Menu cuyas acciones actualizan una selección. Apple admite menús con constructores de etiquetas personalizados, así que la etiqueta mostrada puede incluir tanto una categoría como la opción seleccionada. El diseño distribuye los controles cerrados en la ventana; este ejemplo no intenta controlar la geometría del contenido presentado por el menú. Menu

El diseño aplica tres políticas explícitas:

  • Los controles mantienen su tamaño ideal, en lugar de comprimir sus etiquetas para conservar una fila.
  • Los controles se ajustan a la fila siguiente, respetando su orden actual, cuando el próximo control excedería el ancho propuesto.
  • Un control más ancho que el espacio disponible ocupa su propia fila y hace que el diseño informe de ese ancho mayor.

Esta última política es importante. Ajustar una colección no puede hacer más estrecho un control indivisible. Si tu aplicación debe encajar etiquetas de longitud arbitraria en un panel estrecho, también necesitas una política independiente para las etiquetas, como diseñarlas deliberadamente con límites. Apple señala que fixedSize() conserva el tamaño ideal y, por lo tanto, puede hacer que una vista exceda los límites de su elemento contenedor. fixedSize()

Usar un mismo cálculo para la medición y la colocación

El sistema de diseño de SwiftUI pasa propuestas de tamaño hacia abajo por la jerarquía. Una propuesta con dimensiones no especificadas solicita el tamaño ideal de una vista; es distinta de una propuesta que contiene cero o infinito. Aquí, las mediciones ideales determinan el espacio que ocupa cada control, mientras que el ancho propuesto por el elemento contenedor determina los saltos de fila. ProposedViewSize

El contrato esencial es la coherencia: el contenedor debe reservar el mismo espacio que usa el algoritmo de colocación. Apple exige explícitamente que los cálculos sean coherentes entre el dimensionamiento y la colocación, incluido el espaciado y las respuestas a las propuestas. También especifica que los límites de colocación están en el sistema de coordenadas del elemento contenedor, así que la colocación debe tener en cuenta bounds.minX y bounds.minY. placeSubviews(in:proposal:subviews:cache:)

Crea en Xcode una app SwiftUI para macOS y establece como destino de implementación macOS 14 o posterior. El ejemplo usa Layout, disponible desde macOS 13, y geometryGroup(), disponible desde macOS 14. Coloca la siguiente declaración de diseño y el código de la app de la sección siguiente en un solo archivo Swift, sustituyendo el punto de entrada de la app generado. Juntos, los dos bloques forman el ejemplo completo. Disponibilidad de Layout, Disponibilidad de geometryGroup()

import SwiftUI

struct WrappingControls: Layout {
    var spacing: CGFloat = 8

    struct Plan {
        var size: CGSize
        var origins: [CGPoint]
    }

    func plan(
        proposal: ProposedViewSize,
        subviews: Subviews
    ) -> Plan {
        guard !subviews.isEmpty else {
            return Plan(size: .zero, origins: [])
        }

        let gap = max(0, spacing)
        let limit: CGFloat
        if let width = proposal.width, width.isFinite {
            limit = max(0, width)
        } else {
            limit = .infinity
        }

        var origins: [CGPoint] = []
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0
        var usedWidth: CGFloat = 0
        var rowHasItems = false

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            let nextX = rowHasItems ? x + gap : 0

            if rowHasItems && nextX + size.width > limit {
                y += rowHeight + gap
                x = 0
                rowHeight = 0
                rowHasItems = false
            }

            if rowHasItems {
                x += gap
            }
            origins.append(CGPoint(x: x, y: y))
            x += size.width
            rowHeight = max(rowHeight, size.height)
            usedWidth = max(usedWidth, x)
            rowHasItems = true
        }

        let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
        return Plan(
            size: CGSize(width: width, height: y + rowHeight),
            origins: origins
        )
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        plan(proposal: proposal, subviews: subviews).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let result = plan(proposal: proposal, subviews: subviews)
        for (index, subview) in subviews.enumerated() {
            let origin = result.origins[index]
            subview.place(
                at: CGPoint(
                    x: bounds.minX + origin.x,
                    y: bounds.minY + origin.y
                ),
                anchor: .topLeading,
                proposal: .unspecified
            )
        }
    }
}

Ambos métodos del diseño llaman a plan con la propuesta que proporciona SwiftUI. La medición y la colocación también usan la misma propuesta para las vistas hijas, .unspecified. La API de medición de subvistas de Apple devuelve el tamaño que elige una subvista para la propuesta recibida; el ejemplo utiliza esa respuesta en lugar de estimar el ancho contando caracteres. sizeThatFits(_:)

Para un ancho propuesto finito, el diseño ocupa al menos ese ancho e informa con precisión de cualquier subvista que lo exceda. Si el ancho no está especificado o es infinito, devuelve un tamaño ideal finito de una sola fila para estos controles de tamaño finito. La altura depende del contenido; el diseño ignora intencionalmente la altura propuesta e informa del espacio que necesitan sus filas.

Esta implementación usa una caché Void y vuelve a calcular su pequeño plan. Apple recomienda añadir una caché solo si el perfilado demuestra que ofrece ventajas. Si más adelante almacenas mediciones en caché, conserva el requisito de que recrearla no cambie el resultado del diseño. makeCache(subviews:)

Vincular los cambios de selección con controles estables

El panel sintético tiene tres categorías: Shape, Pattern y Arrangement. Cada categoría almacena su propia cadena seleccionada, y todas las opciones están definidas en el ejemplo. La etiqueta cambia dentro del Menu existente; el programa no sustituye un menú cada vez que cambia una selección.

Añade este código después de la declaración del diseño. Deja una sola declaración @main de la app en el proyecto.

struct ControlChoice: Identifiable, Equatable {
    let id: Int
    let title: String
    let options: [String]
    var selected: String
}

struct DropdownPanel: View {
    @State var choices: [ControlChoice] = [
        ControlChoice(
            id: 1,
            title: "Shape",
            options: ["Circle", "Rounded rectangle"],
            selected: "Circle"
        ),
        ControlChoice(
            id: 2,
            title: "Pattern",
            options: ["Solid", "Alternating diagonal lines"],
            selected: "Solid"
        ),
        ControlChoice(
            id: 3,
            title: "Arrangement",
            options: ["Rows", "Evenly spaced columns"],
            selected: "Rows"
        )
    ]

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text("Layout options")
                    .font(.headline)

                WrappingControls(spacing: 8) {
                    ForEach(choices) { choice in
                        Menu {
                            ForEach(choice.options, id: \.self) { option in
                                Button(option) {
                                    select(option, for: choice.id)
                                }
                            }
                        } label: {
                            Text("\(choice.title): \(choice.selected)")
                                .lineLimit(1)
                        }
                        .fixedSize()
                        .geometryGroup()
                    }
                }
                .animation(
                    .easeInOut(duration: 0.25),
                    value: choices.map(\.selected)
                )

                Text("Choose a longer option, then narrow the window.")
                    .foregroundStyle(.secondary)
            }
            .padding(20)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(minWidth: 320, minHeight: 240)
    }

    func select(_ option: String, for id: Int) {
        guard let index = choices.firstIndex(where: { $0.id == id }),
              choices[index].options.contains(option) else {
            return
        }
        choices[index].selected = option
    }
}

@main
struct RewrappingMenusApp: App {
    var body: some Scene {
        WindowGroup {
            DropdownPanel()
        }
    }
}

El modificador de animación observa una matriz de cadenas seleccionadas. Apple exige que el valor observado cumpla con Equatable; esta matriz satisface ese contrato. Observar los valores seleccionados también significa que este modificador no solicita una nueva animación solo porque cambie el ancho de la ventana. El diseño sigue recalculando el ajuste cuando cambia su propuesta. animation(_:value:)

Los identificadores estables de las categorías vinculan cada menú entre actualizaciones. ForEach crea vistas a partir de datos identificados, mientras que cambiar el .id explícito de una vista restablece su identidad y el estado asociado. Por consiguiente, añadir .id(choice.selected) iría en contra de la continuidad que necesita este ejemplo. Las cadenas de opciones son únicas dentro de cada categoría sintética, por lo que id: \.self es adecuado para esas acciones de menú. ForEach, id(_:)

El ejemplo de diseño personalizado de Apple muestra que conservar la identidad estructural permite que animation(_:value:) anime los cambios de diseño. Este ejemplo aplica ese principio a las mediciones cambiantes dentro de un mismo tipo de diseño. La cadena seleccionada cambia de inmediato; la animación deseada corresponde al movimiento y al cambio de tamaño resultantes, no a interpolar los caracteres de dos etiquetas. Composición de diseños personalizados con SwiftUI

El modificador geometryGroup() establece un límite geométrico para cada menú completo. Apple describe este límite como un mecanismo que resuelve y anima la geometría del elemento contenedor antes de pasarla a sus descendientes. Su uso aquí sigue ese mecanismo documentado para mantener unido el movimiento del contenido interno de cada control; no determina los saltos de fila ni corrige un cálculo de ajuste incorrecto. geometryGroup()

Comprobar los saltos de fila antes de ajustar la animación

Un ejemplo breve permite inspeccionar la política de ajuste. Estas dimensiones son ilustrativas, no corresponden a mediciones de controles de macOS: supongamos que tres controles tienen anchos de 80, 100 y 90 puntos, todos miden 24 puntos de alto, hay un espaciado de 8 puntos y se dispone de 300 puntos.

La fila inicial necesita 286 puntos: 80 + 8 + 100 + 8 + 90. Si el control del medio aumenta a 150 puntos, la fila completa necesitaría 336 puntos. Los dos primeros controles aún caben, pues ocupan 238 puntos, así que el tercero pasa a la fila siguiente. La nueva altura del contenido es de 56 puntos: 24 + 8 + 24.

Condición Resultado esperado de este algoritmo
El ancho combinado es exactamente igual al límite Mantener los controles en esa fila.
El siguiente control excedería el límite Iniciar una fila nueva antes de ese control.
Los controles tienen alturas distintas Avanzar según la altura del control más alto de la fila completada.
Un control supera el ancho propuesto Colocarlo solo e informar del ancho necesario, que será mayor.
El ancho no está especificado o es infinito Devolver una sola fila con el ancho ideal combinado de los controles.
No hay controles Devolver un tamaño de cero.

La aritmética de empaquetado se comprobó por separado con dimensiones sintéticas deterministas, incluidas etiquetas más largas, un control que excede el ancho y un ancho no especificado. La app de SwiftUI no se compiló ni se probó visualmente en este entorno. Compílala y ejecútala en macOS para verificar la apariencia nativa de los controles y la animación.

En la app en ejecución, primero elige opciones más largas sin cambiar el ancho de la ventana. Ajusta la ventana hasta que un cambio de selección cruce un salto de fila y, después, alterna entre las opciones cortas y largas. Comprueba que el texto explicativo debajo de los controles se mueve para dejar espacio a la nueva altura de fila y que, al volver a las etiquetas cortas, desaparece el espacio vertical sobrante.

A continuación, cambia el tamaño de la ventana sin modificar las selecciones. Así podrás distinguir un defecto de ajuste de un defecto de animación: las posiciones finales incorrectas apuntan a un problema de medición o colocación, mientras que las posiciones finales correctas con un movimiento no deseado indican que conviene revisar el alcance de la animación y la agrupación geométrica.

Diagnosticar los problemas restantes

Si las etiquetas cambian pero no los saltos de fila, inspecciona qué mide realmente el diseño. Un contenedor de ancho fijo alrededor de cada menú puede hacer que las etiquetas cortas y largas informen del mismo ancho exterior. En esta implementación, cada menú conserva deliberadamente su tamaño ideal, y el diseño mide el control completo en lugar de estimar el ancho del texto por separado. fixedSize(), sizeThatFits(_:)

Si los controles se superponen o la siguiente sección cubre la segunda fila, compara primero el dimensionamiento y la colocación. Usa el mismo espacio, la misma propuesta para las vistas hijas, la misma condición de salto de fila y el mismo cálculo de altura de fila en ambos métodos. No calcules el dimensionamiento con un ancho y la colocación con otro. La función compartida plan existe para dificultar la introducción de estas discrepancias. placeSubviews(in:proposal:subviews:cache:)

Si los controles desaparecen y vuelven a aparecer durante los cambios de selección, revisa la identidad antes de añadir más modificadores de animación. Mantén estable el identificador de la categoría y actualiza solo su selección. Apple documenta que cambiar el identificador explícito de una vista restablece su identidad, por lo que una etiqueta cambiante es una mala clave de identidad para un control persistente. id(_:)

Para implementar en macOS 13, elimina .geometryGroup() y conserva la implementación de Layout y la animación basada en la selección. Así se mantiene el enfoque de ajuste, pero se elimina el límite geométrico documentado, disponible desde macOS 14; evalúa el movimiento resultante en los sistemas compatibles con tu app. Layout, geometryGroup()

El comportamiento final se basa en tres decisiones concretas: medir los controles reales, usar el mismo plan de filas para informar del tamaño y colocarlos, y conservar la identidad de los controles cuando cambian las etiquetas. Cuando esas decisiones producen una geometría final correcta, la animación basada en la selección facilita seguir el reajuste.

Fuentes