Amsterdam kanava

  Pääsivulle  |  TIETOTEKNIIKAN PÄÄSIVULLE
 

macOS SwiftUI

Viimeisin päivitys: katso alempana luvun "Taulukot" loppuosa

Ohjelmalogiikan kulku

(9.1.2021)
Minun herbaario- ja valokuvasovellukseni ovat melko laajoja ohjelmistoja, joissa on 15-20 erillistä näkymää (View). Jotta niiden käsittely olisi johdonmukaista ja kevyttä, tein selvittelyjen jälkeen aika omanlaisen ratkaisun, millaista en ole missään nähnyt. Sen kuvaus löytyy täältä:  sovelluksen arkkitehtuuri.

Käytän sovellustyyppiä "SwiftUI Lifecycle", joten kaikki esimerkkinikin on tehty siltä pohjalta.


SOVELLUKSEN LOPETUS

Herbaariosovellusta lopetetettaessa haluan, että käyttäjä saa eteensä ilmoituksen, jos tietokantaa on muutettu. Ilmoituksessa kehoitetaan ottamaan tietokannasta varmuuskopio talteen. Jos sovellus lopetetaan itse lisäämästäni painikkeesta "Lopetus", niin koodiin on helppo järjestää Alert-ikkunan näyttäminen, ja sen OK-painikkeesta sovellus suljetaan exit(0)-komennolla. Tämä exit(0) sulkee sovelluksen kokonaan, eikä jätä sitä Applen käyttöliittymästandardin tarkoittamaan valmiustilaan (kun esimerkiksi suljet selaimen, jää ikkunan alalaidassa piste selaimen kuvakkeen alle, eli selainsovellus on valmiustilassa). Jos sovelluksen sulkee punaisesta ruksista, se jää valmiustilaan.

Vasemman ylänurkan punaisen ruksin klikkaukseen pääsee kiinni .onDisappear(perform: {}) -muuttajalla (view modifier). Sen koodi käynnistyy heti kun punaista ruksia on klikattu. Mutta osoittautui, että sovellusta on siinä vaiheessa jo suljettu niin paljon, että Alert-ikkunaa ei enää voi avata. Sopivaa olisi tässä vaiheessa avata näytön oikeaan ylänurkkaan ilmoitus (notification), joita eri sovellukset lähettävät. Mutta sen ohjelmointi joko on vielä buginen tai huonosti dokumentoitu, enkä saanut toimivaa koodia aikaiseksi. Totesin lopuksi, että unix-komentoja voi kyllä käynnistää, ja testasin että sain say-komennolla ilmoituksen puhuttuna. Tein siihen lopulta oman infonäkymän, joka avautuu sheettinä.

Lopulta päätin viis veisata Applen standardista, ja sovellukseni aina sulkeutuessaan näyttää tarvittaessa muistutuksen, ja aina sulkeutuu kokonaan komentoon exit(0).

PERUSNÄKYMIÄ

Eräiden ominaisuuksien koodaaminen SwiftUI:lla kävi hämmästyttävän helposti ja nopeasti ja tuloksena olevassa koodissa on hyvin vähän rivejä. Vastaavia olin aikoinaan tutkinut ja selvitellyt melko pitkästi sekä C#:ssä (WinForm) että XIB-Swiftissä, mutta C# WPF oli jo helpompi. Tässä kyseiset tapaukset:
- kuvajoukon esittäminen matriisissa scrollauksen kera ja ikkunan kokoa tarvittaessa muuttaen, kuvan valitseminen
- karttanäkymä, koordinaatit inputtina (Applen kartta)
- kuvan katsominen siten että se tarvittaessa suurenee ikkunansa mukana, scrollauksen kera
- videon katselu

onAppear

(7.3.2021)
SwiftUI:n sanotaan olevan lähtökohdaltaan näytönkuvailukieli. Näin se pitkälti onkin. Jotta koodia ajettaessa saa esille mitään (järkevää), on kaiken datan oltava valmiina vähintään alkuarvoissaan. Kun data muuttuu, niin näyttö muuttuu välittömästi sen mukaisesti.

Alkuarvoja voi asettaa muuttujan määrittelyn yhteydessä, ympäristömuuttujina tai globaaleina muuttujina. Lisäksi yksittäisessä näkymätiedostossa saa olla osuus .onAppear() {}, joka suoritetaan aluksi. Mistään en ole löytänyt selostusta, mutta kokemuksen mukaan logiikka kulkee näin yksittäisessä struct-tiedostossa:
- struct:n muuttujat (@State jne.) alustetaan alkuarvoihinsa
- body (näkymämäärittely) käydään läpi menemättä kuitenkaan varsinaisiin funktioihin mutta mennään kutsuttuihin muihin struct-tiedostoihin
- suoritetaan .onAppear()
- body käydään tarkoin läpi ja rakennetaan mahdollisesti kutsutut muut struct-näkymät, ja näkymä avataan näytölle

Edellä olevasta on poikkeus näkymä, joka on alinäkymä varsinaiselle. Silloin .onAppear() saattaa jäädä suorittamatta tai mahdollisella suorittamisella ei ole vaikutus sihhen mitä näytöllä näkee. Mutta joissain rakenteissa kuitenkin toimii odotetulla tavalla. Jos ei toimi hyvin, kaikki muuttujat pitää laittaa kuntoon kutsuvassa näkymässä ja tuoda alinäkymään @Binding -määritteinä. 

Erilaista toimintalogiikkaa, kuten funktioita, voidaan kutsua myös  toimintokomentojen yhteydessä: buttonit, alasvetovalikot (onChange), menut jne.

Alertit

(23.3.2021)
Alertti eli käyttäjäilmoitus on ikkuna jossa näytetään varoitus tai kysytään vaihtoehtoa miten jatketaan. Se toteutetaan laittamalla ContentView:n alalaitaan modifioija .alert(..){} jonka sisällä alertin sisältö. Tämä modifioija on sijoitettava ContentView:lle. Nykyään joissain sovelluksissani käytän monipuolisempaa alerttia, jossa on mukana yksinkertainen alertti ja vaihtoehtobuttoneilla varustettu. Otin käyttöön tällaisen alertin (kiitos stackoverflow, nimimerkki aslebedev):

struct AlertItem: Identifiable
{
   var id = UUID()
   var otsa = Text("")
   var sisus: Text?
   var ekaBtn: Alert.Button?
   var tokaBtn: Alert.Button?
}

Ja lähtönäkymän alareunassa minulla on modifioija:
.alert(item: $tila.alertItem)
{
   alertItem in
   let ebtn = alertItem.ekaBtn
   let tbtn = alertItem.tokaBtn
   if ebtn == nil
   {
      return Alert(title: alertItem.otsa, message: alertItem.sisus,
        dismissButton: tbtn!)
   }
   else if tbtn == nil
   {
      return Alert(title: alertItem.otsa, message: alertItem.sisus,
         primaryButton: ebtn!, secondaryButton: .default(Text("Ei")))
   }
   else
   {
      return Alert(title: alertItem.otsa, message: alertItem.sisus,
         primaryButton: ebtn!, secondaryButton: tbtn!)
   }
}

Muuttuja tila on apumuuttuja (@State), joka on input kaikille näkymilleni. Sillä on komponentti alertItem, joka välittää tiedon alertin tarpeesta. Kun esimerkiksi kolmas "päällä" oleva näkymä haluaa alerttia, se saadaan avautumaan apumuuttujan kautta kyseisen kolmannen näkymän päällä.

Alertti saadaan näkyviin esimerkiksi buttonista "Poistu" näin (tässä tilanteessa käyttäjä ei ollut tallentanut):

virhez = "Haluatko hylätä tekemäsi muutokset?\n\n(Kasvimuok C01-02)"
print(virhez)
tila.alertItem = AlertItem(
   otsa: Text("Varmistus"),
   sisus: Text(virhez),
   ekaBtn: .default(Text("Kyllä"), action:
      {     tila.idrivi = EIOO
            seurx.removeFirst()
      } ),
   tokaBtn: .default(Text("En"), action: {  }  ))

Rivi seurx.removeFirst() aiheuttaa sen, että esillä oleva näkymä poistuu ja tilalle tulee pinossa seuraava (pinossa olevan tunnuksen mukaan piirretään ao. näkymä käyttäen muuttujan tila tilatietoja).

Käyttämäni apumuuttujat seurx ja tila, katso selitys täältä:  sovelluksen arkkitehtuuri.

Taulukot

(19.12.2021)  -  Taulukkoasiaa on omalla sivullaan täällä.


Näkymien rakentaminen

(23.10.2020)
SwiftUI:ssa on hyvin yksinkertaista koota ikkuna eri näkymäosista, kuten yllä vaaleansininen osa, vihreä osa ja taulukko. Ne on usein järkevää sijoittaa omiksi tiedostoikseen, joita sitten päätiedosto kutsuu.

Ikkunan osat kootaan samalla periaatteella kuin C# WPF:ssä eli kootaan rinnakkain ja peräkkäin osia (HStack ja VStack) niiden kokoa määrittelemättä ja ne sitten vievät sen koon mitä yksittäiselementtien (tekstikenttä, checkbox jne) tila vaatii. Osille saa kyllä koonkin ilmoittaa, mutta usein se on tarpeen vain koko ikkunalle. Lisäksi voidaan määritellä mihin reunaan osat laitetaan, niiden välimatkoja ym, jos oletusarvot eivät kelpaa.

Tässä esimerkkinä näyttö, jossa muokkaan kasvin löytöpaikan tietoja, ja voin samalla katsoa karttakuvaa todetakseni menivätkö koordinaatit kohdalleen:
Paikan muokkaus


Karttanäytöt

(5.4.2021)
Näytän eri sovelluksissani kolmenlaisia karttoja: Applen kartta, OpenStreetMap ja Maanmittauslaitoksen maastokartta. Näitä varten tarvitaan koordinaatit ja paikkakunnan nimi. Maastokartta haluaa NorthEast-koordinaatit, muut haluavat LatiLongi-koordinaatit. Koordinaattien muuntaminen LatiLongi -> NorthEast ohjelmallisesti löytyy täältä.

Applen karttaa varten annan Tila-muuttujan parametreina tarvittavat tiedot, muille annan valmiin url-merkkijonon:
WebView(filez: urliz)   // Maastokartta, OpenStreetMap

MapView(tila: $tila)    // Applen kartta

Tällä tavoin esitetään maastokartan ja OpenStreetMapin urlit, ja itse sovelluksessa olen hakenut koordinaatit tällaisiin lausekkeisiin:
kartta-urlit

Tässä maastokartan ja OpenStreetMapin näyttäminen:

import SwiftUI
import WebKit

// ************************************
struct WebView: NSViewRepresentable
{
   let filez: String
   
   func makeNSView(context: Context) -> WKWebView
   {
      return WKWebView()
   }
   
   func updateNSView(_ nsView: WKWebView, context: Context)
   {
      let urlReq = URLRequest(url: URL(string: filez)!)
      nsView.load(urlReq)
   }
}

Tässä Applen kartan näyttäminen:

import SwiftUI
import MapKit

// ************************************
struct MapView: View
{
    @Binding var tila: Tila  // täältä saadaan koordinaatit ja paikan nimi
    @State private var region = MKCoordinateRegion()
    @State private var sities = [siti]()
   
    var body: some View
    {
        // kartan zoomaus näppäimillä + ja -
        Map(coordinateRegion: $region, interactionModes: MapInteractionModes.zoom, annotationItems: sities)
        {
            siti in
            MapAnnotation(coordinate: siti.coordinate)
            {
                PlaceAnnotationView(title: siti.nimi)
            }
        }
        .onAppear()
        {
            print("MapView2 MV01-01: latiz = \(tila.latiz)")
            sities.append(.init(nimi: tila.kinfoz, coordinate: .init(latitude: Double(tila.latiz) ?? 0.0, longitude: Double(tila.longiz) ?? 0.0)))
            region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: Double(tila.latiz) ?? 0.0, longitude: Double(tila.longiz) ?? 0.0),
                span: MKCoordinateSpan( latitudeDelta: 0.3, longitudeDelta: 0.3)    // kauempaa esim 10, 10
            )
        }
    }
}

// ************************************
// sijaintipaikan tiedot
struct siti: Identifiable
{
    let id = UUID()
    let nimi: String
    let coordinate: CLLocationCoordinate2D
}

// *************************************
// Paikan osoitin kartalla
struct PlaceAnnotationView: View
{
    @State private var showTitle = true
 
    let title: String
 
    var body: some View
    {
        VStack(spacing: 0)
        {
            Text(title)
              .font(.callout)
              .padding(5)
              .background(Color(.white))
              .cornerRadius(10)
              .opacity(showTitle ? 0 : 1)
       
            Image(systemName: "arrowtriangle.up.fill")
              .font(.caption)
              .foregroundColor(.red)
              .offset(x: 0, y: 5)
            Image(systemName: "mappin.circle.fill")
              .font(.title)
              .foregroundColor(.red)
        }
        .onTapGesture
        {
            withAnimation(.easeInOut)
            {
                showTitle.toggle()
            }
        }
    }
}


Menut

(5.4.2021)
Menuja voi koodata sovellukseen kahdella eri tavalla: ikkunan yläreunaan, tai sovellusikkunan sisään.

Ikkunan yläreunaan saa omat menunsa oletuksena vakiomenujen View (Näytä) ja Window (Ikkuna) väliin. Siihen käytetään SwiftUI-lauseketta CommandMenu, joka on laitettava sovelluksen avaustiedoston sisään, WindowGroup:n muuttajaksi .commands ja netistä löytyy hyvin esimerkkejä. Voi käyttää vain, jos sovelluksen lifecycle  on "SwiftUI Lifecycle".

Itse mieluummin laitan menut sovelluksen pääikkunan kehysten sisään, tässä menut Tilastot, Ylläpito ja Tietoja:
menujen
        käyttäminen

Menurivi koodataan HStack:n sisään kuten muutkin peruselementit

Menu("YLLÄPITO")
{

   Button("Henkilöt", action: { ... })

   Button("Sisätilat", action: { .... })

   Button("Vakiopaikat", action: { ... })

   Button("Laadut", action: { .... })

}

.frame(width: 140)


Kuvamatriisi

(23.10.2020)
Tässä matriisinäkymä, jossa yksi kuva valittuna:
Matriisi

Alla matriisinäkymän koodaus ilman yläreunan buttoneita. Yksittäisen kuvat ovat taulukossa 'kuvat', jonka alkiot ovat class Kuvainfo, jossa alkioina ovat mm. kasvin id tietokannassa, kasvin nimi ym.  Koodin käyttämä taulukko on tarkastettava etukäteen ennen kutsua siten, että saatava kuva ei ole nil, muutoin sovellus kaatuu. Tai sitten koodiin on lisättävä if -lauseke siltä varalta (jota minä käytännössä käytän). Tässä käytetään käyttäjän omissa kansioissa olevia tiedostoja, joten Xcode:ssa on tämä mahdollistettava Entitlements-osiossa laittamalla SandBox pois päältä.

ScrollView
{
   LazyVGrid(columns: gridItems, spacing: 10) // 10 = rivien väli
   {
      ForEach(kuvat, id: \.self)
      {
         kif in    // alkio Kuvainfo
         VStack(spacing: 6)  // 6 = sarakkeiden väli
         {
            Image(nsImage: NSImage(contentsOfFile: kif.polkuz)!)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 120, height: 120)
            Text(kif.Kasnimi)
         }
         .onTapGesture  // kuvan valinta
         {
            valKuva = kif.idi
         }
         .background(self.valKuva == kif.idi ? .gray : Color.vaalHarm) // kuvan taustaväri
         .foregroundColor(self.valKuva == kif.idi ? .white : .black)   // tekstin väri
      }
   }
   .padding(.horizontal)
}
.frame(minWidth: 684, minHeight: 400)


Kuvaikkuna

(31.5.2021)
Kuvan esittäminen erillisessä ikkunassa kuvaa koskevien tietojen kanssa on erittäin yksinkertaista. Alla yksinkertaistettu esimerkki kuvasta ja koodista. Vaikeammaksi koodaus muuttui, kun halusin kuvaikkunaan lisää toiminnallisuuksia: a) näytä ikkunassa kasvin kuva pikselitarkkana ilman pienennystä ja skrollit ympärillä, b) siirrä skrollien avulla kuvan kohtaa joka näkyy pikselitarkkana, c) pienennä kuvaa ikkunassa siten että se näkyy kokonaan ilman skrolleja, d) terävöitä kuvaa. Tämä ei onnistunut useissa tapauksissa, jos latasin kuvatiedoston imageksi kaikkialla ehdotetulla tavalla:

ima = NSImage(contentsOfFile: kpolkuz)
Image(nsImage: ima!)

Totesin, että NSImage piti rakentaa hakemalla suurinta kuvakokoa vastaava BitmapImageRep ja lisäämällä se NSImageen.

Ensimmäinen tapa antaa kuvan mitoiksi esimerkiksi WxH = 944.64 x 628.66 kun taas jälkimmäinen antaa samalle kuvalle 3056 x 4592. Kuvan koko on NSImage-rakenteessa siten, että lauseke Image(nsImage: ima!) käyttää sitä, eikä ns. oikeita mittoja voi erikseen antaa.

Oheisena funktio, jolla Image voidaan hakea tiedostosta. Tästä on riisuttu pois kaikki tarkistukset, jotka on syytä laittaa oikeaan sovellukseen:

func kokoaKuva2b(polkuz: String) -> NSImage?
{
    let reptit = NSBitmapImageRep.imageReps(withContentsOfFile: polkuz)!
    let oli = reptit.count // oltava > 0
    var w = 0
    var h = 0
    var ivali = 0  // suurinta kuvaa edustava repti
       
    // etsitään suurinta kuvaa vastaava imagerep ja se otetaan
    for i in 0..<oli
    {
        if reptit[i].pixelsWide > w
        {
            w = reptit[i].pixelsWide
            h = reptit[i].pixelsHigh
            ivali = i
        }
    }
    let imarepuusi = reptit[ivali]  // laitetaan vain suurimman kuvan imagerep mukaan
    let imax = NSImage.init(size: CGSize(width: w, height: h))
    imax.addRepresentation(imarepuusi)
    return imax
}

Tässä yksinkertainen kuvaikkuna ja alla sen koodi

Kuva

Alla on kuvaikkunan koodaus ilman ylälaidan buttonia ja tekstiä:

ScrollView()
{
   if ima != nil  // koodissa onAppear-osa lataa kuvan tiedostosta
   {
      Image(nsImage: ima!)
      .resizable()    // kuva sovitetaan tarjotulle alueella
      .aspectRatio(contentMode: .fit)
   }
}
.frame(minWidth: 400, minHeight: 300)
.frame(maxWidth: .infinity)


Pikkuvinkkejä

Buttonin kokonaan piilottaminen riippuen jostain muuttujasta:
.opacity(nbshow ? 1 : 0)   // 0 piilottaa eikä voida klikata

Elementille halutun värinen kehys:
.border(Color.blue, width: 2) 

Työntää edeltävän sisällön vasempaan reunaan tai yläreunaan ja perässä olevan sisällön oikealle tai alas:
Spacer()

Miten saa värialueen reunaan asti?
Tärkeää on laittaa HStack:iin viimeiseksi Spacer(), joka työntää buttonit ym. vasempaan ja täyttää loput tyhjällä. Tyhjälle värin hoitaa Hstack:in perään laitettava .background(.red) eikä se muuta vasemman reunan osien värejä

Värit myös keskelle elementtien väliin:
VStack(alignment: .leading, spacing: 0)

Nurkasta ikkunat voi vetää vapaasti mihin kokoon vain yli min-arvon:
.frame(minWidth: 600, minHeight: 500)
.frame(maxWidth: .infinity)


Tehoste esim buttonille:
.shadow(color: Color.gray.opacity(0.4), radius: 8)

Mahdollisesti monirivisen tekstin sallittu max-rivimäärä (loput leikkautuu pois)
Text(kuvadata.Tarina)
.lineLimit(6)


Pikku välitilojen lisääminen
.padding(.bottom, 4)  // alapuolelle pikku tila
.padding(.top, 4)   // yläpuolelle pikku tila
.padding(.horizontal, 5)  // pikku tila vaakasuunnassa molemmin puolin


Kenttä jossa voi muokata tarvittaessa monirivistä tekstiä, wordwrap toimii
TextEditor(text: $kentat.lisatieto)
.frame(width: 400, height: 50)
.border(Color.black, width: 1)
.padding(.top, 4)   // tulee liian lähelle muita, tämä tarpeen


ToolTip
.help(Text("Lajin nimeä ei voi muokata"))

Videon esittäminen. Xcode:ssa on tuotava kirjasto AVKit.framework osiossa Target / Frameworks, Libraries and Embedded Content
import AVKit
VideoPlayer(player: AVPlayer(url: URL(string:"file://\(polkuz)")!))






--------------------
(sivua muokattu 8.1.2022)