Last active
December 15, 2023 03:38
-
-
Save bmc08gt/aaba6cc1fe804747b24687a4ecf7e1ca to your computer and use it in GitHub Desktop.
Jetpack Compose Calendar Implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
sealed class CalendarAction { | |
object MoveBack: CalendarAction() | |
object MoveForward: CalendarAction() | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun DayLabels( | |
renderer: @Composable (dow: DayOfWeek) -> Unit | |
) { | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceEvenly | |
) { | |
for (i in 1..7) { | |
renderer(DayOfWeek.of(i)) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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 | |
) | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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