Skip to content

Commit

Permalink
[LMN-20] SwiftUI Calendar component (#1818)
Browse files Browse the repository at this point in the history
* calendar first pass working

* some fixes

* range extracted approach 1

* range extracted approach 2

* range extracted approach 2.1

* single selection

* simplification

* Refactor CalendarMonthGrid to support empty
leading and trailing day cells

* Refactor calendar view to improve selection logic

* Extracting new calendar range and single selection cells.

* Add calendar screenshots and update calendar UI

* Add accessibility configurations and hide
unnecessary elements in Calendar components

* Add date formatter to
SingleCalendarContainer_Previews

* Add accessibility configurations for calendar
selection types in tests.

* Update CalendarMonthGrid.swift

* Refactor calendar selection type to use a binding
for selection state

* Add CalendarRangeSelectionState enum and update
RangeCalendarContainer

* Update SwiftUIScreenshots.swift

* Update Podfile.lock

* Update Gemfile.lock
  • Loading branch information
frugoman authored Nov 22, 2023
1 parent 63a6bcd commit fd00953
Show file tree
Hide file tree
Showing 54 changed files with 1,987 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/// Create a multi-selection configuration with given accessibility strings.
/// - Parameters:
/// - startSelectionHint: The hint provided to assistive technologies informing a user how to select the first
/// date in the range.
/// - endSelectionHint: The hint provided to assistive technologies informing a user how to select the second
/// date in the range.
/// - startSelectionState: The label provided to assistive technologies informing a user that a date is selected
/// as the first date in the range.
/// - endSelectionState: The label provided to assistive technologies informing a user that a date is selected
/// as the second date in the range.
/// - betweenSelectionState: The label provided to assistive technologies informing a user that a date lies
/// between the first and second selected dates.
/// - startAndEndSelectionState: The label provided to assistive technologies informing a user that a date
/// is selected as both the first and second date in the range.
/// - returnDatePrompt: The prompt provided to assistive technologies informing a user that they should now
/// select a second date.
public struct RangeAccessibilityConfigurations {
let startSelectionHint: String
let endSelectionHint: String
let startSelectionState: String
let endSelectionState: String
let betweenSelectionState: String
let startAndEndSelectionState: String
let returnDatePrompt: String

public init(
startSelectionHint: String,
endSelectionHint: String,
startSelectionState: String,
endSelectionState: String,
betweenSelectionState: String,
startAndEndSelectionState: String,
returnDatePrompt: String
) {
self.startSelectionHint = startSelectionHint
self.endSelectionHint = endSelectionHint
self.startSelectionState = startSelectionState
self.endSelectionState = endSelectionState
self.betweenSelectionState = betweenSelectionState
self.startAndEndSelectionState = startAndEndSelectionState
self.returnDatePrompt = returnDatePrompt
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/// Create a single-selection configuration with given accessibility strings.
/// - Parameters:
/// - selectionHint: The hint provided to assistive technologies informing a user how to select a date.
public struct SingleAccessibilityConfigurations {
let selectionHint: String

public init(selectionHint: String) {
self.selectionHint = selectionHint
}
}
129 changes: 129 additions & 0 deletions Backpack-SwiftUI/Calendar/Classes/BPKCalendar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

/// `BPKCalendar` is a SwiftUI view that represents a calendar.
///
/// This view is designed to be customizable and flexible. It allows you to specify the type of selection,
/// the calendar system, and the valid range of dates.
///
/// - Parameters:
/// - selectionType: The type of selection that the calendar should support. This can be single, range, or multiple.
/// - calendar: The calendar system that the calendar should use. This can be any calendar system supported by
/// the `Calendar` struct in Swift.
/// - validRange: The range of dates that the calendar should allow the user to select.
/// This is specified as a`ClosedRange<Date>`.
///
/// The `BPKCalendar` view also allows you to specify an accessory action. This is a closure that takes a string and
/// a date, and is called when the user interacts with an accessory in the calendar.
public struct BPKCalendar: View {
let calendar: Calendar
let selectionType: CalendarSelectionType
let validRange: ClosedRange<Date>
private var accessoryAction: CalendarMonthAccessoryAction?
private let monthHeaderDateFormatter: DateFormatter

@State private var currentlyShownMonth: Date

public init(
selectionType: CalendarSelectionType,
calendar: Calendar,
validRange: ClosedRange<Date>
) {
_currentlyShownMonth = State(initialValue: validRange.lowerBound)
self.validRange = validRange
self.calendar = calendar
self.selectionType = selectionType

monthHeaderDateFormatter = DateFormatter()
monthHeaderDateFormatter.dateFormat = "MMMM yyyy"
}

public var body: some View {
GeometryReader { calendarProxy in
VStack(spacing: BPKSpacing.none) {
CalendarHeader(calendar: calendar)
ZStack {
CalendarTypeContainerFactory(
selectionType: selectionType,
calendar: calendar,
validRange: validRange
) { monthDate in
CalendarMonthHeader(
monthDate: monthDate,
dateFormatter: monthHeaderDateFormatter,
calendar: calendar,
accessoryAction: accessoryAction,
currentlyShownMonth: $currentlyShownMonth,
parentProxy: calendarProxy
)
}
yearBadge
}
}
}
}

private var yearBadge: some View {
VStack {
CalendarBadge(
currentlyShownMonth: currentlyShownMonth,
calendar: calendar
)
.padding(.top, .base)
Spacer()
}
}

/// Sets the accessory action for the calendar to be applied to each month.
public func monthAccessoryAction(_ action: CalendarMonthAccessoryAction) -> BPKCalendar {
var result = self
result.accessoryAction = action
return result
}
}

struct BPKCalendar_Previews: PreviewProvider {
static var previews: some View {
let calendar = Calendar.current
let minValidDate = calendar.date(from: .init(year: 2023, month: 10, day: 12))!
let maxValidDate = calendar.date(from: .init(year: 2025, month: 12, day: 2))!

let minSelectedDate = calendar.date(from: .init(year: 2023, month: 11, day: 19))!
let maxSelectedDate = calendar.date(from: .init(year: 2023, month: 11, day: 30))!

return BPKCalendar(
selectionType: .range(
selection: .constant(.range(minSelectedDate...maxSelectedDate)),
accessibilityConfigurations: .init(
startSelectionHint: "",
endSelectionHint: "",
startSelectionState: "",
endSelectionState: "",
betweenSelectionState: "",
startAndEndSelectionState: "",
returnDatePrompt: ""
)
),
calendar: calendar,
validRange: minValidDate...maxValidDate
)
.monthAccessoryAction(CalendarMonthAccessoryAction(title: "Select whole month", action: { _ in }))
}
}
33 changes: 33 additions & 0 deletions Backpack-SwiftUI/Calendar/Classes/CalendarBadge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct CalendarBadge: View {
let currentlyShownMonth: Date
let calendar: Calendar

var body: some View {
let shownYear = calendar.component(.year, from: currentlyShownMonth)
if shownYear != calendar.component(.year, from: Date()) {
BPKBadge("\(shownYear)")
.badgeStyle(.brand)
.accessibilityHidden(true)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct CalendarSelectableCell<Cell: View>: View {
@ViewBuilder let cell: Cell
let onSelection: () -> Void

var body: some View {
cell.onTapGesture(perform: onSelection)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct DefaultCalendarDayCell: View {
let calendar: Calendar
let date: Date

var body: some View {
BPKText("\(calendar.component(.day, from: date))", style: .label1)
.lineLimit(1)
.padding(.vertical, .md)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct DefaultEmptyCalendarDayCell: View {
var body: some View {
Color.clear
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct DisabledCalendarDayCell: View {
let calendar: Calendar
let date: Date

var body: some View {
BPKText("\(calendar.component(.day, from: date))", style: .label1)
.lineLimit(1)
.foregroundColor(.textDisabledColor)
.frame(maxWidth: .infinity)
.padding(.vertical, .md)
.accessibilityHidden(true)
}
}
Loading

0 comments on commit fd00953

Please sign in to comment.