Interactive candlestick charts for SwiftUI, built to feel like a native iOS control.
CandleKit gives you momentum scrolling, pinch zoom around your fingers, a long-press crosshair, live updates and infinite history with Apple platform conventions: system colors that adapt to dark mode, haptic feedback, Dynamic Type, and VoiceOver Audio Graphs.
import CandleKit
struct PriceView: View {
let candles: [Candle]
var body: some View {
CandlestickChart(candles)
.indicators([.sma(20), .ema(50)])
.frame(height: 360)
}
}Status: 0.1, pre-release. The core engine has a full unit test suite. The SwiftUI layer has not yet been profiled on device or covered by snapshot tests. Feedback and bug reports are very welcome.
iOS 17 or later, and Xcode 16 or later (Swift 6 toolchain). The engine target, CandleKitCore, has no UI dependencies and builds on macOS and Linux too.
In Xcode, choose File βΊ Add Package Dependencies and enter the repository URL. Or add it to Package.swift:
dependencies: [
.package(url: "https://github.com/LalanaChami/CandleKit.git", from: "0.1.0")
]Then add "CandleKit" to your target's dependencies.
Drag horizontally to scroll, and release with some speed for momentum. Pinch to zoom; the candles under your fingers stay put. Long-press to show the crosshair and move your finger to inspect candles, with a selection haptic as it snaps from one candle to the next. Lift to dismiss. Double-tap to reset the zoom and jump back to the newest candle.
Vertical drags are left alone, so a chart inside a ScrollView doesn't trap the page.
Drag the price axis itself, on the right edge, to rescale prices β up narrows the range, down widens it β and double-tap the axis to return to autoscale. This is a separate gesture region from the rest of the chart, so it doesn't affect scrolling or zooming.
Just update your array. The chart compares each new array with the previous one and adjusts:
| What changed | What the chart does |
|---|---|
| Last candle updated in place | Redraws; scroll position is untouched |
| Candles appended | Scrolls to show them if the newest candle was on screen, otherwise stays put |
| Older candles prepended | Keeps the same candles on screen |
| Unrelated series (e.g. new symbol) | Jumps to the newest candle |
@State private var candles: [Candle] = []
CandlestickChart(candles)
.task {
for await tick in priceFeed.ticks {
if let last = candles.last, tick.time < last.time.addingTimeInterval(60) {
candles[candles.count - 1].close = tick.price
candles[candles.count - 1].high = max(last.high, tick.price)
candles[candles.count - 1].low = min(last.low, tick.price)
} else {
candles.append(Candle(time: tick.time, open: tick.price, high: tick.price, low: tick.price, close: tick.price))
}
}
}Candles must be sorted oldest first, with unique times.
CandlestickChart(candles)
.onReachOldestCandle {
guard let oldest = candles.first else { return }
Task {
let older = try await api.candles(before: oldest.time, limit: 500)
candles.insert(contentsOf: older, at: 0)
}
}The handler fires once per data size, so it won't be called again until your array actually grows.
Create a CandleChartState when you need to drive the chart yourself:
@State private var chartState = CandleChartState(candleSpacing: 10)
CandlestickChart(candles, state: chartState)
.toolbar {
if !chartState.isFollowingLatest {
Button("Jump to latest", systemImage: "arrow.right.to.line") {
chartState.scrollToLatest()
}
}
}CandleChartState also offers resetZoom(), scroll(byCandles:) and the observable crosshairIndex.
Dragging the price axis (see Interacting with the chart) drives CandleChartState.priceScaleMode, an observable .automatic / .manual(ClosedRange<Double>). Set or clear it programmatically too:
chartState.setManualPriceRange(95_000...105_000)
chartState.currentPriceRange // the range currently in effect, whichever mode it came from
chartState.resetPriceScale() // back to autoscalingWhile manual, the range stays put as new candles arrive β it doesn't recompute until you reset it.
ChartLayout is a single, versioned Codable value capturing everything a trader would expect to come back after relaunching: chart style, active indicators (with their colors and settings), drawings, and scroll/zoom position.
let layout = ChartLayout(
style: style.snapshot, // CandleChartStyle β ChartLayout.StyleSnapshot
indicators: indicators.map(\.persisted), // [ChartIndicator] β [PersistedIndicator]
drawings: drawings,
viewport: chartState.currentViewport
)
let data = try JSONEncoder().encode(layout)
try data.write(to: layoutURL)
// Later, or after relaunch:
let restored = try JSONDecoder().decode(ChartLayout.self, from: data)
style = CandleChartStyle(restored.style)
drawings = restored.drawings
chartState.restoreViewport(restored.viewport)
// Rebuild each indicator against your catalog; an entry an app no longer recognizes is dropped
// rather than failing the whole load:
indicators = restored.indicators.compactMap { ChartIndicator($0, catalog: myCatalog) }ChartLayout stores where you scroll and zoom, not what timeframe or symbol you were looking at β those are your app's own concepts, so store them alongside it (there's an optional timeframeIdentifier: String? slot for exactly this, left uninterpreted by CandleKit). Decoding rejects a schemaVersion newer than the library understands, and defaults indicators/drawings to empty when a future version's payload omits them, so old and new versions of your app can each open what the other saved. CandleChartStyle.priceAxisMaterial doesn't round-trip β Material has no public API to read an arbitrary value back into a named case β so a restored style always has it nil; reapply it yourself if you use the glass axis. See the Demo app's Layouts sheet (reachable from the chart-options button) for a complete, working example of saving, loading, renaming, and updating named layouts.
priceCrossings(in:levels:) answers "did the price cross this level," comparing closes between the two most recent candles in your array β nothing more. It's not an alerting system: no notification scheduling, no persisted watchlist, no UI. Wire your own around it:
for crossing in priceCrossings(in: candles, levels: [100_000]) {
// crossing.direction is .upward or .downward; crossing.candle is the one it happened on.
scheduleNotification(for: crossing)
}Works the same whether candles just had a new one appended or had its last candle updated in place by a live tick β call it again after every update, not just once. See the Demo app's price-alert control (the bell in the floating tool dock beneath the chart).
CandleChartState.snapshot(scale:colorScheme:watermark:) renders the chart as it looks right now (same visible range, zoom and price scale) into a UIImage, so it can go to a share sheet, Photos or the pasteboard. Pass your own CandleChartState to the chart so you have something to ask:
@State private var chartState = CandleChartState()
@Environment(\.colorScheme) private var colorScheme
CandlestickChart(candles, state: chartState)
Button("Share") {
if let image = chartState.snapshot(colorScheme: colorScheme, watermark: .candleKit) {
present(shareSheetFor: image)
}
}ImageRenderer can't see your view's environment, so pass colorScheme if the image should match what the person is looking at. The watermark is off unless you ask for one (.candleKit, or ChartWatermark("yourapp.com")). The image has the grid, volume, candles, indicators, axes and last-price tag; it leaves out the crosshair, drawings and the header, and draws a frosted price axis as a plain one. See the Demo app's share button on the Market screen.
.tradingSessions(_:shading:) tints the plot behind pre-market, after-hours and overnight candles, a common ask for US equities apps. CandleKit doesn't know what market a symbol trades on, so you describe the sessions:
CandlestickChart(candles)
.tradingSessions(TradingSession.usEquities)
// or your own:
CandlestickChart(candles)
.tradingSessions([
TradingSession(kind: .afterHours,
startMinute: TradingSession.minute(hour: 16),
endMinute: TradingSession.minute(hour: 20),
timeZone: TimeZone(identifier: "America/New_York")!)
], shading: SessionShadingStyle())Only non-regular kinds are drawn; candles outside every listed window stay unshaded. Windows can run past midnight (20:00 to 04:00) and are matched against each candle's local time in the session's zone, daylight saving included. Sessions apply to series with an interval of one hour or shorter; on longer intervals nothing is shaded. The regions are worked out once when the data changes, not on every frame. See the "Session shading" card in the Demo's Styles tab, or pick AAPL, MSFT or SPY on the Market screen and use Chart Options β Sessions (those equities are simulated and trade only 04:00β20:00 New York time on weekdays).
.comparing(_:normalization:) plots other instruments over the chart so you can see how they moved relative to each other:
CandlestickChart(appleCandles)
.comparing([
ComparisonSeries(name: "MSFT", candles: microsoftCandles),
ComparisonSeries(name: "SPY", candles: indexCandles, color: .gray),
])The price axis switches to percent change from the first candle on screen (or, with normalization: .indexed100, an index that starts at 100), and each line starts on the chart's own price where it first has data, so the axis re-bases as you scroll and zoom. A legend in the chart's top-left corner shows each line's value and follows the crosshair. The series don't need the same candle times: each chart candle shows the latest close at or before it, and a series with no recent data shows a break rather than a flat line. The header, VoiceOver summary and drawings still use the chart's own prices, and there is no separate secondary axis yet. See docs/design/comparison.md, the Demo's "Comparison" card, or Chart Options β Compare on the Market screen.
ReplayController plays a loaded series back one candle at a time, for teaching and backtesting:
@State private var replay = ReplayController()
CandlestickChart(candles)
.replay(replay)
replay.begin(at: 60) // show the first 60 candles
replay.play() // reveal 2 per second (set replay.candlesPerSecond)
replay.step() // or reveal one more by hand; pause(), end() as you'd expectWhile replay is active the chart is given only the first position candles; end() shows the whole series again. Playback stops by itself at the last candle. Timing comes from a 30 Hz task that carries fractional candles forward, so slow speeds like 0.5 per second stay accurate. Replay only goes forward: calling begin(at:) with a smaller number is treated by the chart as a new series, so the viewport resets and the appear animation plays. See the Demo's "Replay" card in the Styles tab, or Chart Options β Replay on the Market screen.
The CandleKitWidgets product (depends only on CandleKitCore) has WidgetCandleChart, a non-interactive chart for WidgetKit and other small surfaces:
import CandleKitWidgets
WidgetCandleChart(candles, mode: .candles, maxCandles: 40, showsLastPrice: true)mode: .line draws a close line with a soft fill. Long series are reduced with CandleAggregator.downsample(_:maxCount:), so the cost of drawing doesn't grow with the data. There are no axes or gestures; put your own header above it. Your host app supplies the candles through a TimelineProvider. See docs/design/widget.md and the CandleKitDemoWidget target in Demo/ (run xcodegen generate in Demo/ after pulling).
CandleKit has no intent type of its own, since intents must live in your app target. docs/guides/app-intents.md describes the pattern, including the cold-launch pitfall, and the Demo has a working ShowChartIntent with an AppShortcutsProvider.
SparklineView (in CandleKitWidgets) draws a price line from plain closing prices, for Live Activities, the Dynamic Island and watchOS. It thins long input to 30 points by default and costs one path to draw. CandleKitCore and CandleKitWidgets support watchOS 10; the interactive chart stays iOS-only. See docs/design/live-activity.md and the Demo's "Live Activity" card.
Compact and expanded Dynamic Island, and the Lock Screen banner (the Lock Screen capture shows the system's Live Activities permission prompt).
| Modifier | Purpose |
|---|---|
.candleChartStyle(_:) |
Colors, hollow candles, body width |
.indicators(_:) |
SMA and EMA overlays |
.volumeVisible(_:) |
Volume bars (on by default) |
.headerVisible(_:) |
Price readout above the chart (on by default) |
.priceFractionDigits(_:) |
Fixed decimal places instead of inferring them |
.onCrosshairChange(_:) |
The inspected candle, or nil when released |
.onReachOldestCandle(_:) |
Load more history |
Three style presets are included. .standard is green up and red down. .redUp follows the convention used in markets such as mainland China, Taiwan and South Korea. .colorBlindSafe uses blue and orange. You can also build your own:
.candleChartStyle(CandleChartStyle(upColor: .mint, downColor: .pink, hollowUpCandles: true))The chart is a single VoiceOver element with a spoken summary of the visible range. Swipe up or down to page through history. It also provides an AXChartDescriptor, so VoiceOver users can open Audio Graphs and hear closing prices as pitch. Axis labels scale with Dynamic Type up to a size that still fits the axes.
Candles are drawn with Canvas and batched into a handful of paths, so the number of draw calls stays constant however many candles are visible. Only visible candles are processed each frame. Indicator values are cached and recomputed only when the data changes, never while scrolling. The crosshair is drawn on its own layer, so moving a finger doesn't redraw the candles.
Momentum scrolling asks for 120 Hz. On ProMotion iPhones that also requires the CADisableMinimumFrameDurationOnPhone key set to YES in your app's Info.plist.
The package has two targets. CandleKitCore is pure Foundation and holds every decision that can be tested without a screen: the Viewport (index-based positions, pan, anchored zoom, clamping), series change detection, price autoscaling and tick selection, time label placement, and indicators. CandleKit holds the SwiftUI and UIKit layers: the chart view, the Canvas renderers, the crosshair overlay, and a transparent UIKit view that owns the gesture recognizers.
The x-axis is positional rather than time-based, so nights, weekends and holidays don't leave gaps. Time labels are placed on multiples of a stride, so they don't jitter as you scroll, and a label is promoted to a coarser unit when it crosses a day, month or year boundary.
swift testThis runs the engine tests on macOS or Linux. To build the SwiftUI layer, open Package.swift in Xcode and build for an iOS Simulator. The previews in Previews.swift include a live-streaming demo with history loading.
Planned next: separate indicator panes (RSI, MACD), drawing tools anchored to time and price, eased autoscaling, macOS and visionOS input, DocC documentation, and snapshot tests for the renderers. Further out: a home screen widget, a Live Activity, a watchOS companion, and other things a WebView-based chart can't do. Full detail, including what's deliberately out of scope, is in docs/ROADMAP.md. If one of these matters to you, open an issue so it can be prioritized.
Contributions are welcome. See CONTRIBUTING.md.
CandleKit is available under the MIT license. See LICENSE.










