https://github.com/claustrofob/Yotei
A modular, customizable Swift calendar for iOS - SwiftUI API with UIKit performance. Date picker, schedule list, day timeline, week strip, and more.
https://github.com/claustrofob/Yotei
calendar calendar-component calendar-view datepicker day-view ios ios16 schedule schedule-view spm swift swift-calendar swiftui swiftui-calendar swiftui-components timeline uikit week-view
Last synced: 10 days ago
JSON representation
A modular, customizable Swift calendar for iOS - SwiftUI API with UIKit performance. Date picker, schedule list, day timeline, week strip, and more.
- Host: GitHub
- URL: https://github.com/claustrofob/Yotei
- Owner: claustrofob
- License: mit
- Created: 2026-03-24T14:49:15.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-05-08T12:33:46.000Z (about 2 months ago)
- Last Synced: 2026-05-29T00:28:38.261Z (about 1 month ago)
- Topics: calendar, calendar-component, calendar-view, datepicker, day-view, ios, ios16, schedule, schedule-view, spm, swift, swift-calendar, swiftui, swiftui-calendar, swiftui-components, timeline, uikit, week-view
- Language: Swift
- Homepage:
- Size: 738 KB
- Stars: 126
- Watchers: 3
- Forks: 4
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
- fucking-awesome-swift - Yotei - A modular, customizable SwiftUI/UIKit calendar package for iOS. (Libs / UI)
- awesome-swift - Yotei - A modular, customizable SwiftUI/UIKit calendar package for iOS. (Libs / UI)
README
# Yotei[^1]
[](https://swift.org)
[](https://developer.apple.com)
[](https://swift.org/package-manager)
[](LICENSE)
A highly modular, highly customizable calendar package for iOS. Built with SwiftUI and UIKit under the hood for the best performance and native feel.
Every component can be used on its own or composed into a full calendar app. Pick only what you need — a date picker, a schedule list, a day timeline, an all-day grid — or wire them all together.

## Table of Contents
- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Typed Event Data](#typed-event-data)
- [Colors Customization](#colors-customization)
- [Fonts Customization](#fonts-customization)
- [Customization with View Factories](#customization-with-view-factories)
- [Handling User Interaction](#handling-user-interaction)
- [Drag to Reschedule](#drag-to-reschedule)
- [Example App](#example-app)
- [Roadmap](#roadmap)
- [License](#license)
## Features
- **Composable by design.** Each view is an independent SwiftUI `View` — use one, use several, arrange them however your layout requires.
- **SwiftUI API, UIKit performance.** Heavy surfaces (scrolling schedule list, paging strip, date tabs) are backed by `UICollectionView` and `UIPageViewController` for smooth scrolling even with thousands of events.
- **Deep customization via view factories.** Every cell, header, button, marker, and layout metric is produced by a protocol you can implement — no subclassing, no private API, no fighting the framework.
- **Calendar-aware.** Respects the `\.calendar` environment, custom time zones, first-weekday settings, and locale-driven symbols.
- **Drop-in defaults, escape hatches everywhere.** Start with `YoteiScheduleView(...)` and ship in two lines. Need a branded event pill? Implement one factory method. Need a fully custom day cell? Implement another. You are never locked in.
- **Modern Swift.** Swift 6.2, strict concurrency, `@MainActor`-correct factories, `Sendable` domain types.
- **Production ready.** Support for iOS 16+ makes Yotei available not only for modern startups but even for mature projects.
## Requirements
- iOS 16+
- Swift 6.2+
- Xcode 16+
## Installation
### Swift Package Manager
Add Yotei to your `Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/claustrofob/Yotei.git", branch: "main"),
]
```
Then add it to your target:
```swift
.target(
name: "YourApp",
dependencies: ["Yotei"]
)
```
Or in Xcode: **File → Add Package Dependencies…** and enter `https://github.com/claustrofob/Yotei.git`.
Then import where needed:
```swift
import Yotei
```
## Quick Start
A minimal agenda-style calendar with the built-in strip, weekday titles, and a scrolling schedule list:
```swift
import SwiftUI
import Yotei
struct CalendarScreen: View {
@State private var focusedDate = Date()
@State private var data = YoteiEventsInterval()
var body: some View {
VStack(spacing: 0) {
YoteiWeekdayTitlesView()
YoteiStripContainerView(focusedDate: $focusedDate)
YoteiScheduleView(
focusedDate: $focusedDate,
data: $data
)
}
.onChange(of: focusedDate) { _ in
// Load events for the visible month and assign them to `data.events`
}
}
}
```
A standalone date picker with a min/max range:
```swift
import SwiftUI
import Yotei
struct PickerScreen: View {
@State private var selectedDate = Date()
var body: some View {
YoteiDatePicker(
selectedDate: $selectedDate,
minDate: Calendar.current.date(byAdding: .day, value: -1, to: Date()),
maxDate: Calendar.current.date(byAdding: .month, value: 2, to: Date())
)
.padding()
}
}
```
A full day view with an all-day header and a scrollable hour timeline:
```swift
YoteiPagesDayView(focusedDate: $focusedDate) { date in
VStack(spacing: 0) {
YoteiAllDayEventsTopView(
startDate: date,
numberOfDays: 1,
data: $data
)
YoteiDayEventsView(
dayDate: date,
numberOfDays: 1,
data: $data,
contentOffset: $contentOffset
)
}
}
```
A full month grid with paging between months and multi-day event bars:
```swift
VStack(spacing: 0) {
YoteiWeekdayTitlesView()
YoteiPagesMonthView(focusedDate: $focusedDate) { date in
YoteiPagesMonthPageView(
selectedDate: $focusedDate,
data: $data,
dateInMonth: date
)
}
}
```
## Typed Event Data
`YoteiEvent` is generic over a `Data` payload so you can carry your own domain model alongside the calendar fields without subclassing, wrapping, or type-erasing:
```swift
public struct YoteiEvent: Equatable, Identifiable, Sendable {
public let id: String
public let title: String
public let start: Date
public let end: Date
public let isAllDay: Bool
public let data: Data
}
public typealias YoteiEventData = Equatable & Sendable
```
`Data` is whatever you need — a color, a list of attendees, a remote ID, a source enum, a full DTO from your backend. The only requirement is that it is `Equatable` and `Sendable`.
### Attach your domain model
```swift
nonisolated struct EventPayload: Equatable, Sendable {
let calendarID: String
let tint: Color
let attendees: [String]
let isReadOnly: Bool
}
let event = YoteiEvent(
id: "evt-42",
title: "Design review",
start: start,
end: end,
isAllDay: false,
data: EventPayload(
calendarID: "work",
tint: .indigo,
attendees: ["alex", "sam"],
isReadOnly: false
)
)
```
The generic parameter propagates through the whole pipeline so your payload is available at every extension point, fully typed.
### Read your payload inside factories
```swift
struct TintedDayEventsFactory: YoteiDayEventsViewFactoryProtocol {
func eventView(event: YoteiEvent) -> some View {
YoteiDayEventsViewFactory()
.eventView(event: event)
.tint(event.data.tint)
.opacity(event.data.isReadOnly ? 0.6 : 1.0)
}
}
```
### Don't need extra data?
Use an empty marker struct:
```swift
nonisolated struct EventData: Equatable, Sendable {}
```
You pay nothing for the generic — the payload is a zero-sized field — and you can introduce real data later without rewriting any call sites.
## Colors Customization
Every default view uses standard SwiftUI shape styles — `.tint`, `.background`, `.primary`, `.secondary`, `.tertiary` — so you can re-color the calendar with the standart SwiftUI modifiers:
- `.foregroundStyle(_:_:_:)` - redefine .primary, .secondary and .tertiary styles
- `.backgroundStyle(_:)` - redefine .background style
- `.tint(_:)` - redefine .tint style
You have a few options to set custiom colors in calendar:
- apply the above modifiers globally on calendar component
- aply them on individual default views in [custom view factories](#customization-with-view-factories)
- use your custom views with custom colors in view factories.
### Examples
```swift
VStack(spacing: 0) {
YoteiWeekdayTitlesView()
YoteiStripContainerView(focusedDate: $focusedDate)
YoteiScheduleView(focusedDate: $focusedDate, data: $data)
}
.tint(.purple)
```
Because `.tint` is a normal SwiftUI environment value, you can scope it to one component too — tint only the strip, only the schedule, only one page:
```swift
YoteiStripContainerView(focusedDate: $focusedDate)
.tint(.indigo)
YoteiScheduleView(focusedDate: $focusedDate, data: $data)
.tint(.orange)
```
Inside a view factory you can apply `.tint` on a per-event basis using the typed `event.data` payload — the default event view fills with `.tint`, so changing the tint changes the pill color:
```swift
struct BrandedDayEventsFactory: YoteiDayEventsViewFactoryProtocol {
func eventView(event: YoteiEvent) -> some View {
YoteiDayEventsViewFactory()
.eventView(event: event)
.tint(event.data.tint)
}
}
```
Default cells render text with `.primary` / `.secondary` / `.tertiary` and surfaces with `.background`, so they automatically follow the system's light/dark appearance and any `.preferredColorScheme(_:)` you set. To diverge from the system palette, wrap the default factory output and apply `.foregroundStyle(_:)` or `.background(_:)` on top — there is no need to re-implement the cell.
For anything finer-grained — borders, gradients, conditional colors per state — drop into a [view factory](#customization-with-view-factories) and override only the method you need.
## Fonts Customization
Every default view renders text using a small, shared set of font roles exposed via `YoteiFontStyle`.
The active style lives in the SwiftUI environment under `\.yoteiFontStyle`, so you can override it globally on a calendar component, scope it to an individual view, or apply it inside a view factory.
You have a few options to set custom fonts in calendar:
- inject a custom `YoteiFontStyle` globally on calendar component via the `\.yoteiFontStyle` environment key
- inject it on individual default views in [custom view factories](#customization-with-view-factories)
- use your custom views with custom fonts in view factories.
### Example
Apply a branded font style to the whole calendar:
```swift
VStack(spacing: 0) {
YoteiWeekdayTitlesView()
YoteiStripContainerView(focusedDate: $focusedDate)
YoteiScheduleView(focusedDate: $focusedDate, data: $data)
}
.environment(\.yoteiFontStyle, YoteiFontStyle(
caption: .system(.caption, design: .rounded),
caption2: .system(.caption2, design: .rounded),
body: .system(.body, design: .rounded),
headline: .system(.headline, design: .rounded).weight(.semibold),
subheadline: .system(.subheadline, design: .rounded)
))
```
Scope styles to a single component:
```swift
YoteiStripContainerView(focusedDate: $focusedDate)
.environment(\.yoteiFontStyle, YoteiFontStyle(headline: .title3.bold()))
```
Override individual styles:
```swift
YoteiScheduleView(focusedDate: $focusedDate, data: $data)
.environment(\.yoteiFontStyle.subheadline, .custom("Avenir-Heavy", size: 16))
.environment(\.yoteiFontStyle.caption2, .custom("Avenir-Book", size: 12))
```
## Customization with View Factories
Every event-aware component accepts a **view factory** — a protocol with default implementations. Override only the methods you care about; the rest stay at their defaults.
### Example: custom-colored day-timeline events
```swift
import SwiftUI
import Yotei
struct BrandedDayEventsFactory: YoteiDayEventsViewFactoryProtocol {
private let palette: [Color] = [.red, .blue, .yellow, .green, .purple]
func eventView(event: YoteiEvent) -> some View {
let color = palette[abs(event.id.hashValue) % palette.count]
return YoteiDayEventsViewFactory()
.eventView(event: event)
.tint(color)
}
func timeSlotView(date: Date) -> some View {
MyCustomTimeSlotRow(date: date)
}
}
```
Use it:
```swift
YoteiDayEventsView(
dayDate: focusedDate,
numberOfDays: 1,
data: $data,
contentOffset: $contentOffset,
viewFactory: BrandedDayEventsFactory()
)
```
### Example: custom strip expand indicator
```swift
struct PurpleStripFactory: YoteiStripViewFactoryProtocol {
func expandView(isExpanded: Bool) -> some View {
YoteiStripViewFactory()
.expandView(isExpanded: isExpanded)
.foregroundStyle(.purple)
}
}
YoteiStripContainerView(
focusedDate: $focusedDate,
viewFactory: PurpleStripFactory()
)
```
Because factories are plain structs with protocol-provided defaults, you can start by overriding a single method and add more as your design grows. You can also wrap the default factory (`YoteiScheduleViewFactory()`, `YoteiDayEventsViewFactory()`, etc.) and apply SwiftUI modifiers on top of its output instead of re-implementing a view from scratch.
## Handling User Interaction
Implement `YoteiDelegate` and pass it to calendar using `.yoteiDelegate(_:)` modifier:
```swift
final class CalendarCoordinator: YoteiDelegate {
func calendarDidSelectEvent(with id: YoteiEvent.ID) {
// Open event detail
}
func calendarDidSelectAllDay(date: Date) {
// Show the all-day list for that day
}
func calendarDidSelect(dateInterval: DateInterval, completion: () -> Void) {
// The user tapped an empty time slot — show a "new event" sheet.
// Call completion() to clear the placeholder when the sheet is dismissed.
}
func calendarDidSelectMonthDay(date: Date) {
// The user tapped a day cell in the month view — open the day's agenda or switch scope.
}
func calendarDidUpdateEvent(
with id: YoteiEvent.ID,
oldDateInterval: DateInterval,
newDateInterval: DateInterval
) {
// The user dragged an event to a new time — persist the new interval.
}
}
```
## Drag to Reschedule
`YoteiDragEventView` adds **drag-to-reschedule** to a day or week timeline without forcing you to rebuild the timeline itself. Wrap it around an existing `YoteiPagesDayView` / `YoteiPagesWeekView` (with `YoteiDayEventsView` inside) and the new gesture layer is live — long-press an event to "pick it up", drag it to a new time slot, release to commit.
### What it does for you
- **Long-press + pan gesture.** A long press on an event activates drag mode, and the subsequent pan moves a floating proxy view.
- **Auto-scroll near vertical edges.** When the finger gets close to the top or bottom of the visible area, the inner timeline scrolls automatically — proportional to how close you are to the edge — so you can drop an event onto a time that wasn't on screen.
- **Page flip near horizontal edges.** When the finger lingers near the left or right edge, the focused date jumps one page (one day in `YoteiPagesDayView`, one week in `YoteiPagesWeekView`).
- **Time snapping.** The new start time snaps to a configurable minute interval (default `15`).
- **Cross-day moves.** Dropping an event onto a different day column moves it to that day; the duration is preserved.
- **Delegate-based commit.** On release, Yotei calls `calendarDidUpdateEvent(with:oldDateInterval:newDateInterval:)` on your `YoteiDelegate`. Persist the change in your store and the calendar re-renders from the updated `data`.
### Wiring it up
`YoteiDragEventView` needs the same three bindings the underlying timeline uses (`data`, `contentOffset`, `focusedDate`) so it can read event frames, drive auto-scroll, and trigger page flips. Keep the contained `YoteiPagesDayView` / `YoteiPagesWeekView` and `YoteiDayEventsView` exactly as you had them — just wrap them.
Day view with drag-to-reschedule:
```swift
@State private var focusedDate = Date()
@State private var data = YoteiEventsInterval()
@State private var contentOffset: CGPoint?
var body: some View {
VStack(spacing: 0) {
YoteiWeekdayTitlesView()
YoteiStripContainerView(focusedDate: $focusedDate)
YoteiDragEventView(
data: $data,
contentOffset: $contentOffset,
focusedDate: $focusedDate
) {
YoteiPagesDayView(focusedDate: $focusedDate) { date in
VStack(spacing: 0) {
YoteiAllDayEventsTopView(
startDate: date,
numberOfDays: 1,
data: $data
)
YoteiDayEventsView(
dayDate: date,
numberOfDays: 1,
data: $data,
contentOffset: $contentOffset
)
}
}
}
}
.yoteiDelegate(coordinator)
}
```
### Persisting the new time
Implement `calendarDidUpdateEvent` on your `YoteiDelegate` and update the event in your store. Once `data.events` reflects the move, the calendar re-renders automatically:
```swift
final class CalendarCoordinator: YoteiDelegate {
func calendarDidUpdateEvent(
with id: YoteiEvent.ID,
oldDateInterval: DateInterval,
newDateInterval: DateInterval
) {
store.updateEvent(id: id, newStart: newDateInterval.start, newEnd: newDateInterval.end)
}
// … other YoteiDelegate methods …
}
```
## Example App
A full example app is bundled in `YoteiAppExample/`. It demonstrates different usage examples and possible customization options.
To run it:
1. Clone the repository: `git clone https://github.com/claustrofob/Yotei.git`
2. Open `YoteiAppExample/YoteiAppExample.xcodeproj` in Xcode 16 or newer.
3. Select an iOS 16+ simulator or device.
4. Build and run (`⌘R`).
The example project depends on the local `Yotei` package at the repo root, so any edits you make to `Sources/` are picked up on the next build.
## Roadmap
- [x] Color customization for every component
- [x] Custom views for events
- [x] Stability improvements
- [x] Font customization
- [x] Month view
- [x] Drag/drop to update event time/duration
- [ ] Accessibility
## License
Copyright © 2026 Mikalai Zmachynski. All rights reserved.
[^1]: Named after the Japanese word 予定 (yotei), meaning "schedule" or "planned event".