SwiftUI Week Calendar View. #6: Customization


Greetings, traveler!

We continue our series about SwiftUI Week Calendar View. This is the last part, where we will add some customization options.

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

WeekView

Let’s start with the WeekView. There is nothing complex we can do. We can just pass values for colors and fonts via its initializer.

Let’s take a look at the final result.

public struct WeekView: View {
    
    var week: Week
    @Binding var selectedDay: Date
    
    private let accentCircleColor: Color
    private let accentTextColor: Color
    private let defaultTextColor: Color
    private let font: Font
    private let circleHeight: CGFloat
    
    init(
        week: Week,
        selectedDay: Binding<Date>,
        accentCircleColor: Color,
        accentTextColor: Color,
        defaultTextColor: Color,
        font: Font,
        circleHeight: CGFloat
    ) {
        self.week = week
        _selectedDay = selectedDay
        self.accentCircleColor = accentCircleColor
        self.accentTextColor = accentTextColor
        self.defaultTextColor = defaultTextColor
        self.font = font
        self.circleHeight = circleHeight
    }
        
    public var body: some View {
        HStack {
            ForEach(week.dates, id: \.self) { date in                
                VStack {
                    Text(date.formatted(.dateTime.weekday()).capitalized)
                        .foregroundColor(defaultTextColor)
                        .frame(maxWidth: .infinity)
                        .font(font)
                        .fontWeight(.semibold)
                   
                    Circle()
                        .foregroundColor(date.isSameDay(with: selectedDay) && !date.isToday ? accentCircleColor.opacity(0.5) : .clear)
                        .frame(height: circleHeight)
                        .overlay {
                            ZStack {
                                if date.isToday {
                                    Circle()
                                        .foregroundColor(accentCircleColor)
                                        .frame(height: circleHeight)
                                }
                                
                                Text(date.formatted(.dateTime.day(.twoDigits)))
                                    .frame(maxWidth: .infinity)
                                    .font(font)
                                    .foregroundColor(date.isToday || date.isSameDay(with: selectedDay) ? accentTextColor : defaultTextColor)
                            }
                        }
                }.onTapGesture {
                    selectedDay = date
                }
                .frame(maxWidth: .infinity)
            }
        }
        .padding()
    }
    
}

WeekCalendarView

Here, we can do the same to pass values to the WeekView initializer.

ublic struct WeekCalendarView: View {
    
    @Binding var selectedDay: Date
    
    private let accentCircleColor: Color
    private let accentTextColor: Color
    private let defaultTextColor: Color
    private let font: Font
    private let circleHeight: CGFloat
    
    @State private var provider: WeekProvider = .init()
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    public init(
        accentCircleColor: Color = .blue,
        accentTextColor: Color = .white,
        defaultTextColor: Color = .primary,
        font: Font = .system(size: 20, weight: .semibold),
        circleHeight: CGFloat = 45,
        selectedDay: Binding<Date>
    ) {
        self.accentCircleColor = accentCircleColor
        self.accentTextColor = accentTextColor
        self.defaultTextColor = defaultTextColor
        self.font = font
        self.circleHeight = circleHeight
        _selectedDay = selectedDay
        customWeekView = nil
    }
    
    public var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                WeekView(
                    week: provider.weekDict[position, default: .default],
                    selectedDay: $selectedDay,
                    accentCircleColor: accentCircleColor,
                    accentTextColor: accentTextColor,
                    defaultTextColor: defaultTextColor,
                    font: font,
                    circleHeight: circleHeight
                )
                .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: selectedDay) { _, newValue in
            provider.setDate(newValue)
        }
    }
    
}

However, we can add some flexibility and allow the creation of a custom view instead of WeekView.

Custom Content

First, let’s make our WeekCalendarView generic. Then, let’s create a new initializer. As a parameter, we will use a @ViewBuilder completion. We will assign its value to our property for custom content.

public struct WeekCalendarView<Content: View>: View {
    
    @Binding var selectedDay: Date
    
    private let accentCircleColor: Color
    private let accentTextColor: Color
    private let defaultTextColor: Color
    private let font: Font
    private let circleHeight: CGFloat
    
    @State private var provider: WeekProvider = .init()
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    private var customWeekView: ((_ week: Week) -> Content)?
    
    public init(
        accentCircleColor: Color = .blue,
        accentTextColor: Color = .white,
        defaultTextColor: Color = .primary,
        font: Font = .system(size: 20, weight: .semibold),
        circleHeight: CGFloat = 45,
        selectedDay: Binding<Date>
    ) {
        self.accentCircleColor = accentCircleColor
        self.accentTextColor = accentTextColor
        self.defaultTextColor = defaultTextColor
        self.font = font
        self.circleHeight = circleHeight
        _selectedDay = selectedDay
        customWeekView = nil
    }
    
    public init(@ViewBuilder customContent: @escaping (_ week: Week) -> Content) {
        self.init()
        self.customWeekView = customContent
    }
    
    public var body: some View {
        TabView(selection: $activeTab) {
            ...
        }
    }
    
}

We can create a method to determine whether to display the default WeekView or custom content and provide the appropriate value to the parent view.

public struct WeekCalendarView<Content: View>: View {
    
    @Binding var selectedDay: Date
    
    private let accentCircleColor: Color
    private let accentTextColor: Color
    private let defaultTextColor: Color
    private let font: Font
    private let circleHeight: CGFloat
    
    @State private var provider: WeekProvider = .init()
    @State private var activeTab: WeekPosition = .middle
    @State private var scrollDirection: WeekPosition = .middle
    
    private var customWeekView: ((_ week: Week) -> Content)?
    
    public init(
        accentCircleColor: Color = .blue,
        accentTextColor: Color = .white,
        defaultTextColor: Color = .primary,
        font: Font = .system(size: 20, weight: .semibold),
        circleHeight: CGFloat = 45,
        selectedDay: Binding<Date>
    ) {
        self.accentCircleColor = accentCircleColor
        self.accentTextColor = accentTextColor
        self.defaultTextColor = defaultTextColor
        self.font = font
        self.circleHeight = circleHeight
        _selectedDay = selectedDay
        customWeekView = nil
    }
    
    public init(@ViewBuilder customContent: @escaping (_ week: Week) -> Content) {
        self.init()
        self.customWeekView = customContent
    }
    
    public var body: some View {
        TabView(selection: $activeTab) {
            ForEach(WeekPosition.allCases) { position in
                weekView(for: provider.weekDict[position, default: .default])
                    .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: selectedDay) { _, newValue in
            provider.setDate(newValue)
        }
    }
    
    @ViewBuilder
    private func weekView(for week: Week) -> some View {
        if let customWeekView {
            customWeekView(week)
        } else {
            WeekView(
                week: week,
                selectedDay: $selectedDay,
                accentCircleColor: accentCircleColor,
                accentTextColor: accentTextColor,
                defaultTextColor: defaultTextColor,
                font: font,
                circleHeight: circleHeight
            )
        }
    }
    
}

Great!

Now, let’s use it. We can use the closure to provide Week value when using a custom view.

struct ContentView: View {
    
    @State var selectedDay: Date = .now
    
    var body: some View {
        WeekCalendarView { week in // Custom content
            Text(week.referenceDate.formatted())
        }
    }
    
}

Since our WeekCalendarView is a generic struct, we should explicitly provide a View type when using it. It can be any type of View. For this example, we will use AnyView.

struct ContentView: View {
    
    @State var selectedDay: Date = .now
    
    var body: some View {
        WeekCalendarView<AnyView>(selectedDay: $selectedDay) // Default WeekView
    }
    
}

However, there is a way to avoid this type of management while using an initializer for WeekView customization.

Initialization with the generic type specification

We can create an extension that explicitly specifies a Content type to avoid this generic initialization stuff. First of all, let’s change our initializer with custom content closure. We should configure our constants there, even if we won’t use them afterwards.

public init(
    selectedDay: Binding<Date>,
    @ViewBuilder customContent: @escaping (_ week: Week) -> Content
) {
    self.accentCircleColor = .blue
    self.accentTextColor = .white
    self.defaultTextColor = .primary
    self.font = .system(.body)
    self.circleHeight = 32
    
    _selectedDay = selectedDay
    self.customWeekView = customContent
}

After that, we can remove the second initializer and create an extension with another one. Here, we will specify the type, so we don’t need to provide it every time we initialize this view.

public extension WeekCalendarView where Content == AnyView {
    
    init(
        selectedDay: Binding<Date>,
        accentCircleColor: Color = .blue,
        accentTextColor: Color = .white,
        defaultTextColor: Color = .primary,
        font: Font = .system(size: 20, weight: .semibold),
        circleHeight: CGFloat = 45
    ) {
        self.accentCircleColor = accentCircleColor
        self.accentTextColor = accentTextColor
        self.defaultTextColor = defaultTextColor
        self.font = font
        self.circleHeight = circleHeight
        
        _selectedDay = selectedDay
        customWeekView = nil
    }
    
}

Now, let’s try to create our WeekCalendarView once again.

struct ContentView: View {
    
    @State var selectedDay: Date = .now
    
    var body: some View {
        WeekCalendarView(selectedDay: $selectedDay)
        
        HStack {
            Button {
                selectedDay = .now
            } label: {
                Text("Today")
            }
            
            Spacer()
            
            DatePicker("", selection: $selectedDay)
        }
        .padding()
    }
    
}

Nice!

Conclusion

We learned how to create and customize the Infinite Week Calendar written in SwiftUI. We stuck to our rules and concepts and achieved good results.