SwiftUI: componente Image

¿Qué es Image?

El componente Image permite mostrar una imagen. Es un componente equivalente a UIImageView de UIKit.

Aquí podéis consultar la documentación oficial

El componente tiene varios ‘inicializadores’ con diferentes parámetros y propósitos. A nivel más básico, se crea de la siguiente forma:

Este método crea una imagen a partir de un String. Este String debe coincidir con el nombre de una imagen del .xcassets. El .xcassets es un tipo de fichero que podemos añadir a nuestro proyecto de Xcode y en él incluiremos todas las imágenes de nuestro proyecto (entre otros recursos).

Otro ‘inicializador’ interesante es el siguiente:

Image(uiImage: UIImage(named: "imageName"))

Este ‘inicializador’ nos permite cargar una imagen a partir de un UIImage de UIKit. Es muy útil cuando tenemos que coexistir con el framework de UIKit y las imágenes ya están ‘inicializadas’ en formato UIImage.

Excluir imágenes de la lectura de pantalla con VoiceOver

VoiceOver es la herramienta que Apple proporciona para la lectura de pantalla para aquellas personas invidentes o con visión reducida. Esta herramienta lee todo el contenido de la pantalla, incluidas las imágenes.

Hay ocasiones en las que las imágenes no tienen que ser leídas, ya que son elementos visuales colocados por temas estéticos (como un fondo de pantalla). Para estos casos se debería usar el siguiente ‘inicializador’:

Image(decorative: "imageName")

Este ‘inicializador’ excluye a la imagen de ser leídas por VoiceOver.

Modificadores comunes para Image

A parte de los modificadores que se explicarán a continuación, el componente Image  comparte los mismos métodos de personalización que el componente View y pueden ser consultados en el siguiente enlace.

Para los ejemplos vamos a usar una imagen de un póster de Star Wars:

resizable

Permite redimensionar la imagen para que se ocupe todo el espacio del que disponga el componente.

Image("star_wars")
    .resizable()
    .frame(height: 400)

Por defecto el componente Image define su tamaño dependiendo de la vista que cargue, por lo que muchas veces la imagen sobrepasará el propio tamaño de pantalla (prueba el código anterior sin resizable ni frame)

Por lo general, este modificador se usará en la mayoría de los casos.

aspectRatio / scaledToFit / scaledToFill

aspectRatio permite indicar la relación de aspecto que debe respetar el componente. Si no se indica, la imagen se deformará para ocupar todo el contenedor disponible.

Image("star_wars")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: 100, height: 200)
Image("star_wars")
    .resizable()
    .aspectRatio(contentMode: .fill)
    .frame(width: 100, height: 200)
    .clipped()
contentMode

El parámetro  puede tener dos valores:

  • .fit. Indica que la imagen debe verse completamente respetando el contenedor disponible. El método scaledToFit() consigue el mismo resultado que aplicar .aspectRatio(contentMode: .fit)
  • .fill. Indica que la imagen debe ocupar todo el contenedor disponible aunque la imagen no se visualize completamente. El método scaledToFill() consigue el mismo resultado que aplicar .aspectRatio(contentMode: .fill). Cuando apliquemos este modificador conviene aplicar .clipped() para que la imagen no se pinte fuera de sus límites permitidos

interpolation

Permite modificar el nivel de suavizado de pixeles que proporciona el sistema cuando presenta una imagen pequeña en un contenedor más grande.

HStack(spacing: 20) {
    VStack {
        Image("lion")
            .resizable()
            .interpolation(.high)
            .aspectRatio(contentMode: .fit)
            .frame(width: 150, height: 200)
        Text(".high")
    }
    VStack {
        Image("lion")
            .resizable()
            .interpolation(.none)
            .aspectRatio(contentMode: .fit)
            .frame(width: 150, height: 200)
        Text(".none")
    }
    
}
.

Si no se indica este modificador el sistema aplica una interpolación de nivel .high

Para esta prueba puedes usar una imagen pequeña como está:

renderingMode

Permite indicar cómo se debe ‘renderizar’ la vista.

HStack(spacing: 20) {
    VStack {
        Button(action: {}, label: {
            Image("marker")
                .resizable()
                .renderingMode(.template)
                .aspectRatio(contentMode: .fit)
                .frame(width: 100, height: 100)
        })
        Text(".template")
    }
    VStack {
        Button(action: {}, label: {
            Image("marker")
                .resizable()
                .renderingMode(.original)
                .aspectRatio(contentMode: .fit)
                .frame(width: 100, height: 100)
        })
        Text(".original")
    }
    
}

En algunas ocasiones el sistema aplica una capa personalizada a los componentes haciendo que estos se tinten de un color dependiendo de la configuración global o la indicada por el modificador .accentColor.

Con este modificador podemos indicar si queremos que esto se aplique (.template) o no (.original).

Para esta prueba se ha usado está imagen:

Cómo definir el área visible de una vista en base a una máscara

Hay ocasiones en las que necesitamos que una vista tenga una forma irregular diferente a un cuadrado o círculo. Para estos casos se puede usar una máscara para definir el área visible de una vista, y de esta forma podemos conseguir formas irregulares de una manera muy sencilla.

Para aplicar esta máscara debemos aplicar el modificador mask y pasarle por parámetro una vista que será la que defina la zona visible de la vista objetivo.

Rectangle()
    .foregroundColor(.green)
    .frame(height: 200)
    .background(Color.blue)
    .mask(Image("message_bubble")
            .resizable(capInsets: EdgeInsets(top: 10, leading: 50, bottom: 20, trailing: 10))
    )
Image("star_wars")
    .resizable()
    .frame(width: 300, height: 200)
    .mask(Text("Hello, World!")
            .font(Font.system(size: 60, weight: .bold)
            )
    )

Para la burbuja se ha usado la siguiente imagen:

Cómo cargar imágenes desde una URL

El componente Image no implementa una forma de cargar imágenes desde una url, por lo que es responsabilidad de los desarrolladores implementarlo de la forma que necesiten en cada proyecto.

Esta es una necesidad muy común en todos los proyectos, por lo que vamos a implementar un nuevo componente que tenga esta capacidad de forma que pueda ser escalable para cualquier otra necesidad que pueda surgir.

¿Qué necesitamos para cargar una imagen desde una URL?

Lo primero de todo es saber que es lo que necesitamos exactamente. Para mostrar una imagen desde una URL tenemos que hacer principalmente dos cosas:

  1. Obtener la imagen desde la URL.
  2. Mostrar la imagen en pantalla.

Para conseguir el primer paso tenemos que hacer una llamada a una url que contenga una imagen con el framework de peticiones URLSession (lo explicaremos en profundidad más adelante).

:

Para el segundo paso tenemos que tener en cuenta que cuando mostremos la pantalla la imagen podrá estar o no, ya que traerla desde internet puede llevar un tiempo indeterminado que dependerá de la conexión y el peso de la imagen. Entonces para mostrar la imagen correctamente tenemos que controlar dos estados de la vista: un estado donde no tenemos la imagen y otro estado donde sí la tenemos. ¿Cómo se representa esto en una pantalla con SwiftUI? Con variables de tipo @State

@State var image: UIImage? = nil

Este tipo de variables son usadas para manejar los estados de las vistas y harán que las vistas se refresquen automáticamente cuando cambie su valor.

Esto quiere decir que lo que lo único que necesitaremos para mostrar una imagen desde una URL es modificar una variable de estado asignando la propia imagen a mostrar (la petición de la imagen la haremos desde la lógica de negocio de la aplicación) y el propio framework de SwiftUI se encargará de pintar la imagen.

import SwiftUI

struct ContentView: View {
    @State var image: UIImage? = nil
    
    var body: some View {
        Group {
            if let image = image {
                Image(uiImage: image)
                    .resizable()
            } else {
                Text("No image")
            }
        }.onAppear {
            getImage("https://picsum.photos/id/43/3000")
        }
    }
    
    func getImage(_ url: String) {
        guard let url = URL(string: url) else { return }
        URLSession.shared.dataTask(with: url) { (data, _, _) in
            if let data = data, let image = UIImage(data: data) {
                self.image = image
            }
        }.resume()
    }
}

Este ejemplo muestra inicialmente el texto “No image“, ya que la variable image es nil.

,

Al mostrarse la pantalla se llama al método getImage que se encarga de obtener la imagen y asignarla a la variable de estado image por lo que ya deja de estar a nil. Como esta variable ha cambiado su estado la pantalla se refresca mostrando la imagen porque la condición ya se cumple.

El ejemplo es ilustrativo y no se recomienda que tengamos esa función getImage en la propia pantalla.

Creando un componente para la carga de imágenes

Una vez entendido el objetivo podemos crearnos nuestro propio componente reutilizable para todas las pantallas donde tengamos que cargar una imagen desde una URL.

Primero vamos a crear una clase que implemente el protocolo ObservableObject. Esto nos permitirá referenciar una instancia de esta clase con el atributo @ObservableObject (muy similar a la etiqueta @State), haciendo que los cambios en esta clase modifiquen el estado de la vista.

import SwiftUI
import Combine

public class ImageLoader: ObservableObject {
    @Published public var image: UIImage? = nil
    private let url: URL?
    private var cancellable: AnyCancellable? = nil
    
    init(_ url: String?) {
        if let url = url {
            self.url = URL(string: url)
        } else {
            self.url = nil
        }
    }
    
    init(_ url: URL?) {
        self.url = url
    }
    
    deinit {
        cancel()
    }
    
    func load() {
        guard let url = url else { return }
        cancel()
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .receive(on: DispatchQueue(label: "ImageQueue", qos: DispatchQoS.background))
            .map {
                if let image = UIImage(data: $0.data) {
                    return image
                } else {
                    return nil
                }
            }
            .replaceError(with: UIImage(named: "error"))
            .replaceNil(with: UIImage(named: "error"))
            .receive(on: DispatchQueue.main)
            .assign(to: .image, on: self)
    }
    
    func cancel() {
        cancellable?.cancel()
    }
}

La clase ImageLoader se encargará de la lógica para solicitar una imagen desde una URL.

Después vamos a implementar nuestro componente personalizado ImageAsync que será el que solicitará la imagen a ImageLoader y la pintará.

import SwiftUI

public struct ImageAsync: View {
    @ObservedObject var imageLoader: ImageLoader
    private let content: ((Image) -> Image)?
    fileprivate var placeholder: AnyView? = nil
    
    init(_ url: String?, content: ((Image) -> Image)? = nil) {
        imageLoader = ImageLoader(url)
        self.content = content
    }
    
    init(_ url: URL?, placeholder: AnyView? = nil, content: ((Image) -> Image)? = nil) {
        imageLoader = ImageLoader(url)
        self.content = content
    }
    
    public var body: some View {
        let image: Image?
        if let i = imageLoader.image {
            image = Image(uiImage: i)
        } else {
            image = nil
        }
        let result: AnyView?
        if let image = image {
            if let r = content?(image) {
                result = AnyView(r)
            } else {
                result = AnyView(image)
            }
        } else {
            result = AnyView(Color.clear)
        }
        return Group {
            if imageLoader.image == nil {
                result.overlay(placeholder)
            } else {
                result
            }
        }.onAppear {
            imageLoader.load()
        }.onDisappear {
            imageLoader.cancel()
        }
    }
}

La clase ImageAsync también tiene implementado una forma de colocar un placeholder  mientras que la imagen no ha sido cargada. Vamos a crear una extensión de ImageAsync para que sea más fácil ‘setear’ este 

import SwiftUI

public extension ImageAsync {
    func placeholder(@ViewBuilder content: () -> T) -> ImageAsync {
        var result = self
        result.placeholder = AnyView(content())
        return result
    }
    
    func placeholder(_ text: Text) -> ImageAsync {
        var result = self
        result.placeholder = AnyView(text)
        return result
    }
    
    func placeholder(_ image: Image) -> ImageAsync {
        var result = self
        result.placeholder = AnyView(image)
        return result
    }
}

De esta forma solo tendremos que usar nuestro nuevo componente ImageAsync en lugar de Image para cargar imágenes desde una URL.

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            ForEach((100...200), id: .self) {
                ImageURLCellView(index: $0)
            }
        }
    }
}

struct ImageURLCellView: View {
    var index: Int
    
    var body: some View {
        HStack {
            Spacer()
            ImageAsync("https://picsum.photos/id/(index)/300") {
                $0.resizable()
            }
            .placeholder {
                placeholder
            }
            .aspectRatio(contentMode: .fit)
            .frame(height: 200, alignment: .center)
            Spacer()
        }
    }
    
    var placeholder: some View {
        Group {
            if index % 3 == 0 {
                Text("...")
            } else if index % 3 == 1 {
                Text("Loading")
            } else {
                Text("")
            }
        }
    }
}

Ejemplo

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

Rafael Fernández,
iOS Tech Lider