Skip to content

Instantly share code, notes, and snippets.

@bmc08gt
Last active December 15, 2023 03:38
Show Gist options
  • Save bmc08gt/aaba6cc1fe804747b24687a4ecf7e1ca to your computer and use it in GitHub Desktop.
Save bmc08gt/aaba6cc1fe804747b24687a4ecf7e1ca to your computer and use it in GitHub Desktop.
Jetpack Compose Calendar Implementation
interface CalendarScope {
var changeMonthAnimation: FiniteAnimationSpec<Float>
var changeMonthSwipeTriggerVelocity: Int
var header: @Composable (day: CalendarDay, actioner: (CalendarAction) -> Unit) -> Unit
var dayLabel: @Composable (dayOfWeek: DayOfWeek, labelWidth: Dp) -> Unit
var day: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay, today: CalendarDay) -> Unit
var inDates: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay) -> Unit
var outDates: @Composable BoxScope.(padding: PaddingValues, dayDate: CalendarDay) -> Unit
}
class CalendarScopeImpl : CalendarScope {
var _animation: FiniteAnimationSpec<Float> = tween(durationMillis = 200)
var _velocity: Int = 300
var _header: @Composable (day: CalendarDay, actioner: (CalendarAction) -> Unit) -> Unit = { _, _ -> }
var _dayLabel: @Composable (dayOfWeek: DayOfWeek, labelWidth: Dp) -> Unit = { dow, _ ->
Text(
modifier = Modifier.padding(4.dp),
text = dow.name.take(3),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Medium)
)
}
var _day: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay, today: CalendarDay) -> Unit = { padding, d, _ ->
Text(
"${d.day}",
modifier = Modifier.padding(padding).align(Alignment.Center),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Medium)
)
}
var _inDates: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay) -> Unit = { padding, d ->
Text(
"${d.day}",
modifier = Modifier.padding(padding).align(Alignment.Center),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f)
)
}
var _outDates: @Composable BoxScope.(padding: PaddingValues, dayDate: CalendarDay) -> Unit = _inDates
override var changeMonthAnimation: FiniteAnimationSpec<Float>
get() = _animation
set(value) {
_animation = value
}
override var changeMonthSwipeTriggerVelocity: Int
get() = _velocity
set(value) {
_velocity = value
}
override var header: @Composable (day: CalendarDay, actioner: (CalendarAction) -> Unit) -> Unit
get() = _header
set(value) {
_header = value
}
override var dayLabel: @Composable (dayOfWeek: DayOfWeek, labelWidth: Dp) -> Unit
get() = _dayLabel
set(value) {
_dayLabel = value
}
override var day: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay, today: CalendarDay) -> Unit
get() = _day
set(value) {
_day = value
}
override var inDates: @Composable BoxScope.(padding: PaddingValues, day: CalendarDay) -> Unit
get() = _inDates
set(value) {
_inDates = value
}
override var outDates: @Composable BoxScope.(padding: PaddingValues, dayDate: CalendarDay) -> Unit
get() = _outDates
set(value) {
_outDates = value
}
}
fun YearMonth.toCalendarDay() = CalendarDay.create(this)
data class CalendarDay(val day: Int, val dayOfWeek: DayOfWeek, val month: YearMonth) {
companion object {
fun create(from: YearMonth = YearMonth.now()) = with(from) {
val today =
SimpleDateFormat("dd", Locale.US).format(Calendar.getInstance().time).toInt()
CalendarDay(
day = today,
dayOfWeek = atDay(today).dayOfWeek,
month = this
)
}
}
fun plusMonth() = create(month.plusMonths(1))
fun minusMonth() = create(month.minusMonths(1))
}
sealed class CalendarAction {
object MoveBack: CalendarAction()
object MoveForward: CalendarAction()
}
@Composable
fun DayLabels(
renderer: @Composable (dow: DayOfWeek) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
for (i in 1..7) {
renderer(DayOfWeek.of(i))
}
}
}
@Composable
fun MonthCalendar(
modifier: Modifier = Modifier,
month: YearMonth = CalendarDay.create().month,
onMonthChanged: (CalendarDay) -> Unit,
properties: CalendarScope.() -> Unit = { }
) {
BoxWithConstraints(modifier = modifier) {
val adjustedScope = CalendarScopeImpl()
adjustedScope.apply(properties)
val todayMonth = YearMonth.now()
val (currentMonth, setCurrentMonth) = remember { mutableStateOf(month) }
fun moveMonthBack() {
val previous = currentMonth.minusMonths(1)
setCurrentMonth(previous)
onMonthChanged(previous.toCalendarDay())
}
fun moveMonthForward() {
val next = currentMonth.plusMonths(1)
setCurrentMonth(next)
onMonthChanged(next.toCalendarDay())
}
Crossfade(
targetState = currentMonth,
animationSpec = adjustedScope.changeMonthAnimation
) { m ->
Box(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = DraggableState {},
onDragStopped = { velocity ->
if (velocity > adjustedScope.changeMonthSwipeTriggerVelocity) {
moveMonthBack()
} else if (velocity < -adjustedScope.changeMonthSwipeTriggerVelocity) {
moveMonthForward()
}
}
)
) {
CalendarMonth(modifier, m, todayMonth, adjustedScope) { action ->
when (action) {
CalendarAction.MoveBack -> moveMonthBack()
CalendarAction.MoveForward -> moveMonthForward()
}
}
}
}
}
}
@Composable
internal fun CalendarMonth(
modifier: Modifier,
month: YearMonth,
todayMonth: YearMonth,
scope: CalendarScope,
actioner: (CalendarAction) -> Unit,
) {
val firstDayOffset = month.atDay(1).dayOfWeek.ordinal
val monthLength = month.lengthOfMonth()
val priorMonthLength = month.minusMonths(1).lengthOfMonth()
val lastDayCount = (monthLength + firstDayOffset) % 7
val weekCount = (firstDayOffset + monthLength) / 7
val today = SimpleDateFormat("dd", Locale.US).format(Calendar.getInstance().time).toInt()
BoxWithConstraints(modifier = modifier) {
val box = this
Column(modifier = Modifier.width(box.maxWidth)) {
scope.header(month.toCalendarDay(), actioner)
for (i in -1..weekCount) {
if (i == -1) {
DayLabels { scope.dayLabel(it, box.maxWidth / 7) }
} else {
CalendarWeek(
startDayOffSet = firstDayOffset,
endDayCount = lastDayCount,
monthWeekNumber = i,
weekCount = weekCount,
priorMonthLength = priorMonthLength,
today = CalendarDay(
day = today,
dayOfWeek = todayMonth.atDay(today).dayOfWeek,
month = todayMonth
),
month = month,
scope = scope
)
}
}
}
}
}
@Composable
internal fun CalendarWeek(
modifier: Modifier = Modifier,
startDayOffSet: Int,
endDayCount: Int,
monthWeekNumber: Int,
weekCount: Int,
priorMonthLength: Int,
today: CalendarDay,
month: YearMonth,
scope: CalendarScope
) {
Layout(
modifier = modifier,
content = {
val padding = PaddingValues(horizontal = 4.dp, vertical = 8.dp)
if (monthWeekNumber == 0) {
for (i in 0 until startDayOffSet) {
val priorDay = (priorMonthLength - (startDayOffSet - i - 1))
Box(modifier = Modifier.fillMaxSize()) {
scope.inDates(
this,
padding,
CalendarDay(
priorDay,
month.minusMonths(1).atDay(priorDay).dayOfWeek,
month.minusMonths(1)
)
)
}
}
}
val endDay = when (monthWeekNumber) {
0 -> 7 - startDayOffSet
weekCount -> endDayCount
else -> 7
}
for (i in 1..endDay) {
val day =
if (monthWeekNumber == 0) i else (i + (7 * monthWeekNumber) - startDayOffSet)
Box(modifier = Modifier.fillMaxSize()) {
scope.day(
this,
padding = padding,
CalendarDay(day, DayOfWeek.of(i), month),
today
)
}
}
if (monthWeekNumber == weekCount && endDayCount > 0) {
for (i in 0 until (7 - endDayCount)) {
val nextMonthDay = i + 1
Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) {
scope.outDates(
this,
padding = padding,
CalendarDay(
nextMonthDay,
month.plusMonths(1).atDay(nextMonthDay).dayOfWeek,
month.plusMonths(1)
)
)
}
}
}
}
) { measurables, constraints ->
val measurablesByWeek = measurables.chunked(7)
val placeables = measurablesByWeek.map { weekOfMeasurables ->
weekOfMeasurables.map { measurable ->
measurable.measure(Constraints.fixedWidth(constraints.maxWidth / 7))
}
}
val heightOfWeeks = placeables.sumOf { weekOfPlaceables ->
weekOfPlaceables.maxOf { it.height }
}
layout(constraints.maxWidth, heightOfWeeks) {
var accHeight = 0
placeables.forEach { weekOfPlaceables ->
var accWidth = 0
weekOfPlaceables.forEach {
it.placeRelative(accWidth, accHeight)
accWidth += it.width
}
accHeight += weekOfPlaceables.maxOf { it.height }
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment