paint-brush
Yahoo Finance Style Stock Price Label in SwiftUIby@prokhorovxo
1,944 reads
1,944 reads

Yahoo Finance Style Stock Price Label in SwiftUI

by Fedor ProkhorovApril 13th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

GitHub repository: https://github.com/prokhorovxo/PriceView
featured image - Yahoo Finance Style Stock Price Label in SwiftUI
Fedor Prokhorov HackerNoon profile picture

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:


Yahoo Finance iOS App reference

TL;DR

GitHub repository: https://github.com/prokhorovxo/PriceView.

The plan

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:


  1. Defining a number formatting style
  2. Describe rules for color highlighting
  3. Create model for price representation
  4. Develop SwiftUI View

Number formatter

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
    }()
}

Highlighting rules

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:


  • $1,000.00 → Changed by $0.01 → $1,000.0 1
  • $1,000.00 → Changed by $0.10 → $1,000 .10
  • $1,000.00 → Changed by $1.00 → $1,00 1.00
  • $1,000.00 → Changed by $10.00 → $1,0 10.00
  • $1,000.00 → Changed by $100.00 → $1 ,100.00
  • $1,000.00 → Changed by $1,000.00 → $ 2,000.00

Model

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:


  • Currency symbol. In our case is "$" for US dollars.
  • Digits: The numerical digits that represent the value of the price. These digits may be placed before or after decimal separator.
  • Grouping separator: The symbol used to separate groups of digits in the price. In the United States, a comma (",") is used to separate groups of thousands.
  • Decimal separator: The symbol used to separate the whole and fractional parts of the price. In the United States, a period (".") is used as the decimal separator.


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 PriceComponents 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
    }
}

Develop SwiftUI View

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))
    }
}


  1. We will pass a price value to PriceView from outside
  2. Array of price components that represents current price atomically
  3. It is color of highlighting, this color will change after new price has setted
  4. Init PriceView with binding double and create initial price components
  5. PriceView is a horizontal stack of price components. As you can see, in ForEach, we iterate through each price component and handle it in a switch case statement. For each specific price component we create his own Text. In my implementation I would like to use small font size for currency symbol and decimals.

You can use any font and any font size, but be careful with baselineOffset value


Let’s look on the Preview:


PriceView Preview

Animations

PriceView already looks good; let's make it even better by adding animation. To animate price updates we have to:


  1. On every price update we need to create new price components and determine which components we should highlight
  2. Set new price components


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))
    }
}


  1. onChange modifier in which we will observe newPrice
  2. Convert the current array of PriceComponents back to the Double type; we need to retrieve the current price because the price in this block is already new. Later, we will add this method.
  3. Create new price components based on old price components and new price
  4. Set new price components to update UI
  5. Determine which color we need to use for highlight. It depends on rise or fall of price


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
    }
}


  1. This method creates PriceComponent array for the new price, while considering the old price. Considering the old price is necessary to understand what part of the new price we should highlight.
  2. Convert PriceComponent array to Double


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()
    }
}


  1. Button, when clicked, generates a new price with a range of $1.0



ContentView preview with not animated PriceView


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
                    }
                }
            }
        }
    }
}

// ...


  1. If you set a new value in the withAnimation block, it will be set using the default animation.
  2. After animation is completed, reset text color


ContentView preview with PriceView that animated basically


And last touch is “countdown” animation, it’s something about SwiftUI magic. Let’s add


.transition()


// 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
            // ...
        }
    }
}

// ...


  1. New values will be animated with a top-down motion
  2. I don't think it's necessary to animate grouping and decimal separators in any way.



Conclusion

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!