<img height="1" width="1" src="https://www.facebook.com/tr?id=1272750026111903&amp;ev=PageView &amp;noscript=1">

SwiftUI – En praktisk introduksjon

Øyvind Tangen

Øyvind Tangen | 15. oktober 2019

Selv om Swift kom i 2014 har det aldri føltes helt “Swifty” å utvikle apper med Swift. UIKit og AppKit, dagens to rammeverk for utvikling av apper til iOS og macOS var primært laget for Objective-C og stammer tilbake til Apples NeXT-arv og begynner dermed å dra på årene. Det var derfor en uvanlig stor nyhet da Apple i sommer lanserte rammeverkene SwiftUI og Combine, som tydelig viser Apples visjon for utvikling på deres plattformer framover. SwiftUI og Combine drar nytte av nye funksjoner kun tilgjengelig i Swift. Endelig føles det “Swifty” å utvikle apper med Swift.

I denne artikkelen vil jeg stegvis gå gjennom hvordan du lager en enkel SwiftUI-app samtidig som jeg presenterer både SwiftUI og Combine. Avslutningsvis vil jeg løfte blikket, diskutere mobillandskapet og svare på om SwiftUI og Combine er noe du bør ta i bruk allerede i dag.

Hello World

SwiftUI følger i rekken av komponentbaserte UI-rammeverk, og en app i SwiftUI bygges opp av flere små eller store komponenter, som i SwiftUI kalles Views. View er en ny protokoll du bruker til å komponere egne Views. Det eneste kravet er at du har en computed property med navn body som returnerer et nytt View.

Hello World-eksempelet under viser et View ContentView som kun består av et Text-element som skriver “Hello World” på skjermen.

HelloWorld

I tillegg til Text kommer SwiftUI med en rekke innebygde Views som får deg godt i gang med utvikling. Du kan lese mer om de her: https://developer.apple.com/documentation/swiftui/views_and_controls

Forhåndsvisning

Nederst i Hello World-eksempelet kan vi se kode inne i en DEBUG-blokk. Dette er en nyhet i SwiftUI og Xcode 11 som lar deg få en hurtigoppdaterende forhåndsvisning av appen du jobber med. Endelig har det blitt like enkelt å drive rask prototyping og testing av endringer i grensesnittet som det er med blant annet React Native of Flutter. Se her hvordan det fungerer i praksis:

HotReload

SwiftUI er deklarativt

Med React i spissen, har det de siste årene vært mye prat om forskjellen på deklarativ og imperativ programmering, samt fordelene av et deklarativt system. SwiftUI følger trenden og er nok et deklarativt rammeverk for å bygge brukergrensesnitt. For å forstå forskjellene på de to programmeringsparadigmene kan vi se på det slik:

Imperativ: Du vet hva du vil ha og beskriver ned til minste detalj hvordan en jobb skal gjøres for å få det du ønsker.

Deklarativ: Du vet hva du vil ha og ber om det.

Et eksempel er når du kommer inn på et kafé og bestiller kaffe:

Imperativ kaffebestilling

  • Si du ønsker en kopp svart kaffe og forklar ned til minste detalj hvordan baristaen skal lage koppen: Kvern kaffebønner, kok vann, våt kaffefilter, plasser kaffe i filteret, hell varmt vann over kaffen, vent, hell kaffe over i kopp.

Deklarativ kaffebestilling

  • Si at du ønsker en stor kopp svart kaffe

Dette er kanskje et litt søkt eksempel, men poenget er at du «outsourcer» hvordan jobben skal gjøres til baristaen. Du befinner deg på et høyere abstraksjonsnivå og beskriver hva du vil ha, samtidig som du stoler på at systemet gir deg det du ønsker tilbake.

Vi lager en SwiftUI-app

Sammen skal vi lage en app som lister opp medlemmene i min lille familie. Vi skal kunne klikke oss inn på hvert familiemedlem og lese litt info om de. Det skal også være mulig å klikke en knapp som sender en hilsning til familiemedlemmet. Se den ferdige appen her:

FamilyApp

Før vi starter
For å komme i gang med SwiftUI-utviklingen må du:

  1. Laste ned og installer Xcode 11
  2. Klone dette repoet: https://github.com/oyvindrt/family-app
  3. Åpne prosjektet i Xcode 11 og åpne ContentView.swift. Det er i denne filen vi skal starte å jobbe.
  4. (Dersom du vil legge inn egne familiemedlemmer kan du endre bildene fra Assets.xcassets og oppdatere filen family.json)

Automatisk forhåndsvisning fungerer kun dersom du kjører på macOS 10.15 Catalina. Dersom du kjører Xcode 11 på Mojave må du, bygge appen hver gang du vil se endringer.

Liste opp familiemedlemmer

Det første vi skal gjøre er å hente inn alle familiemedlemmene. De tre familiemedlemmene finnes i filen family.json og jeg har lagt til en extension-funksjon på Bundle.main som vi kan bruke til å laste inn data fra fil (detaljer finnes i Data.swift).

På toppen inne i ContentView-structet skriver du følgende for å laste inn familiemedlemmene fra fil:

let familyMembers: [FamilyMember] = Bundle.main.loadData("family.json")


Erstatt returverdien til body med en liste som lister opp navnene på alle tre i familien:

List(familyMembers) { familyMember in
    Text(familyMember.name)
}

Nå skal du se en liste over Toril, Øyvind og Melis.

For å få den listeraden du ønsker, bytt ut i Text-elementet med en horisontal stabel – HStack. Det første elementet i den horisontale stabelen skal være et bilde av familiemedlemmet og det andre elementet skal være navnet på familiemedlemmet det gjelder:

HStack {
    Image("\(familyMember.name) thumb")
    Text(familyMember.name)
        .font(.title)
}

Du skal også få alder på familiemedlemmet inn i listeraden. Legg Text-elementet i en VStack slik:

VStack(alignment: .leading) {
    Text(familyMember.name)
        .font(.title)
    Text("\(familyMember.age) years old")
        .font(.subheadline)
}.padding([.top, .bottom], 10)

Listen er nå slik vi ønsker den.

Fullstending kode i ContentView.swift er så langt:

struct ContentView: View {
	let familyMembers: [FamilyMember] = Bundle.main.loadData("family.json")
    
    var body: some View {
    	List(familyMembers) { familyMember in
			HStack {
				Image("\(familyMember.name) thumb")
				VStack(alignment: .leading) {
					Text(familyMember.name)
						.font(.title)
					Text("\(familyMember.age) years old")
						.font(.subheadline)
				}.padding([.top, .bottom], 10)
			}
		}
	}
}
Navigasjon

Videre ønsker vi å gjøre det mulig å klikke på en listerad og navigere inn på en ny side med detaljer om valgt familiemedlem.

Legg hele List-elementet i et NavigationView og sett .navigationBarTitle på listen:

NavigationView {
	List(familyMembers) { familyMember in
		…
	}
	.navigationBarTitle("Family")
}

Lag så en ny fil ved å klikke ⌘-N. Velg et nytt “SwiftUI View” og kall filen “FamilyMemberView.swift”. Endre FamilyMemberView-structet til å ta i mot et familiemedlem som parameter, og skriv navnet på skjerm:

struct FamilyMemberView: View {
	var familyMember: FamilyMember
    
    var body: some View {
		Text(familyMember.name)
	}
}

Ettersom dette nye viewet krever et familiemedlem som parameter, må vi også endre koden i DEBUG-blokken for at forhåndsvisningen fortsatt skal funke. Skriv inn følgende i DEBUG-blokken i FamiliMemberView.swift:

static let familyMembers: [FamilyMember] = Bundle.main.loadData("family.json")

static var previews: some View {
	FamilyMemberView(familyMember: familyMembers[0])
}

Tilbake i ContentView.swift, bygg inn HStacken i en NavigationLink:

NavigationLink(destination: FamilyMemberView(familyMember: familyMember)) {
	HStack {
		…
	}
}

Nå kan du kjøre appen og se at det er mulig å navigere inn i et nytt View ved å klikke på en listerad.

Detaljvisning

Erstatt Text-elementet i FamilyMemberView.swift med følgende kode for å liste opp informasjon om den valgte personen:

VStack {
	Image(familyMember.name)
	Text(familyMember.name)
	Text("\(familyMember.age) years old")
	Text(familyMember.description)
	Button(action: {
		
	}) {
		Text("Say hello 👋")
	}
}
.navigationBarTitle(Text("Hello"), displayMode: .inline)

Endre så på stilen og legg inn en Spacer for å presse alt oppover:

VStack {
	Image(familyMember.name)
	Text(familyMember.name)
		.font(.title)
	Text("\(familyMember.age) years old")
		.font(.headline)
		.foregroundColor(.secondary)
	Text(familyMember.description)
		.padding()
		.lineLimit(5)
	Button(action: {
		
	}) {
		Text("Say hello 👋")
	}
    .padding()
    .background(Color(red: 187/255, green: 42/255, blue: 37/255, opacity: 1))
    .cornerRadius(25)
    .foregroundColor(.white)
    .font(.headline)
	Spacer()
}
.navigationBarTitle(Text("Hello"), displayMode: .inline)

Hele FamilyMemberView ser nå slik ut:

struct FamilyMemberView: View {
    var familyMember: FamilyMember
    
    var body: some View {
        VStack {
            Image(familyMember.name)
            Text(familyMember.name)
                .font(.title)
            Text("\(familyMember.age) years old")
                .font(.headline)
                .foregroundColor(.secondary)
            Text(familyMember.description)
                .padding()
                .lineLimit(5)
            Button(action: {
                
            }) {
                Text("Say hello 👋")
            }
            .padding()
            .background(Color(red: 187/255, green: 42/255, blue: 37/255, opacity: 1))
            .cornerRadius(25)
            .foregroundColor(.white)
            .font(.headline)
            Spacer()
        }
        .navigationBarTitle(Text("Hello"), displayMode: .inline)
    }
}

Godt jobba! Du har nå laget en enkel, men godt fungerende liten app med navigasjon i SwiftUI. Kjør appen og se resultatet selv.

Det kan nå være greit å ta et steg tilbake og se på hva vi har gjort. For å sammenligne SwiftUI med UIKit har jeg implementert et tilsvarende FamilyMemberView i UIKit. Se all koden som måtte til:

UIKit

Det som er ≈30 linjer kode i SwiftUI er over 90 linjer i UIKit. Det kan virke som dette eksempelet er laget for å sette UIKit i dårlig lys, men det viser tydelig hvor stor forskjell det kan være i mengde kode mellom SwiftUI og UIKit i enkelte tilfeller. Apple selv sier at SwiftUI lar deg bruke mer tid på din kode og mindre på boilerplate-kode, noe jeg mener stemmer godt.

@State

Fra React kjenner vi komponentlokale tilstandsvariabler og det er når disse endrer seg at React tegner en komponent på nytt. Det samme konseptet finnes i SwiftUI. Når en variabel annotert med @State endrer verdi, vil SwiftUI tegne brukergrensesnittet på nytt, dersom det er behov for det. Internt er dette muliggjort med en ny funksjon i Swift 5.1 kalt Property Wrapper. John Sundell har skrevet om Property Wrappere her: https://www.swiftbysundell.com/posts/the-swift-51-features-that-power-swiftuis-api

Vi skal nå oppdatere appen med en teller for hvor mange ganger vi har klikket på Say Hello-knappen. Legg inn en lokal @State-variabel i ytterste nivå i FamilyMemberView-structet vårt:

@State var numberOfGreetings = 0

Endre så action-blokken på knappen slik at den inkrementerer variabelen:

self.numberOfGreetings += 1

Til slutt legger vi til et tekstelement under knappen:

Text("Greeted \(numberOfGreetings) times")

Test appen og se at grensesnittet oppdaterer seg i det du klikker på knappen.

Bevaring av tilstand - @ObservedObject og @EnvironmentObject

Vi teller nå antall hilsninger, men det er et forbedringspotensiale slik appen er nå. Antall hilsninger nulles ut i det du navigerer deg tilbake til listen. Det er fordi @State-variabelen initieres med verdien 0 på nytt hver gang vi åpner FamilyMemberView. I tillegg til @State, har Apple gitt oss to andre Property Wrappere som kan løse dette problemet for oss: ObservedObject og EnvironmentObject. Disse to er en del av Apples andre nye rammeverk – Combine.

Combine
Selv om SwiftUI er den store nyheten denne sommeren, hadde det ikke vært komplett uten Combine. Combine er et deklarativt rammeverk for å prosessere verdier over tid, f.eks databasespørringer, nettverkskall eller asynkrone operasjoner. Dette er Apples svar på Reaktiv-programmering og går rett i duell med RxSwift.

I Combine er det to protokoller vi må ha kjennskap til: Publisher og Subscriber. En publisher tilgjengeliggjør en sekvens av verdier over tid. På den andre siden har vi en subscriber som vil prosessere dataene en publisher har gjort tilgjengelig. Combine er et omfattende rammeverk og fortjener sin egen bloggpost. Jeg går derfor ikke mer inn i dybden på Combine her, men vi skal heller se på en protokoll og noen Property Wrappere rammeverket tilbyr som vil hjelpe oss med å bevare tilstand mellom forskjellige Views.

ObservableObject og @Published
For å ta vare på antall knappetrykk i appen vår, trenger vi et globalt tilstandsobjekt som lever et annet sted enn i hvert enkelt View. Lag en ny blank Swift-fil med navn AppState.swift. Skriv inn følgende kode:

import Foundation
import Combine
 
class AppState: ObservableObject {
    @Published var familyMembers: [FamilyMember] = Bundle.main.loadData("family.json")
}

Her er det et par ting vi må gå igjennom:

@Published

  • Ved å annotere familyMember med @Published gjør vi den om til en publisher.

ObservableObject

  • Et objekt av denne typen er et objekt som har en eller flere publishere i seg som vil gi beskjed dersom verdien de holder på endrer seg.

Et objekt av typen ObservableObject med en @Published-annotert verdi inne i seg er det som skal til for å lage et globalt tilstandsobjekt.

@ObservedObject
Åpne ContentView.swift. Vi skal nå se på hvordan vi kan lese fra, skrive til og lytte til endringer fra dette tilstandsobjektet i SwiftUI med en Property Wrapper som heter ObservedObject. Vi ønsker å hente familiemedlemmene fra AppState og ikke lese rett fra fil. Erstatt innlastingen av familiemedlemmer med:

@ObservedObject var appState: AppState

Nå er familiemedlemmene våre tilgjengelig på appState.familyMembers. Du må derfor endre listen til å iterere over familiemedlemmene fra AppState:

List {
    ForEach(0..<appState.familyMembers.count) { index in
        NavigationLink(destination: FamilyMemberView(familyMember: self.$appState.familyMembers[index])) {
            HStack {
                Image("\(self.appState.familyMembers[index].name) thumb")
                VStack(alignment: .leading) {
                    Text(self.appState.familyMembers[index].name)
                        .font(.title)
                    Text("\(self.appState.familyMembers[index].age) years old")
                        .font(.subheadline)
                }.padding([.top, .bottom], 10)
            }
        }
    }
}.navigationBarTitle("Family")

Legg merke til at vi nå sender inn en bundet verdi inn til FamilyMemberView ved å bruke $-tegn foran appState. Dette lar oss hente ut den faktiske verdien Binding<FamilyMember> som Property Wrapperen @ObservedObject skjuler for oss. Du kan se på det som en referanse.

Du er også nødt til å endre DEBUG-blokken i bunnen ContentView.swift. ContentView skal nå instansieres med en AppState:

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(appState: AppState())
    }
}

Gå så til FamilyMemberView.swift. Her skal vi nå ta i mot den bundne verdien som parameter. Erstatt både familiyMember-variabelen og den lokale @State-variabelen med:

@Binding var familyMember: FamilyMember

@Binding forteller SwiftUI at dette er en verdi som kommer fra et annet sted enn lokalt i viewet. I vårt tilfelle lever denne verdien i vår AppState.

Endre så action-blokken for knappen til:

self.familyMember.greetings += 1

Endre også Text-elementet som viser antall hilsninger til:

Text("Greeted \(familyMember.greetings) times")

Ettersom FamilyMemberView nå har en @Binding-variabel som parameter ved instansiering, må vi også endre DEBUG-blokken i FamilyMemberView.swift til:

struct FamilyMemberView_Previews: PreviewProvider {
    @ObservedObject static var appState = AppState()
    
    static var previews: some View {
        FamilyMemberView(familyMember: $appState.familyMembers[0])
    }
}

Til slutt må vi opprette AppState i SceneDelegate.swift:

window.rootViewController = UIHostingController(rootView: ContentView(appState: AppState()))

Nå er appen klar til å testes og du vil se at antall hilsninger til hvert familiemedlem lagres selv om du blar deg bakover i hierarkiet. @ObservedObject er en kraftfull Property Wrapper som gir oss fordelene av @State, uten at det trenger å være bundet til ett enkelt View.

@EnvironmentObject
Når en app vokser i kompleksitet og har mange forskjellige Views som trenger tilgang til AppState, ville det fort blitt tungvint å sende en referanse til AppState fra View til View. Med @ObservedObject må dette gjøres slik:

ObservedObject

Det vi ønsker er en måte å "injecte" AppState inn akkurat der vi trenger den. Slik som det her:

EnvironmentObject

Property Wrapperen @EnvironmentObject gjør det mulig. Det siste vi skal gjøre med familie-appen vår er å gjøre AppState tilgjengelig for ContentView som et @EnvironmentObject.

Endre @ObserverObject-variabelen i ContentView.swift til:

@EnvironmentObject var appState: AppState

Så må du i SceneDelegate.swift initiere ContentView.swift med en miljøvariabel:

window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(AppState()))

Dette må også gjøres i Preview-blokken i ContentView:

ContentView().environmentObject(AppState())

Nå vil AppState være mulig å hente ut som et @EnvironmentObject for alle Views som er under ContentView i View-treet. Dersom verdien bak @EnvironmentObject-variabel endrer seg i ett View, vil endringen automatisk spre seg til andre View som også bruker variabelen.

Oppsummert

Du har laget en enkel app på deklarativt vis i SwiftUI, som lar deg navigere mellom to skjermer og bevarer tilstand ved hjelp av Combine, alt dette på relativt få linjer kode. Det er selvsagt mye mer å gå i dybden på, f.eks persistering av data, nettverkskall og frontend-arkitekturer (Combine gjør det blant annet relativt enkelt å ta i bruk MVVM eller Redux). Allikevel viser denne appen hvor enkelt det er å håndtere dataflyt og oppdatere grensesnittet som følge av endringer i datamodellen.

Avslutningsvis

Det er ikke bare Apple som har store nyheter for mobilutviklere dette året. På I/O viste Google fram Jetpack Compose. Compose er Googles nye offisielle deklarative og komponentbaserte UI-rammeverk og er foreløpig i det de selv kaller et “pre-alpha”-stadie. De har likevel gjort det klart at Compose er fremtiden for Android. 2019 blir dermed året både Google og Apple kom med sine deklarative rammeverkalternativer, som svar på React Native og Flutter. Det kan se ut til at bransjen nå er i gang med et paradigmeskifte mot en felles metode som startet på webben – mot deklarative verktøy.

SwiftUI og Combine er utvilsomt store nyheter og det er lenge siden jeg har sett en slik positivitet og entusiasme for utvikling på Apples plattformer, som det utviklermiljøet har vist i sommer. Det har tydelig vært et ønske om mer moderne og deklarative verktøy – en teknisk attraktiv plattform trekker nye utviklere og skaper engasjement.

Så, det store spørsmålet – skal du som apputvikler ta i bruk SwiftUI og Combine i dag? Ikke nødvendigvis. Begge rammeverkene krever at du kjører iOS 13, macOS 10.15 Catalina, iPadOS, watchOS 6 eller tvOS 13. Dette er de nyeste versjonene av Apples operativsystemer og har nettopp blitt lansert. Det betyr at det vil ta tid før vi kan bruke SwiftUI og Combine i produksjonsklare prosjekter, i hvert fall om vi skal støtte brukere som ikke har nyeste programvare installert. Historisk sett har imidlertid Apple-brukere vært raske med å oppdatere. Det tok bare én måned for iOS 12 å nå 50% av alle enheter og i august 2019, nesten ett år etter iOS 12 ble lansert, er brukerbasen på over 90%.

Det kan uansett være gode grunner til å teste ut SwiftUI allerede nå. SwiftUI kan brukes side om side med UIKit, så en gradvis innføring er mulig. Ellers er det ingen dum idé å forberede seg litt på hvordan apputviklerfremtiden blir.

Lykke til!



Del på sosiale medier: