skip to Main Content

I’m working on building a calendar UI in SwiftUI using LazyVGrid. My intention is for the calendar to correctly display the dates of the month, adjusting for the position of the first day of the month (for example, if a month begins on a Wednesday, then the "1" should appear under the "Wed" column).

The issue I’m facing is that when I switch to a new month by updating the currentMonth state, the LazyVGrid doesn’t seem to update the positioning of the dates correctly. While the days in the month change according to the new month, the first day’s position remains the same as the previous month.

Here’s a simplified snippet of the code I’ve been working with:

import SwiftUI

struct SimpleCalendarView: View {
    @State private var currentMonth = Date()
    private var daysInMonth: Int {
        let range = Calendar.current.range(
            of: .day,
            in: .month, for: currentMonth
        )
        return range?.count ?? 0
    }

    private var firstDayOfMonth: Int {
        let components = Calendar.current.dateComponents(
            [.year, .month], 
            from: currentMonth
        )
        guard let firstDay = Calendar.current.date(from: components) else {
            return 1 // Default
        }
        return Calendar.current.component(.weekday, from: firstDay)
    }

    private let columns: [GridItem] = Array(
        repeating: .init(.flexible()), count: 7
    )

    var body: some View {
        VStack {
            HStack {
                Button(action: {
                    self.currentMonth = Calendar.current.date(
                        byAdding: .month,
                        value: -1,
                        to: self.currentMonth
                    ) ?? self.currentMonth
                }, label: {
                    Text("Previous Month")
                })
                
                Button(action: {
                    self.currentMonth = Calendar.current.date(
                        byAdding: .month,
                        value: 1,
                        to: self.currentMonth
                    ) ?? self.currentMonth
                }, label: {
                    Text("Next Month")
                })
            }
            
            LazyVGrid(columns: columns) {
                // Add padding for the start day of the month
                ForEach(1..<firstDayOfMonth) { _ in
                    Text("")
                }
                
                ForEach(1...daysInMonth, id: .self) { day in
                    Text("(day)")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
        }
        .padding()
    }
}

How can make the view update to reflect this change, so that the first day (1) does not always start on the same Weekday?

2

Answers


  1. Chosen as BEST ANSWER

    The issue seems to be related to the way LazyVGrid handles updates in the SwiftUI lifecycle.

    When navigating through months, the expected behavior is for the calendar to correctly adjust the starting position of the month's first day. If the previous month started on a Wednesday, for example, and the next month starts on a Friday, we'd expect two empty cells at the beginning of the grid for the next month. However, as observed, LazyVGrid does not always update to reflect these changes.

    To force LazyVGrid to redraw and respect the changes, a key can be assigned to it that changes with every update, in this case I just used the currentMonth, as when I press the button this changes. This tells informs SwiftUI that this component should be redrawn from scratch.

    Here's how I fixed it:

    LazyVGrid(columns: columns, content: {
        // ...
    }).id(currentMonth)
    

    Adding .id(currentMonth) tells SwiftUI to treat the LazyVGrid as a new view whenever the currentMonth changes, causing it to redraw entirely and respect the changes in the layout.


  2. SwiftUI updates the views based upon their identity. By default, it will assign the identities consecutively. So, it sees that nothing has changed when you switch months because the views are still just (View(0), View(1), …).

    If you give each day an identity based upon the actual date, then the views will appear in their new location. Also, give the padding ids (…, View(-3), View(-2), View(-1), View(0)).

    struct SimpleCalendarView: View {
    
        @State private var currentMonth = Date()
        private var daysInMonth: Int {
            let range = Calendar.current.range(of: .day, in: .month, for: currentMonth)
            return range?.count ?? 0
        }
        
        private var firstDayOfMonth: Int {
            let components = Calendar.current.dateComponents([.year, .month], from: currentMonth)
            guard let firstDay = Calendar.current.date(from: components) else {
                return 1 // Default
            }
            return Calendar.current.component(.weekday, from: firstDay)
        }
        
        private let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 7)
        
        var body: some View {
            VStack {
                HStack {
                    Button(action: {
                        self.currentMonth = Calendar.current.date(byAdding: .month, value: -1, to: self.currentMonth) ?? self.currentMonth
                    }, label: {
                        Text("Previous Month")
                    })
                    
                    Button(action: {
                        self.currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: self.currentMonth) ?? self.currentMonth
                    }, label: {
                        Text("Next Month")
                    })
                }
                
                LazyVGrid(columns: columns) {
                    // Add padding for the start day of the month
                    ForEach(1..<firstDayOfMonth, id: .self) { pad in
                        Text("")
                            .id(pad - firstDayOfMonth)
                    }
                    
                    ForEach(1...daysInMonth, id: .self) { day in
                        Text("(day)")
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                            .id(day)
                    }
                }
            }
            .padding()
        }
    }
    

    For Fun

    Now, if you surround the assignment of self.currentMonth with withAnimation, you can see the days animate to their new positions:

    Button(action: {
        withAnimation {
            self.currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: self.currentMonth) ?? self.currentMonth
        }
    }, label: {
        Text("Next Month")
    })
    

    Animated Demo running in simulator

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search