Slik bygger du søyle-, linje-, sparkline- og radargrafer med Swift Charts
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 tre playgrounds du kan lime rett inn i Xcode.
Alle grafene her finnes i Musklr, treningsloggen vår for iPhone og Apple Watch. Eksempeldataene under er oppdiktet, så vil du heller se formene med ekte trening i seg, er appen gratis i App Store.
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.
| 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.
/// 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.
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.
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.
Å 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
}
}
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:
- Velg Fil, så Ny, så Playground i Xcode.
- Velg malen Blank og sett plattform til macOS. Grafene er ren SwiftUI, så macOS gir deg en live-visning uten å starte en simulator.
- Slett den ene linjen malen legger inn i filen.
- Lim inn en av filene under.
- 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())
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
RectangleMarkmedBarMark(x:y:)ogwidth: .ratio(0.7), så forsvinner søylene helt, fordi en kontinuerlig datoskala ikke har noe steg å ta en andel av. -
Endre
0.35til0.05, så er stakittgjerdet tilbake. -
Fjern
half(...)-leddene fralooghi, 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))
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())
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.
Alle tre finnes i appen, tegnet av det du faktisk har løftet og ikke av en generert serie. Musklr er gratis i App Store, og grafene ligger under Progresjon.
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)
}
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.
Appen disse grafene kommer fra
Musklr logger et sett med to trykk, på iPhone og Apple Watch, og det fungerer offline. Den er gratis å bruke så lenge du vil, uten konto. Pro er valgfritt, rundt 25 kr i måneden, for ekstrafunksjoner som ubegrenset antall rutiner og skysynkronisering.
Se alt Musklr gjør på hjemmesiden →
Nysgjerrig på hvordan resten av appen er bygget? Les hvordan vi bygget animasjonen i pausetimeren.