Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor calendar to support dynamic selection type changes #2130

Merged
merged 5 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 1 addition & 23 deletions Backpack-SwiftUI/Calendar/Classes/BPKCalendar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ public struct BPKCalendar<DayAccessoryView: View>: View {
private var accessoryAction: ((Date) -> CalendarMonthAccessoryAction?)?
private var initialMonthScroll: MonthScroll?
private let monthHeaderDateFormatter: DateFormatter
private let calendarAccessibilityConfiguration: CalendarAccessibilityConfiguration

private let dayAccessoryView: (Date) -> DayAccessoryView
@State private var currentlyShownMonth: Date
Expand All @@ -50,15 +49,13 @@ public struct BPKCalendar<DayAccessoryView: View>: View {
calendar: Calendar,
validRange: ClosedRange<Date>,
initialMonthScroll: MonthScroll? = nil,
calendarAccessibilityConfiguration: CalendarAccessibilityConfiguration,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now handled as it was before, as part of the binding itself, to not allow the API to lead to invalid states

dayAccessoryView: @escaping (Date) -> DayAccessoryView = { _ in EmptyView() }
) {
self.dayAccessoryView = dayAccessoryView
_currentlyShownMonth = State(initialValue: validRange.lowerBound)
self.validRange = validRange
self.calendar = calendar
self.selectionType = selectionType
self.calendarAccessibilityConfiguration = calendarAccessibilityConfiguration
self.initialMonthScroll = initialMonthScroll

monthHeaderDateFormatter = DateFormatter()
Expand Down Expand Up @@ -91,8 +88,7 @@ public struct BPKCalendar<DayAccessoryView: View>: View {
parentProxy: calendarProxy
)
},
dayAccessoryView: dayAccessoryView,
calendarAccessibilityConfiguration: calendarAccessibilityConfiguration
dayAccessoryView: dayAccessoryView
)
yearBadge
}
Expand Down Expand Up @@ -143,24 +139,6 @@ struct BPKCalendar_Previews: PreviewProvider {
),
calendar: calendar,
validRange: minValidDate...maxValidDate,
calendarAccessibilityConfiguration: CalendarAccessibilityConfiguration(
singleSelection: .init(
accessibilityConfigurations: .init(selectionHint: "hint"),
dateFormatter: DateFormatter()
),
rangeSelection: .init(
accessibilityConfigurations: .init(
startSelectionHint: "startSelectionHint",
endSelectionHint: "endSelectionHint",
startSelectionState: "startSelectionState",
endSelectionState: "endSelectionState",
betweenSelectionState: "betweenSelectionState",
startAndEndSelectionState: "startAndEndSelectionState",
returnDatePrompt: "returnDatePrompt"
),
dateFormatter: DateFormatter()
)
),
dayAccessoryView: { _ in
BPKText("20", style: .caption)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,111 +18,11 @@

import SwiftUI

struct CalendarSelectableCell: View {
let selectionType: CalendarSelectionType
let calendar: Calendar
let accessibilityProvider: CalendarAccessibilityConfiguration
let dayDate: Date
let onSelection: (Date) -> Void
struct CalendarSelectableCell<Cell: View>: View {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing to see here, just reverting back to the old implementation, which supports better composition of views among other things

@ViewBuilder let cell: Cell
let onSelection: () -> Void

var body: some View {
Group {
switch selectionType {
case .range(let selectionState, _):
rangeView(selectionState: selectionState.wrappedValue)
case .single(let selection, _):
singleView(selectionState: selection.wrappedValue)
}
}.onTapGesture(perform: {
onSelection(dayDate)
})
}

@ViewBuilder private func rangeView(selectionState: CalendarRangeSelectionState?) -> some View {
if case .intermediate(let date) = selectionState,
initialSelection(date, matchesDate: dayDate) {
singleCell(date: date)
} else if case .range(let range) = selectionState, range.contains(dayDate) {
rangeCell(closedRange: range, highlightRangeEnds: true)
} else if case .wholeMonth(let range) = selectionState, range.contains(dayDate) {
wholeMonthRangeCell(range: range)
} else {
defaultCell
}
}

@ViewBuilder private func singleView(selectionState: CalendarSingleSelectionState?) -> some View {
switch selectionState {
case .single(let date):
if date == dayDate {
singleCell(date: date)
} else {
defaultCell
}
case .wholeMonth(let closedRange, _):
if closedRange.contains(dayDate) {
rangeCell(closedRange: closedRange, highlightRangeEnds: false)
} else {
defaultCell
}
case .none:
defaultCell
}
}

private var defaultCell: some View {
DefaultCalendarDayCell(calendar: calendar, date: dayDate)
.accessibilityLabel(Text(
accessibilityProvider.rangeSelection.accessibilityLabel(for: dayDate)
))
}

private func singleCell(date: Date) -> some View {
SingleSelectedCell(calendar: calendar, date: dayDate)
.accessibilityLabel(
Text(
accessibilityProvider.rangeSelection.accessibilityLabel(
for: dayDate,
intermediateSelectionDate: date
)
)
)
}

private func rangeCell(closedRange: ClosedRange<Date>, highlightRangeEnds: Bool) -> some View {
RangeSelectionCalendarDayCell(
date: dayDate,
selection: closedRange,
calendar: calendar,
highlightRangeEnds: highlightRangeEnds
)
.accessibilityLabel(Text(
accessibilityProvider.rangeSelection.accessibilityLabel(
for: dayDate,
selection: closedRange
)
))
.accessibility(addTraits: .isSelected)
}

private func wholeMonthRangeCell(range: ClosedRange<Date>) -> some View {
RangeSelectionCalendarDayCell(
date: dayDate,
selection: range,
calendar: calendar,
highlightRangeEnds: false
)
.accessibilityLabel(Text(
accessibilityProvider.rangeSelection.accessibilityLabel(
for: dayDate,
selection: range
)
))
.accessibility(addTraits: .isSelected)
}

private func initialSelection(_ initialDateSelection: Date, matchesDate date: Date) -> Bool {
let matchingDayComponents = calendar.dateComponents([.year, .month, .day], from: date)
return calendar.date(initialDateSelection, matchesComponents: matchingDayComponents)
cell.onTapGesture(perform: onSelection)
}
}
86 changes: 52 additions & 34 deletions Backpack-SwiftUI/Calendar/Classes/Core/CalendarMonthGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ struct CalendarMonthGrid<

@State private var dayCellHeight: CGFloat = 0
@ViewBuilder let dayCell: (Date) -> DayCell
@ViewBuilder let emptyLeadingDayCell: (EmptyCellInfo) -> EmptyLeadingDayCell
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty cell info handled internally, as it's not something of interes outside this particualr view.

@ViewBuilder let emptyTrailingDayCell: (EmptyCellInfo) -> EmptyTrailingDayCell
@ViewBuilder let emptyLeadingDayCell: () -> EmptyLeadingDayCell
@ViewBuilder let emptyTrailingDayCell: () -> EmptyTrailingDayCell
@ViewBuilder let dayAccessoryView: (Date) -> DayAccessoryView

private let daysInAWeek = 7
Expand All @@ -40,56 +40,74 @@ struct CalendarMonthGrid<
let firstWeekday = calendar.firstWeekday // Locale-aware first day of the week
let weekdayOfMonthStart = calendar.component(.weekday, from: monthDate)
// Calculate the offset based on the first weekday
let weekdaysOffset = (weekdayOfMonthStart - firstWeekday + daysInAWeek) % daysInAWeek
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverting these lines to where they were before

let daysFromPreviousMonth = weekdaysOffset
let emptyDaysLeading = (0..<daysFromPreviousMonth).map { index in
EmptyCellInfo(cellIndex: index, month: monthDate)
}

let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: monthDate)!.count
let totalCellsUsed = numberOfDaysInMonth + daysFromPreviousMonth
let remainingCells = daysInAWeek - (totalCellsUsed % daysInAWeek)
let emptyDaysTrailing = (0..<remainingCells).map { index in
EmptyCellInfo(cellIndex: index, month: monthDate)
}
let daysFromPreviousMonth = (weekdayOfMonthStart - firstWeekday + daysInAWeek) % daysInAWeek

LazyVGrid(
columns: Array(repeating: GridItem(spacing: BPKSpacing.none.value), count: daysInAWeek),
spacing: BPKSpacing.lg.value
) {
// Create cells for the days from the previous month that are shown in the first week of the current month.
ForEach(emptyDaysLeading) { emptyDayInfo in
VStack(spacing: BPKSpacing.none) {
emptyLeadingDayCell(emptyDayInfo)
.frame(height: dayCellHeight)
Spacer(minLength: BPKSpacing.none)
}
}

previousEmptyCells(daysFromPreviousMonth: daysFromPreviousMonth)
let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: monthDate)!.count
// Create cells for the days in the current month
currentMonthDayCell(numberOfDaysInMonth: numberOfDaysInMonth)

// Create cells for the days from the next month that are shown in the last week of the current month
// The total number of cells used is the sum of the number of days in the current month and the number of
// days from the previous month that are shown

if remainingCells < daysInAWeek {
ForEach(emptyDaysTrailing) { emptyDayInfo in
VStack(spacing: BPKSpacing.none) {
emptyTrailingDayCell(emptyDayInfo)
.frame(height: dayCellHeight)
Spacer(minLength: BPKSpacing.none)
}
let totalCellsUsed = numberOfDaysInMonth + daysFromPreviousMonth
let remainingCells = daysInAWeek - (totalCellsUsed % daysInAWeek)

remainingEmptyCells(remainingCells: remainingCells)
}
}

@ViewBuilder
private func previousEmptyCells(daysFromPreviousMonth: Int) -> some View {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted this from the above method to make it easier to read, also introducing the usage of a small internal struct DayCellIdentifiable, as the preiovus range only approach weas leading to mis-reusage of some cells, as they had repeating ids (which was a bug).
Now we can set specific IDs to each cell and make sure they do not repeat across weeks/months in the calendar, fixing the bug where sometimes the cells were not rendering correctly

let preEmptyCells = Array(0..<daysFromPreviousMonth)
.map {
DayCellIdentifiable(id: "pre-\($0)\(monthDate)", index: $0)
}
ForEach(preEmptyCells) { _ in
VStack(spacing: BPKSpacing.none) {
emptyLeadingDayCell()
.frame(height: dayCellHeight)
Spacer(minLength: BPKSpacing.none)
}
}
}

@ViewBuilder
private func remainingEmptyCells(remainingCells: Int) -> some View {
if remainingCells < daysInAWeek {
let remainingEmptyCells = Array(0..<remainingCells)
.map {
DayCellIdentifiable(id: "rem-\($0)\(monthDate)", index: $0)
}
ForEach(remainingEmptyCells) { _ in
VStack(spacing: BPKSpacing.none) {
emptyTrailingDayCell()
.frame(height: dayCellHeight)
Spacer(minLength: BPKSpacing.none)
}
}
}
}

private struct DayCellIdentifiable: Identifiable {
let id: String
let index: Int
}

@ViewBuilder
private func currentMonthDayCell(numberOfDaysInMonth: Int) -> some View {
ForEach(0..<numberOfDaysInMonth, id: \.self) { cellIndex in
let days = Array(0..<numberOfDaysInMonth)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, using the struct rather than a ForEach with a range, that led to bugs before

.map {
DayCellIdentifiable(id: "\(monthDate)\($0)", index: $0)
}
ForEach(days) { cellIndex in
let dayDate = calendar.date(
byAdding: .init(day: cellIndex),
byAdding: .init(day: cellIndex.index),
to: monthDate
)!

Expand Down Expand Up @@ -123,8 +141,8 @@ struct CalendarMonthGrid_Previews: PreviewProvider {
dayCell: { day in
BPKText("\(calendar.component(.day, from: day))")
},
emptyLeadingDayCell: { _ in Color.red },
emptyTrailingDayCell: { _ in Color.green },
emptyLeadingDayCell: { Color.red },
emptyTrailingDayCell: { Color.green },
dayAccessoryView: { _ in
BPKText("$200", style: .caption)
.foregroundColor(.infoBannerSuccessColor)
Expand Down
Loading
Loading