SwiftUI Week Calendar View. #5: WeekCalendarView


Greetings, traveler!

We continue our series about SwiftUI Week Calendar View. This is the fifth part, where we will create the WeekCalendarView. The title of this article looks a bit weird, but it marks that this article is a key one.

If you just want to check out the complete source code, here it is.

As we discussed, we should create a TabView. It stores and regenerates three tabs whenever the parent view retrieves a new reference date value.

For data management, we will use the WeekProvider we created earlier.

Add two additional properties. The first one is the scrollDirection. This property will help the provider calculate new Week models to display. The second activeTab property will hold the active tab value. We will use it to switch tabs programmatically. Both of them will have a WeekPosition type.

Also, remember to add the selectedDate binding property.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {}
    }
    
}

Now, we can create a ForEach view with three tabs for each week. To do this, we will use our WeekPosition enum. But first, we should add conformance to Hashable and Identifiable protocols.

public enum WeekPosition: Int, Identifiable, Hashable, CaseIterable {
    case left = -7
    case middle = 0
    case right = 7
    
    public var id: Int { rawValue }
}

Let’s return to our view, then. Create a ForEach loop and put the WeekView instance there. We can unwrap an optional Week value with a dictionary’s subscript with the default parameter. As a default value, we can create the default static property.

public struct Week {
    public let dates: [Date]
    public let referenceDate: Date
    
    public static let `default`: Self = Week.week(for: .now, at: .middle)
    
    public init(dates: [Date], referenceDate: Date) {
        self.dates = dates
        self.referenceDate = referenceDate
    }
    
    public static func week(
        for date: Date,
        at position: WeekPosition
    ) -> Self {
        let date = date.sameDay(at: position)
        let startOfWeek = Calendar.current.date(from: Calendar.current.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))
        let weekDates = (0...6).compactMap {
            Calendar.current.date(
                byAdding: .day,
                value: $0,
                to: startOfWeek ?? .now
            )
        }
        
        return Week(
            dates: weekDates,
            referenceDate: date
        )
    }
    
}

Remember to add a tag to the WeekView.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDate: $selectedDate
                )
                .tag(position)
                .frame(maxWidth: .infinity)
            }
        }
    }
    
}

We will change the scrollDirection value by switching the activeTab, but only if the active tab is left or right. This will help us update the WeekProvider with the correct data.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDate: $selectedDate
                )
                .tag(position)
                .frame(maxWidth: .infinity)
            }
        }
        .onChange(of: activeTab) { _, newValue in
            switch newValue {
            case .left, .right:
                scrollDirection = newValue
            case .middle:
                break
            }
        }
    }
    
}

When we scroll, our active tab changes. Moving to the right or left from the middle tab, we should update our provider to recalculate weeks and re-render the view. After that, we must update our scrollDirection value and switch the activeTab to the middle. However, we should do this only after the middle tab disappears to ensure our UI is stable.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDate: $selectedDate
                )
                .tag(position)
                .frame(maxWidth: .infinity)
                .onDisappear {
                    guard position == .middle, scrollDirection != .middle else { return }
                    provider.update(by: scrollDirection)
                    scrollDirection = .middle
                    activeTab = .middle
                }
            }
        }
        .onChange(of: activeTab) { _, newValue in
            switch newValue {
            case .left, .right:
                scrollDirection = newValue
            case .middle:
                break
            }
        }
    }
    
}

Handle date selection

As we discussed, when users choose a date, we must update its value in the provider. Since we have a binding property, we can track its changes and update the provider’s date.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDate: $selectedDate
                )
                .tag(position)
                .frame(maxWidth: .infinity)
                .onDisappear {
                    guard position == .middle, scrollDirection != .middle else { return }
                    provider.update(by: scrollDirection)
                    scrollDirection = .middle
                    activeTab = .middle
                }
            }
        }
        .onChange(of: activeTab) { _, newValue in
            switch newValue {
            case .left, .right:
                scrollDirection = newValue
            case .middle:
                break
            }
        }
        .onChange(of: selectedDate) { _, newValue in
            provider.setDate(newValue)
        }
    }
    
}

The final touch

Now, we can hide TabView indicators. And we are good to go.

struct WeekCalendarView: View {
    
    @State private var provider: WeekProvider = .init()
    
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    @Binding var selectedDate: Date
    
    var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDate: $selectedDate
                )
                .tag(position)
                .frame(maxWidth: .infinity)
                .onDisappear {
                    guard position == .middle, scrollDirection != .middle else { return }
                    provider.update(by: scrollDirection)
                    scrollDirection = .middle
                    activeTab = .middle
                }
            }
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
        .onChange(of: activeTab) { _, newValue in
            switch newValue {
            case .left, .right:
                scrollDirection = newValue
            case .middle:
                break
            }
        }
        .onChange(of: selectedDate) { _, newValue in
            provider.setDate(newValue)
        }
    }
    
}

Conclusion

We are almost finished with our views. It’s time to add some customization tools.