Novedades de SwiftUI para iOS 15

Con cada nueva versión de iOS, Apple sigue evolucionando SwiftUI, incluyendo nuevos componentes y modificadores a su framework que nos permiten crear pantallas cada vez más completas sin necesidad de recurrir a UIKit.

Este año se han presentado novedades en componentes que no teníamos (como el pull to refresh o el searchbar) y se han incluido otros (como AsyncImage) que exprimen al máximo el nuevo patrón ascyn/await. En este post vamos a intentar verlas todos junto con algunos ejemplos de uso.

Todos los casos que incluimos han sido desarrollados y probados usando Xcode Version 13.0 beta 2.

Carga tus datos con task

Con la llegada del nuevo patrón asyn/await se ha incluido el nuevo modificador task. Este tiene un uso similar al ya conocido onAppear, pero está preparado para que podamos usar funciones marcadas como asycn de forma nativa. Por ejemplo, es muy útil para realizar la carga de datos inicial de una pantalla.

import SwiftUI

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        VStack {
            if model.items.count > 0 {
                List {
                    ForEach(model.items, id: .self) {
                        Text("($0)")
                    }
                }
            } else {
                Text("No Results")
            }
        }
        .task {
            await model.loadData()
        }
    }
}

@MainActor
final class ContentViewModel: ObservableObject {
    @Published var items: [String] = [String]()
    
    init() { }
    
    func loadData() async {
        for i in 0..<100 {
            items.append("Index (i)")
        }
    }
    
}

Pull to refresh con refreshable

El viejo componente Pull to refresh ha llegado a SwiftUI en forma del modificador de View llamado refreshable. Ahora es más fácil que nunca incorporarlo en nuestras aplicaciones. Lo único que tenemos que hacer es incluirlo en el componente List al que queramos darle esta capacidad y encargarnos de realizar el refresco en el bloque que nos proporciona.

import SwiftUI

struct RefreshView: View {
    @ObservedObject var model = RefreshViewModel()
    
    var body: some View {
        VStack {
            if model.items.count > 0 {
                List {
                    ForEach(model.items, id: .self) {
                        Text("($0)")
                    }
                }
            } else {
                Text("No Results")
            }
        }
        .refreshable {
            await model.loadData()
        }
        .task {
            await model.loadData()
        }
    }
}

@MainActor
final class RefreshViewModel: ObservableObject {
    @Published var items: [String] = [String]()
    
    init() { }
    
    func loadData() async {
        for i in 0..<100 {
            items.append("Index (i)")
        }
        items = items.shuffled()
    }
    
}

Implementa un buscador con searchable

Ahora podemos añadir el componente UISearchBar de UIKit en nuestras vistas de SwiftUI usando el modificador searchable. Lo único que tendremos que hacer es pasarle como parámetro una variable de estado de tipo String que será donde se almacene la búsqueda que el usuario realice. Más fácil, imposible.

import SwiftUI
import Combine

struct SearchView: View {
    @ObservedObject var model = SearchViewModel()
    
    var body: some View {
        NavigationView {
            VStack {
                if model.items.count > 0 {
                    List {
                        ForEach(model.items, id: .self) {
                            Text("($0) ")
                        }
                    }
                } else {
                    Text("No results")
                }
            }
            .searchable(text: $model.searchText)
            .navigationBarTitle("searchable", displayMode: .inline)
            .task {
                await model.loadData()
            }
        }
    }
}

@MainActor
final class SearchViewModel: ObservableObject {
    private var localItems: [String] = [String]()
    private var cancellable = Set()
    
    @Published var searchText: String = ""
    @Published var items: [String] = [String]()
    
    init() {
        $searchText
            .receive(on: RunLoop.main)
            .sink { _ in
                async {
                    await self.filterResults()
                }
            }
            .store(in: &cancellable)
    }
    
    func loadData() async {
        for i in 0..<100 {
            localItems.append("Index (i)")
        }
        localItems = localItems.shuffled()
        searchText = ""
        await filterResults()
    }
    
    func filterResults() async {
        if searchText.isEmpty {
            items = localItems
        } else {
            items = localItems.filter {
                $0.contains(searchText)
            }
        }
    }
    
}

Ocultar campos sensibles con privacySensitive

SwiftUI ha añadido el nuevo modificador privacySensitive, que permite que los campos que lo usen no sean visibles cuando la aplicación no se encuentre en primer plano. El ejemplo más claro es cuando usamos la multitarea. Aquellas vistas que usen este modificador pasarán a no mostrar el contenido real cuando estemos con la multitarea activada. En su lugar se mostrará un placeholder genérico.

struct PrivacySensitiveView: View {
    @ObservedObject var model = PrivacySensitiveViewModel()
    
    var body: some View {
        VStack {
            if model.items.count > 0 {
                List {
                    ForEach(model.items, id: .self) {
                        PrivacySensitiveCell(text: $0)
                    }
                }
            } else {
                Text("No results")
            }
        }
        .task {
            await model.loadData()
        }
    }
}

struct PrivacySensitiveCell: View {
    var text: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            Text("Privacy Sensitive text: (text)")
                .privacySensitive()
            Text("Normal text: (text)")
        }
        .padding(.vertical, 5)
    }
}

@MainActor
final class PrivacySensitiveViewModel: ObservableObject {
    @Published var items: [String] = [String]()
    
    init() { }
    
    func loadData() async {
        for i in 0..<100 {
            items.append("Index (i)")
        }
    }
    
}

Efecto de difuminado (blur effect)

En esta nueva versión de SwiftUI se ha añadido la posibilidad de realizar el efecto blur para los fondos de nuestros componente, pero Apple ha decidido tomar un nuevo planteamiento diferente a lo que se realiza con UIKit.

En esta ocasión el efecto se consigue usando el modificador background junto con los nuevos tipos Material, que definirán cómo de pronunciado se deberá ver el efecto:

  • Material.ultraThinMaterial
  • Material.thinMaterial
  • Material.regularMaterial
  • Material.thickMaterial
  • Material.ultraThickMaterial

Al aplicar este modificador todo lo que se encuentre por detrás del componente con este background se verá con el efecto blur. De esta forma, será común encontrarnos su uso junto con ZStack para apilar vistas y conseguir el efecto deseado.

import SwiftUI

struct MaterialItem {
    let title: String
    let materialModifier: Material
}

struct BlurView: View {
    var items: [MaterialItem] = [
        MaterialItem(title: "ultraThickMaterial", materialModifier: .ultraThickMaterial),
        MaterialItem(title: "thickMaterial", materialModifier: .thickMaterial),
        MaterialItem(title: "regularMaterial", materialModifier: .regularMaterial),
        MaterialItem(title: "thinMaterial", materialModifier: .thinMaterial),
        MaterialItem(title: "ultraThinMaterial", materialModifier: .ultraThinMaterial),
    ]
    var body: some View {
        List {
            ForEach(items, id: .title) {
                BlurCell(materialItem: $0)
            }
        }
        .listStyle(.plain)
    }
}

struct BlurCell: View {
    var materialItem: MaterialItem
    
    var body: some View {
        ZStack {
            Image("background_big_sur")
                .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 80)
                    .clipped()
            
            Text("_(materialItem.title)_")
                .padding(15)
                .background(materialItem.materialModifier)
                .clipShape(
                    Capsule()
                )
        }
        .listRowInsets(EdgeInsets())
        .padding(.vertical, 5)
        .listRowSeparator(.hidden)
    }
}

Aplicando estilos con foregroundStyle

El nuevo modificador foregroundStyle nos permitirá aplicar un mismo estilo a todos los componentes a la vez y de forma unificada. Ya no será necesario ir componente por componente aplicando los estilos correspondientes para que queden iguales.

struct ForegroundStyleView: View {
    var body: some View {
        VStack(spacing: 50) {
            HStack {
                Image(systemName: "triangle.fill")
                Text("Hello, world!")
                RoundedRectangle(cornerRadius: 5)
                    .frame(width: 40, height: 20)
            }
            .foregroundStyle(
                .linearGradient(
                    colors: [.yellow, .blue],
                    startPoint: .top,
                    endPoint: .bottom
                )
            )
            HStack {
                Image(systemName: "triangle.fill")
                Text("Hello, world!")
                RoundedRectangle(cornerRadius: 5)
                    .frame(width: 40, height: 20)
            }
            .foregroundStyle(Color.teal)
        }
    }
}

Mostrar una imagen desde internet con AsyncImage

¡Al fin! Solo han hecho falta 12 años para que Apple incluya de forma nativa un componente de vista que sea capaz de pintar una imagen que venga desde internet. El nuevo componente AsyncImage se encargará de pintar una imagen dada una URL, con posibilidades de mostrar un placeholder mientras carga.

Este componente usa las bondades del patrón async/await, así que garantizamos que está a la última.

struct AsyncImageView: View {
    var body: some View {
        AsyncImage(url: URL(string: "https://sdos.es/sites/default/files/Blog/SwiftUI/Image/star_wars.jpg")) { image in
            image
                .resizable()
        } placeholder: {
            ZStack {
                Color.gray
                ProgressView()
            }
        }
        .aspectRatio(contentMode: .fit)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .padding()
        .clipped()
    }
}

Soporte para markdown

Como ya introducimos en el resumen de la WWDC21, Apple ha dado soporte para que los componentes de texto puedan interpretar markdown. Su uso es muy sencillo, ya que lo único que hay que hacer es usar las etiquetas markdown en los propios componentes y estos lo pintarán correctamente sin necesidad de nada más.

struct MarkdownView: View {
    var body: some View {
        VStack {
            Text("This is a **bold text**")
            Text("This is a *italic text* and this is other _italic way_")
            Text("This is a **bold and _italic_ text**")
            Text("This is a ~~strikethrough text~~")
            Text("This is a `monospaced text`")
            Text("This is a [Link](https://www.sdos.es/blog)")
        }
    }
}

Personalizar el separador de List

Ya tenemos la posibilidad de personalizar los separadores de las listas cuando trabajamos con SwiftUI. Tenemos dos nuevos modificadores para ello:

  • listRowSeparatorTint. Permite personalizar el color del separador.
  • listRowSeparator. Permite indicar si el separador se debe ocultar o no.

Como vemos, los modificadores se deben aplicar a cada celda. De esta forma tendremos el control total de cómo se deben visualizar los separadores.

import SwiftUI

struct RowsSeparatorView: View {
    @ObservedObject var model = RowsSeparatorViewModel()
    
    var body: some View {
        VStack {
            if model.items.count > 0 {
                List {
                    ForEach(model.items, id: .self) {
                        SeparatorCell(text: $0)
                    }
                }
            } else {
                Text("No results")
            }
        }
        .task {
            await model.loadData()
        }
    }
}

struct SeparatorCell: View {
    var text: String
    
    var body: some View {
        Text(text)
            .listRowSeparatorTint(Bool.random() ? .green : .blue)
            .listRowSeparator(Bool.random() ? .hidden : .visible)
    }
}

@MainActor
final class RowsSeparatorViewModel: ObservableObject {
    @Published var items: [String] = [String]()
    
    init() { }
    
    func loadData() async {
        for i in 0..<100 {
            items.append("Index (i)")
        }
    }
    
}

Nuevo LocationButton

Como sabemos, durante los últimos años Apple ha querido poner a disposición de los usuarios el control sobre los elementos que afectan a su privacidad. Uno de ellos es la posibilidad de obtener la su localización. Muchos usuarios son reticentes a dar dicho permiso a las aplicaciones, a no ser que esté debidamente justificado.

Con este fin, Apple ha creado el nuevo componente LocationButton. Se trata de un botón diseñado específicamente para que el usuario permita conocer la localización de forma puntual en el momento que se pulse dicho botón. De esta forma, el usuario se asegura de tener el control de cuando quiere compartir la localización y los/as desarrolladores/as podemos obtener este dato para ofrecer la mejor experiencia. Es importante destacar que este permiso no requiere que se manifieste en el Info.plist de nuestra aplicación y solo se le solicitará al usuario la primera vez que pulse el botón.

import SwiftUI
import MapKit
import CoreLocationUI

struct LocationButtonView: View {
    
    @ObservedObject var model = LocationButtonViewModel()
    
    var body: some View {
        GeometryReader { proxy in
            Map(coordinateRegion: $model.region)
                .overlay(
                    Group {
                    if model.fetchingLocation {
                        ProgressView()
                            .tint(.black)
                    } else {
                        LocationButton(.currentLocation) {
                            model.requestLocation()
                        }
                        .overlay(
                            Circle()
                                .stroke(.gray, lineWidth: 1)
                        )
                    }
                }
                .frame(width: 44, height: 44)
                .cornerRadius(22)
                .labelStyle(.iconOnly)
                .symbolVariant(.fill)
                .tint(.white)
                .offset(x: proxy.size.width / 2 - 44, y: proxy.size.height / 2 - 64)
                .padding()
                )
        }
    }
}

class LocationButtonViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
    private lazy var manager = CLLocationManager()
    private static let span = MKCoordinateSpan(latit�lta: 0.008, longit�lta: 0.008)
    
    @Published var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 37.408672852282024, longitude: -5.944668759610994), span: LocationButtonViewModel.span)
    @Published var fetchingLocation: Bool = false
    
    override init() {
        super.init()
        manager.delegate = self
    }
    
    func requestLocation() {
        fetchingLocation = true
        manager.requestLocation()
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.first?.coordinate {
            region = MKCoordinateRegion(center: location, span: LocationButtonViewModel.span)
        }
        fetchingLocation = false
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print(error)
        fetchingLocation = false
    }
}

Acciones en las celdas con swipeActions

Con el nuevo modificar swipeActions podemos añadir acciones deslizantes a las celdas de una lista.

Para añadir una acción solo tenemos que usar el modificador en una celda y añadir los botones que queramos dentro de su bloque de inicialización. Además, podemos personalizar si permitimos que la acción se ejecute al realizar un gesto amplio y/o si queremos colocar el borde donde queremos irán los botones.

import SwiftUI

struct SwipeActionsView: View {
    @ObservedObject var model = SwipeActionsViewModel()
    
    var body: some View {
        VStack {
            if model.items.count > 0 {
                List {
                    ForEach(model.items, id: .self) {
                        SwipeActionCell(text: $0)
                    }
                }
            } else {
                Text("No results")
            }
        }
        .task {
            await model.loadData()
        }
    }
}

struct SwipeActionCell: View {
    var text: String
    
    var body: some View {
        Text(text)
            .swipeActions {
                Button {
                    print("Action Favorite")
                } label: {
                    Label("Favorite", systemImage: "star.fill")
                }
                .tint(Color.yellow)

                Button(role: .destructive) {
                    print("Action Delete")
                } label: {
                    Label("Delete", systemImage: "trash.fill")
                }
            }
            .swipeActions(edge: .leading, allowsFullSwipe: true) {
                Button {
                    print("Action Archive")
                } label: {
                    Label("Archive", systemImage: "folder.fill")
                }
                .tint(Color.blue)
            }
    }
}

@MainActor
final class SwipeActionsViewModel: ObservableObject {
    @Published var items: [String] = [String]()
    
    init() { }
    
    func loadData() async {
        for i in 0..<100 {
            items.append("Index (i)")
        }
    }
    
}

Mejoras en el control de los TexField y el teclado

Los TextField y el teclado han recibido algunas mejoras interesantes que echábamos de menos en el desarrollo de apps.

En primer lugar, ahora podemos posicionar el foco en un TextField sin necesidad de interacción del usuario. Para ello tenemos el modificador focused y el property wrapper  @FocusState. Combinándolos podemos conseguir poner el foco de cualquier TextField de la pantalla, haciendo que se presente el teclado.

@FocusState var focus: Bool

...

TextField("Address", text: $address)
    .focused($focus)

También tenemos una mejora para el modificador toolbar: ahora podemos indicar que el objeto ToolbarItemGroup este asociado a .keyboard. De esta forma se pintará una barra justo encima del teclado con los componentes que queramos.

...
.toolbar {
    ToolbarItemGroup(placement: .keyboard) {
        Button {
            if let focus = focus {
                self.focus = focus.previous()
            }
        } label: {
            Image(systemName: "chevron.backward")
        }
        .disabled(!hasPreviousTextField)
        Button {
            if let focus = focus {
                self.focus = focus.next()
            }
        } label: {
            Image(systemName: "chevron.forward")
        }
        .disabled(!hasNextTextField)
        Spacer()
    }
}

Disponemos además del nuevo modificador onSubmit, que nos permitirá recoger el flujo cuando el usuario pulse sobre el botón Aceptar del teclado.

TextField("Name", text: $name)
    .onSubmit {
        print("Do something")
    }

Por último, podemos personalizar el texto del botón Aceptar del teclado a través del nuevo modificador submitLabel, pudiendo seleccionar una de las opciones que nos proporciona el sistema.

TextField("Surname", text: $surname)
    .submitLabel(.next)
TextField("Address", text: $address)
    .submitLabel(.done)

De esta forma el control que podemos tener sobre los campos de textos es mucho mayor que el que podíamos realizar hasta ahora, permitiéndonos personalizar los flujos de la pantalla de forma que podamos ayudar y guiar a los usuarios por nuestros formularios.

Aquí podemos ver un ejemplo completo con todas las propiedades anteriormente mostradas:

truct TextFieldAndKeyboardView: View {
    @State var name: String = ""
    @State var surname: String = ""
    @State var address: String = ""
    
    @State var lastSubmit: String = "None"
    @FocusState var focus: OrderField?
    
    enum OrderField: Int, Hashable, CaseIterable {
        case name
        case surname
        case address
        
        func next() -> OrderField? {
            let all = Self.allCases
            let idx = all.firstIndex(of: self)!
            let next = all.index(after: idx)
            if next == all.endIndex {
                return nil
            } else {
                return all

Puedes descargarte el proyecto con todos estos ejemplos en nuestro repositorio de GitHub. Te dejamos también un enlace que, si has llegado leyendo hasta aquí, posiblemente te interese mucho: nuestra completa guía SwiftUI.

Rafael Fernández,
iOS Tech Lider