Post

Swift Charts AreaMark Selection Jitter

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 拆成多層

第一個錯誤方向是把面積圖拆成多層 AreaMarkRectangleMark。想法是:既然漸層會抖,就不要用漸層,改用固定分區。

這個方向的問題是視覺完全變了。原本需求是「只在測量資料範圍下方有上色」,拆成固定背景後會變成整片 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 血糖曲線時,顏色範圍不再抖動。
This post is licensed under CC BY 4.0 by the author.