Kello ja Hokkeli


  Pääsivulle  |  TIETOTEKNIIKAN PÄÄSIVULLE
 

Taulukko SwiftUI:n näkymänä

SwiftUI:ssa on perinteisesti suositeltu käyttämään elementtiä List taulukoiden tekemiseen, eli esitetään näytöllä vierekkäisiä listoja. Muita mahdollisuuksia ovat SwiftUI:n tuore elementti Table / TableColumn, ja AppKit:in NSTableView:n käyttäminen. Seuraavassa näistä kaikista omia kokeilujani ja havaintoja.

Tarvitsen taulukoita siten, että voin valita sieltä rivin ja tehdä sen jälkeen jotain (buttonin avulla) valitsemani rivin tiedoilla. Taulukossa voi olla kymmeniä rivejä, ja oikean rivin löytämiseksi taulukkoa pitää voida lajitella.


List-elementit

PERUSVERSIO

Olen oppinut, että List-esitystä varten sovellus on yksinkertaisinta koodata tällä tavalla: kootaan taulukossa (table) esitettävä data sarakkeiden taulukkoon (array) ja tämä sitten koodataan sisäkkäisin ForEach-lausekkein. Lisää koodausta on tehtävä sille, että valittaessa koko rivi olisi halutulla huomiovärillä väritetty, ja jos halutaan lajitella taulukko jonkin sarakkeen mukaan.

Lisäksi on otettava huomioon, että alla kuvattu List-rakenne ei toimi muuttujalajin @ObservedObject kanssa. 

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ä.

Määritellään sarakkeiden taulukko, jossa ensimmäinen arvo on sarakkeen otsake:
@State var sarat = [[String]]()

Sijoitetaan sarakkeiden otsakkeet valmiiksi paikoilleen, tässä esimerkki:
let apus = [""] // yksi sarake
sarat = Array(repeating: apus, count: 6)  // taulukon kaikki sarakkeet
sarat[0][0] = "idKas"
sarat[1][0] = "Suom_nimi"
sarat[2][0] = "Tiet_nimi"
sarat[3][0] = "Heimo"
sarat[4][0] = "Kpl"
sarat[5][0] = "Lisätiedot"

Tämän jälkeen normaali tietokantakutsu, nyt
"SELECT idKas, Sunimi, LatSuku, Latlaji, HeiNimi, kpl, KasHuom FROM ..."

Puretaan hakutulos kenttäkohtaisiin muuttujiin ja sijoitetaan taulukkoon:
sarat[0].append(String(idi))
sarat[1].append(suni.isEmpty ? " " : suni) // huomaa välilyönnit paikanvaraajina
sarat[2].append(tiet.isEmpty ? " " : tiet)
sarat[3].append(heiz.isEmpty ? " " : heiz)
sarat[4].append(String(kpl))
sarat[5].append(huom.isEmpty ? " " : huom)


Tyhjää paikkaa varten tarvitaan välilyönti, jotta vierekkäiset listat olisivat samankorkuisia. Lisäksi on huolehdittava, että pitkä yhden paikan sisältö ei jakaudu useaksi riviksi, koska silloinkin näkymä sotkeutuu. Eli on  lisättävä koodiin .lineLimit(1).  Ja tässä on koodi, jolla taulukkonäkymä muodostuu:
Taulukon koodaus

Koodissa on otettu huomioon, että joissain sarakkeissa tekstien tulee olla oikeassa laidassa, ja muissa vasemmassa. Lisäksi valittu rivi saa huomiovärit. Alla kasvien hakunäyttö, jossa tällä koodilla on muodostettu hakutuloksen taulukkonäkymä. Riveillä olevat tiedot on haettu tietokannasta:
Kasvien haku

Taulukossa on sellainen kiusallinen ominaisuus, että kun sitä skrollaa alaspäin, otsakerivit valuvat piiloon. Lajittelu pitää koodata siten, että otsakkeesta klikkaaminen muodostaa uuden SQL-lauseen (...ORDER BY..) ja tiedot haetaan uudestaan.

Lisäksi totesin, että sovelluksen kehittämistä aivan tällaisena ei kannataa jatkaa macOS Monterey:ssä. Käännettäessä Xcode:ssa sain konsoliin ForEach-rakenteeseen liittyviä vakavia varoituksia "the ID .. occurs multiple times within the collection, this will give undefined results". Tämä tarkoittaa käytännössä, että macOS ei kohta tue kyseistä koodia. Niinpä selvittelin millä tavoin varoituksista pääsisi eroon.


MONIPUOLISEMPI FOREACH-LISTA

Kun pysytään em. ForEach-rakenteessa, niin tein uuden version, jossa tietosisältö on edelleen tavallisessa taulukossa [String], mutta esittämistä varten käytetään Identifiable-muuttujaa:

struct Taulu: Identifiable
{
    var id = UUID()
    var sara: [String]  // sarakkeen data
    var piilo: Bool   // onko piilotettava sarake
}

Näin ForEach-rakenne toimii hyvin, eikä tule konsoliin varoituksia. Samalla tein malliin seuraavia ominaisuuksia:
- sarakkeiden otsake on erillään, eikä skrollaa taulukon mukana piiloon
- otsakkeen ja sarakkeen leveys ovat suunnilleen samat, ja ne toimivat yhtäläisesti, kun ikkunan leveyttä pienentää
- otsaketta klikkaamalla saadaan taulukko lajiteltua ao. sarakkeen suhteen
- tietojen päivitys taulukossa onnistuu hyvin

Tässä testiprojektin ikkuna:
Testiprojektin ikkuna

Taulukon data on muuttujassa:
@State var sisus = [Taulu]()

ja taulukon esittävä koodi on oheisessa kuvassa (tässä ei ole otsaketta mukana):
ForEach-example

Voit ladata Xcode-projektikansion täältä


SwiftUI:n tuore elementti Table / TableColumn

Uusilla elementeillä voi esittää taulukon, valita rivin ja jatkaa prosessia rivin tietojen kanssa. Näin taulukko määritellään:

struct Vakrivi: Identifiable   // esitettävä rivin tieto
{
  let id = UUID()
  let idi: String
  let jarj: String
  let nimi: String
  let suku: String
  let kpl: String
  let apvz: String
  let opvz: String
  let kanta: String
}

// -----
@State private var rivit: [Vakrivi] = [Vakrivi]()  // taulukon rivien sisältö
@State var rivi: [String] = [String]()       // yhden rivin sisältö
@State var otsa: [String] = Array(repeating: " ", count: 7)   // taulukon otsakkeet
@State private var selerivi: Vakrivi.ID?    // valitun rivin tieto
@State private var sortOrder = [KeyPathComparator(\Vakrivi.jarj)]   // kokeilua
// vaikka vain yksi sortOrder on määritelty, voidaan lajitella kaikkien sarakkeiden mukaan
// ----
Table(rivit, selection: $selerivi, sortOrder: $sortOrder)
{
    TableColumn(otsat[0], value: \.idi)
        .width(min: 30, ideal: 60, max: 100)  // sarakkeen leveyden määrittelyä
    TableColumn(otsat[1], value: \.jarj) 
    TableColumn(otsat[2], value: \.nimi)
    TableColumn(otsat[3], value: \.suku)
    TableColumn(otsat[4], value: \.kpl)
    TableColumn(otsat[5], value: \.apvz)
    TableColumn(otsat[6], value: \.opvz)
    TableColumn(otsat[7], value: \.kanta)
}
.onChange(of: sortOrder)  // lajittelu toiseen suuntaan
{
    rivit.sort(using: $0)
}
.onChange(of: selerivi!, perform: { (value) in teeJotain(selerivi!) } )
// jos valittu rivi vaihtuu, käynnistetään funktio

Ehdollinen sisältö taulukkoon:
TableColumn("Maa") { Text($0.apvz == "fi" ? "Suomi" : "Muu maa") }

Taulukon otsakkeen ja sakkeen alignment (otsakkeessa välilyönneillä):
TableColumn("     "+otsat[0])   // estää lajittelun ao. sarakkeen mukaan
{
    Text($0.idi)
    .frame(width: 50, alignment: .trailing)
}
.width(min: 50, ideal: 50, max: 100)

Yhteen soluun (riviin) voi myös kohdistaa jatkotoimenpiteen:
TableColumn(otsa[2])  // perform toimii, mutta ei valitse samalla riviä
{
    Vakrivi in
    Text(Vakrivi.nimi)
    .onTapGesture(count: 1, perform: {print("\(Vakrivi.nimi) click")})
}

if-lauseketta ei voi laittaa TableColumin():ien väliin jos esimerkiksi halutaan että tietty sarake ei tulisi aina.


NSTableView osaksi SwiftUI-näkymää

Taulukkoesitys saadaan myös leipomalla AppKit-taulukko (NSTableView) SwiftUI-koodin sisään. Tällöin saadaan erinomaisen hyvin käyttäytyvä taulukko: rivejä voi valita jatkohommia varten, lajittelu onnistuu hienosti ja saadaan tieto mikä rivi valittiin. Myös taulukon ulkomittoja voi ohjata SwiftUI-puolelta, joten NSTableView tulee saumattomaksi osaksi SwiftUI:n View-rakennetta.

NSTableView:n saaminen SwiftUI-näkymään hoidetaan ns. wrapper-rakenteen avulla. Siinä on määriteltävä NSViewControllerRepresentable, Class Coordinator ja kaksi apufunktiota. Netin ohjeet olivat hyvin sekavat ja Applen doku liian suppea, mutta löytämiäni tiedonmurusia hyödyntäen ja loput kokeilemalla sain tehtyä toimivan kokonaisuuden. Hyödyllisin lähde oli nimimerkki cpahull GitHubissa.

Taulukossa minua kiusasi, että valittuna olevan rivin väri on tummansininen tai keskiharmaa riippuen siitä onko taulukolla focus vai ei. Hoidin tilanteen ohjelmoimalla, että väri on aina sama. Taustaväri järjestetään toimenpiteellä "Subclassing of NSTableRowView":

Tarvitaan delegaatti  NSTableViewDelegate ja sille funktio
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView?
{
    return OmaRowView()
}


class OmaRowView: NSTableRowView
{
  //-------------------------------------------
  // Valitulle riville haluttu taustaväri
  override func drawSelection(in dirtyRect: NSRect)
  {
    if self.selectionHighlightStyle != .none
    {
      let selectionRect = NSInsetRect(self.bounds, 1.0, 1.0) //, 2.5, 2.5)
               
      let valittu = NSColor(red: 240/255, green: 230/255, blue: 140/255, alpha: 1.0)
      valittu.setFill()

      let selectionPath = NSBezierPath.init(roundedRect: selectionRect, xRadius: 0, yRadius: 0)
      selectionPath.fill()
    }   
  }
 
  //-------------------------------------------
  // Valitun rivin tekstiväri normaali musta eikä inverted (valkoinen):
  override var isEmphasized: Bool
  {
    set {}
    get { return false }
  }

  override var selectionHighlightStyle: NSTableView.SelectionHighlightStyle
  {
    set {}
    get { return .regular }
  }
}

TESTIPROJEKTIT

Toimivan monipuolisen testiprojektin näyttö on alla. Projektissa on demottu miten taulukosta saa rivitiedot SwiftUI-puolelle muokattavaksi ja infona Labeliin. Lisäksi voidaan lajitella, tehdä uusi kasvi, poistaa rivi sekä tyhjennyksen jälkeen ottaa tiedot esille siten, että sama kasvi on valittuna kuin viimeksi. Viestinviejänä SwiftUI:n ja NSTableView:n välillä käytetään kahta asiaa: sisältö taulukossa on kenttä 'valittu', joka saa arvon "x" jos rivi on valittuna, sekä muuttujaa 'tilaidkasvi', joka sisältää valitun rivin arvon idKas. Molemmat ovat @State -muuttujia SwiftUI-puolella, mutta vain taulukon sisältö nähdään ja voidaan muokata rajoituksetta sekä SwiftUI- että NSTableView-puolella.

Lisäksi valitun rivin ulkoasua on muutettu siten, että se on sama okra olipa taulukolla focus tai ei.

Ohjelmoinnin kannalta on merkittävää, että NSTableView:n delegaattia NSTableViewDataSource
ei voi käyttää wrapperissä tai sen kutsumassa, taulukon sisältävässä luokassa. Lisäksi on huomattava, että delegaatti NSTableViewDelegate on wrapperissa, joten sinne tuli yksi delegaattifunktio ja Subclassing of NSTableRowView.
 
Tässä kuva sovelluksesta:

TaulutestiK

Yläreunan buttonit, ja alareunan toiminnallisuudet ovat SwiftUI-puolella. Sovelluksen ominaisuuksia:
- oletuksena ei mitään ole valittuna
- valitun rivin sisältöä kopioituu alareunan kenttiin
- valitun rivin huomiorivi pysyy okrana vaikka focus siirtyisi alareunan tekstikenttään
- taulukkoa voidaan lajitella halutun sarakkeen perusteella, ja valinta säilyy samalla kasvilla
- kasvin nimeä voidaan muuttaa alareunassa, päivitys buttonista Tallenna
- button tyhjennä tyhjentää taulukon ja 'Tiedot esille' tuo tiedot, ja aiempi kasvi on valittuna
- button 'Poista rivi' poistaa rivin
- ongelma: valittu rivi ei kaikissa tilanteissa skrollaudu esiin

Toimivan testisovellukseni Xcode-projekti + docx selityksineen on ladattavissa täältä.

Yksinkertaisempi demosovellukseni Taulutesti8 on pitkälti samanlainen kuin edellä. Siinä button 'Näytä' vie valitun rivin tietoja tekstiboksiin. Se soveltuu hyvin sinne, missä riviä klikatessa ei tarvitse tapahtua jotain (esim. tietojen kopiointia tai toisen näkymän avaamista). Valittu rivi on aina okran värinen mustine teksteineen:
Testiprojekti

Yläreunan buttonit ja tekstikenttä ovat SwiftUI-puolella. Xcode-projektikansio löytyy täältä









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