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.
If you enjoyed this article, please feel free to follow me on my social media: