Question: String text alignment by decimal point (Swift)

Is there a way to achieve such kind of alignment of numbers in multiple strings, preferably in interface builder? Please see the attached screenshot.



Answer:
One approach to achieve this is by using a table view and programmatically adding constraints to align the separator symbol. This method offers scalability as it only includes elements visible on the screen. An interesting aspect of this approach is that the separator's position may change based on the largest offset currently on the screen. Whether this behavior is desired or not depends on your specific requirements.
In this solution, I suggest splitting the string into three components and placing them into three separate labels: one for the content before the separator, one for the separator itself, and another for the content after the separator. Additionally, create an invisible reference view at the top level, which will be used to connect the separator label.
Although the following code is implemented programmatically for clarity in understanding constraint connections, it is recommended to move most of the code into the storyboard for better organization and maintainability.
I hope this code snippet helps you solve your problem.
class OffsetNumberViewController: UIViewController {
    
    var values: [NSDecimalNumber] = [] {
        didSet {
            tableView.reloadData()
        }
    }
    
    private lazy var tableView: UITableView = {
        let view = UITableView()
        view.delegate = self
        view.dataSource = self
        return view
    }()
    
    private lazy var numberFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.decimalSeparator = "."
        formatter.usesGroupingSeparator = true
        formatter.groupingSeparator = ","
        formatter.groupingSize = 3
        formatter.maximumFractionDigits = 5
        return formatter
    }()
    
    private lazy var referenceView = {
        let view = UIView()
        view.isHidden = true
        self.view.addSubview(view)
        view.translatesAutoresizingMaskIntoConstraints = false
        self.view.addConstraints([
            .init(item: view, attribute: .trailing, relatedBy: .lessThanOrEqual, toItem: self.view, attribute: .trailing, multiplier: 1.0, constant: 0.0),
            .init(item: view, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1.0, constant: 0.0),
            .init(item: view, attribute: .bottom, relatedBy: .equal, toItem: self.view, attribute: .bottom, multiplier: 1.0, constant: 0.0)
        ])
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.register(NumberTableViewCell.self, forCellReuseIdentifier: "amountCell")
        tableView.frame = view.bounds
        view.addSubview(tableView)
        
        values = generateRandomValues(count: 1000)
    }
    
    private func generateRandomValues(count: Int) -> [NSDecimalNumber] {
        (0..<count).map { index in
            let startValue: Int = 1234567890
            let maximumDivision = 5
            
            let randomDivision: Int = 1<<Int.random(in: 0...maximumDivision)
            return .init(integerLiteral: startValue).dividing(by: .init(integerLiteral: randomDivision))
        }
    }
    
    private func formatValue(_ value: NSDecimalNumber) -> String {
        numberFormatter.string(for: value) ?? "NaN"
    }
    
}

// MARK: - UITableViewDelegate, UITableViewDataSource

extension OffsetNumberViewController: UITableViewDelegate, UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        values.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = tableView.dequeueReusableCell(withIdentifier: "amountCell", for: indexPath) as? NumberTableViewCell {
            cell.setup(withNumberAsString: formatValue(values[indexPath.row]), decimalSeparator: numberFormatter.decimalSeparator)
            return cell
        } else {
            return UITableViewCell()
        }        
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        (cell as? NumberTableViewCell)?.attachCenterTo(referenceView, parent: self.view)
    }
    
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        (cell as? NumberTableViewCell)?.detachExternalConstraints()
    }
    
}

// MARK: - NumberTableViewCell

extension OffsetNumberViewController {
    
    class NumberTableViewCell: UITableViewCell {
        
        lazy private var leftSideLabel: UILabel = UILabel()
        lazy private var rightSideLabel: UILabel = UILabel()
        lazy private var separatorLabel: UILabel = UILabel()
        
        private var currentExternalConstraints: [NSLayoutConstraint] = []
        
        lazy private var stackView: UIStackView = {
            let stackView = UIStackView()
            stackView.translatesAutoresizingMaskIntoConstraints = false
            stackView.alignment = .fill
            stackView.axis = .horizontal
            stackView.distribution = .fill
            
            stackView.addArrangedSubview(leftSideLabel)
            stackView.addArrangedSubview(separatorLabel)
            stackView.addArrangedSubview(rightSideLabel)
            
            addSubview(stackView)
            
            addConstraints([
                .init(item: stackView, attribute: .right, relatedBy: .equal, toItem: self, attribute: .right, multiplier: 1.0, constant: -12.0),
                .init(item: stackView, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1.0, constant: 0.0),
                .init(item: stackView, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1.0, constant: 0.0),
            ])
            
            return stackView
        }()
        
        func setup(withNumberAsString numberString: String, decimalSeparator: String) {
            let components = numberString.components(separatedBy: decimalSeparator)
            
            let _ = stackView
            
            separatorLabel.text = decimalSeparator
            if components.count == 1 {
                leftSideLabel.text = components[0]
                rightSideLabel.text = ""
                separatorLabel.alpha = 0
            } else if components.count == 2 {
                leftSideLabel.text = components[0]
                rightSideLabel.text = components[1]
                separatorLabel.alpha = 1
            } else {
                // Something went wrong
                leftSideLabel.text = ""
                rightSideLabel.text = "error"
                separatorLabel.alpha = 0
            }
        }
        
        func detachExternalConstraints() {
            currentExternalConstraints.forEach { constrain in
                constrain.isActive = false
            }
            currentExternalConstraints = []
        }
        
        func attachCenterTo(_ referenceView: UIView, parent: UIView) {
            currentExternalConstraints = [
                .init(item: separatorLabel,
                                           attribute: .centerX,
                                           relatedBy: .greaterThanOrEqual,
                                           toItem: referenceView,
                                           attribute: .centerX,
                                           multiplier: 1.0,
                                           constant: 0.0),
                .init(item: referenceView,
                                           attribute: .centerX,
                                           relatedBy: .greaterThanOrEqual,
                                           toItem: separatorLabel,
                                           attribute: .centerX,
                                           multiplier: 1.0,
                                           constant: 0.0)
            ]
            parent.addConstraints(currentExternalConstraints)
        }
        
    }
    
}