Year Month Selector in SwiftUI

Introduction

The new iOS driver app for Cab9 is built using SwiftUI. For years the E9 development teams have been doing reactive programming on web frameworks so when we discovered that SwiftUI provides mechanisms for reactive programming with BindableObject, ObjectBinding, and the whole Combine framework, we had to give it a go.

We had already built and released a fully functioning mobile app using SwiftUI so we decided to use it for Cab9's driver driver app too.

DatePickers and SwiftUI

SwiftUI’s DatePicker view is analogous to UIDatePicker, and comes with a variety options for controlling how it looks and works. Like all controls that store values, it does need to be bound to some sort of state in your app.

However for our view we needed a DatePicker that only allowed the selection of a particular month in a year. The page will allow a driver to choose the month and see the payments received in that month.

The payments page for Cab9 Driver App rendered via Zeplin.

Designing the Year/Month DatePicker

As per the design the Datepicker is divided into two parts.

  1. Year Selector - Allows the user to choose one year at a time.
  2. Month Selector - A scrollable pane to choose the month from an year

The years could be changed by tapping the < and > chevrons. The design required the user to be able to see and choose only one year at a time.

Let's look at the SwiftUI code for designing the year view:

Stack {
    Image(systemName: "chevron.left")
        .foregroundColor(.white)
        .frame(width: 24.0)
    Text("2021").foregroundColor(.white).font(.textBold)
        .transition(.move(edge: .trailing))
    Spacer()
    Image(systemName: "chevron.right")
        .foregroundColor(.white)
        .frame(width: 24.0)
}.padding(.all, 12.0)
.background(Color.brandPrimaryColor)

The images with chevron.left and chevron.right will be used to reduce and increase the years.

Now lets look at the month view

let months: [String] = Calendar.current.shortMonthSymbols

ScrollView(.horizontal) {
    HStack() {
        ForEach(months, id: \.self) { item in
            Text(item)
                .foregroundColor((item == currentMonth) ? .blue:.black)
                .padding(.all, 12.0)
        }
    }
}

Since this is a month/year selector, we decided the final result should be a range and that range had to be made available as a @State variable to it could be passed from the parent.

@State var dateRange: Range<Date>

With our view and strategy in place, we had to write the vanilla functionality of changing the years and the month.

Final Result

Year Month Selector in SwiftUI

Please find below final code for the month/year selector using SwiftUI.

import SwiftUI

struct YearMonthCalendarView: View {
    @State var selectedYear: Int = Calendar.current.component(.year, from: Date())
    @State var selectedMonth: String = Date().monthShort
    let months: [String] = Calendar.current.shortMonthSymbols
    @State var dateRange: Range<Date>
    
    var body: some View {
        // Year View
        VStack(spacing:0) {
            Group {
                HStack {
                    Image(systemName: "chevron.left")
                        .foregroundColor(.white)
                        .frame(width: 24.0)
                        .onTapGesture {
                            selectedYear -= 1;
                            selectedMonth = ""
                        }
               Text(String(selectedYear)).foregroundColor(.white).font(.textBold)
                        .transition(.move(edge: .trailing))
                    Spacer()
                    Image(systemName: "chevron.right")
                        .foregroundColor(.white)
                        .frame(width: 24.0)
                        .onTapGesture {
                            selectedYear += 1;
                            selectedMonth = ""
                        }
                }.padding(.all, 12.0)
                .background(.blue)
            }
            Group {
                ScrollView(.horizontal) {
                    HStack() {
                        ForEach(months, id: \.self) { item in
                            Text(item)
                                .foregroundColor((item == selectedMonth) ? .blue:.black)
                                .padding(.all, 12.0)
                                .onTapGesture {
                                    self.setPeriod(selectedMonth: item)
                                }
                        }
                    }
                }
            }
            Divider()
        }
        .onAppear() {
            selectedYear = Int(dateRange.lowerBound.yearFull)!
            selectedMonth = dateRange.lowerBound.monthShort
        }
    }
    
    func setPeriod(selectedMonth: String) {
        self.selectedMonth = selectedMonth
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "dd/MMM/yyy"
        let startDate = dateFormatter.date(from: "01/" + selectedMonth + "/" + String(selectedYear))
        let endDate = startDate?.endOfMonth
        self.dateRange = .init(uncheckedBounds: (lower: startDate!, upper: endDate!))
    }
}

struct YearMonthCalendarView_Previews: PreviewProvider {
    static var previews: some View {
        YearMonthCalendarView(dateRange: .init(uncheckedBounds: (lower: Calendar.current.date(byAdding: .month, value: 2, to: Date())!.startOfMonth, upper: Calendar.current.date(byAdding: .month, value: 2, to: Date())!.endOfMonth)))
    }
}