Swift Charts AreaMark Selection Jitter
MeowCare 的胰島素頁有一個 31 天血糖圖。圖上每一天是一個 DayBandChart,血糖用 AreaMark 畫出曲線下方面積,顏色用血糖值分級:
- 60…75:紅色
- 75…100:黃色
- 100…200:綠色
- 200…300:橘色
- 300…420:紅色
這次問題是:touch 血糖曲線時,callout 可以正常顯示血糖值,但滑動到另一個點的過程中,原本固定的色塊範圍會抖動。使用者看到的現象像是綠色本來對應 100…200,拖曳中卻看起來變成 110…220。
原本的寫法
原本血糖面積使用 AreaMark,並且已經用 alignsMarkStylesWithPlotArea() 讓漸層對齊固定 plot area,而不是每一天自己的資料範圍。
1
2
3
4
5
6
7
8
9
10
ForEach(day.glucose) { pt in
AreaMark(
x: .value("時", pt.hour),
yStart: .value("底", 60),
yEnd: .value("血糖", pt.value)
)
.foregroundStyle(glucoseGradient)
.alignsMarkStylesWithPlotArea()
.interpolationMethod(.catmullRom)
}
漸層本身也是用固定 y 軸 domain 60...420 算 stop:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private var glucoseGradient: some ShapeStyle {
let range = 420.0 - 60.0
let stop75 = (75.0 - 60.0) / range
let stop100 = (100.0 - 60.0) / range
let stop200 = (200.0 - 60.0) / range
let stop300 = (300.0 - 60.0) / range
let r = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 0.3)
let y = Color(.sRGB, red: 1, green: 1, blue: 0, opacity: 0.3)
let g = Color(.sRGB, red: 0, green: 0.8, blue: 0.2, opacity: 0.3)
let o = Color(.sRGB, red: 1, green: 0.6, blue: 0, opacity: 0.3)
return LinearGradient(
stops: [
.init(color: r, location: 0),
.init(color: r, location: stop75),
.init(color: y, location: stop75 + 0.01),
.init(color: y, location: stop100),
.init(color: g, location: stop100 + 0.01),
.init(color: g, location: stop200),
.init(color: o, location: stop200 + 0.01),
.init(color: o, location: stop300),
.init(color: r, location: stop300 + 0.01),
.init(color: r, location: 1.0),
],
startPoint: .bottom,
endPoint: .top
)
}
所以一開始懷疑的是 Swift Charts 在 chartXSelection 高頻更新時,讓 AreaMark 的 style 或 layout 重新解析。
錯誤方向一:把 AreaMark 拆成多層
第一個錯誤方向是把面積圖拆成多層 AreaMark 或 RectangleMark。想法是:既然漸層會抖,就不要用漸層,改用固定分區。
這個方向的問題是視覺完全變了。原本需求是「只在測量資料範圍下方有上色」,拆成固定背景後會變成整片 plot area 都有色帶,尤其資料少或曲線很高時,看起來像背景被塗滿,而不是血糖面積圖。
結論:不能只因為色階要固定,就把面積圖改成背景圖。
錯誤方向二:chartBackground + Canvas
第二個錯誤方向是用 chartBackground 取得 ChartProxy,再用 Canvas 畫固定 y 座標色帶,最後用血糖曲線下方面積 path 裁切。
簡化後像這樣:
1
2
3
4
5
.chartBackground { proxy in
GeometryReader { geometry in
glucoseAreaBackground(proxy: proxy, geometry: geometry)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Canvas { context, size in
var areaPath = Path()
areaPath.move(to: CGPoint(x: first.x, y: baseY))
for point in points {
areaPath.addLine(to: point)
}
areaPath.addLine(to: CGPoint(x: last.x, y: baseY))
areaPath.closeSubpath()
context.clip(to: areaPath)
for band in glucoseCanvasBands(proxy: proxy, width: size.width) {
context.fill(Path(band.rect), with: .color(band.color))
}
}
這個寫法看起來很合理,因為 Apple 的 ChartProxy 確實可以把 chart value 轉成 plot area 座標:
1
2
let x = proxy.position(forX: point.hour)
let y = proxy.position(forY: point.value)
但實際畫面有兩個問題。
第一,Canvas 裁切出來的視覺仍然太像固定背景色帶,和原本 AreaMark 的面積感不同。
第二,如果血糖 peak 超過 chartYScale(domain: 60...420) 的上界,Swift Charts 的 LineMark 會在 plot area 邊界裁切顯示,但 ChartProxy.position(forY:) 可能拿不到超出 domain 的 y 座標。於是 Canvas path 會跳過 peak,peak 下方出現空白。
後來試著把 y 值 clamp 到 60…420:
1
2
let clippedValue = min(max(point.value, 60), 420)
let y = proxy.position(forY: clippedValue)
這只能避免點被丟掉,不能解決視覺不對的根本問題。白線和填色仍會有不自然的裁切感。
結論:Canvas 可以解固定座標問題,但不適合這個頁面的視覺需求。
真正有效的方向
最後保留 AreaMark,把重點放回互動時的 layout 與 animation。
原本整張 Chart 對 selectedHour 套動畫:
1
2
.chartXSelection(value: $selectedHour)
.animation(.easeInOut(duration: 0.15), value: selectedHour)
但 selectedHour 在 touch / drag 時會高頻更新。這表示每一個手指位置變化,都可能讓整張 chart 的 mark、annotation、selection indicator 一起進動畫與重新配置。對 AreaMark 這類帶漸層且對齊 plot area 的 mark 來說,這很容易造成視覺上的色塊範圍抖動。
修正後拿掉整張 Chart 的 selectedHour 動畫:
1
2
3
4
5
.chartXSelection(value: $selectedHour)
// 不對整張 Chart 套 selectedHour 動畫。touch 拖曳會高頻更新
// selectedHour;若整個 chart 都參與動畫,AreaMark 的填色與
// annotation 會在過程中反覆重新配置,造成血糖色塊範圍看起來抖動。
.animation(nil, value: visibleLayers)
同時把胰島素水滴在 touch 過程中的尺寸固定。原本附近水滴會放大:
1
let dropSize: CGFloat = isNearby ? baseSize * 1.3 : baseSize
修正後只用固定大小:
1
2
let baseSize: CGFloat = fills.count >= 3 ? 16 : (fills.count == 2 ? 20 : 24)
let dropSize: CGFloat = baseSize
劑量文字維持原本大小:
1
2
3
4
Text(String(format: "%.2fu", pt.value))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.cyan)
.fixedSize()
最後,水滴時間 bubble 不能用 if isNearby 插入/移除,因為 annotation 高度會在拖曳中反覆改變。改成固定佔位,只切換透明度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HStack(spacing: 4) {
Image(systemName: "alarm")
.font(.caption2)
Text(formatHour(pt.hour))
.font(.caption.bold())
}
.foregroundStyle(.cyan)
.fixedSize()
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.cyan, lineWidth: 1)
)
.opacity(isNearby ? 1 : 0)
.accessibilityHidden(!isNearby)
.animation(.easeInOut(duration: 0.15), value: isNearby)
血糖面積和線最後也改成 .linear,不是因為它單獨能修掉抖動,而是為了讓面積與線的形狀一致,避免 Catmull-Rom 在資料點密集或 peak 附近製造額外曲線視覺。
1
2
3
4
5
6
7
8
AreaMark(
x: .value("時", pt.hour),
yStart: .value("底", 60),
yEnd: .value("血糖", pt.value)
)
.foregroundStyle(glucoseGradient)
.alignsMarkStylesWithPlotArea()
.interpolationMethod(.linear)
1
2
3
4
5
6
7
LineMark(
x: .value("時", pt.hour),
y: .value("血糖", pt.value)
)
.foregroundStyle(.white.opacity(0.7))
.interpolationMethod(.linear)
.lineStyle(StrokeStyle(lineWidth: 2))
記住
這次不是「Swift Charts 完全無解」。比較可靠的整理是:
AreaMark + alignsMarkStylesWithPlotArea()可以保留原本好看的面積圖。- 不要讓高頻互動狀態驅動整張
Chart動畫。 - touch 中不要讓 annotation 改變尺寸或插入/移除 layout。
- 不要為了解固定色階,把面積圖改成整片背景色帶;那會解錯問題。
驗證結果:
- peak 下方不再空白。
- touch 血糖曲線時,顏色範圍不再抖動。