I asked on iosdev.space Mastodon if anyone could spot my stupid mistake ... seems that the ForEach(transactionList) { transaction in .... is runing once for the 10 items in the list and keeps on looping around and running another 10 time. Doubling the number of items in the list. But I don't know why? Nor how to correct this behavior.
Many Mastodon Swift coders are looking at it... stumped ...
The console print outs at bottom show the element count of the list (10) and then the index count as ForEach loops up to 20.
https://twitter.com/howtomakeanappp/status/1620619870667939840
A simpler example:
import SwiftUI
struct ContentView: View {
func message(index: Int) -> some View {
return HStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world! \(index)")
}
}
var body: some View {
VStack {
let _ = Self._printChanges() // debug the View refresh
ForEach(1...10, id: \.self ) { index in
let _ = print("\(index) of ForEach()")
message(index: index)
let _ = Self._printChanges() // debug the View refresh
}
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When I extracted the message logic into a new View - so that the Self._printChanges() would be fired for the MessageView I noticed something... adding a bit more logging and I see that the first loop thru the ForEach does NOT actually call the MessageView. It is only upon the second loop of the 10 iterations that the MessageView is called.
It appears that the first loop thru the ForEach MessageView is created but NOT executed. Then after the creation loop - an execution loop is run.
I wish we could get Apple SwiftUI team to comment and enlighten us.
Another coder related that the first loop is to do view layout... then once layout is computed... execute the body code.
Source Code for explaination.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
let _ = Self._printChanges() // debug the View refresh
ForEach(1...10, id: \.self ) { index in
let _ = print("\(index) of ForEach()")
MessageView(index: index)
let _ = print("called MessageView(index: \(index))")
let _ = Self._printChanges() // debug the View refresh
}
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
//---- MessageView
import SwiftUI
struct MessageView: View {
var index: Int
var body: some View {
let _ = print(" message(\(index)) called")
let _ = Self._printChanges() // debug the View refresh
return HStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world! \(index)")
}
}
}
struct MessageView_Previews: PreviewProvider {
static var previews: some View {
MessageView(index: 66)
}
}
Above is a simple example - code to explore the ForEach double loop.
Below is the original project code that I discovered this weird behavior.
Had this idea last night... tried it this morning. For all the people on Mastodon that think something is causing the Chart to be refreshed in the View structure. Apple provided this debug print for refresh debugging. Notice 20 "ChartView: unchanged." messages in the console log.
let _ = Self._printChanges()
Here is StockChartDetail_View
struct StockChartDetail_View: View {
@StateObject var chartVM: Chart_ViewModel
@StateObject var quotesVM: QuotesViewModel
@Environment(\.dismiss) private var dismiss
@State var selectedFolio = FolioItem.defaultFolio // these are STATIC vars - that's why it works here
@State var folioList = FolioItem.preLoadedList // STATIC var
@EnvironmentObject var folioCKVM: FolioCKViewModel // use folioCKVM.transactionList
@State var transactionList: [Transaction] // updateTransactionList() onAppear
// cannot @State var transactionList: [Transaction] = folioCKVM.transactionList
// so we use setTransactionsInList() in onTask or onAppear - is this good practice???
@State var price: Double = 1.00
@State var wlTicker: WLTicker
@State private var showingBuySellStock = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
headerView.padding(.horizontal)
// Apple Inc | AAPL NASDAQ USD
Divider() // topmost divider line
.padding(.vertical, 8)
.padding(.horizontal)
priceDiffRowView
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
.padding(.horizontal)
Divider() // 2nd divider line
.padding(.vertical, 8)
.padding(.horizontal)
chartDurationPickerView
Divider() // 3rd divider line
.padding(.vertical, 8)
.padding(.horizontal)
loadingStateOrChart // contains the Chart scrolls horz.
Divider() // 4th divider line
.padding(.vertical, 8)
.padding(.horizontal)
scrollingMarketDataView
}
.padding(.top)
.background(Color(uiColor: .systemBackground))
.task(id: chartVM.selectedRange.rawValue) {
if quotesVM.quote == nil {
await quotesVM.fetchOneQuote()
wlTicker = quotesVM.ticker
price = quotesVM.quote?.regularMarketPrice ?? 1.00
}
await chartVM.fetchData() // Call API get chart data
updateTransactionListOnTask() // update TransactionList for this Symbol
// setTransactionsInList()
}
.sheet(isPresented: $showingBuySellStock) {
//Text("This sheet brought to you by Hacking with Swift")
PurchaseStockView(isPresented: $showingBuySellStock,
symbol: wlTicker.symbol,
marketPrice: $price)
.presentationDetents([.medium, .large])
}
// .onAppear() {
// updateTransactionListOnAppear() // in onAppear
// // setTransactionsInList()
// }
}
//MARK: - View Components -
private var headerView: some View {
HStack(alignment: .top) {
// Company Name & Symbol
VStack(alignment: .leading, spacing: 4) {
if let shortName = quotesVM.ticker.shortname {
HStack {
Text(shortName) // Apple Inc.
.font(.subheadline.weight(.semibold))
.foregroundColor(.text_Secondary)
FolioPickerView(selection: $selectedFolio, list: $folioList)
}
}
// Ticker and Exchange
HStack(alignment: .lastTextBaseline) {
Text(quotesVM.ticker.symbol).font(.title.bold())
// AAPL - symbol
HStack(spacing: 4) { // Exchange and Currency
if let exchange = quotesVM.ticker.exchange { // exchDisp is now exchange
Text(exchange)
// NASDAQ
}
if let currency = quotesVM.quote?.currency {
Text("·") // spacer dot
Text(currency)
// USD
}
} // hstack
.font(.caption)
.foregroundColor(.text_Secondary)
} // hstack
} // vstack
Spacer() // middle
// Action Buttons Buy/Sell
VStack {
buyPlaceholder
sellPlaceholder
}
Spacer() // middle
closeButton
} // hstack
}
private var buyPlaceholder: some View {
Button {
//onBuyButtonTapped()
showingBuySellStock.toggle()
} label: {
Text("Buy")
.font(.caption)
}
.buttonStyle(EnlargeButtonAnnimation())
.frame(width: 40, height: 25)
}
private var sellPlaceholder: some View {
Button {
//onSellButtonTapped()
showingBuySellStock.toggle()
} label: {
Text("Sell")
.font(.caption)
}
.buttonStyle(EnlargeButtonAnnimation())
.frame(width: 40, height: 25)
}
@ViewBuilder
private var quoteDetailRowView: some View {
switch quotesVM.phase {
case .fetching: LoadingStateView()
case .failure(let error): ErrorStateView(error: "Get Quote: \(error.localizedDescription)")
.padding(.horizontal)
case .success(let quote):
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(quote.columnItems) {
QuoteDetailRowColumnView(item: $0)
}
}
.padding(.horizontal)
.font(.caption.weight(.semibold))
.lineLimit(1)
} // scrollview
.scrollIndicators(.hidden)
default: EmptyView()
}
}
private var priceDiffRowView: some View {
VStack(alignment: .leading, spacing: 8) {
if let quote = quotesVM.quote {
HStack {
// active trading now
if quote.isTrading,
let price = quote.regularPriceText,
let diff = quote.regularDiffText {
priceDiffStackView(price: price, diff: diff, caption: nil)
Spacer()
} else {
// market is closed now
if let atCloseText = quote.regularPriceText,
let atCloseDiffText = quote.regularDiffText {
priceDiffStackView(price: atCloseText, diff: atCloseDiffText, caption: "At Close")
}
Spacer()
if let afterHourText = quote.postPriceText,
let afterHourDiffText = quote.postPriceDiffText {
priceDiffStackView(price: afterHourText, diff: afterHourDiffText, caption: "After Hours")
}
} // else - if quote.isTrading
} // hstack
} // if let quote =
}
}
private func priceDiffStackView(price: AttributedString, diff: String, caption: String?) -> some View {
VStack(alignment: .trailing) {
// Latest Price Quote
Text(price).font(.headline.bold())
// the Delta of Price or other options
Text(diff).font(.caption2.weight(.semibold))
.foregroundColor(diff.hasPrefix("-") ? .Chart_red : .Chart_green)
//FIXME: chart_red or text_red
// }
if let caption {
Text(caption)
.font(.subheadline.weight(.semibold))
.foregroundColor(.text_Secondary)
}
} // vstack
}
//MARK: - Chart Components -
private var chartDurationPickerView: some View {
VStack {
ScrollView(.horizontal, showsIndicators: false) {
ZStack {
DateRangePickerView(selectedRange: $chartVM.selectedRange)
.opacity(chartVM.durationSelectionOpacity)
// displayed when touching selecting a date on graph
Text(chartVM.selectedDurationText)
.font(.headline)
.padding(.vertical, 4)
.padding(.horizontal)
}
}
.scrollIndicators(.hidden)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
//FIXME: is this var redundant -> move it to chartView
private var loadingStateOrChart: some View {
chartView
.padding(.horizontal)
.frame(maxWidth: .infinity, minHeight: 220)
}
@ViewBuilder
private var chartView: some View {
switch chartVM.phase {
case .fetching: LoadingStateView()
case .success(let data):
ChartView(chartViewData: data, transactionList: transactionList, chartVM: chartVM)
case .failure(let error):
ErrorStateView(error: "Get Chart: \(error.localizedDescription)")
default: EmptyView()
}
}
//MARK: - View Components -
private var scrollingMarketDataView: some View {
ScrollView(.horizontal, showsIndicators: false) {
// footer below graph
// 3 rows high scrolling horiz view
quoteDetailRowView
.frame(maxWidth: .infinity, minHeight: 80)
} // scrollview
.scrollIndicators(.hidden)
.frame(maxWidth: .infinity, alignment: .leading)
}
//MARK: - Page Icons
private var closeButton: some View {
Button {
dismiss()
} label: {
Circle()
.frame(width: 20, height: 20)
.foregroundColor(.gray.opacity(0.1))
.overlay {
Image(systemName: "multiply.circle")
.font(.system(size: 16).bold())
.foregroundColor(.text_Secondary)
}
}
.buttonStyle(.plain)
}
//MARK: - Form functions -
func setTransactionsInList() {
self.transactionList = folioCKVM.transactionList
Self.logger.trace("setTransactionsInList for \(wlTicker.symbol) \(transactionList.count) transactions")
}
func updateTransactionListOnAppear() {
// refreshes the Published transactionList
Self.logger.trace("updateTransactionListOnAppear for \(wlTicker.symbol) \(transactionList.count) transactions")
folioCKVM.loadTransactionList(for: wlTicker.symbol) // updates Published transactionList
self.transactionList = folioCKVM.transactionList
Self.logger.trace("set TransactionsList for \(wlTicker.symbol) \(transactionList.count) transactions")
// Self.logger.trace( "Found \(folioCKVM.transactionList.count) transactions in List.")
// Self.logger.trace( "\(folioCKVM.transactionList)")
}
func updateTransactionListOnTask() {
// refreshes the Published transactionList
Self.logger.trace("updateTransactionListOnTask for \(wlTicker.symbol) \(transactionList.count) transactions")
folioCKVM.loadTransactionList(for: wlTicker.symbol) // updates Published transactionList
self.transactionList = folioCKVM.transactionList
Self.logger.trace("set TransactionsList for \(wlTicker.symbol) \(transactionList.count) transactions")
// Self.logger.trace( "Found \(folioCKVM.transactionList.count) transactions in List.")
// Self.logger.trace( "\(folioCKVM.transactionList)")
}
private static let logger = Logger(
subsystem: "App",
category: String(describing: StockChartDetail_View.self)
)
}
//MARK: - Previews -
#if DEBUG
struct StockTickerView_Previews: PreviewProvider {
static var tradingStubsQuoteVM: QuotesViewModel = {
var mockAPI = MockStocksAPI()
mockAPI.stubbedFetchQuotesCallback = {
[Quote.stub(isTrading: true)]
}
return QuotesViewModel(ticker: .stub, stocksAPI: mockAPI)
}()
static var closedStubsQuoteVM: QuotesViewModel = {
var mockAPI = MockStocksAPI()
mockAPI.stubbedFetchQuotesCallback = {
[Quote.stub(isTrading: false)]
}
return QuotesViewModel(ticker: .stub, stocksAPI: mockAPI)
}()
static var loadingStubsQuoteVM: QuotesViewModel = {
var mockAPI = MockStocksAPI()
mockAPI.stubbedFetchQuotesCallback = {
await withCheckedContinuation { _ in
}
}
return QuotesViewModel(ticker: .stub, stocksAPI: mockAPI)
}()
static var errorStubsQuoteVM: QuotesViewModel = {
var mockAPI = MockStocksAPI()
mockAPI.stubbedFetchQuotesCallback = {
throw NSError(domain: "error", code: 0, userInfo: [NSLocalizedDescriptionKey: "An error has occured."])
}
return QuotesViewModel(ticker: .stub, stocksAPI: mockAPI)
}()
static var chartVM: Chart_ViewModel {
Chart_ViewModel(ticker: .stub, apiService: MockStocksAPI())
}
static var previews: some View {
Group {
StockChartDetail_View(chartVM: chartVM, quotesVM: tradingStubsQuoteVM,
transactionList: [], wlTicker: tradingStubsQuoteVM.ticker)
.previewDisplayName("Trading")
.frame(height: 700)
StockChartDetail_View(chartVM: chartVM, quotesVM: closedStubsQuoteVM,
transactionList: [], wlTicker: tradingStubsQuoteVM.ticker)
.previewDisplayName("Closed")
.frame(height: 700)
StockChartDetail_View(chartVM: chartVM, quotesVM: loadingStubsQuoteVM,
transactionList: [], wlTicker: tradingStubsQuoteVM.ticker)
.previewDisplayName("Loading Quote")
.frame(height: 700)
StockChartDetail_View(chartVM: chartVM, quotesVM: errorStubsQuoteVM,
transactionList: [], wlTicker: tradingStubsQuoteVM.ticker)
.previewDisplayName("Error Quote")
.frame(height: 700)
}.previewLayout(.sizeThatFits)
}
}
#endif
Here's the Transaction - a transactionList is just an [Transaction] array. I quit using plurals for arrays a while back and started the convention of prepending "List" to make the code WAY more obvious! This structure is presisted in CloudKit so it contains a ckRecord and quite a few extensions for converting to/from CloudKit.
//
// Transaction.swift
// AssetsFolio
//
// Created by David on 1/1/23.
//
import Foundation
import CloudKit
struct Transaction: CKPrecipitable, Hashable, Identifiable {
var ckRecord: CKRecord?
var id = UUID()
public let symbol: String
public let shortname: String?
public let exchange: String?
public let transactionType: String?
public let shares: Double?
public let price: Double?
public let fee: Double?
public let date: Date?
public let splitRatio: String? // must handle both 3:2 and 1:5
public let transactionTotal: Double?
public var folioName: String? = "WatchList" // for CK - must be primitive not FolioItem
// init("AAPL") all optional except the symbol - no ckRecord.
public init(symbol: String, shortname: String?, exchange: String?,
transactionType: String?,
shares: Double?,
price: Double?, fee: Double?, date: Date?,
splitRatio: String?, transactionTotal: Double?,
folioName: String?
) {
self.symbol = symbol
self.shortname = shortname
self.exchange = exchange
self.transactionType = transactionType
self.shares = shares
self.price = price
self.fee = fee
self.date = date
self.splitRatio = splitRatio
self.transactionTotal = transactionTotal
self.folioName = folioName
self.ckRecord = nil
}
// init("AAPL") all optional except the symbol - no ckRecord.
public init(symbol: String, shortname: String?, exchange: String?,
transactionType: String?,
shares: Double?,
price: Double?, fee: Double?, date: Date?,
splitRatio: String?, transactionTotal: Double?,
folioName: String?,
record: CKRecord) {
self.symbol = symbol
self.shortname = shortname
self.exchange = exchange
self.transactionType = transactionType
self.shares = shares
self.price = price
self.fee = fee
self.date = date
self.splitRatio = splitRatio
self.transactionTotal = transactionTotal
self.folioName = folioName
self.ckRecord = record
}
// A FAILALBE init() - returns Optional Transaction? - Converts from CKRecord to Transaction
init?(from ckRecord: CKRecord) {
// print("Transaction init from CKRecord: recordID is: \(ckRecord.recordID)")
// print("Transaction init from CKRecord: record is: \(ckRecord)")
guard
let symbol = ckRecord[TransactionRecordKeys.symbol.rawValue] as? String,
let shortname = ckRecord[TransactionRecordKeys.shortname.rawValue] as? String,
let exchange = ckRecord[TransactionRecordKeys.exchange.rawValue] as? String,
let transactionType = ckRecord[TransactionRecordKeys.transactionType.rawValue] as? String,
let shares = ckRecord[TransactionRecordKeys.shares.rawValue] as? Double,
let price = ckRecord[TransactionRecordKeys.price.rawValue] as? Double,
let fee = ckRecord[TransactionRecordKeys.fee.rawValue] as? Double,
let date = ckRecord[TransactionRecordKeys.date.rawValue] as? Date,
let splitRatio = ckRecord[TransactionRecordKeys.splitRatio.rawValue] as? String,
let transactionTotal = ckRecord[TransactionRecordKeys.transactionTotal.rawValue] as? Double,
let folioName = ckRecord[TransactionRecordKeys.folioName.rawValue] as? String
else {
return nil
}
self = .init(symbol: symbol, shortname: shortname,
exchange: exchange,
transactionType: transactionType,
shares: shares, price: price, fee: fee, date: date,
splitRatio: splitRatio, transactionTotal: transactionTotal,
folioName: folioName,
record: ckRecord)
}
static let timestamp = Date(timeIntervalSince1970: 1671645600)
static let preview = Transaction(symbol: "AAPL", shortname: "Apple Inc.", exchange: "NASDAQ",
transactionType: "BUY", shares: 10.0, price: 122.34, fee: 0.0,
date: timestamp, splitRatio: "1:1", transactionTotal: 1223.40, folioName: "Awesome")
}
// In the example below, you see the simple Transaction value type that I want to store on CloudKit.
// CloudKit provides us CKRecord type representing items in the CloudKit database.
// Usually, we need to implement a converter from/to CKRecord for our custom types.
// CKRecord is a Dictionary with some special Meta data fields such as RecordID
extension Transaction {
/// A type converter from Transaction to CKRecord. Wraps the Transaction as a CKRecord-Dictionary using TransactionRecordKeys subscripts.
var convertToRecord: CKRecord {
let record : CKRecord
if self.ckRecord != nil {
// use existing record and update it.
record = self.ckRecord!
record[TransactionRecordKeys.symbol.rawValue] = symbol
record[TransactionRecordKeys.shortname.rawValue] = shortname
record[TransactionRecordKeys.exchange.rawValue] = exchange
record[TransactionRecordKeys.transactionType.rawValue] = transactionType
record[TransactionRecordKeys.shares.rawValue] = shares
record[TransactionRecordKeys.price.rawValue] = price
record[TransactionRecordKeys.fee.rawValue] = fee
record[TransactionRecordKeys.date.rawValue] = date
record[TransactionRecordKeys.splitRatio.rawValue] = splitRatio
record[TransactionRecordKeys.transactionTotal.rawValue] = transactionTotal
record[TransactionRecordKeys.folioName.rawValue] = folioName
} else {
record = CKRecord(recordType: TransactionRecordKeys.type.rawValue)
record[TransactionRecordKeys.symbol.rawValue] = symbol
record[TransactionRecordKeys.shortname.rawValue] = shortname
record[TransactionRecordKeys.exchange.rawValue] = exchange
record[TransactionRecordKeys.transactionType.rawValue] = transactionType
record[TransactionRecordKeys.shares.rawValue] = shares
record[TransactionRecordKeys.price.rawValue] = price
record[TransactionRecordKeys.fee.rawValue] = fee
record[TransactionRecordKeys.date.rawValue] = date
record[TransactionRecordKeys.splitRatio.rawValue] = splitRatio
record[TransactionRecordKeys.transactionTotal.rawValue] = transactionTotal
record[TransactionRecordKeys.folioName.rawValue] = folioName
}
return record // a CKRecord
}
}
extension Transaction : CustomStringConvertible {
var description: String {
if self.ckRecord != nil {
return "( \(symbol) \(transactionType!) \(shares!) @ $\(price!) on \(date!) )"
} else {
return "( \(symbol) \(transactionType!) \(shares!) @ $\(price!) on \(date!) ckRecord is nil )"
}
}
}
enum TransactionRecordKeys: String {
case type = "Transaction"
// no id
case symbol // AAPL
case shortname // Apple Inc.
case exchange // NASDAQ
case transactionType // BUY or SELL or SPLIT or ... Dividend, Fee, other
case shares
case price
case fee
case date
case splitRatio
case transactionTotal
case folioName
}
// See https://www.rambo.codes/posts/2020-02-25-cloudkit-101 Improving the code with a custom subscript
// Usage:
// record[.symbol] = "AAPL"
// record[.shortname] = "Apple Inc."
/// Extend CKRecord with TransactionRecordKeys as subscripts.
extension CKRecord {
subscript(key: TransactionRecordKeys) -> Any? {
get {
return self[key.rawValue]
}
set {
self[key.rawValue] = newValue as? CKRecordValue
}
}
}
Output of a console trace...
let _ = print("\(count):Transaction date \(transaction.date!) for $\(transaction.price!) Vertical RuleMark Buy at \(position) list count \(transactionList.count)")
Notice: the list count is constant at 10 the whole time. The loop executes 20 times (2X 10). Element 11 is same as Element 1....
1:Transaction date 2023-01-28 00:50:51 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
2:Transaction date 2021-01-27 23:14:00 +0000 for $138.53 Vertical RuleMark Buy at -90.0 list count 10
3:Transaction date 2022-01-26 23:43:00 +0000 for $147.53 Vertical RuleMark Buy at -90.0 list count 10
4:Transaction date 2023-01-28 00:51:04 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
5:Transaction date 2023-01-28 00:50:55 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
6:Transaction date 2022-12-29 03:09:00 +0000 for $136.53 Vertical RuleMark Buy at 105.0 list count 10
7:Transaction date 2022-01-26 23:43:00 +0000 for $140.53 Vertical RuleMark Buy at -90.0 list count 10
8:Transaction date 2023-01-28 00:50:58 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
9:Transaction date 2023-01-11 23:40:00 +0000 for $147.53 Vertical RuleMark Buy at 114.0 list count 10
10:Transaction date 2023-01-28 00:51:01 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
11:Transaction date 2023-01-28 00:50:51 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
12:Transaction date 2021-01-27 23:14:00 +0000 for $138.53 Vertical RuleMark Buy at -90.0 list count 10
13:Transaction date 2022-01-26 23:43:00 +0000 for $147.53 Vertical RuleMark Buy at -90.0 list count 10
14:Transaction date 2023-01-28 00:51:04 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
15:Transaction date 2023-01-28 00:50:55 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
16:Transaction date 2022-12-29 03:09:00 +0000 for $136.53 Vertical RuleMark Buy at 105.0 list count 10
17:Transaction date 2022-01-26 23:43:00 +0000 for $140.53 Vertical RuleMark Buy at -90.0 list count 10
18:Transaction date 2023-01-28 00:50:58 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10
19:Transaction date 2023-01-11 23:40:00 +0000 for $147.53 Vertical RuleMark Buy at 114.0 list count 10
20:Transaction date 2023-01-28 00:51:01 +0000 for $147.53 Vertical RuleMark Buy at 125.0 list count 10