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.
Designing the Year/Month DatePicker
As per the design the Datepicker is divided into two parts.
- Year Selector - Allows the user to choose one year at a time.
- 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
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)))
}
}