Skip to content

Commit

Permalink
Range selector
Browse files Browse the repository at this point in the history
- add UI implementation of range selections
- add RoundedRangeIllustrator and UnderlineIllustrator
as basic types of usage
- add RangeIllustrator to make it possible write own
illustrators for ranges
  • Loading branch information
WojciechOsak committed Mar 25, 2024
1 parent f9c9f21 commit df5f1e0
Show file tree
Hide file tree
Showing 12 changed files with 314 additions and 62 deletions.
4 changes: 2 additions & 2 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ calendar picker for any platform you want: Android, iOS, Desktop or even Web!

Features:

| Platform | Supported |
| Feature | Supported |
|:--------------------------:|:---------:|
| Single month calendar view ||
| Week calendar ||
Expand All @@ -24,7 +24,7 @@ Features:
| Month/Year picker ||
| Scroll to date animation ||
| Vertical calendar ||
| Range selection | 🔜 |
| Range selection | |

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.wojciechosak.calendar.modifiers

import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import io.wojciechosak.calendar.range.RangeConfig
import kotlinx.datetime.LocalDate

internal fun Modifier.drawRange(
date: LocalDate,
selectedDates: List<LocalDate>,
config: RangeConfig? = null,
) = composed {
if (config == null) return@composed this

drawBehind {
with(config) {
val range =
if (selectedDates.size == 2) {
if (selectedDates.first() >= selectedDates.last()) {
Pair(selectedDates.last(), selectedDates.first())
} else {
Pair(selectedDates.first(), selectedDates.last())
}
} else {
null
}

if (range != null && date == range.second) {
rangeIllustrator.drawEnd(this@drawBehind)
} else if (range != null && date == range.first) {
rangeIllustrator.drawStart(this@drawBehind)
} else if (range != null && date in (range.first..range.second)) {
rangeIllustrator.drawMiddle(this@drawBehind)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.wojciechosak.calendar.range

import androidx.compose.ui.graphics.Color
import io.wojciechosak.calendar.utils.Pallete.LightBlue

data class RangeConfig(
val color: Color = LightBlue,
val rangeIllustrator: RangeIllustrator = RoundedRangeIllustrator(color),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.wojciechosak.calendar.range

import androidx.compose.ui.graphics.drawscope.DrawScope

interface RangeIllustrator {
fun drawEnd(drawScope: DrawScope)

fun drawStart(drawScope: DrawScope)

fun drawMiddle(drawScope: DrawScope)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.wojciechosak.calendar.range

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope

class RoundedRangeIllustrator(
private val color: Color,
) : RangeIllustrator {
override fun drawEnd(drawScope: DrawScope) {
drawScope.apply {
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 180f,
useCenter = true,
)
drawRect(
color = color,
size = size.copy(width = size.width * 0.5f),
)
}
}

override fun drawStart(drawScope: DrawScope) {
drawScope.apply {
drawArc(
color = color,
startAngle = 180f,
sweepAngle = 360f,
useCenter = true,
)
drawRect(
color = color,
size = size.copy(width = size.width * 0.5f),
topLeft = Offset(size.width * 0.5f, 0f),
)
}
}

override fun drawMiddle(drawScope: DrawScope) {
drawScope.apply {
drawRect(color = color)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.wojciechosak.calendar.range

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope

class UnderlineIllustrator(
private val color: Color,
private val y: (Size) -> Float = { it.height },
private val strokeWidth: Float = 10f,
) : RangeIllustrator {
override fun drawEnd(drawScope: DrawScope) {
drawScope.apply {
drawLine(
color = color,
strokeWidth = strokeWidth,
start = Offset(0f, y(size)),
end = Offset(size.width, y(size)),
)
}
}

override fun drawStart(drawScope: DrawScope) {
drawScope.apply {
drawLine(
color = color,
strokeWidth = strokeWidth,
start = Offset(0f, y(size)),
end = Offset(size.width, y(size)),
)
}
}

override fun drawMiddle(drawScope: DrawScope) {
drawScope.apply {
drawLine(
color = color,
strokeWidth = strokeWidth,
start = Offset(0f, y(size)),
end = Offset(size.width, y(size)),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import io.wojciechosak.calendar.config.CalendarConfig
import io.wojciechosak.calendar.config.DayState
import io.wojciechosak.calendar.config.MonthYear
import io.wojciechosak.calendar.config.SelectionMode
import io.wojciechosak.calendar.modifiers.drawRange
import io.wojciechosak.calendar.modifiers.passTouchGesture
import io.wojciechosak.calendar.range.RangeConfig
import io.wojciechosak.calendar.utils.monthLength
import io.wojciechosak.calendar.utils.today
import kotlinx.datetime.DateTimeUnit
Expand Down Expand Up @@ -45,8 +47,9 @@ fun CalendarView(
textAlign = TextAlign.Center,
)
},
selectionMode: SelectionMode = SelectionMode.Multiply(5),
selectionMode: SelectionMode = SelectionMode.Multiply(3),
onDateSelected: (List<LocalDate>) -> Unit = {},
rangeConfig: RangeConfig? = null,
modifier: Modifier = Modifier,
) {
val yearMonth by remember { mutableStateOf(config.value.monthYear) }
Expand Down Expand Up @@ -86,62 +89,107 @@ fun CalendarView(
val weekDaysCount = if (state.showWeekdays) 7 else 0

items(previousMonthDays + daysInCurrentMonth + nextMonthDays + weekDaysCount) { iteration ->
val isWeekdayLabel = state.showWeekdays && iteration < weekDaysCount
val previousMonthDay =
iteration >= weekDaysCount && iteration < weekDaysCount + previousMonthDays
val nextMonthDay =
iteration >= weekDaysCount + previousMonthDays + daysInCurrentMonth
var newDate = LocalDate(year = yearMonth.year, month = yearMonth.month, dayOfMonth = 1)
Item(
iteration = iteration,
config = config,
weekDaysCount = weekDaysCount,
previousMonthDays = previousMonthDays,
daysInCurrentMonth = daysInCurrentMonth,
dayOfWeekLabel = dayOfWeekLabel,
yearMonth = yearMonth,
state = state,
selectionMode = selectionMode,
onDateSelected = onDateSelected,
isActiveDay = isActiveDay,
rangeConfig = rangeConfig,
day = day,
)
}
}
}

if (previousMonthDay && config.value.showPreviousMonthDays) {
newDate =
newDate.plus(iteration - weekDaysCount - previousMonthDays, DateTimeUnit.DAY)
} else if (nextMonthDay && config.value.showNextMonthDays) {
newDate =
newDate
.plus(1, DateTimeUnit.MONTH)
.plus(
iteration - previousMonthDays - weekDaysCount - daysInCurrentMonth,
DateTimeUnit.DAY,
)
} else if (!isWeekdayLabel) {
newDate =
newDate.plus(iteration - previousMonthDays - weekDaysCount, DateTimeUnit.DAY)
}
newDate = newDate.plus(state.dayOfWeekOffset, DateTimeUnit.DAY)
@Composable
private fun Item(
iteration: Int,
config: MutableState<CalendarConfig>,
weekDaysCount: Int,
previousMonthDays: Int,
daysInCurrentMonth: Int,
dayOfWeekLabel: @Composable (DayOfWeek) -> Unit,
yearMonth: MonthYear,
state: CalendarConfig,
selectionMode: SelectionMode,
onDateSelected: (List<LocalDate>) -> Unit,
isActiveDay: (LocalDate) -> Boolean,
rangeConfig: RangeConfig?,
day: @Composable (DayState) -> Unit,
) {
val isWeekdayLabel = state.showWeekdays && iteration < weekDaysCount
val previousMonthDay =
iteration >= weekDaysCount && iteration < weekDaysCount + previousMonthDays
val nextMonthDay =
iteration >= weekDaysCount + previousMonthDays + daysInCurrentMonth
var newDate = LocalDate(year = yearMonth.year, month = yearMonth.month, dayOfMonth = 1)

if (state.showWeekdays && iteration + state.dayOfWeekOffset < 7 + state.dayOfWeekOffset) {
val dayOfWeekIndex =
if (iteration + state.dayOfWeekOffset >= DayOfWeek.entries.size) {
iteration + state.dayOfWeekOffset - DayOfWeek.entries.size
} else if (iteration + state.dayOfWeekOffset < 0) {
DayOfWeek.entries.size + iteration + state.dayOfWeekOffset
} else {
iteration + state.dayOfWeekOffset
}
dayOfWeekLabel(DayOfWeek.entries[dayOfWeekIndex])
} else if ((!state.showPreviousMonthDays && previousMonthDay) || (!state.showNextMonthDays && nextMonthDay)) {
Text("")
if (previousMonthDay && config.value.showPreviousMonthDays) {
newDate =
newDate.plus(iteration - weekDaysCount - previousMonthDays, DateTimeUnit.DAY)
} else if (nextMonthDay && config.value.showNextMonthDays) {
newDate =
newDate
.plus(1, DateTimeUnit.MONTH)
.plus(
iteration - previousMonthDays - weekDaysCount - daysInCurrentMonth,
DateTimeUnit.DAY,
)
} else if (!isWeekdayLabel) {
newDate =
newDate.plus(iteration - previousMonthDays - weekDaysCount, DateTimeUnit.DAY)
}
newDate = newDate.plus(state.dayOfWeekOffset, DateTimeUnit.DAY)

if (state.showWeekdays && iteration + state.dayOfWeekOffset < 7 + state.dayOfWeekOffset) {
val dayOfWeekIndex =
if (iteration + state.dayOfWeekOffset >= DayOfWeek.entries.size) {
iteration + state.dayOfWeekOffset - DayOfWeek.entries.size
} else if (iteration + state.dayOfWeekOffset < 0) {
DayOfWeek.entries.size + iteration + state.dayOfWeekOffset
} else {
Box(
modifier =
Modifier.passTouchGesture {
val selectionList = selectDate(date = newDate, mode = selectionMode, list = config.value.selectedDates)
config.value = config.value.copy(selectedDates = selectionList)
onDateSelected(config.value.selectedDates)
},
) {
day(
DayState(
date = newDate,
isActiveDay = isActiveDay(newDate),
isForPreviousMonth = previousMonthDay,
isForNextMonth = nextMonthDay,
enabled = newDate >= state.minDate && newDate <= state.maxDate,
),
)
}
iteration + state.dayOfWeekOffset
}
dayOfWeekLabel(DayOfWeek.entries[dayOfWeekIndex])
} else if ((!state.showPreviousMonthDays && previousMonthDay) || (!state.showNextMonthDays && nextMonthDay)) {
Text("")
} else {
val selectedDates = config.value.selectedDates
Box(
modifier =
Modifier
.passTouchGesture {
val selectionList =
selectDate(
date = newDate,
mode = selectionMode,
list = selectedDates,
)
config.value = config.value.copy(selectedDates = selectionList)
onDateSelected(selectionList)
}
.drawRange(
selectedDates = selectedDates,
date = newDate,
config = rangeConfig,
),
) {
day(
DayState(
date = newDate,
isActiveDay = isActiveDay(newDate),
isForPreviousMonth = previousMonthDay,
isForNextMonth = nextMonthDay,
enabled = newDate >= state.minDate && newDate <= state.maxDate,
),
)
}
}
}
Expand All @@ -165,8 +213,9 @@ private fun selectDate(

SelectionMode.Range -> {
result.add(0, date)
if (result.size >= 2) {
result.removeLast()
if (result.size > 2) {
result.clear()
result.add(0, date)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ fun MonthPicker(
val selectedMonth = Month.entries.getOrNull(index)
Box(
modifier =
Modifier
.passTouchGesture { selectedMonth?.let { month -> onMonthSelected(month) } },
Modifier.passTouchGesture {
selectedMonth?.let { month ->
onMonthSelected(
month,
)
}
},
contentAlignment = Alignment.Center,
) {
selectedMonth?.let { month -> monthView(month) }
Expand Down
1 change: 1 addition & 0 deletions sample/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ kotlin {
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.material)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class MenuScreen : Screen {
item { ScreenButton(WeekViewScreen(), "Week view") }
item { ScreenButton(SingleSelectionScreen(), "Single selection") }
item { ScreenButton(MultipleSelectionScreen(), "Multiple selection") }
item { ScreenButton(AnimationScreen(), "Scroll animation") }
item { ScreenButton(RangeSelectionScreen(), "Range selection (\uD83D\uDD1C)") }
item { ScreenButton(AnimationScreen(), "Animations") }
item { ScreenButton(RangeSelectionScreen(), "Range selection") }
item { ScreenButton(FullDateScreen(), "Full date selector (day/month/year)") }
item { Text("Lib version: 0.0.5") }
}
Expand Down
Loading

0 comments on commit df5f1e0

Please sign in to comment.