One day I was inspired by a small yet crucial UI component in the Yahoo Finance iOS app.
This UI component represents stock price changes in real time. The most interesting thing in this component for me it’s the accurate color accents on some parts of a price that was changed. Let’s see a brief example of AAPL stock:
GitHub repository: https://github.com/prokhorovxo/PriceView.
I want to recreate this component in this article but with better animations and a different aesthetic. Before the start of the development process let’s shortly describe the plan:
I will use the US format of prices that looks like this:
$1,000.00
To obtain this price format, let's develop a proper NumberFormatter. Create a file called NumberFormatter+.swift
and put this code inside:
// NumberFormatter+.swift
import Foundation
extension NumberFormatter {
static let usNumberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .init(identifier: "en_US")
formatter.generatesDecimalNumbers = true
formatter.currencyCode = "USD"
formatter.currencySymbol = "$"
formatter.numberStyle = .currency
formatter.usesGroupingSeparator = true
formatter.decimalSeparator = "."
formatter.groupingSeparator = ","
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
return formatter
}()
}
The goal is to highlight a part of the price based on its change and color this part based on whether the price change is positive or negative. In the development process I prefer to go from the specific case to common, so let’s take $1,000.00 price for example:
Before we dive into the code, let's take a closer look at the individual components that make up a price.
In general, a price can be broken down into the following atoms:
By breaking down a price into these atoms, we can more easily manipulate and format the price as needed. Let’s represent $1,000.00 atomic:
$1,000.00 → $
1
,
0
0
0
.
0
0
For every component, we need to know what type it is (currency symbol, integer/decimal number, grouping separator or decimal separator), its value, and whether we should highlight this component after the price changes.
Let’s create our model:
// PriceComponent.swift
import Foundation
enum PriceComponent: Identifiable {
case currencySymbol(String)
case number(id: String = UUID().uuidString,
value: Int,
isDecimal: Bool,
isHighlighted: Bool = false)
case groupingSeparator(id: String = UUID().uuidString,
value: String,
isHighlighted: Bool = false)
case decimalSeparator(id: String = UUID().uuidString,
value: String,
isHighlighted: Bool = false)
var id: String {
switch self {
case .currencySymbol(let value):
return "currencySymbol.\(value)"
case .number(let id, _, _, _),
.groupingSeparator(let id, _, _),
.decimalSeparator(let id, _, _):
return id
}
}
var stringValue: String {
switch self {
case .currencySymbol(let value): return value
case .number(_, let value, _, _): return "\(value)"
case .groupingSeparator(_, let value, _): return value
case .decimalSeparator(_, let value, _): return value
}
}
var isHighlighted: Bool {
switch self {
case .currencySymbol:
return false
case .number(_, _, _, let isHighlighted),
.groupingSeparator(_, _, let isHighlighted),
.decimalSeparator(_, _, let isHighlighted):
return isHighlighted
}
}
}
And let’s write a convenient method for converting Double
price to an array of PriceComponent
s that we define.
// PriceComponent.swift
// ...
// MARK: - Price components factory
extension PriceComponent {
static func createPriceComponents(from price: Double,
usingFormatter numberFormatter: NumberFormatter) -> [PriceComponent] {
// 1. Get formatted string from price
let decimalPrice = NSDecimalNumber(decimal: Decimal(price))
guard let priceString = numberFormatter.string(from: decimalPrice) else {
return []
}
var result: [PriceComponent] = []
// 2. True if forEach iterate over decimal separator
var isDecimalSeparatorPassed = false
// 3. Iterate through each character and create price component for each one
priceString.forEach { char in
let stringChar = String(char)
switch stringChar {
case numberFormatter.currencySymbol:
result.append(.currencySymbol(stringChar))
case numberFormatter.groupingSeparator:
result.append(.groupingSeparator(value: stringChar))
case numberFormatter.decimalSeparator:
result.append(.decimalSeparator(value: stringChar))
isDecimalSeparatorPassed = true
default:
guard char.isNumber, let value = Int(stringChar) else {
break
}
result.append(.number(value: value, isDecimal: isDecimalSeparatorPassed))
}
}
return result
}
}
It's time to begin working on the UI, so let's create a new file named PriceView.swift
:
// PriceView.swift
import SwiftUI
struct PriceView: View {
// 1
@Binding var price: Double
// 2
@State private var priceComponents: [PriceComponent]
// 3
@State private var highlightedColor: Color = .primary
// 4
init(price: Binding<Double>) {
self._price = price
self.priceComponents = PriceComponent.createPriceComponents(from: price.wrappedValue,
usingFormatter: .usNumberFormatter)
}
// 5
var body: some View {
HStack(alignment: .bottom, spacing: .zero) {
ForEach(priceComponents, id: \.id) {
switch $0 {
case .currencySymbol(let value):
Text(
AttributedString(
value,
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 15
]
)
)
)
case .number(_, let value, let isDecimal, let isHighlighted):
if isDecimal {
Text(
AttributedString(
"\(value)",
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 12
]
)
)
)
.foregroundColor(isHighlighted ? highlightedColor : .primary)
} else {
Text("\(value)")
.font(.system(size: 100.0, weight: .bold))
.foregroundColor(isHighlighted ? highlightedColor : .primary)
}
case .groupingSeparator(_, let value, let isHighlighted):
Text("\(value)")
.font(.system(size: 100.0, weight: .bold))
.foregroundColor(isHighlighted ? highlightedColor : .primary)
case .decimalSeparator(_, let value, let isHighlighted):
Text(
AttributedString(
value,
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 12
]
)
)
)
.foregroundColor(isHighlighted ? highlightedColor : .primary)
}
}
}
}
}
struct PriceView_Preview: PreviewProvider {
static var previews: some View {
PriceView(price: .constant(159.23))
}
}
You can use any font and any font size, but be careful with baselineOffset value
Let’s look on the Preview:
PriceView
already looks good; let's make it even better by adding animation. To animate price updates we have to:
Let’s dive into code. Add onChange block to HStack:
// PriceView.swift
import SwiftUI
struct PriceView: View {
// ...
var body: some View {
HStack(alignment: .bottom, spacing: .zero) {
ForEach(priceComponents, id: \.id) {
// ...
}
}
// 1
.onChange(of: price) { newPrice in
// 2
let oldPrice = PriceComponent.createPrice(from: priceComponents, usingFormatter: .usNumberFormatter)
// 3
let newPriceComponents = PriceComponent.createPriceComponents(oldPriceComponents: priceComponents,
newPrice: newPrice,
usingFormatter: .usNumberFormatter)
// 4
priceComponents = newPriceComponents
// 5
highlightedColor = newPrice == oldPrice ? .primary : (newPrice > oldPrice ? .green : .red)
}
}
}
struct PriceView_Preview: PreviewProvider {
static var previews: some View {
PriceView(price: .constant(159.23))
}
}
Ok, let’s add a few methods to PriceComponent:
// PriceComponent.swift
// ...
// MARK: - Price components factory
extension PriceComponent {
// ...
// 1
static func createPriceComponents(oldPriceComponents: [PriceComponent],
newPrice: Double,
usingFormatter numberFormatter: NumberFormatter) -> [PriceComponent] {
let oldPrice = PriceComponent.createPrice(from: oldPriceComponents, usingFormatter: numberFormatter)
guard let oldPriceString = numberFormatter.string(from: NSDecimalNumber(decimal: Decimal(oldPrice))),
let newPriceString = numberFormatter.string(from: NSDecimalNumber(decimal: Decimal(newPrice))) else {
return []
}
let changedIndex = oldPriceString
.enumerated()
.first(where: {
let index = String.Index(utf16Offset: $0.offset, in: newPriceString)
return $0.element != newPriceString[index]
})?.offset
?? newPriceString.count - 1
var result: [PriceComponent] = []
var isDecimalSeparatorPassed = false
newPriceString.enumerated().forEach { i, char in
let stringChar = String(char)
switch stringChar {
case numberFormatter.currencySymbol:
result.append(.currencySymbol(stringChar))
case numberFormatter.groupingSeparator:
let nextNumberIndex = i + 1
let isHighlighted = nextNumberIndex >= changedIndex
result.append(
.groupingSeparator(id: isHighlighted ? UUID().uuidString : oldPriceComponents[i].id,
value: stringChar,
isHighlighted: isHighlighted)
)
case numberFormatter.decimalSeparator:
let nextNumberIndex = i + 1
let isHighlighted = nextNumberIndex >= changedIndex
result.append(
.decimalSeparator(id: isHighlighted ? UUID().uuidString : oldPriceComponents[i].id,
value: stringChar,
isHighlighted: isHighlighted)
)
isDecimalSeparatorPassed = true
default:
guard char.isNumber, let value = Int(stringChar) else {
break
}
let isHighlighted = i >= changedIndex
result.append(
.number(id: isHighlighted ? UUID().uuidString : oldPriceComponents[i].id,
value: value,
isDecimal: isDecimalSeparatorPassed,
isHighlighted: isHighlighted)
)
}
}
return result
}
}
// MARK: - Double factory
extension PriceComponent {
// 2
static func createPrice(from priceComponents: [PriceComponent],
usingFormatter numberFormatter: NumberFormatter) -> Double {
let priceString = priceComponents.map { $0.stringValue }.joined()
return numberFormatter.number(from: priceString)?.doubleValue ?? .zero
}
}
Let’s add PriceView to parent view add look on the Preview:
// ContentView.swift
import SwiftUI
struct ContentView: View {
@State var price: Double = 159.95
private let incrementValue = 0.5
var body: some View {
VStack(alignment: .center, spacing: 25.0) {
Spacer()
PriceView(price: $price)
.clipped()
Spacer()
// 1
Button {
price = Double.random(in: (price - incrementValue)...(price + incrementValue))
} label: {
Text("Update price")
.font(.system(size: 17.0, weight: .semibold))
.frame(height: 44.0)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.accentColor)
.cornerRadius(22.0)
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It’s ok but we don’t have animations, let’s add them
// PriceView.swift
import SwiftUI
struct PriceView: View {
// ...
var body: some View {
HStack(alignment: .bottom, spacing: .zero) {
ForEach(priceComponents, id: \.id) {
// ...
}
}
.onChange(of: price) { newPrice in
// 1
withAnimation(.easeOut(duration: 0.25)) {
let oldPrice = PriceComponent.createPrice(from: priceComponents, usingFormatter: .usNumberFormatter)
let newPriceComponents = PriceComponent.createPriceComponents(oldPriceComponents: priceComponents,
newPrice: newPrice,
usingFormatter: .usNumberFormatter)
priceComponents = newPriceComponents
highlightedColor = newPrice == oldPrice ? .primary : (newPrice > oldPrice ? .green : .red)
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeIn) {
highlightedColor = .primary
}
}
}
}
}
}
// ...
And last touch is “countdown” animation, it’s something about SwiftUI magic. Let’s add
// PriceView.swift
import SwiftUI
struct PriceView: View {
// ...
var body: some View {
HStack(alignment: .bottom, spacing: .zero) {
ForEach(priceComponents, id: \.id) {
switch $0 {
case .currencySymbol(let value):
Text(
AttributedString(
value,
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 15
]
)
)
)
case .number(_, let value, let isDecimal, let isHighlighted):
if isDecimal {
Text(
AttributedString(
"\(value)",
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 12
]
)
)
)
.foregroundColor(isHighlighted ? highlightedColor : .primary)
.transition(.push(from: .top)) // 1
} else {
Text("\(value)")
.font(.system(size: 100.0, weight: .bold))
.foregroundColor(isHighlighted ? highlightedColor : .primary)
.transition(.push(from: .top))
}
case .groupingSeparator(_, let value, let isHighlighted):
Text("\(value)")
.font(.system(size: 100.0, weight: .bold))
.foregroundColor(isHighlighted ? highlightedColor : .primary)
.transition(.identity) // 2
case .decimalSeparator(_, let value, let isHighlighted):
Text(
AttributedString(
value,
attributes: AttributeContainer(
[
.font: UIFont.systemFont(ofSize: 50.0, weight: .bold),
.baselineOffset: 12
]
)
)
)
.foregroundColor(isHighlighted ? highlightedColor : .primary)
.transition(.identity)
}
}
}
.onChange(of: price) { newPrice in
// ...
}
}
}
// ...
In this tutorial, we've explored how to create a stock price label UI component using SwiftUI. We started by defining a number formatting style and highlighting rules for the price change. Then, we created a custom PriceComponent
type to represent the individual components of a price, and used it to format the price in our UI component. Finally, we developed a SwiftUI view that displays the stock price and its change in a visually appealing way.
By following this tutorial, you should now have a better understanding of how to create custom UI components in SwiftUI and format prices using the NumberFormatter
class.
You can find the full source code for this project on our GitHub repository: https://github.com/prokhorovxo/PriceView.
Thank you for reading, and happy coding!