DEV Community

Chanh Le
Chanh Le

Posted on • Edited on

Make a Calendar app with SwiftUI - part 1

I assume you can find out how to setup a SwiftUI project using Xcode. So, I will jump right into how to build calendar views and load data from SQLite.

App Views

We will make a very simple app with only 2 views: MainView for displaying current month and DetailView for displaying current date and week.

View hierarchy

View hierarchy

MainView

MainView.swift

import SwiftUI

struct MainView: View {

    @Binding var currentDate: CalendarDay
    var onSwipeUp: (() -> Void)?
    var onToday: () -> Void

    var body: some View {
        VStack(spacing: 20) {
            CalendarView(
                currentDate: $currentDate,
                onNavigate: changeMonth,
                formatTitle: monthYearString,
                onSwipeUp: onSwipeUp,
                onSwipeLeft: { withAnimation { nextMonth() } },
                onSwipeRight: { withAnimation { prevMonth() } }
            ) {
                let days = Calendar.current.monthDays(for: currentDate.solar)
                MonthView(days: days, currentDate: $currentDate)
            }

            Spacer()

            MenuView(
                mode: .month,
                onToday: onToday
            )
        }
    }


    private func changeMonth(by value: Int) {
        if let newMonth = Calendar.current.date(byAdding: .month, value: value, to: currentDate.solar) {
            currentDate = Calendar.current.lunarDay(for: newMonth)
        }
    }

    private func monthYearString(for date: CalendarDay) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMMM yyyy"
        return formatter.string(from: date.solar)
    }


    private func nextMonth() {
        // swipe left → next month
        let next = Calendar.current.date(byAdding: .month, value: 1, to: currentDate.solar)!
        currentDate = Calendar.current.lunarDay(for: next)
    }

    private func prevMonth() {
        // swipe right → previous month
        let prev = Calendar.current.date(byAdding: .month, value: -1, to: currentDate.solar)!
        currentDate = Calendar.current.lunarDay(for: prev)
    }
}
Enter fullscreen mode Exit fullscreen mode

This view consists of two nested shared views: CalendarView and MenuView. They are organized in a vertical stack VStack, separated by a Spacer. Spacer is just a way to fulfill empty space between views.
This MainView is responsible for displaying calendar of current month, and navigating to next/previous month. We can swipe left/right or click left/right buttons next to month name to navigate.
The MenuView currently has only one menu: Today for going back to Today while navigating.

DetailView

DetailView.swift

import SwiftUI

struct DetailView: View {
    @Binding var currentDate: CalendarDay
    var onToday: () -> Void
    @Environment(\.dismiss) private var dismiss

    var body: some View {

        // Header with today’s date
        VStack {
            Text(currentDate.solarFormatted())
                .font(.headline)
                .foregroundColor(.gray)
            Text("Lunar: \(currentDate.lunarFormatted())")
                .font(.headline)
                .fontWeight(.semibold)
                .padding(.vertical, 5)

            CalendarView(
                currentDate: $currentDate,
                onNavigate: changeWeek,
                formatTitle: formatWeek,
                onSwipeLeft: nextWeek,
                onSwipeRight: prevWeek,
                onSwipeDown: { dismiss() }
            ) {
                let days = Calendar.current.weekDays(for: currentDate.solar)
                WeekView(days: days, currentDate: $currentDate)

                if currentDate.isHoliday {
                    HolidayView(currentDate: $currentDate)
                }

            }

            Spacer()

            // Bottom Navigation
            MenuView(
                mode: .home,
                onToday: onToday
            )
        }
        .padding()
        .contentShape(Rectangle())

    }

    private func changeWeek(_ by: Int) {
        if let newDate = Calendar.current.date(byAdding: .weekOfYear, value: by, to: currentDate.solar) {
            currentDate = Calendar.current.lunarDay(for: newDate)
        }
    }

    private func formatWeek(_ date: CalendarDay) -> String {
        let week = Calendar.current.dateComponents([.weekOfYear, .year], from: date.solar)
        let prefix = String(localized: "week")
        return "\(prefix) \(week.weekOfYear!) \(week.year!)"
    }


    private func nextWeek() {
        // swipe left → next week
        let next = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: currentDate.solar)!
        currentDate = Calendar.current.lunarDay(for: next)
    }

    private func prevWeek() {
        // swipe right → previous week
        let prev = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: currentDate.solar)!
        currentDate = Calendar.current.lunarDay(for: prev)
    }
}
Enter fullscreen mode Exit fullscreen mode

This DetailView is actually similar with MainView with some additional components for displaying current date in both solar and lunar(Vietnamese) format, following by CalendarView and MenuView.
However, this time the swipe actions and buttons will allow you to navigate between weeks instead of months.

The HomeView

Actually, the app will first load another view as entry instead of the two views above. I call this view HomeView. It is where we store the app state like current date and which views to show based on the state.

HomeView.swift

import SwiftUI

struct HomeView: View {
    @State private var currentDate  = Calendar.current.lunarDay(for: Date())
    @State private var showDetailView = false
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            MainView(
                currentDate: $currentDate,
                onSwipeUp: showDayView,
                onToday: onToday
            )
        }
        .contentShape(Rectangle())
        .sheet(isPresented: $showDetailView) {
            DetailView(
                currentDate: $currentDate,
                onToday: onToday
            )
        }
    }

    private func showDayView() {
        showDetailView = true
    }


    private func onToday() {
        currentDate = Calendar.current.lunarDay()
    }
}
Enter fullscreen mode Exit fullscreen mode

The CalendarView

So far, we don't see yet where and how the swipe events are being handled. Yes, you're right. That's the shared CalendarView.

CalendarView.swift


import SwiftUI

struct CalendarView<Content: View>: View {


    @Binding var currentDate: CalendarDay
    var onNavigate: (_ by: Int) -> Void
    var formatTitle: (_ date: CalendarDay) -> String
    var onSwipeUp: (() -> Void)?
    var onSwipeLeft: (() -> Void)?
    var onSwipeRight: (() -> Void)?
    var onSwipeDown: (() -> Void)?

    @ViewBuilder var content: () -> Content


    var body: some View {
        VStack {
            HStack {
                Button(action: { onNavigate(-1) }) {
                    Image(systemName: "chevron.left")
                        .frame(width: 44, height: 44)
                        .contentShape(Rectangle())
                }
                Spacer()
                Text(formatTitle(currentDate))
                    .font(.title2)
                    .fontWeight(.bold)
                Spacer()
                Button(action: { onNavigate(1) }) {
                    Image(systemName: "chevron.right")
                        .frame(width: 44, height: 44)
                        .contentShape(Rectangle())
                }
            }
            .padding()

            Divider()

            content()

            Spacer()

        }
        .contentShape(Rectangle()) // make whole area hittable
        .gesture(
            DragGesture()
                .onEnded { value in
                    if value.translation.height < -50 { // swipe up
                        onSwipeUp?()
                    } else if value.translation.width < -50 { // swipe left
                        onSwipeLeft?()
                    } else if value.translation.width > 50 { // swipe right
                        onSwipeRight?()
                    } else if value.translation.height > 50 { // swipe down
                        onSwipeDown?()
                    }
                }
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

This view is responsible for capturing swift events and calling the event handlers which are passed from parent view of this CalendarView. This view is being reused across MainView and DetailView. Each views is providing different handlers for the swipe events. That's swiping on MainView will allow you to navigate between although on DetailView you will be navigated between weeks.

You might also see the onNavigate handler. Yes, this is the handler for left/right buttons next to the title of the CalendarView. This title is also flexible, depends on what the parent view provided.
About the onToday handler, it is used for handling the Today button's click event. And you might guess it is the MenuView's responsibility to catch the event and map it with the corresponding handler.

Conclusion

You can use VStack and HStack for organizing views. You can capture events using gestures or actions(events). You can pass event handlers via View constructor. You can store state at the root/entry view and bind state using @Binding annotation. Which binding you can modify the state at anywhere.

Next, I will continue with part 2 - loading lunar data from SQLite.

You can see the source code of this app here.

Top comments (0)