Candlestick Chart
A candlestick chart of open, high, low and close prices, with a rule that reads the day under the finger.

- Charts
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- candlestick · ohlc · stocks · trading · price chart · finance · swift charts · velas · ohlc · bolsa · trading · gráfica de precios · finanzas · swift charts
Description
Section titled “Description”A candlestick chart: each day is a thin line from its low to its high and a body from its open to its close, in one color when the price rose and another when it fell.
Drag over it to read a day: a rule follows the finger and selection gets its candle. VoiceOver treats the chart as one value to adjust, and reads the date and the four prices of a day at a time. With Differentiate Without Color on, the days that fell are drawn lighter, so the two kinds do not depend on the color alone.
struct CandlestickChartDemo: View { @State private var selection: CandlestickChartCandle?
/// A month of made-up prices, the same on every run. private let candles: [CandlestickChartCandle] = { var seed: UInt64 = 42 func next() -> Double { seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 return Double(seed >> 33) / Double(1 << 31) } let start = Date(timeIntervalSinceReferenceDate: 815_000_000) var price = 182.0 return (0..<30).map { day in let open = price let close = open + (next() - 0.47) * 9 let high = max(open, close) + next() * 4 let low = min(open, close) - next() * 4 price = close return CandlestickChartCandle(date: start.addingTimeInterval(Double(day) * 86_400), open: open, high: high, low: low, close: close) } }()
private var shown: CandlestickChartCandle? { selection ?? candles.last }
var body: some View { VStack(alignment: .leading, spacing: 18) { if let shown { VStack(alignment: .leading, spacing: 4) { Text("ACME") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline) { Text(shown.close, format: .currency(code: "USD")) .font(.largeTitle.bold()) Text(CandlestickChartMath.change(of: shown), format: .percent.precision(.fractionLength(1)).sign(strategy: .always())) .font(.headline) .foregroundStyle(shown.isRising ? .green : .red) } .monospacedDigit() .contentTransition(.numericText()) Text(selection == nil ? "Last close · drag to read a day" : shown.date.formatted(date: .abbreviated, time: .omitted)) .font(.footnote) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine)
CandlestickChart(candles: candles, selection: $selection) .frame(height: 280)
HStack { price("Open", shown.open) price("High", shown.high) price("Low", shown.low) price("Close", shown.close) } } } .padding(20) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) }
private func price(_ title: String, _ value: Double) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.caption) .foregroundStyle(.secondary) Text(value, format: .number.precision(.fractionLength(2))) .font(.subheadline.weight(.semibold)) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
candles | [CandlestickChartCandle] | Required |
selection | Binding<CandlestickChartCandle?> | Required |
risingColor | Not written | Color.green |
fallingColor | Not written | Color.red |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import Chartsimport SwiftUI
nonisolated struct CandlestickChartCandle: Identifiable, Hashable, Sendable { var date: Date var open: Double var high: Double var low: Double var close: Double
var id: Date { date } var isRising: Bool { close >= open }}
nonisolated enum CandlestickChartMath { /// The prices the chart shows: from the lowest low to the highest high, with some room at /// both ends so no candle touches the edge. static func domain(of candles: [CandlestickChartCandle], padding: Double = 0.08) -> ClosedRange<Double> { guard let low = candles.map(\.low).min(), let high = candles.map(\.high).max(), high >= low else { return 0...1 } let room = max(high - low, abs(high) * 0.01, 0.01) * max(padding, 0) return (low - room)...(high + room) }
/// The two ends of the body of a candle. A day that closed where it opened still gets a body /// `minimum` tall, so it shows as a line instead of vanishing. static func body(of candle: CandlestickChartCandle, minimum: Double) -> ClosedRange<Double> { let low = min(candle.open, candle.close) let high = max(candle.open, candle.close) let missing = max(minimum - (high - low), 0) return (low - missing / 2)...(high + missing / 2) }
/// How much the price moved from open to close, as a share of the open. static func change(of candle: CandlestickChartCandle) -> Double { candle.open != 0 ? (candle.close - candle.open) / candle.open : 0 }
/// The candle of the day closest to a date, which is how a touch between two days picks one. static func nearest(to date: Date, in candles: [CandlestickChartCandle]) -> CandlestickChartCandle? { candles.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } }}
/// A candlestick chart: each day is a thin line from its low to its high and a body from its/// open to its close, in one color when the price rose and another when it fell.////// Drag over it to read a day: a rule follows the finger and `selection` gets its candle./// VoiceOver treats the chart as one value to adjust, and reads the date and the four prices/// of a day at a time. With Differentiate Without Color on, the days/// that fell are drawn lighter, so the two kinds do not depend on the color alone.struct CandlestickChart: View { let candles: [CandlestickChartCandle] @Binding var selection: CandlestickChartCandle? var risingColor = Color.green var fallingColor = Color.red
@State private var selectedDate: Date? @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
var body: some View { let domain = CandlestickChartMath.domain(of: candles) let minimumBody = (domain.upperBound - domain.lowerBound) * 0.004
Chart { ForEach(candles) { candle in let color = candle.isRising ? risingColor : fallingColor let prices = CandlestickChartMath.body(of: candle, minimum: minimumBody)
RuleMark( x: .value("Day", candle.date, unit: .day), yStart: .value("Low", candle.low), yEnd: .value("High", candle.high) ) .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round)) .foregroundStyle(color) .accessibilityHidden(true)
RectangleMark( x: .value("Day", candle.date, unit: .day), yStart: .value("Open", prices.lowerBound), yEnd: .value("Close", prices.upperBound), width: .ratio(0.62) ) .foregroundStyle(color.opacity(!candle.isRising && differentiateWithoutColor ? 0.45 : 1)) .clipShape(.rect(cornerRadius: 1.5)) .accessibilityLabel(candle.date.formatted(date: .abbreviated, time: .omitted)) .accessibilityValue(description(of: candle)) }
if let selection { RuleMark(x: .value("Selected", selection.date, unit: .day)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .foregroundStyle(.secondary) .accessibilityHidden(true) } } .chartYScale(domain: domain) .chartXAxis { AxisMarks(preset: .aligned) } .chartXSelection(value: $selectedDate) .onChange(of: selectedDate) { _, date in selection = date.flatMap { CandlestickChartMath.nearest(to: $0, in: candles) } } .sensoryFeedback(.selection, trigger: selection) // Charts groups marks this close into ranges of dates, with no prices. One element that // steps through the days reads them instead. .accessibilityElement(children: .ignore) .accessibilityLabel("Candlestick chart") .accessibilityValue(accessibilityValue) .accessibilityAdjustableAction { direction in guard !candles.isEmpty else { return } let last = candles.count - 1 let current = selection.flatMap { chosen in candles.firstIndex { $0.date == chosen.date } } ?? last switch direction { case .increment: selection = candles[min(current + 1, last)] case .decrement: selection = candles[max(current - 1, 0)] @unknown default: break } } }
private var accessibilityValue: String { guard let candle = selection ?? candles.last else { return "" } return "\(candle.date.formatted(date: .abbreviated, time: .omitted)), \(description(of: candle))" }
private func description(of candle: CandlestickChartCandle) -> String { let prices = [("Open", candle.open), ("high", candle.high), ("low", candle.low), ("close", candle.close)] return prices.map { "\($0.0) \($0.1.formatted(.number.precision(.fractionLength(2))))" }.joined(separator: ", ") }}
// MARK: - Demo
struct CandlestickChartDemo: View { @State private var selection: CandlestickChartCandle?
/// A month of made-up prices, the same on every run. private let candles: [CandlestickChartCandle] = { var seed: UInt64 = 42 func next() -> Double { seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 return Double(seed >> 33) / Double(1 << 31) } let start = Date(timeIntervalSinceReferenceDate: 815_000_000) var price = 182.0 return (0..<30).map { day in let open = price let close = open + (next() - 0.47) * 9 let high = max(open, close) + next() * 4 let low = min(open, close) - next() * 4 price = close return CandlestickChartCandle(date: start.addingTimeInterval(Double(day) * 86_400), open: open, high: high, low: low, close: close) } }()
private var shown: CandlestickChartCandle? { selection ?? candles.last }
var body: some View { VStack(alignment: .leading, spacing: 18) { if let shown { VStack(alignment: .leading, spacing: 4) { Text("ACME") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline) { Text(shown.close, format: .currency(code: "USD")) .font(.largeTitle.bold()) Text(CandlestickChartMath.change(of: shown), format: .percent.precision(.fractionLength(1)).sign(strategy: .always())) .font(.headline) .foregroundStyle(shown.isRising ? .green : .red) } .monospacedDigit() .contentTransition(.numericText()) Text(selection == nil ? "Last close · drag to read a day" : shown.date.formatted(date: .abbreviated, time: .omitted)) .font(.footnote) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine)
CandlestickChart(candles: candles, selection: $selection) .frame(height: 280)
HStack { price("Open", shown.open) price("High", shown.high) price("Low", shown.low) price("Close", shown.close) } } } .padding(20) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) }
private func price(_ title: String, _ value: Double) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.caption) .foregroundStyle(.secondary) Text(value, format: .number.precision(.fractionLength(2))) .font(.subheadline.weight(.semibold)) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) }}
#Preview { CandlestickChartDemo()}