Hopp til innhold

Slik bygger du søyle-, linje-, sparkline- og radargrafer med Swift Charts

Musklrs oversiktsgraf med ukentlige søyler og en trendlinje, ved siden av det håndtegnede radardiagrammet for muskelbalanse.

Tre måneder med trening, fjorten søyler, én trendlinje.

Formen er ikke tilfeldig. En søyle svarer på "hvor mye gjorde jeg i denne perioden" og en linje svarer på "hvilken vei går dette", og oversiktsskjermen måtte svare på begge deler. Derfor har den et glidende gjennomsnitt oppå søylene i stedet for å velge én av dem.

Det er hele poenget med denne artikkelen samlet i ett bilde. En graf gjør nytte for seg når den svarer på spørsmålet personen som åpner skjermen faktisk har, og formen følger av spørsmålet i stedet for av hva som ser pent ut. Musklr tegner fem typer grafer, og hver av dem finnes fordi et nytt spørsmål trengte en ny form.

Videre handler det om hvilken graf som svarer på hvilket spørsmål, hvordan du bygger hver av dem i Swift Charts inkludert den ene Swift Charts ikke tegner for deg, og håndverket som avgjør om en graf er lesbar når den først er på skjermen. All koden er hentet fra appen slik den står i dag, og mot slutten ligger en playground som setter sammen hele oversiktsgrafen i én fil du kan lime rett inn i Xcode.

Jobben grafen skal gjøre bestemmer formen

Enhver graf svarer på et spørsmål, og ulike spørsmål vil ha ulike former. Før du velger én, hjelper det å skrive ned hva personen som åpner skjermen faktisk lurer på. Her er det vi har laget, og enda nyttigere, hvor hver enkelt slutter å fungere.

Søyle-, linje-, sparkline-, radar- og varmekartgrafer sammenlignet på spørsmålet de svarer på og hvor de slutter å fungere
Graf Spørsmålet den svarer på Hvor den slutter å fungere
Søyle Hvor mye, per periode? Trend. Søyler tvinger deg til å jevne ut i hodet.
Linje Hvilken retning, over tid? Sammenligning av enkeltverdier. En linje antyder sammenheng mellom punkter som kan ligge uker fra hverandre.
Sparkline Omtrent hvordan går dette, med et raskt blikk? Alt som krever en verdi. Den har ingen akser, og det er med vilje.
Radar Er jeg i balanse på tvers av et fast sett? Størrelse. Den er en form, ikke en måling.
Varmekart Hvilken muskel har jeg trent? Hvor mye. Fargestyrke er en svak kanal for tallverdier.

Søyler svarer på hvor mye, linjer på hvilken vei

Dette er to forskjellige spørsmål, og oversiktsgrafen trengte begge. Søylene gir deg en periode du kan peke på. Linjen gir deg retningen du ikke ser når fjorten søyler går opp og ned.

Tre måneder med treningsvarighet i Musklr, gruppert i fjorten ukentlige søyler med et glidende gjennomsnitt tegnet over.
Ukentlige søyler for mengden, glidende gjennomsnitt for retningen.
/// Centered rolling mean, one output per input. The window shrinks at the
/// edges to whatever neighbours exist, so the line spans the full chart
/// instead of starting late and ending early.
static func rollingAverage(_ values: [Double], window: Int) -> [Double] {
  guard !values.isEmpty, window > 0 else { return [] }
  let radius = window / 2

  return values.indices.map { index in
    let lower = max(values.startIndex, index - radius)
    let upper = min(values.index(before: values.endIndex), index + radius)
    let slice = values[lower...upper]
    return slice.reduce(0, +) / Double(slice.count)
  }
}

La vinduet krympe mot kantene. Et fast vindu som starter linjen på indeks to gjør at trenden begynner for sent og slutter for tidlig, og et hull i enden av en graf leses som manglende data heller enn som utjevning. Klemmer du utsnittet mot de naboene som finnes, dekker linjen hele flaten.

Linjen er oransje over blå søyler, og stiplet. Begge deler er valg tatt av hensyn til tilgjengelighet, ikke av estetiske grunner. Samme farge med lavere dekkevne gjør at linjen forsvinner nettopp der den krysser en søyle, altså der en trendlinje blir lest mest. Blått og oransje skiller seg på blå-gul-aksen, som rød-grønn fargeblindhet lar være i fred, og det stiplede mønsteret er en ekstra kanal som overlever at alt gjøres om til gråtoner.

Aksene i et radardiagram må ligge fast

Dette er den det er verdt å få riktig først. Hvis aksene i radaren kommer fra dataene, for eksempel brukerens åtte mest trente muskelgrupper denne måneden, er ikke to perioder sammenlignbare. En form tegnet i juni og en form tegnet i juli har ulike ekere, så forskjellen mellom dem betyr ingenting. Sammenlignbarhet er det eneste en radar gjør bedre enn et søylediagram, og akser hentet fra toppen av dataene kaster nettopp det bort.

Vi bretter tjuesju muskelgrupper fra databasen ned på åtte faste akser. Femten id-er havner på en akse, og tolv er bevisst holdt utenfor.

/// The axis set is fixed rather than derived from whatever the user trained
/// most, so a polygon drawn for one period is directly comparable with one
/// drawn for another. Case order is spoke order: clockwise from the top.
enum MuscleRadarGroup: String, CaseIterable, Identifiable {
  case back, glutes, hamstrings, quadriceps
  case biceps, triceps, shoulders, chest

  /// Matched by id, not name: the seed names are English, and renaming one
  /// must not silently empty an axis.
  var muscleGroupIds: Set<Int> {
    switch self {
    case .back:      return [9, 12, 13, 24, 26]
    case .glutes:    return [14, 22]
    case .shoulders: return [1, 23, 25]
    case .chest:     return [2]
    // ...
    }
  }
}

List opp unntakene eksplisitt i stedet for å la id-er uten treff falle gjennom. Det er det som gjør at en test roper høyt når databasen får en ny gruppe, i stedet for at gruppen stille forsvinner fra grafen. Ingen melder fra om en muskelgruppe som sluttet å bli talt med.

Tell unike økter per akse i stedet for å summere antallet per gruppe. Det er det som holder en akse med fem medlemmer ærlig: én økt med roing, skulderryggninger og ryggstrekk treffer tre grupper som alle havner på Rygg, og en summering ville gitt det tre ryggøkter.

Musklrs radardiagram for muskelbalanse med åtte faste akser: rygg, sete, baklår, quadriceps, biceps, triceps, skuldre og bryst.
De samme åtte ekerne hver gang, slik at to perioder kan sammenlignes på formen alene.

En sparkline er et blikk, ikke en graf

Vår er omtrent 100 ganger 30 punkter i en rad i øvelseslisten. Ingen akser, ingen etiketter, ingen tall. Den eneste jobben er å fortelle deg om noe går opp, ned eller ingen steder, og den gjør nytte for seg nettopp ved å være lesbar uten at du ser ordentlig på den.

Gi den et fast domene forankret til nå. To økter på en fri skala strekker seg fra kant til kant og antyder en tetthet som aldri fant sted, og et vindu sentrert på dataene mister hvor ferskt noe er, som er mesteparten av det linjen er til for.

/// Thirteen whole weeks, so a once-a-week trainer gets thirteen evenly
/// spaced sessions rather than a count that shifts with the hour of the day.
static let sparklineWindowDays = 91

/// The last `sparklineWindowDays`, ending at the present rather than
/// centred on the data.
static func sparklineWindow(lastPoint: Date, now: Date = Date()) -> ClosedRange<Date> {
  let end = max(lastPoint, now)
  return end.addingTimeInterval(-TimeInterval(sparklineWindowDays) * 24 * 3600)...end
}

Nittién dager og ikke tre måneder, fordi månedsregning og sekundregning spriker med opptil tre dager avhengig av hvilke måneder vinduet spenner over. Tretten hele uker gjør også at en som trener én gang i uken får tretten jevnt fordelte punkter, i stedet for et antall som endrer seg med tiden på døgnet. Del vinduet med spørringen som henter dataene, så dagene som tegnes er nøyaktig de dagene som hentes.

Forankret på denne måten legger en øvelse som sist ble trent for tretten uker siden seg inntil venstre kant med tom plass etter seg, mens en som ble trent i går strekker seg helt ut til høyre. Sentrert ville begge sett like ut.

En øvelsesliste i Musklr der hver rad har en liten sparkline som viser trenden for den øvelsen.
Én økt tegner bare et punkt. En historikk tegner en linje du kan lese uten å se ordentlig på den.

Den ene som ikke er en Swift Charts-graf i det hele tatt

Musklr tegner også et anatomisk varmekart: en kroppsfigur der hver muskel farges etter hvor mye den er trent. Den svarer på et spørsmål ingen av grafene over kan svare på, fordi anatomien er aksen. Ingen trenger å lære seg en oversettelse først.

Den står her som en kontrast, ikke som en lærdom. Det ligger ingen Chart, ingen markering og ingen Canvas-geometri bak den. Det er en illustrasjon med fargelagte lag, så det er ingenting i den som lærer deg noe om Swift Charts. Og det er nettopp poenget: når spørsmålet er "hvor", er svaret kanskje ikke en graf i det hele tatt, og å gripe etter et grafbibliotek ville gjort det verre.

Kroppsfigurer forfra og bakfra i Musklr med muskler farget etter hvor mye de er trent.
Musklrs muskelvarmekart. Ingen grafbibliotek involvert: anatomien er aksen.

Å bygge dem i Swift Charts

BarMark, LineMark, RectangleMark, PointMark og AreaMark dekker fire av de fem. Radaren er unntaket, og grunnen er enkel: Swift Charts har ingen polar- eller radarmarkering. Det finnes ingen utvidelse og ingen bakvei. Du tegner den selv i et Canvas.

Geometrien er ikke vanskelig, men to detaljer avgjør om den blir riktig. Eker null ligger på minus pi over to for at den første aksen skal peke opp, og vinklene går med klokken fordi y-aksen i SwiftUI vokser nedover. Skriver du den samme formelen som i en matematikkbok, kommer grafen speilvendt ut.

/// Angle of spoke `index`, measured so spoke 0 points straight up and the
/// rest advance clockwise.
private func angle(at index: Int) -> Double {
  guard !axes.isEmpty else { return 0 }
  return (Double(index) / Double(axes.count)) * 2 * .pi - .pi / 2
}

private func point(center: CGPoint, radius: CGFloat, index: Int, scale: Double) -> CGPoint {
  let theta = angle(at: index)
  let distance = radius * CGFloat(scale)
  return CGPoint(
    x: center.x + CGFloat(cos(theta)) * distance,
    y: center.y + CGFloat(sin(theta)) * distance
  )
}

Resten av radaren er ringer, eker og en fylt mangekant, alt tegnet fra den ene point-funksjonen i ulike målestokker. Det pirkete er etikettene og ikke selve grafen. De ligger utenfor ringene, så plassen de trenger går av radien. Løs hver eker mot sin egen etikett i stedet for mot ett verstefall: en lang etikett på en diagonal eker har hjørnet å vokse inn i og bør ikke krympe grafen slik den samme etiketten på klokken tre må.

Dra for å lese med iOS 16.1 som gulv

Musklr støtter iOS 16.1 og nyere, og chartXSelection kom først i iOS 17, så markeringen er håndlaget. Et dra uten minsteavstand oversetter x-en til berøringen gjennom grafens proxy til en dato, og punktet som ligger nærmest i tid vinner.

/// Resets itself the moment the gesture ends *or* is cancelled. An
/// `onEnded`-cleared `@State` would miss every cancel and leave the callout
/// stranded on screen.
@GestureState private var dragX: CGFloat?

content
  .contentShape(Rectangle())
  .gesture(
    // Zero minimum distance means a tap also selects, which is what a user
    // expects when they poke a chart rather than drag across it.
    DragGesture(minimumDistance: 0)
      .updating($dragX) { value, state, _ in state = value.location.x }
  )

At minsteavstanden er null gjør at et trykk også markerer, og det er det folk forventer når de peker på en graf i stedet for å dra over den. Bruk @GestureState og ikke @State, så nullstiller markeringen seg i det bevegelsen slutter eller blir avbrutt. En systembevegelse som tar over, eller et varsel som drar seg ned, ville ellers latt informasjonsboksen bli stående igjen på skjermen.

Selve oversettelsen er to kall i hver sin retning. value(atX:) gjør en berøring om til en verdi i domenet, og position(forX:) gjør en verdi i domenet om til en posisjon på skjermen igjen.

/// Nearest point in time to the dragged x position, by domain distance
/// rather than pixel distance so the result stays correct at any scale.
private func nearestPoint(toX x: CGFloat) -> ScrubPoint? {
  guard !points.isEmpty else { return nil }
  // `proxy` speaks plot-local coordinates; the overlay spans the whole chart.
  let plotLocalX = min(max(x - plotFrame.origin.x, 0), plotFrame.width)
  guard let date: Date = proxy.value(atX: plotLocalX) else { return nil }
  return points.min {
    abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
  }
}

To ting der er verdt å kopiere. Søk etter nærmeste punkt målt i domenet og ikke i piksler, så holder svaret seg riktig uansett målestokk. Og hold styr på koordinatsystemene: proxyen svarer i koordinater inne i plottet, mens laget over dekker hele grafen inkludert kolonnen med akseetiketter, så trekk fra plotFrame.origin før du bruker verdien. Hopper du over det, blir hver eneste avlesning stille forskjøvet med bredden på y-etikettene.

Når du tegner selve markeringen, gi laget en tydelig stabel. Et overleggs justering plasserer gruppen som helhet, og inne i gruppen legger løse barn seg sentrert mot det bredeste av dem, som hos oss er en informasjonsboks låst til 160 punkter. En strek på ett punkt havner da en halv boks unna verdien den skal markere.

// Right. An explicit stack gives both children the same leading anchor.
.overlay(alignment: .topLeading) {
  ZStack(alignment: .topLeading) {
    if let selected {
      Rectangle().frame(width: 1, height: plotFrame.height)
      callout.frame(width: 160)
    }
  }
}

Håndverket som gjør grafen lesbar

Ingenting av det som følger er smart. Det er bredden på grupperingen, bredden på søylen, domenet, og hvor mye tekst aksen får lov til å tegne. Til sammen avgjør de om grafen øverst i artikkelen leses som en graf eller som et stakittgjerde.

Grupper etter perioden, ikke etter dataene

Én søyle per økt er en beslutning om oppløsning forkledd som en ærlig gjengivelse. Nitti merker fordelt over tre hundre punkter kan ikke vise en trend uansett hvor nøyaktig hvert av dem er. Velg heller bredden på grupperingen ut fra perioden brukeren har valgt.

/// Bucket width per range. Long ranges use wide buckets so bars stay legible
/// instead of collapsing into hairlines.
static func granularity(for range: TimeRange) -> ChartGranularity {
  switch range {
  case .oneWeek, .oneMonth:     return .day
  case .threeMonths, .sixMonths: return .week
  case .year, .allTime:         return .month
  }
}

Ta med tomme perioder

En uke uten økter må finnes som en null i stedet for å hoppes over. Ellers overser det glidende gjennomsnittet stille de ukene brukeren gikk glipp av, og det er nøyaktig den informasjonen trendlinjen finnes for å formidle. En graf som fjerner de dårlige ukene er smigrende og feil.

var result: [ChartBucket] = []
var cursor = earliest
while cursor <= latest {
  guard let next = calendar.date(byAdding: component, value: 1, to: cursor) else { break }
  // Absent periods are emitted as zero rather than skipped.
  let entry = totals[cursor] ?? (0, 0, 0)
  result.append(
    ChartBucket(
      start: cursor,
      end: next,
      durationSeconds: entry.duration,
      volume: entry.volume,
      sessionCount: entry.count
    )
  )
  cursor = next
}

Oppgi bredden på søylen som et datospenn

Griper du etter BarMark(width: .ratio(0.7)), får du ingenting i det hele tatt. .ratio er en andel av skalaens steg, og en kontinuerlig datoskala har ikke noe steg å ta en andel av. Oppgi heller bredden i samme enhet som aksen.

/// Half the drawn width of a bar, as a time offset either side of its
/// bucket's start. The bar covers 70% of its period, leaving the rest as
/// the gap that separates it from its neighbours.
private func halfBarWidth(of bucket: ChartBucket) -> TimeInterval {
  bucket.end.timeIntervalSince(bucket.start) * 0.35
}

RectangleMark(
  xStart: .value(dateName, bucket.start.addingTimeInterval(-halfBarWidth(of: bucket))),
  xEnd:   .value(dateName, bucket.start.addingTimeInterval(halfBarWidth(of: bucket))),
  yStart: .value(valueName, 0),
  yEnd:   .value(valueName, value)
)

RectangleMark er riktig markering her også utover spørsmålet om bredde: den er den eneste der alle fire grensene er dataverdier, fordi BarMark sine initialiserere for x-spenn tar skjermkoordinater for y. Sytti prosent av perioden er der en søyle leses som en søyle og mellomrommet fremdeles skiller den fra naboen.

Utvid domenet med en halv periode i hver ende

Markeringene plasseres ved starten av perioden på en kontinuerlig skala, så hver søyle sentreres på det tidspunktet og flyter ut med halve bredden til hver side. Et domene som slutter ved starten av den siste perioden tegner den siste søylen halvveis utenfor plottet.

let bucketLength = last.end.timeIntervalSince(last.start)
let halfBucket = bucketLength / 2

// Half a bucket before the first start, half a bucket after the last.
let lower = first.start.addingTimeInterval(-first.end.timeIntervalSince(first.start) / 2)
let upper = last.start.addingTimeInterval(halfBucket)

En halv periode, symmetrisk, og ikke mer. Å utvide helt ut til slutten av den siste perioden ser ut som den samme løsningen, men er det ikke: da står det igjen en hel tom periode som luft til høyre, og trendlinjen stopper før kanten av plottet.

Færre etiketter når teksten vokser

Akseetiketter er den ene teksten i en graf som må få plass rundt det den beskriver, så hvert punkt de vokser går rett av plottet. Ved tilgjengelighetsstørrelser tar y-etiketter uten tak det meste av bredden på et kort og lar søylene bli hårfine streker, og da sitter leseren igjen med en kolonne med tall og ingen graf.

Et tak på skriftstørrelsen redder det ikke alene. Swift Charts klemmer en akseetikett inn i båsen sin og kutter den så av, og rammeverket dropper aldri en markering for å gi de gjenværende plass. Derfor kommer fem datoer over bredden på et kort ved en tilgjengelighetsstørrelse ut som "2… 6… 1… 2…". Tre hele etiketter bærer mer enn fem ødelagte.

/// Labelled ticks an axis may carry when the reader is at `requested`.
///
/// Charts squeezes a label into its slot and then truncates it; it never
/// drops a mark to give the survivors room. So the count has to come down
/// as the text goes up.
func axisTickCount(at requested: DynamicTypeSize) -> Int {
  let effective = axisTypeSizeCeiling.map { min(requested, $0) } ?? requested
  switch self {
  case .card:
    if effective >= .xxxLarge { return 3 }
    if effective >= .xxLarge  { return 4 }
    return 5
  case .fullScreen:
    if effective >= .accessibility4 { return 4 }
    if effective >= .xxxLarge       { return 5 }
    return 7
  }
}
Den samme varighetsgrafen ved stor tilgjengelighetstekst, med tre hele datoetiketter i stedet for fem avkuttede.
Færre etiketter, alle lesbare, og søylene beholder bredden sin.

Hvor langt ned det må, er en egenskap ved flaten og ikke bare ved teksten. Plottet på et kort er rundt 270 punkter bredt når kolonnen med y-etiketter er betalt for, og det i liggende format rundt 630. Gir du fullskjermflaten de tre etikettene kortet har, har du undergravd grunnen til å åpne den, for større etiketter og færre av dem er ingen forbedring.

En finurlighet i det API-et. desiredCount er et hint, og Charts runder det opp til det datointervallet som ligger nærmest, så ber du om nøyaktig det antallet bredden har plass til, kommer det tilbake over det tallet og kutter igjen. Be om ett hakk mindre.

Bygg dem selv i en playground

Hver graf under er én selvstendig fil. Ingenting importeres fra Musklr, det er ingen filer å laste ned, og eksempeldataene lages i filen selv. Et lim inn er alt som skal til.

Sett opp en tom playground én gang og bruk den til alle tre:

  1. Velg Fil, så Ny, så Playground i Xcode.
  2. Velg malen Blank og sett plattform til macOS. Grafene er ren SwiftUI, så macOS gir deg en live-visning uten å starte en simulator.
  3. Slett den ene linjen malen legger inn i filen.
  4. Lim inn en av filene under.
  5. Vis live-visningen med bryteren øverst til høyre i editoren, eller Editor, så Live View. Grafen tegnes så snart playgrounden har kjørt ferdig første gang.

Kodene er på engelsk, så etikettene i grafene blir det også. Det er de samme strengene du selv skriver i filen.

1. Søyler med en trendlinje over

Dette er oversiktsgrafen i miniatyr: ukentlig gruppering, tomme uker som nuller, søyler på sytti prosent av perioden, et domene utvidet med en halv periode i hver ende, og et glidende gjennomsnitt som dekker hele plottet.

import Charts
import PlaygroundSupport
import SwiftUI

// MARK: - 1. Some sessions to draw (three months, with a two-week gap)

struct Session { let date: Date; let minutes: Double }

let sessions: [Session] = {
  let cal = Calendar.current
  let today = cal.startOfDay(for: Date())
  return stride(from: 90, through: 0, by: -1).compactMap { day -> Session? in
    guard day % 3 == 0, !(30...44).contains(day) else { return nil }
    let d = cal.date(byAdding: .day, value: -day, to: today)!
    return Session(date: d, minutes: Double(45 + (day % 7) * 6))
  }
}()

// MARK: - 2. Bucket to the range, and emit the empty weeks

struct Bucket: Identifiable {
  var id: Date { start }
  let start: Date, end: Date, minutes: Double
}

func weeklyBuckets(_ sessions: [Session], cal: Calendar = .current) -> [Bucket] {
  var totals: [Date: Double] = [:]
  for s in sessions {
    let week = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: s.date))!
    totals[week, default: 0] += s.minutes
  }
  guard let first = totals.keys.min(), let last = totals.keys.max() else { return [] }
  var out: [Bucket] = []
  var cursor = first
  while cursor <= last {
    let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor)!
    out.append(Bucket(start: cursor, end: next, minutes: totals[cursor] ?? 0))
    cursor = next
  }
  return out
}

// MARK: - 3. A trend that spans the whole chart

func rollingAverage(_ v: [Double], window: Int) -> [Double] {
  guard !v.isEmpty, window > 0 else { return [] }
  let r = window / 2
  return v.indices.map { i in
    let s = v[max(0, i - r)...min(v.count - 1, i + r)]
    return s.reduce(0, +) / Double(s.count)
  }
}

// MARK: - 4. The chart

struct BarTrendChart: View {
  let buckets = weeklyBuckets(sessions)

  var body: some View {
    let trend = rollingAverage(buckets.map(\.minutes), window: 5)
    let half = { (b: Bucket) in b.end.timeIntervalSince(b.start) * 0.35 }
    let lo = buckets.first!.start.addingTimeInterval(-half(buckets.first!))
    let hi = buckets.last!.start.addingTimeInterval(half(buckets.last!))

    Chart {
      ForEach(buckets) { b in
        RectangleMark(
          xStart: .value("Start", b.start.addingTimeInterval(-half(b))),
          xEnd: .value("End", b.start.addingTimeInterval(half(b))),
          yStart: .value("Minutes", 0),
          yEnd: .value("Minutes", b.minutes)
        )
        .foregroundStyle(.blue)
      }
      ForEach(buckets.indices, id: \.self) { i in
        LineMark(x: .value("Week", buckets[i].start), y: .value("Trend", trend[i]))
          .foregroundStyle(.orange)
          .lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round, dash: [6, 4]))
          .interpolationMethod(.catmullRom)
      }
    }
    .chartXScale(domain: lo...hi)
    .chartXAxis { AxisMarks(values: .automatic(desiredCount: 5)) }
    .frame(width: 640, height: 260)
    .padding()
  }
}

PlaygroundPage.current.setLiveView(BarTrendChart())
Søylegraf-playgrounden som kjører i Xcode, med koden til venstre og live-visningen til høyre som viser fjorten ukentlige søyler under en stiplet oransje trendlinje.
Den samme filen i Xcode. Live-visningen oppdateres for hver endring, og det er nettopp derfor forsøkene under er verdt å gjøre.

Eksempeldataene er tjueseks økter over tre måneder med et bevisst opphold på to uker i midten. Det grupperes ned til fjorten søyler, og de to tomme ukene kommer med som nuller, som er det som drar trendlinjen ned gjennom midten av grafen.

Ødelegg den nå, én linje av gangen, og se live-visningen svare:

  • Slett guard-en som hopper over dag 30 til 44, så fylles oppholdet igjen. Trenden flater ut, og det er forskjellen på en graf som innrømmer at du mistet fjorten dager og en som skjuler det.
  • Bytt RectangleMark med BarMark(x:y:) og width: .ratio(0.7), så forsvinner søylene helt, fordi en kontinuerlig datoskala ikke har noe steg å ta en andel av.
  • Endre 0.35 til 0.05, så er stakittgjerdet tilbake.
  • Fjern half(...)-leddene fra lo og hi, så mister de ytterste søylene halvparten av seg selv over kanten av plottet.

2. Radaren Swift Charts ikke tegner for deg

Ingen Chart, ingen markeringer. Denne er et Canvas: konsentriske ringer, åtte eker og en fylt mangekant, alt plassert av den ene vinkelfunksjonen.

import PlaygroundSupport
import SwiftUI

// Swift Charts has no polar mark, so a radar is drawn by hand in a Canvas.

struct Axis: Identifiable { var id: String { label }; let label: String; let value: Double }

let axes = [
  Axis(label: "Back", value: 0.74), Axis(label: "Glutes", value: 0.36),
  Axis(label: "Hamstrings", value: 0.26), Axis(label: "Quadriceps", value: 0.59),
  Axis(label: "Biceps", value: 0.46), Axis(label: "Triceps", value: 0.51),
  Axis(label: "Shoulders", value: 0.64), Axis(label: "Chest", value: 0.97),
]

struct RadarChart: View {
  let axes: [Axis]
  var rings = 4

  // Spoke 0 points straight up; the rest advance clockwise because SwiftUI's
  // y-axis grows downward. Write this like a maths textbook and it mirrors.
  func angle(_ i: Int) -> Double { (Double(i) / Double(axes.count)) * 2 * .pi - .pi / 2 }

  func point(_ c: CGPoint, _ radius: CGFloat, _ i: Int, _ scale: Double) -> CGPoint {
    let t = angle(i)
    return CGPoint(x: c.x + cos(t) * radius * scale, y: c.y + sin(t) * radius * scale)
  }

  var body: some View {
    GeometryReader { geo in
      let c = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
      // The inset has to clear the widest label, not just the ring. The real
      // app solves each spoke against its own label; a flat inset is the
      // playground-sized version of the same idea.
      let r = min(geo.size.width, geo.size.height) / 2 - 72
      Canvas { ctx, _ in
        for ring in 1...rings {                       // guide rings
          var p = Path()
          for i in axes.indices {
            let v = point(c, r, i, Double(ring) / Double(rings))
            i == 0 ? p.move(to: v) : p.addLine(to: v)
          }
          p.closeSubpath()
          ctx.stroke(p, with: .color(.gray.opacity(0.35)), lineWidth: 1)
        }
        for i in axes.indices {                       // spokes
          var p = Path(); p.move(to: c); p.addLine(to: point(c, r, i, 1))
          ctx.stroke(p, with: .color(.gray.opacity(0.25)), lineWidth: 1)
        }
        var poly = Path()                             // the value polygon
        for (i, a) in axes.enumerated() {
          let v = point(c, r, i, min(max(a.value, 0), 1))
          i == 0 ? poly.move(to: v) : poly.addLine(to: v)
        }
        poly.closeSubpath()
        ctx.fill(poly, with: .color(.blue.opacity(0.35)))
        ctx.stroke(poly, with: .color(.blue), lineWidth: 2)
      }
      ForEach(Array(axes.enumerated()), id: \.element.id) { i, a in
        Text(a.label).font(.caption2).foregroundStyle(.secondary)
          .position(point(c, r + 22, i, 1))
      }
    }
    .frame(width: 420, height: 420)
  }
}

PlaygroundPage.current.setLiveView(RadarChart(axes: axes))
Et radardiagram med åtte navngitte akser tegnet i et SwiftUI-Canvas, med en ujevn blå mangekant.
Snu fortegnet i vinkelfunksjonen, så speilvendes hele grafen. Den detaljen er verdt å kjenne på én gang.

Prøv å sette alle verdiene til det samme tallet. Da får du en regulær åttekant, som er slik balanse ser ut, og samtidig den raskeste måten å sjekke at geometrien stemmer. Endre så -.pi / 2 til +.pi / 2 og se grafen snu, som er feilen en matematikkbok leder deg rett inn i.

3. En sparkline, og hvorfor vinduet er forankret til nå

To rader, to øvelser, én skala. Den eneste forskjellen på dem er når de sist ble trent.

import Charts
import PlaygroundSupport
import SwiftUI

// A sparkline is a glance, not a chart: no axes, no labels, ~100x30pt.

struct Point: Identifiable { let id = UUID(); let date: Date; let value: Double }

func series(_ days: [Int], _ values: [Double]) -> [Point] {
  let today = Calendar.current.startOfDay(for: Date())
  return zip(days, values).map {
    Point(date: Calendar.current.date(byAdding: .day, value: -$0, to: today)!, value: $1)
  }
}

// Trailing 91 days ending now, NOT centred on the data: centring loses recency.
func window(_ points: [Point]) -> ClosedRange<Date> {
  let end = max(points.map(\.date).max() ?? Date(), Date())
  return end.addingTimeInterval(-91 * 24 * 3600)...end
}

struct Sparkline: View {
  let points: [Point]
  var body: some View {
    Chart(points) {
      LineMark(x: .value("Day", $0.date), y: .value("Value", $0.value))
        .foregroundStyle(.blue)
        .interpolationMethod(.monotone)
    }
    .chartXScale(domain: window(points))
    .chartXAxis(.hidden)
    .chartYAxis(.hidden)
    .frame(width: 100, height: 30)
  }
}

struct SparklineDemo: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 18) {
      HStack { Text("Trained recently").frame(width: 150, alignment: .leading)
        Sparkline(points: series([84, 70, 56, 42, 28, 14, 2], [60, 62, 65, 64, 70, 74, 78])) }
      HStack { Text("Stopped 10 weeks ago").frame(width: 150, alignment: .leading)
        Sparkline(points: series([88, 84, 78, 72, 70], [40, 44, 46, 45, 48])) }
    }
    .font(.caption)
    .padding()
  }
}

PlaygroundPage.current.setLiveView(SparklineDemo())
To sparklines som deler én skala. Øvelsen som er trent nylig når helt ut til høyre kant; den som ble stoppet for ti uker siden ligger inntil venstre med tom plass etter seg.
Den ene når høyre kant, den andre stopper før. Det mellomrommet er informasjonen.

Slett .chartXScale-linjen, så strekker begge linjene seg fra kant til kant, helt like, og det at den ene øvelsen ikke har vært rørt på ti uker forsvinner ut av grafen.

Liggende format gjør nytte for seg på tidsserier

En rotert fullskjermgraf er ingen luksus når x-aksen er tid og telefonen er 393 punkter bred. Dobler du bredden på plottet, dobler du omtrent hvor mange etiketter du kan bære, og det er den ene flaten der akseetikettene kan vokse til den størrelsen leseren har bedt om.

Å rotere tilbake er den delen som krever omtanke. UIKit husker svaret hver kontroller gir på supportedInterfaceOrientations og spør bare på nytt når en kontroller sier at svaret er endret, så det holder ikke å snu verdien app-delegaten din leser. Gå gjennom kjeden av presenterte kontrollere først, og be så om oppdatert geometri.

// UIKit caches each controller's answer to `supportedInterfaceOrientations`
// and only re-asks when a controller says the answer changed. Without this
// walk, the return to portrait is refused and the app stays landscape.
var controller = (scene.keyWindow ?? scene.windows.first)?.rootViewController
while let current = controller {
  current.setNeedsUpdateOfSupportedInterfaceOrientations()
  controller = current.presentedViewController
}

scene.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) { error in
  Logger.log(
    "Chart orientation request refused: \(error.localizedDescription)",
    category: .app, type: .info)
}
Musklrs fullskjermgraf i liggende format med markeringsstrek og en boks som viser 1. juni, 300,2 minutter.
Dobbelt så bred flate gir omtrent dobbelt så mange etiketter. Streken står midt på søylen den markerer.

Kall den tilbakestillingen fra onDisappear og ikke fra lukkeknappen. Lukkeknappen er den eneste utgangen brukeren ser, men den er ikke den eneste måten grafen forlater skjermen på, og en lukking utført fra koden ville ellers latt appen bli liggende på siden.

Tester du rotasjon, bør du teste mot XCUIDevice og vindusrammen i stedet for mot noe som helst ved et skjermbilde. simctl io screenshot gir deg en stående pikselflate uansett hvilken retning grensesnittet står i, så en liggende skjerm fanges som et stående bilde med grensesnittet på siden, og målene på bildet sier ingenting om retningen.

Hvor du bør bruke tiden

Velg grafen som svarer på spørsmålet brukeren kom med. Bruk så tiden din på gruppering, utvidelse av domenet og dynamisk tekst, for det er de tingene folk kjenner på selv når de ikke kan sette ord på dem.

En graf er ikke et bilde av en tabell. Den er den korteste veien mellom et spørsmål noen har og et svar de kan se uten å bli lært opp i hvordan det skal leses.

Gratis på iPhone og Apple Watch. Logg et sett med to trykk.

Se hva Musklr gjør

Nysgjerrig på hvordan resten av appen er bygget? Les hvordan vi bygget animasjonen i pausetimeren.