SwiftUI: componente GeometryReader

Qué es GeometryReader

El componente GeometryReader es un contenedor de vistas que permite acceder a su tamaño y posición. De esta forma podemos alinear correctamente elementos, igualar tamaños de vistas, proporcionar animaciones basadas en la posición y cualquier cosa que requiera trabajar con los tamaños y posiciones en pantalla.

Aquí podéis consultar la documentación oficial

Para crear el componente solo necesitaremos embeber en su ViewBuilder las vistas sobre las que queramos controlar los tamaños:

GeometryReader{ geometry in
    Text("Hello, World!")
        .frame(width: geometry.size.width)
        .background(Color.yellow.opacity(0.3))
}

El ViewBuilder de inicialización tiene un parámetro GeometryProxy que contiene la información sobre la posición y tamaño del área que ocupa el GeometryReader. Este tamaño puede ser relativo al contenedor en el que se encuentra o a toda la pantalla donde se presenta. Se puede saber estos tamaños accediendo a la propiedad geometry.frame y usando el valor .local o .global dependiendo del caso.

struct ContentView: View {
    var body: some View {
        GeometryReader{ geometry in
            VStack (spacing: 10) {
                Text("Coordinates")
                Divider()
                VStack(spacing: 5) {
                    Text("Global minX: (geometry.frame(in: .global).minX)")
                    Text("Global maxX: (geometry.frame(in: .global).maxX)")
                }
                .background(Color.yellow.opacity(0.5))
                Divider()
                    .background(Rectangle().fill(Color.yellow.opacity(0.3)))
                    .background(Color.yellow.opacity(0.3))
                VStack(spacing: 5) {
                    Text("Local minX: (geometry.frame(in: .local).minX)")
                    Text("Local maxX: (geometry.frame(in: .local).maxX)")
                }
                .background(Color.orange.opacity(0.5))
            }
        }
        .frame(width: 200, height: 200)
        .background(Color.blue.opacity(0.2))
    }
}

Modificadores comunes para GeometryReader

El componente GeometryReader comparte los mismos métodos de personalización que el componente View y pueden ser consultados en el siguiente enlace.

Cómo ocupar todo el tamaño de pantalla disponible con GeometryReader

Podemos combinar GeomteryReader con otros componentes de SwiftUI para conseguir nuevos efectos que de otra forma serían imposible. Por ejemplo, podemos posicionar elementos en un HStack en el que queramos que queramos que todos los elementos tengan el mismo ancho para que ocupen toda la pantalla disponible.

Con GeometryReader tendremos la información del ancho disponible en pantalla y solamente tendremos que dividirla entre los elementos a mostrar.

GeometryReader { geometry in
    HStack(spacing: 0) {
        Text("Left")
            .frame(width: geometry.size.width / 3, height: 60)
            .background(Color.blue.opacity(0.3))
        Text("Middle")
            .frame(width: geometry.size.width / 3, height: 60)
            .background(Color.orange.opacity(0.3))
        Text("Right")
            .frame(width: geometry.size.width / 3, height: 60)
            .background(Color.red.opacity(0.3))
    }
}

Cómo alinear vistas con GeometryReader y PreferenceKey

La maquetación de una aplicación es algo muy importante y hacer que todos sus componentes estén completamente alineados es esencial para conseguir un resultado profesional.

Por ejemplo, vamos a pintar un formulario con varias opciones para que el usuario pueda introducir sus datos. Para ello vamos a usar el siguiente código:

struct ContentView: View {
    
    var body: some View {
        Form {
            HStack {
                Text("Name")
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("Surname")
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("Address")
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("DNI")
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
        }
    }
}

Visualmente se verá de la siguiente forma:

Como se ve, el resultado no es estético y no tiene forma de formulario en condiciones. Idealmente deberíamos alinear los TextField para que todos empiecen en la misma posición, dando una sensación de uniformidad mucho más agradable a la vista. Para conseguir este efecto vamos a combinar GeometryReader y PreferenceKey:

  • Con GeometryReader vamos a recoger el valor del ancho de cada Text de forma que nos quedaremos con el de mayor tamaño.
  • Con PreferenceKey vamos a notificar cada vez que cambie el tamaño de un Text. A través de esta notificación vamos a actualizar el frame de los Text haciendo que todos tenga el mismo ancho, consiguiendo el efecto de alineación que buscamos.

Para conseguir esto tendremos que crear las siguientes estructuras:

struct WidthPreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { }
}

WidthPreferenceKey es el PreferenceKey que almacenará y notificará del valor CGFloat correspondiente al ancho de cada Text

struct WidthBackground: View {
    var body: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(key: WidthPreferenceKey.self, value: geometry.size.width)
        }
        .scaledToFill()
    }
}

WidthBackground se usará para conocer el ancho exacto de cada Text y se lo tendrá que enviar a WidthPreferenceKey a través de la siguiente línea:

.preference(key: WidthPreferenceKey.self, value: geometry.size.width)

De esta forma ya estaría todo listo para actualizar el código del formulario anterior para que reciba los parámetros que notifica WidthPreferenceKey y fije el ancho de cada Text.

struct ContentView: View {
    @State var maxLabelWidth: CGFloat = 0
    
    var body: some View {
        Form {
            HStack {
                Text("Name")
                    .frame(width: maxLabelWidth, alignment: .leading)
                    .background(WidthBackground())
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("Surname")
                    .frame(width: maxLabelWidth, alignment: .leading)
                    .background(WidthBackground())
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("Address")
                    .frame(width: maxLabelWidth, alignment: .leading)
                    .background(WidthBackground())
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            HStack {
                Text("DNI")
                    .frame(width: maxLabelWidth, alignment: .leading)
                    .background(WidthBackground())
                TextField("", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
        }
        .onPreferenceChange(WidthPreferenceKey.self) {
            if $0 > self.maxLabelWidth {
                self.maxLabelWidth = $0
            }
        }
    }
    
}

Tl y como se ve el modificador onPreferenceChange indica que debe escuchar los cambios que ocurran en WidthPreferenceKey. De esta forma almacenamos el valor más grande en la variable maxLabelWidth, que también usamos para definir el ancho de cada Text.

Es importante que cada Text tenga el modificador .background(WidthBackground()) ya que esta es la forma en la que indicamos que Text se tienen que tener en cuenta para la alineación.

Con este código conseguimos el resultado esperado.

Cómo crear animaciones con GeometryReader

Podemos ayudarnos del componente GeometryReader para realizar animaciones basadas en la posición actual de las vistas.

Por ejemplo, podemos conseguir añadir el modificador scaleEffect para que al hacer scroll en un listado de textos consigamos que el que ocupe la posición central se vea más grande que los demás.

Para ello usaremos dos GeometryReader: uno global para conocer el tamaño total de pantalla y otro especifico de cada Text para conocer su posición en cada momento. De esta forma seremos capaces de determinar dónde se encuentra cada Text con respecto a toda la pantalla y podremos aplicar los cálculos necesarios para conseguir aplicar el factor de escala correcto en cada caso.

struct ContentView: View {
    let colors: [Color] = [.blue, .red, .orange, .yellow, .green, .purple]
    
    var body: some View {
        Group {
            GeometryReader { geometryParent in
                ScrollView {
                    ForEach(0..<50) { index in
                        GeometryReader { geometry in
                            Text("Index (index)")
                                .font(.title2)
                                .frame(width: geometryParent.size.width)
                                .background(colors

Para realizar el calculo obtenemos el alto completo de la pantalla y lo dividimos entre dos para obtener el punto medio, que es donde queremos que la escala sea más grande geometryParent.size.height / 2.

Después obtenemos la posición actual del Text con respecto a toda la pantalla para saber qué escala hay que aplicarle geometry.frame(in: .global).minY. Con estos dos datos podemos aplicar la función ratioScale que se encargará de devolver el valor correspondiente.

Ejemplo

Puedes encontrar este ejemplo en https://github.com/SDOSLabs/SwiftUI-Test bajo el apartado GeometryReader.

Rafael Fernández,
iOS Tech Lider