Mi diario Lanternly tiene dos estructuras. En iPhone hay una barra de pestañas abajo; en iPad, una barra lateral con NavigationSplitView. Una sola línea elegía entre ellas: if UIDevice.current.userInterfaceIdiom == .pad. En el iPhone Duo esa línea es lo primero que se rompe. El Duo es un iPhone, su idioma es .phone en las dos pantallas, y la pantalla interior de 7,6 pulgadas recibiría una barra de pestañas de teléfono estirada.
En este artículo te cuento cómo adapté la app al iPhone Duo con SwiftUI: la estructura se elige por el ancho de la ventana y la sección abierta no se pierde al plegar o girar el dispositivo. Todo está comprobado en Xcode 27.1 RC con el simulador del Duo e iOS 27.1. La visión general la expliqué en la guía para adaptar apps al iPhone Duo; aquí va la práctica de una sola app.
Por qué el idioma ya no responde a «¿teléfono o tableta?»#
El Duo tiene dos pantallas. La exterior se comporta como un iPhone normal: compact × regular en vertical. La interior es regular × regular en cualquier orientación. El idioma es el mismo en las dos. Así que la pregunta «qué estructura mostramos» no se le hace al dispositivo, sino a la ventana: cuánto espacio horizontal hay ahora mismo.
Eso es justo lo que da el horizontal size class en SwiftUI, con @Environment(\.horizontalSizeClass). Cambia al plegar, al girar y en pantalla dividida, y SwiftUI redibuja todo lo que depende de él.
La regla de estructura para iPhone Duo en SwiftUI#
Saqué la decisión a un solo tipo para que no se esparciera por las pantallas:
import SwiftUI
enum ShellLayout {
static func usesSplit(horizontalSizeClass: UserInterfaceSizeClass?) -> Bool {
UIDevice.current.userInterfaceIdiom == .pad || horizontalSizeClass == .regular
}
}Una ventana ancha (la pantalla interior del Duo, un iPhone Pro Max en horizontal) recibe la barra lateral. Una estrecha (la pantalla exterior del Duo, la mitad estrecha de la pantalla dividida) recibe la barra de pestañas.
El idioma queda exactamente en un sitio, como excepción para el iPad. Antes del Duo, el iPad siempre vivía en la barra lateral, también en una pantalla dividida estrecha, donde NavigationSplitView se pliega solo en una pila. Pasar el iPad a barra de pestañas cada vez que su ventana se estrecha cambiaría un comportamiento que ya funciona, por un dispositivo que no tiene nada que ver. Así que la regla dice: «iPad o ventana ancha».
En el Mac esta pregunta ni siquiera aparece. Allí la app tiene su propia estructura, MacRootView, con barra lateral y una pila de navegación por sección, así que toda la elección queda dentro de #if !os(macOS). Puedes estrechar la ventana del Mac todo lo que quieras y nunca tendrá barra de pestañas. Es una decisión consciente: una barra de pestañas abajo de una ventana de Mac se vería ajena.
La raíz de la app lee ahora el size class del entorno:
struct RootView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var shellSelection = ShellSelection()
var body: some View {
Group {
if ShellLayout.usesSplit(horizontalSizeClass: horizontalSizeClass) {
PadRootView() // NavigationSplitView con barra lateral
} else {
MainTabView() // barra de pestañas
}
}
.environment(shellSelection)
}
}El efecto secundario principal: al plegar, una estructura se elimina y la otra se crea desde cero. Todo lo que vivía en el @State de la primera desaparece. Quien estaba en Calendario acaba en Hoy al desplegar.
Cómo la sección abierta sobrevive al plegado#
Cada estructura guarda su propio selection, y sus tipos son distintos: la barra de pestañas tiene cuatro pestañas y la lateral, nueve secciones. Lo único compartido es «qué está abierto ahora», como texto:
import Observation
@Observable
final class ShellSelection {
var section: String?
}nil significa «todavía no se ha mostrado ninguna estructura». Es importante: en el primer arranque la nueva estructura empieza con su valor por defecto y no pisa los flags de depuración ni los enlaces que llegan desde los widgets.
Cada estructura escribe en este objeto al cambiar de sección y lo lee al aparecer:
struct MainTabView: View {
@Environment(ShellSelection.self) private var shell: ShellSelection?
@State private var selection: AppTab = .today
var body: some View {
content
.onAppear(perform: restoreSection)
.onChange(of: selection) { _, new in shell?.section = new.rawValue }
}
private func restoreSection() {
guard let raw = shell?.section else { return }
// Secciones como Diarios no existen en la barra de pestañas: vuelve a Hoy.
selection = AppTab(rawValue: raw) ?? .today
}
}AppTab y las secciones laterales comparten valores (today, entries, calendar, luna), así que el texto pasa sin tabla de equivalencias. El valor del entorno es opcional (ShellSelection?), y las vistas previas siguen funcionando sin configuración extra.
¿Por qué no @SceneStorage? También guarda la sección entre arranques. La app se abriría en la última sección y no en Hoy, y eso cambia cómo se comporta el producto. Yo solo necesitaba memoria mientras vive la escena.
La barra de navegación depende de la estructura, no del dispositivo#
Había otra comprobación de idioma escondida en las pantallas. Hoy, Calendario y Chat ocultan la barra de navegación del sistema en iPhone porque dibujan su propia cabecera. En iPad la barra hace falta: ahí está el botón que pliega la barra lateral. Antes se decidía así:
.toolbar(UIDevice.current.userInterfaceIdiom == .pad ? .automatic : .hidden, for: .navigationBar)En la pantalla interior del Duo esa pantalla queda dentro de la estructura con barra lateral, pero se cree en un teléfono y oculta el botón. La pregunta correcta es «en qué estructura me muestran». La responde una clave de entorno propia que fija la estructura lateral:
private struct SplitShellKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var isSplitShell: Bool {
get { self[SplitShellKey.self] }
set { self[SplitShellKey.self] = newValue }
}
}
// En PadRootView:
NavigationSplitView { sidebar } detail: {
detail(for: selection)
.environment(\.isSplitShell, true)
}
// En una pantalla:
@Environment(\.isSplitShell) private var isSplitShell
// ...
.toolbar(isSplitShell ? .automatic : .hidden, for: .navigationBar)Después de esto, en el código queda una sola comprobación de idioma, en ShellLayout. Todas las demás decisiones dependen de la ventana o de la estructura.
Probar el cambio de estructura sin tocar nada#
Un iPhone Pro Max sirve para probar el cambio en caliente: en horizontal su ancho también es regular. No se puede girar un simulador desde la línea de comandos, así que añadí un flag de depuración que pide a la escena girar con requestGeometryUpdate:
#if DEBUG
static func applyOrientationFlag() {
let args = CommandLine.arguments
guard let i = args.firstIndex(of: "-orientation"), i + 1 < args.count else { return }
for (step, name) in args[i + 1].split(separator: ",").enumerated() {
let mask: UIInterfaceOrientationMask = name == "portrait" ? .portrait : .landscapeRight
DispatchQueue.main.asyncAfter(deadline: .now() + Double(step + 1) * 4) {
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
}
}
}
#endifCon -orientation landscape,portrait la pantalla gira a los 4 y a los 8 segundos. En mi prueba, Entradas pasó por «barra de pestañas → barra lateral → barra de pestañas» y siguió seleccionada. En iPad, en el modo de ventanas de iPadOS 27, el sistema ignora esta petición, pero ahí la estructura tampoco debe cambiar.
Plegar y desplegar el Duo no se puede desde la línea de comandos: son botones de DeviceHub. Cómo lo resolví lo cuento en el artículo sobre probar el iPhone Duo en el simulador.
Qué funciona y qué queda#
En el simulador del Duo con iOS 27.1 comprobé plegar y desplegar en caliente en ambos sentidos, el giro de las dos pantallas y la postura de libro. La sección abierta se conserva. En el iPhone 18 Pro Max la estructura cambia al girar; en el iPad Pro 13 todo sigue igual.
No probé en vivo la pantalla dividida del iPhone Duo con dos apps. La mitad de la pantalla interior cuenta como ventana estrecha, así que por la regla recibe la barra de pestañas, el mismo camino que la pantalla exterior. Pero es una deducción, no una prueba: con el ratón no conseguí hacer el gesto de arrastrar desde el Dock en el simulador.
La estructura fue solo la mitad del trabajo. La otra mitad apareció en las propias pantallas: la barra de pestañas bajo la franja vertical del sistema, avisos que caían sobre el pliegue, el campo del chat cruzando la bisagra. Todo eso está en un artículo aparte sobre los problemas al adaptar la app al iPhone Duo.



