Search code examples
iosswiftswiftui

What is the reason that the func isToday is not executed correctly?


I tried my hand at my own version of a calendar app. I can change the year displayed in the overview using arrow buttons. The current date is colored red.

However, if I change the year and then switch back to the current date, the highlight is gone. I can't figure out how to change this. Can you help me? Here I show you the code:

import SwiftUI

struct MonthOverviewView: View {
    @State private var year: Int = Calendar.current.component(.year, from: Date()) // Aktuelles Jahr
    @State private var scrollProxy: ScrollViewProxy? = nil
    @State private var hasScrolledToToday = false // Verhindert mehrfaches Scrollen beim Laden der View
    private let calendar = Calendar.current
    private let daysOfWeek = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
    private let currentDay = Calendar.current.component(.day, from: Date())
    private let currentMonth = Calendar.current.component(.month, from: Date())
    private let currentYear = Calendar.current.component(.year, from: Date())

    var body: some View {
        VStack {
            // Jahresnavigation mit Heute Button
            HStack {
                Button(action: {
                    year -= 1
                }) {
                    Image(systemName: "chevron.left")
                        .font(.title2)
                        .padding()
                }
                
                // Vermeide Tausendertrennung in der Jahreszahl
                Text("\(String(year))")
                    .font(.largeTitle)
                    .bold()
                
                Button(action: {
                    year += 1
                }) {
                    Image(systemName: "chevron.right")
                        .font(.title2)
                        .padding()
                }
                
                Spacer()
                
                // Heute-Button für das Springen zum aktuellen Tag
                TodayButton {
                    withAnimation {
                        scrollProxy?.scrollTo("day-\(currentDay)-\(currentMonth)", anchor: .center)
                    }
                    year = currentYear
                }
            }
            .padding(.vertical, 10)
            
            // Scrollbare Monatsübersicht für das gesamte Jahr
            ScrollViewReader { proxy in
                ScrollView { 
                    VStack(spacing: 40) {
                        ForEach(1...12, id: \.self) { month in
                            VStack {
                                // Monatsüberschrift
                                Text("\(calendar.monthSymbols[month - 1])")
                                    .font(.title)
                                    .bold()
                                
                                // Wochentage
                                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 10) {
                                    ForEach(daysOfWeek, id: \.self) { day in
                                        Text(day)
                                            .font(.headline)
                                            .frame(maxWidth: .infinity)
                                    }
                                }
                                
                                // Tage des Monats anzeigen
                                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 20) {
                                    ForEach(getDaysInMonth(month: month, year: year), id: \.self) { day in
                                        if day != 0 {
                                            VStack(alignment: .leading) {
                                                // Highlight für den aktuellen Tag durch rote Ziffer
                                                Text("\(day)")
                                                    .font(.body) // Kleinere Ziffern
                                                    .bold()
                                                    .foregroundColor(isToday(day: day, month: month, year: year) ? .red : .black)
                                                    .padding(.bottom, 10) // Abstand zum unteren Bereich
                                                    .padding(.leading, 5)
                                                
                                                // Platzhalter für spätere Termine, größerer Bereich
                                                Rectangle()
                                                    .fill(Color.clear) // Hintergrundfarbe entfernen
                                                    .frame(height: 60) // Mehr Platz für Termine
                                            }
                                            .frame(minHeight: 120) // Vergrößerter Bereich für jeden Tag
                                            .border(Color.gray.opacity(0.2), width: 1) // Dezenter Rahmen
                                            .id("day-\(day)-\(month)") // Eindeutige ID für jeden Tag, damit genau dorthin gescrollt werden kann
                                        } else {
                                            Color.clear // Platzhalter für leere Felder
                                        }
                                    }
                                }
                                .padding(.horizontal)
                            }
                        }
                    }
                    .onAppear {
                        scrollProxy = proxy // ScrollViewProxy speichern

                        // Nur einmalig beim ersten Laden der Ansicht zum aktuellen Tag scrollen
                        if !hasScrolledToToday && year == currentYear {
                            DispatchQueue.main.async {
                                withAnimation {
                                    proxy.scrollTo("day-\(currentDay)-\(currentMonth)", anchor: .center)
                                    hasScrolledToToday = true // Verhindert erneutes Scrollen beim erneuten Aufrufen der View
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    
    // Funktion zur Berechnung der Tage des Monats
    func getDaysInMonth(month: Int, year: Int) -> [Int] {
        let dateComponents = DateComponents(year: year, month: month)
        guard let firstOfMonth = calendar.date(from: dateComponents),
              let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else {
            return []
        }
        
        let numberOfDays = range.count
        let firstWeekday = calendar.component(.weekday, from: firstOfMonth) - 1 // Sonntag = 1, daher -1
        
        var days = Array(repeating: 0, count: firstWeekday == 0 ? 6 : firstWeekday - 1) // Leerzeichen für die ersten Tage der Woche
        days += Array(1...numberOfDays)
        return days
    }
    
    // Überprüft, ob der aktuelle Tag angezeigt wird
    func isToday(day: Int, month: Int, year: Int) -> Bool {
        return day == currentDay && month == currentMonth && year == currentYear
    }
}

struct TodayButton: View {
    var action: () -> Void
    
    var body: some View {
        Button(action: action) {
            Text("button_today")
               // .font(.headline)
                .padding(.horizontal)
                .padding(.vertical, 8)
                //.background(Color.blue.opacity(0.1))
                .cornerRadius(10)
        }
        .padding(.trailing, 10)
    }
}

No matter what I try, when I switch back from another year to the current year, the func isToday does not seem to be executed and the current date is no longer highlighted. This always seems to happen when I change the year, because as long as I only scroll up or down in the current year, the highlight remains when the page is loaded for the first time.


Solution

  • The problem is the use of a view identifier that consists of the day and the month. Yes, that affords the scrolling that you were looking for, but view identifiers have a second purpose, to let SwiftUI know which views need to be re-rendered. But your identifier consists of just the day and the month, so you can end up with un-rerendered days when year is updated.

    I might suggest, for example, using an identifier that captures the year, too:

    private extension ContentView {
        func identifier(for date: Date) -> String {
            let components = calendar.dateComponents([.day, .month, .year], from: date)
            return "day-\(components.day!)-\(components.month!)-\(components.year!)"
        }
    }
    

    For more information about how view identifiers are used to improve performance by not re-rendering views unnecessarily, see 2021’s Demystify SwiftUI video or 2022’s Demystify SwiftUI performance.


    Thus, perhaps:

    import SwiftUI
    
    struct ContentView: View {
        @State private var year: Int = Calendar.current.component(.year, from: .now) // Aktuelles Jahr
        @State private var scrollProxy: ScrollViewProxy? = nil
        @State private var hasScrolledToToday = false // Verhindert mehrfaches Scrollen beim Laden der View
        private let calendar = Calendar.current
        private let daysOfWeek = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
        
        var body: some View {
            VStack {
                // Jahresnavigation mit Heute Button
                HStack {…}
                .padding(.vertical, 10)
                
                // Scrollbare Monatsübersicht für das gesamte Jahr
                ScrollViewReader { proxy in
                    ScrollView { 
                        VStack(spacing: 40) {
                            ForEach(1...12, id: \.self) { month in
                                VStack {
                                    // Monatsüberschrift
                                    Text("\(calendar.monthSymbols[month - 1])")
                                        .font(.title)
                                        .bold()
                                    
                                    // Wochentage
                                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 10) {
                                        ForEach(daysOfWeek, id: \.self) { day in
                                            Text(day)
                                                .font(.headline)
                                                .frame(maxWidth: .infinity)
                                        }
                                    }
                                    
                                    // Tage des Monats anzeigen
                                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 20) {
                                        ForEach(datesInMonth(month: month, year: year), id: \.self) { date in
                                            if let date {
                                                let day = calendar.component(.day, from: date)
                                                VStack(alignment: .leading) {
                                                    // Highlight für den aktuellen Tag durch rote Ziffer
                                                    Text("\(day)")
                                                        .font(.body) // Kleinere Ziffern
                                                        .bold()
                                                        .foregroundColor(calendar.isDateInToday(date) ? .red : .primary)
                                                        .padding(.bottom, 10) // Abstand zum unteren Bereich
                                                        .padding(.leading, 5)
                                                    
                                                    // Platzhalter für spätere Termine, größerer Bereich
                                                    Rectangle()
                                                        .fill(Color.clear) // Hintergrundfarbe entfernen
                                                        .frame(height: 60) // Mehr Platz für Termine
                                                }
                                                .frame(minHeight: 120) // Vergrößerter Bereich für jeden Tag
                                                .border(Color.gray.opacity(0.2), width: 1) // Dezenter Rahmen
                                                .id(identifier(for: date))
                                            } else {
                                                Color.clear // Platzhalter für leere Felder
                                            }
                                        }
                                    }
                                    .padding(.horizontal)
                                }
                            }
                        }
                        .onAppear {
                            scrollProxy = proxy // ScrollViewProxy speichern
                            
                            // Nur einmalig beim ersten Laden der Ansicht zum aktuellen Tag scrollen
                            if !hasScrolledToToday && year == currentYear {
                                DispatchQueue.main.async {
                                    withAnimation {
                                        proxy.scrollTo(identifier(for: .now), anchor: .center)
                                        hasScrolledToToday = true // Verhindert erneutes Scrollen beim erneuten Aufrufen der View
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    
    private extension ContentView {
        /// Funktion zur Berechnung der Tage des Monats
        func datesInMonth(month: Int, year: Int) -> [Date?] {
            let dateComponents = DateComponents(year: year, month: month)
            guard 
                let firstOfMonth = calendar.date(from: dateComponents),
                let range = calendar.range(of: .day, in: .month, for: firstOfMonth) 
            else {
                return []
            }
            
            let numberOfDays = range.count
            let firstWeekday = calendar.component(.weekday, from: firstOfMonth) - 1 // Sonntag = 1, daher -1
            
            let placeholders: [Date?] = Array(repeating: nil, count: firstWeekday == 0 ? 6 : firstWeekday - 1)
            let dates = (0..<numberOfDays).map { calendar.date(byAdding: .day, value: $0, to: firstOfMonth) }
            
            return placeholders + dates
        }
    
        func identifier(for date: Date) -> String {
            let components = calendar.dateComponents([.day, .month, .year], from: date)
            return "day-\(components.day!)-\(components.month!)-\(components.year!)"
        }
        
        var currentYear: Int { calendar.component(.year, from: .now) }
    }
    
    struct TodayButton: View {
        var action: () -> Void
        
        var body: some View {
            Button(action: action) {
                Text("button_today")
                // .font(.headline)
                    .padding(.horizontal)
                    .padding(.vertical, 8)
                //.background(Color.blue.opacity(0.1))
                    .cornerRadius(10)
            }
            .padding(.trailing, 10)
        }
    }
    

    That having been said, I might recommend a few other things:

    • Use localized shortStandaloneWeekdaySymbols and factor in the firstDayOfWeek;
    • I might use a single LazyVGrid to make sure days of the week and the dates line up properly and don’t have different padding;
    • I might suggest using .primary instead of .black, so it renders nicely in dark mode, and
    • Pull the DayView out of the main view.

    Thus, perhaps:

    struct ContentView: View {
        @State private var year: Int = Calendar.current.component(.year, from: .now) // Aktuelles Jahr
        @State private var scrollProxy: ScrollViewProxy? = nil
        @State private var hasScrolledToToday = false // Verhindert mehrfaches Scrollen beim Laden der View
        private let calendar: Calendar = .current
        
        var body: some View {
            VStack {
                // Jahresnavigation mit Heute Button
                HStack {
                    Button (action: { year -= 1 }) {
                        Image(systemName: "chevron.left")
                            .font(.title2)
                            .padding()
                    }
                    
                    // Vermeide Tausendertrennung in der Jahreszahl
                    Text("\(String(year))")
                        .font(.largeTitle)
                        .bold()
                    
                    Button(action: { year += 1 }) {
                        Image(systemName: "chevron.right")
                            .font(.title2)
                            .padding()
                    }
                    
                    Spacer()
                    
                    // Heute-Button für das Springen zum aktuellen Tag
                    TodayButton {
                        year = calendar.component(.year, from: .now)
                        withAnimation {
                            scrollProxy?.scrollTo(startOfToday, anchor: .center)
                        }
                    }
                }
                .padding(.vertical, 10)
                
                // Scrollbare Monatsübersicht für das gesamte Jahr
                ScrollViewReader { proxy in
                    ScrollView { 
                        VStack(spacing: 40) {
                            ForEach(1...12, id: \.self) { month in
                                VStack {
                                    // Monatsüberschrift
                                    Text("\(calendar.monthSymbols[month - 1])")
                                        .font(.title)
                                        .bold()
                                    
                                    // Wochentage
                                    LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 10) {
                                        ForEach(weekdaySymbols, id: \.self) { day in
                                            Text(day)
                                                .font(.headline)
                                                .frame(maxWidth: .infinity)
                                        }
                                        
                                        ForEach(datesInMonth(month: month, year: year), id: \.self) { date in
                                            if let date {
                                                DayView(date: date)
                                            } else {
                                                Color.clear // Platzhalter für leere Felder
                                            }
                                        }
                                    }
                                    .padding(.horizontal)
                                }
                            }
                        }
                        .onAppear {
                            scrollProxy = proxy // ScrollViewProxy speichern
                            
                            // Nur einmalig beim ersten Laden der Ansicht zum aktuellen Tag scrollen
                            if !hasScrolledToToday, year == currentYear {
                                DispatchQueue.main.async {
                                    withAnimation {
                                        hasScrolledToToday = true
                                        proxy.scrollTo(startOfToday, anchor: .center)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }   
    }
    
    private extension ContentView {
        /// Weekday symbols, reflecting the `firstWeekday` for the device
        var weekdaySymbols: [String] {
            let symbols = calendar.shortStandaloneWeekdaySymbols
            let startOfWeek = calendar.firstWeekday - 1
            return Array(symbols[startOfWeek...]) + Array(symbols[..<startOfWeek])
        }
        
        /// Start of today’s date
        var startOfToday: Date { calendar.startOfDay(for: .now) }
        
        /// Start of today’s date
        var currentYear: Int { calendar.component(.year, from: .now) }
        
        /// Funktion zur Berechnung der Tage des Monats
        func datesInMonth(month: Int, year: Int) -> [Date?] {
            let dateComponents = DateComponents(year: year, month: month)
            guard 
                let firstOfMonth = calendar.date(from: dateComponents),
                let range = calendar.range(of: .day, in: .month, for: firstOfMonth) 
            else {
                return []
            }
            
            let numberOfDays = range.count
            let weekdayOfFirstOfMonth = calendar.component(.weekday, from: firstOfMonth)
            let blankDays = (weekdayOfFirstOfMonth + 7 - calendar.firstWeekday) % 7
            
            let placeholders: [Date?] = Array(repeating: nil, count: blankDays)
            let dates = (0..<numberOfDays).map { calendar.date(byAdding: .day, value: $0, to: firstOfMonth) }
            
            return placeholders + dates
        }
    }
    
    struct DayView: View {
        let date: Date
        
        var body: some View {
            VStack(alignment: .leading) {
                Text("\(Calendar.current.component(.day, from: date))")
                    .font(.body) // Kleinere Ziffern
                    .bold()
                    .foregroundColor(calendar.isDateInToday(date) ? .red : .primary)
                    .padding(.bottom, 10) // Abstand zum unteren Bereich
                    .padding(.leading, 5)
                Rectangle()
                    .fill(Color.clear) // Hintergrundfarbe entfernen
                    .frame(height: 60) // Mehr Platz für Termine
            }
            .frame(minHeight: 120) // Vergrößerter Bereich für jeden Tag
            .border(Color.gray.opacity(0.2), width: 1) // Dezenter Rahmen
            .id(date)
        }    
    }
    
    struct TodayButton: View {
        var action: () -> Void
        
        var body: some View {
            Button(action: action) {
                Text("button_today")
                    .padding(.horizontal)
                    .padding(.vertical, 8)
                    .cornerRadius(10)
            }
            .padding(.trailing, 10)
        }
    }