WWDC22 SALE: Save 50% on all my Swift books and bundles! >>

Day 38, iExpense challenge. A few issues remain.

Forums > 100 Days of SwiftUI

This is my take on iExpense, Day 38: the challenge. Mostly I succeeded, I think, but there remain a few issues.

Task 1: Using another currency. I chose Euro by changing the currency code from USD to EUR. Indeed, the currency symbol now is € instead of $. So far, so good. But I noticed a few issues:

  • When I edit the field, I have to remove the € symbol, or else whatever I enter gets converted to €0.00. This is not needed with the $ symbol, that I can leave it in place if I want. I can also enter a $ value in the Euro field, but it gets then converted to €. Not very neat I think. Can it be improved?
  • I have found no way to change the thousands separator to a point, and the decimal point to a comma, as is normal when working in Euro. I can type the Euro way, but upon entering it gets displayed the other way.
  • As an aside, I found that I wanted to disable autocorrection for the name field.

Task 2: Styling. I have chosen a very simple styling, green for small amounts, red for large amounts, and blue in between. That works fine.

Task 3: Sectioning the display according to type. This was the most challenging. The challenge is twofold: first, get separate sections for Personal and Business, and second, deleting the right expense item.

For the first, I created a function that, given a type, returns an array with only those elements that are in that type. In the View I then loop over all types, and create a separate section for each of them with the items in this array. That takes care of the display issue.

I found it convenient to create a separate view "ItemView" for this.

The second I found trickier. The problem is that the onDelete function gives me an index, but that is the index in the reduced array, not in the full array where I need to delete the element.

I have found no built-in way of handling this, so I wrote my own function for it. From the onDelete function I get an indexSet containing one element. This element I use to find the element in the array for the type. This gives me an UUID as id. With this UUID I go into the full expenses array and retrieve the index for the element. Finally, with this index I can remove it with remove(at:).

Along the way I applied a few other changes. I have concentrated the declaration of the type names in one place, so as not to have to enter duplicate information with the risk of mistakes. All other uses of the type names are derived from this array definition. An added bonus is that I can now add extra types, such as Family, just by adding them to the array and all functionality takes that now into account.

Any comments and suggestions will be welcomed. In particular, how about the issues with the currency field? And is there an easier (maybe built-in) way to delete from a sectioned list?

import SwiftUI

let types = ["Personal", "Business"]

struct ExpenseItem: Identifiable, Codable {
    var id = UUID()
    let name: String
    let type: String
    let amount: Double
}

class Expenses: ObservableObject {
    @Published var items = [ExpenseItem]() {
        didSet {
            if let encoded = try? JSONEncoder().encode(items) {
                UserDefaults.standard.set(encoded, forKey: "Items")
            }
        }
    }

    init() {
        if let savedItems = UserDefaults.standard.data(forKey: "Items") {
            if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems) {
                items = decodedItems
                return
            }
        }
        items = []
    }
}

struct ItemView: View {
    var item: ExpenseItem

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(item.name)
                    .font(.headline)
            }

            Spacer()
            Text(item.amount, format: .currency(code: "EUR"))
                .foregroundColor(item.amount < 10 ? .green : (item.amount < 100) ? .blue : .red)
        }
    }

    init(_ item: ExpenseItem) {
        self.item = item
    }
}

struct ContentView: View {
    @StateObject var expenses = Expenses()
    @State private var showingAddExpense = false

    func removeItems(at offsets: IndexSet, for type: String) {
        let catExpenses = typeExpenses(type: type)
        let chosenElement = catExpenses[offsets.first!]
        let uuid = chosenElement.id
        let index = expenses.items.firstIndex(where: { $0.id == uuid })!
        expenses.items.remove(at: index)
    }

    func typeExpenses(type: String) -> [ExpenseItem] {
        expenses.items.filter {
            $0.type == type
        }
    }

    var body: some View {
        VStack {
            NavigationView {
                List {
                    ForEach(types, id: \.self) { type in
                        Section(header: Text(type)) {
                                ForEach(typeExpenses(type: type)) { item in
                                    VStack {
                                        ItemView(item)
                                    }
                                }
                                .onDelete(perform: { indexSet in removeItems(at: indexSet, for: type) })
                        }
                    }
                    .navigationTitle("iExpense")
                    .toolbar {
                        Button {
                            showingAddExpense = true
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                    .sheet(isPresented: $showingAddExpense) {
                        AddView(expenses: expenses)
                    }
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

import SwiftUI

struct AddView: View {
    @State private var name = ""
    @State private var type = types[0]
    @State private var amount = 0.0

    @ObservedObject var expenses: Expenses

    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationView {
            Form {
                TextField("Name", text: $name)
                    .disableAutocorrection(true)

                Picker("Type", selection: $type) {
                    ForEach(types, id: \.self) { type in
                        Text(type)
                    }
                }

                TextField("Amount", value: $amount, format: .currency(code: "EUR"))
                    .keyboardType(.decimalPad)
            }
            .navigationTitle("Add new expense")
            .toolbar {
                Button("Save") {
                    let item = ExpenseItem(name: name, type: type, amount: amount)
                    expenses.items.append(item)
                    dismiss()
                }
            }
        }
    }
}

struct AddView_Previews: PreviewProvider {
    static var previews: some View {
        AddView(expenses: Expenses())
    }
}

   

Jero makes great progress but would appreciate some feedback:

The problem is that the onDelete function gives me an index, but that is the index in the reduced array, not in the full array where I need to delete the element. I have found no built-in way of handling this, so I wrote my own function for it.

This is probably the way to do this.

From the onDelete function I get an indexSet containing one element. This element I use to find the element in the array for the type. This gives me an UUID as id. With this UUID I go into the full expenses array and retrieve the index for the element.
Finally, with this index I can remove it with remove(at:). Is there an easier (maybe built-in) way to delete from a sectioned list?

What's NOT the Swifty way is putting this code into a function in your ContentView!

First, ugh, please. Please rename your ContentView to something more descriptive! You may as well call the view FooBarView, or something equally generic and ugly. Pick a name that describes what the view displays. AlbumView? ProduceView? SaleItemView? GameBoardView? You can do way better than the default ContentView.

Second, try to think of views as structs whose main job is to take some data and parameters from a model, and use clever techniques to display that data on the screen somehow. Now the view might have a Button, or TextField. And those actions may change the data, sure. But give that job to someone else to do!

Consider your delete dilemma. It's not the View's job to pick the item out of the array, match its UUID, and delete the element from the array. That's a business rule! Which object in your application is best suited to encapsulate all the business rules around expense elements?

I would say, the Expenses class is best suited to execute business logic surrounding elements. Need to know how many expenses from the last month? Ask the Expenses object. Want to know how many trips to the gas station this quarter? Ask the Expenses object. Want to know how many expenses over 300 pounds sterling? Ask the Expenses object.

If you want to delete an object from the expenses collection, let the user select the object via a View. Capture the UUID() of the selected object in the View. Then, if the user expresses their intent to DELETE the object (pressing a button, or swipe gesture) take that UUID, and execute a method encapsulated in the Expenses class.

What happens next? the Expenses object will happily oblige, and delete the element. This, in turn, publishes a change. Any view observing that object for changes will get notified. Arrays will recalculate their contents. Views will update themselves based on the new arrays. Bingo, your UI is updated.

1      

Jero has some questions about using Euro.

I have found no way to change the thousands separator to a point, and the decimal point to a comma,
as is normal when working in Euro. I can type the Euro way, but upon entering it gets displayed the other way.

Here are some wise words I read in John Sundell's blog.

The task of formatting numbers into human-readable strings is most likely something that we want to delegate to the system as much as possible, especially when we wish to produce descriptions that are localized and otherwise adapted to the current user’s locale.

See-> Formatting Numbers Blog

Maybe you can get some hints from this post. Please come back and let us know how you implemented your solution.

   

@Obelix, re: proper place for delete function

Thank you for taking the time for this elaborate answer!

On your remark about renaming ContentView, I agree. I actually considered it for a moment but found it not necessary for the purpose of this challenge, which I took as just amending the given app where needed for the new functionality, and nothing more. But you do have a point. I renamed it to ExpensesView.

On your second remark, about the proper place for model related functionality: I did not think of that, but just amended the existing removal function from the course, which happened to be inside ContentView. But again, you are right in that it does not belong there.

Therefore I moved it. What I actually did is take matters one step further than your suggestion: in my opinion, even the knowledge that there is a UUID as part of ExpenseItem is not, in principle, a matter for ExpensesView. It turns out that it is easily removed. The changes I applied:

  • Moved the function typeExpenses(type:) that yields all items with a certain type out of ContentView (now: ExpensesView) and into the Expenses class (and renamed it to expensesForType(_:)). How to retrieve these items is not a matter for ExpensesView, in my opinion. And moving it helps in removing UUID knowledge from ExpensesView.

  • Moved the removeItems(at:for:) function (my version, with UUID) to Expenses, as you suggested.

  • Applied a few simple changes here and there to reflect the changed location of these functions, and to improve readability.

In this way I was able to keep the .onDelete action, which I liked as it was, only slightly adapted.

The new portions of Expenses:

    func expensesForType(_ type: String) -> [ExpenseItem] {
        items.filter {
            $0.type == type
        }
    }

    func removeItems(givenBy offsets: IndexSet, for type: String) {
        let chosenElement = expensesForType(type)[offsets.first!]
        let uuid = chosenElement.id
        let index = items.firstIndex(where: { $0.id == uuid })!
        items.remove(at: index)
    }

The new version of .onDelete in ExpensesView:

.onDelete { indexSet in expenses.removeItems(givenBy: indexSet, for: type) }

It was simple to do, it works, and it is indeed much cleaner. Thank you for your insights.

   

Jero updates his code with skill!

It was simple to do, it works, and it is indeed much cleaner. Thank you for your insights.

Well done!

It's not quite apparent in these challenges and exercises. But one of the benefits of having all your business logic in ONE class is your ability to add that functionality to another program! Once you have a fully functional Expenses class, think how easy it will be to move that to a similar application!

If you had the "add expense" logic in one view, the "delete expense" logic in another view, the "tally monthly expenses" logic in a third view, it would be a mess trying to port expense logic to another application.

On your remark about renaming ContentView, I agree. I actually considered it for a moment but found it not necessary for the purpose of this challenge.

For the most part, I agree. View names for homework assignments and challenges isn't the most important topic to bother yourself with. However, I encourage junior developers on my team to always be ready to defend function, variable, and view names. When we discuss cooking on a grill, we don't use code names, or generic descriptions.

Put the reddish hot stuff on the birdPart.

We say chicken, steak, or chops! We use specific names for seasonings and rubs. Use natural language, and your team will understand your approach and intentions.

It's not technically necessary, but it's a good habit to embrace.

   

@Obelix, re: formatting of currency

Thank you for your pointer. I found that currency formatting is to a large extent governed by locale. In my simulator the locale was set to "en_US". While setting the currency code to "EUR" did fix the currency symbol problem, it did nothing to fix the formatting of the number in terms of thousands and decimal separators.

Once I set the language in the simulator to "Nederlands" (i.e., Dutch), and the region to "Netherlands", all was properly (for me) formatted.

I had a look into achieving this programmatically, and I came up with the following.

Instead of using format: currency(code: "EUR") I used a NumberFormatter which I could set to the proper locale "nl_NL". This was done in the initalizer of ExpensesView. A few other changes to the code were necessary to make it all work. The following are the essential changes.

On the global level a declaration:

let dutchCurrencyFormatter = NumberFormatter()

An init() for ExpensesView:

    init() {
        dutchCurrencyFormatter.numberStyle = .currency
        dutchCurrencyFormatter.locale = Locale(identifier: "nl_NL")
    }

A different line to display the amount in ExpensesView:

Text(dutchCurrencyFormatter.string(from: NSNumber(value: item.amount))!)

and a different line to show it in AddView:

TextField("Amount", value: $amount, formatter: dutchCurrencyFormatter)

That is all. While working on this I found that NumberFormatter is both very complex and extremely powerful.

   

Nice!

Thanks for posting your solution, it's a tricky topic and your answer will certainly help others in the future! Enjoyed testing the coding tips you posted. Helped clarify the solution.

All this talk about spending Euros in the Nederlands has made think back fondly on the few bottles of Westvleteren I enjoyed on my trip!

   

Save 50% in my Black Friday sale.

SAVE 50% To celebrate WWDC22, all our books and bundles are half price, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.