Expense Report UI

Saturday, 21 December 2019
Updated 3 years ago
I will start sending email newsletters once in a while, so in order to get notified when I release new content, make sure to follow me on twitter @liquidcoder .

Introduction

In this week’s swift ui tutorial, we are going to build a fairly complex ui with animations. You are going to learn how to animation various shapes in swift ui and how to animate text while designing a complex user interface.

Let’s start writing some swift code, shall we?

Requirements

  • Xcode 11 or greater
  • MacOS Catalina or greater
  • Swift 5.0 or greater
  • iOS 12 or greater

Get started

  • Open Xcode
  • Create a new Xcode project
  • Select single view app and click next
  • Name your app and make sure the user interface is Swift UI
  • Last, click Finish.
  • You can modifier your main ContentView.swift file to whatever you want or leave it like that, it doesn’t really matter.

Models

In your Xcode root project, create a folder named Models, and inside it, create a swift file named Expense.swift containing the following code:

struct Expense {

    var month: String = ""
    var budget: CGFloat = 0
    var consumed: CGFloat = 0
    var percentConsumed: CGFloat = 0
    private static let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

    static func getRandom() -> [Expense]{
        months.map { month in
            let budget = CGFloat.random(in: 2000...10000)
            let consumed = CGFloat.random(in: 2000...budget)
            let percentConsumed = consumed / budget
            return Expense(month: month, budget: budget , consumed: consumed, percentConsumed: percentConsumed)
        }
    }

}

For the sake of this tutorial, we will use dummy data. The above code generate some random expenses for each month of the year.

In the same folder, create another file named ExpensesBreakDown.swift, and inside it, add the following code:

import SwiftUI

struct ExpenseCategory: Identifiable {
    var id: UUID = UUID()
    var name: String = ""
    var percent: CGFloat = 0
    var amount: CGFloat = 0
    var colors: [Color] = []
}

class ExpensesBreakDown: ObservableObject {

    @Published var categories = [ExpenseCategory]()
    var expense = Expense()

    init() {
        generateRandom(Expense.getRandom().first(where: { $0.month == Date.month })!)
    }

    func generateRandom(_ expense: Expense){
        let percent1 = CGFloat.random(in: 0...1)
        let percent2 = CGFloat.random(in: 0...(1-percent1))
        let percent3 = CGFloat.random(in: 0...(1-(percent2+percent1)))
        let percent4 = CGFloat.random(in: 0...(1-(percent2+percent1+percent3)))
        let percent5 = 1 - (percent4+percent2+percent1+percent3)
        let categories = [
            ExpenseCategory(name: "Groceries", percent: percent1, amount: expense.consumed * percent1, colors: [Color.red, Color.clearPurple]),
            ExpenseCategory(name: "Education", percent: percent2, amount: expense.consumed * percent2, colors: [Color.darkYellow, Color.lightYellow]),
            ExpenseCategory(name: "Home", percent: percent3, amount: expense.consumed * percent3, colors: [Color.darkPurple, Color.lightPurple]),
            ExpenseCategory(name: "Health", percent: percent4, amount: expense.consumed * percent4, colors: [Color.red, Color.clearPurple]),
            ExpenseCategory(name: "Personal care", percent: percent5, amount: expense.consumed * percent5, colors: [Color.darkYellow, Color.lightYellow]),
        ]

        self.expense = expense

        self.categories = categories
    }

}

The struct will hold our categories. For this sample swift ui project, we will hard-code 5 categories which I create in the ObservableObejct class below the struct. Your code will have a compiler error because you haven’t created the Date extension, so let’s do that. In your Xcode root project, create a folder named Extensions containing a file named _DateExt.swift _which in turns contains the following code:


extension Date{

    static var month: String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMM"
        return formatter.string(from: Date())
    }

    static func fullMonthName(short: String)-> String{
        let months = [
            "Jan":"January",
            "Feb":"February",
            "March":"Mar",
            "Apr":"April",
            "May":"May",
            "June":"June",
            "Jul":"July",
            "Aug":"August",
            "Sep":"September",
            "Oct":"October",
            "Nov":"November",
            "Dec":"December",
        ]

        return months[short] ?? "January"
    }
}

That’s our models.

Views

In your Xcode root project, create a folder named Views, and create a swift file inside named CardView with the following code inside:

import SwiftUI

struct CardView: View {

    var category = ExpenseCategory()
    @State var percent: CGFloat = 0.0

    var body: some View {
        VStack{
            HStack(spacing: 20) {
                renderRing()
                renderDetails()
            }.frame(maxWidth: .infinity, maxHeight: 200)
                 .padding()
                .background(LinearGradient(gradient: Gradient(colors: category.colors) , startPoint: .leading, endPoint: .trailing) )
                .cornerRadius(20)
        }.onAppear {
            self.percent = self.category.percent
        }
    }

    fileprivate func renderRing() -> some View {
        return RingView(percent: self.$percent)
        .modifier(PercentIndicator(value: self.percent * 100,color: .white, suffix: "%").animation(.easeIn(duration: 1)))
    }

    fileprivate func renderDetails() -> some View{
        VStack(alignment: .leading) {
            Text(category.name).foregroundColor(.white)
            Text("$\(String(format: "%.2f", category.amount))").font(.title).foregroundColor(.white)
        }.frame(maxWidth: .infinity, alignment: .leading)
    }
}

struct CardView_Previews: PreviewProvider {
    static var previews: some View {
        CardView()
    }
}

The above swift code just creates a card and applies a beautiful gradient on it. The only thing to note here is the modifier that animates the text. To show you how to animate text in swift ui, we will need to create a new folder named Modifiers and inside it add a swift file named PercentAnimator.swift with the following code inside:

import SwiftUI

struct PercentAnimator: AnimatableModifier {

    var value: CGFloat = 0
    var font: Font = .system(size: 18, weight: .light, design: .rounded)
    var color: Color = .white
    var prefix = ""
    var suffix = ""

    var animatableData: CGFloat{
        get { value }
        set { value = newValue }
    }

    func body(content: Content) -> some View {
        content
            .overlay(TextView(value: value, font: font, color: color, prefix: prefix,suffix: suffix))

    }

}

struct TextView: View {
    let value: CGFloat
    var font: Font = .footnote
    var color: Color = .white
    var prefix = ""
    var suffix = ""

    var body: some View {
        Text("\(prefix)\(Int(value))\(suffix)")
            .foregroundColor(color)
            .font(font)
    }
}

As you can see, animating text in swift ui is the same as animating shapes using animatableData with some minor differences.

The above code will not compile because you still haven’t created the RingView. Go ahead and add RingView.swift  file and add the following code inside:

struct RingView: View {

    var strokeWidth: CGFloat = 10
    var size: CGSize = CGSize(width: 70, height: 70)
    var color = Color.white
    var pathColor = Color.white.opacity(0.4)
    @Binding var percent: CGFloat

    var body: some View {
        ZStack {
            Circle()
                .stroke(style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
                .frame(width: size.width, height: size.height)
                .foregroundColor(pathColor)

            Circle()
                .trim(from: 0, to: percent )
                .stroke(style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
                .animation(.easeIn(duration: 1))
                .frame(width: size.width, height: size.height)
                .foregroundColor(color)
                .rotationEffect(Angle(degrees: -90) )
        }
    }
}

struct RingView_Previews: PreviewProvider {
    static var previews: some View {
        RingView(percent: .constant(0.7))
    }
}

That creates the ring view and handles its progress animation. I wrote a swift ui tutorial showing how to create and animate an Apple Watch like ring in using shapes in this tutorial, check it out!

In the same folder, create a new swift file named BarView.swift with the following code inside:


struct BarView: View {
    var size: CGSize = .zero
    var expense = Expense.getRandom().first!
    var isSelected = false
    var onSelected: ((Expense)->()) = {_ in }

    var body: some View {
        VStack {
            ZStack(alignment: .bottom) {
                Capsule()
                    .frame(width: 3, height: size.height)
                    .foregroundColor(Color.lightGray)
                Capsule()
                    .frame(width: 3, height: size.height * expense.percentConsumed)
                    .foregroundColor(isSelected ? Color.red : Color.purpleGray)
            }       .animation(.easeIn(duration: 1) )

            Text(expense.month).font(.caption).foregroundColor(isSelected ? Color.red : Color.purpleGray)
        }.frame(maxWidth: .infinity)
            .onTapGesture {
                self.onSelected(self.expense)
        }
    }
}

In the above code , we create a single bar view that will be re-used to create a barchart view for all 12 months. We also handle the selection of this single bar and sending the corresponding expense value for the selected month.

Next, we will create the barchartView. In the same folder, add a swift ui file named BarchartView.swift and inside it , put the following code:

import SwiftUI

struct BarChartView: View {
    var expenses = Expense.getRandom()
    @State var selectedExpense = Expense()
    var onSelected: ((Expense)->()) = {_ in }

    var body: some View {
        GeometryReader { gr in
            HStack(alignment: .bottom, spacing: 3) {
                ForEach(self.expenses, id: \.month){ expense in
                    BarView(size: gr.size, expense: expense, isSelected:  self.selectedExpense.month == expense.month, onSelected: { selectedExp in
                        self.selectedExpense = selectedExp
                        self.onSelected(selectedExp)
                    })
                }
            }.frame(maxWidth: .infinity ,maxHeight: .infinity, alignment: .bottom)
            .onAppear {
                self.selectedExpense = Expense.getRandom().first(where: { $0.month == Date.month })!
            }
        }
    }
}

struct BarChartView_Previews: PreviewProvider {
    static var previews: some View {
        BarChartView()
    }
}

In the above code, we iterate through all 12 months, and for each month, we create a BarView. Notice how we set the current month to be selected in onAppear because that block will always run as soon as the HStack appears.

Lets now work on the expense category section. In the views folder, add a swift ui file named ExpenseCategoryView.swift , and inside it add the following code:


struct ExpenseCategoryView: View {

    var category = ExpenseCategory()
    @State private var percent:CGFloat = 0.0

    var body: some View {
        GeometryReader{ gr in
            ZStack(alignment: .leading) {
                self.renderHorizontalChart(with: gr.size)
                HStack{
                    self.renderRingView()
                    Text(self.category.name).font(.system(size: 15, weight: .light))
                    Spacer()
                    Text("$\(String(format: "%.2f", self.category.amount) )").font(.system(size: 15, weight: .light))
                }.padding(5)
                    .onAppear {
                        self.percent = self.category.percent
                }
            }
        }.frame(height: 60)
    }

    fileprivate func renderRingView() -> some View {
        return RingView(strokeWidth: CGFloat(3), size: CGSize(width: 35, height: 35), color: self.category.colors.first!, pathColor: Color.lightGray, percent: self.$percent)
        .modifier(PercentAnimator(value: self.percent * 100, font: .caption,color: .textColor)
            .animation(.easeIn(duration: 1)))
    }

    fileprivate func renderHorizontalChart(with size: CGSize) -> some View {
        return Rectangle().frame(width: size.width * self.percent, height: size.height, alignment: .leading)
        .foregroundColor(Color.lightGray)
        .animation(.easeIn(duration: 1))
    }
}

struct ExpenseCategoryView_Previews: PreviewProvider {
    static var previews: some View {
        ExpenseCategoryView()
    }
}

This code creates one category row. Let’s now create the view that will generate the whole list of categories.

So in the same folder, add swift ui file named ExpensesBreakdownView.swift , and put the following code inside:

import SwiftUI

struct ExpensesBreakdownView: View {

    @ObservedObject private var expensesBreakDown = ExpensesBreakDown()
    var categories = [ExpenseCategory]()
    @State var percent:CGFloat = 0.0

    var body: some View {
        VStack{
            HStack{
                Text("Total expenses").bold()
                Spacer()
                Text("$\(String(format: "%.2f", expensesBreakDown.expense.consumed))").bold()
            }.padding(.vertical)

            VStack(spacing: 0) {
                ForEach(categories){ category in
                    ExpenseCategoryView(category: category)
                }
            }
        }
    }
}

struct ExpensesBreakdownView_Previews: PreviewProvider {
    static var previews: some View {
        ExpensesBreakdownView()
    }
}

This code just iterate through the categories arrays and renders a list of ExpenseCategoryView.

Put everything together

In your ContentView.swift file, replace everything inside with the following:


import SwiftUI

struct ContentView: View {
    @ObservedObject var expensesBreakDown = ExpensesBreakDown()

    var body: some View {

        return VStack{

            renderHeader()
            ScrollView(showsIndicators: false) {
                VStack{
                    renderCards()
                    renderBarchart()
                    ExpensesBreakdownView(categories: expensesBreakDown.categories)
                        .padding(.horizontal)
                }
            }

        }.frame(maxHeight: .infinity,alignment: .top)
            .padding(.vertical)
    }

     fileprivate func renderCards() -> some View {
         return ScrollView(.horizontal, showsIndicators: false) {
             HStack(spacing: 10) {
                 ForEach(expensesBreakDown.categories){ category in
                     CardView(category: (category))
                        .frame(width: 330, height: UIScreen.main.bounds.height * 0.22)
                 }
             }.padding(.horizontal)
         }
     }

    fileprivate func renderHeader() -> some View{
        HStack{
            VStack(alignment: .leading) {
                Text(Date.fullMonthName(short: expensesBreakDown.expense.month)).font(.title)
                Text("2019").foregroundColor(Color.purpleGray)
            }
            Spacer()
        }.padding(.horizontal)
    }

    fileprivate func renderBarchart() -> some View {
        return BarChartView(onSelected: { selectedExp in
            self.expensesBreakDown.generateRandom(selectedExp)
        }).frame(height: 50)
        .padding(.vertical, 20)
        .padding(.horizontal)
    }
}

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

Don’t be threatened by the above code. It just puts everything we have just done together.

Conclusion

That’s it folks… Later this week, I am planning to start a series showing you how to build a very complex app, and if you guys like it, I will start releasing similar tutorial series. So make sure you are subscribed and share this tutorial. Happy Coding!!!