DateRangePicker в Android

0
(0)

Сейчас я делаю небольшое мобильное приложение и мне понадобилось добавить DateRangePicker. Material 3 из коробки дает такой компонент, но он пока еще помечен аннотацией Experimental. Вообще это штука, которая позволяет выбирать даты от и до. Обычно подобное можно увидеть в приложениях, где есть бронирование чего-либо.

Примерно так выглядит «дефолтный» DateRangePicker, который дает google. Все бы хорошо, но есть один нюанс, он дико тормозит при скроллинге. Я пробовал задать ограничение на один год, чтобы данных было не много, но даже при таком относительно не большом диапозоне, заметил сильные лаги.

На GITHUB наткнулся вот на такую библиотеку. Выглядит удобной, приятный интерфейс, но запустить здесь и сейчас не получилось. Ловил ошибку

Kotlin
java.lang.NoSuchMethodError: No static method AnimatedContent(Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Alignment;Ljava/lang/String;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V in class Landroidx/compose/animation/AnimatedContentKt; or its super classes (declaration of 'androidx.compose.animation.AnimatedContentKt' appears in /data/app/~~MYhe9ojisCju4HrfbWgumw==/com.andtree-eU-zB3fB53lbIo1smpFvBQ==/base.apk)

Сам репозиторий с либой не обновлялся уже почти год. Для мира Android это очень долго, вероятно версии Compose потеряли совместимость.

Поэтому я решил забрать код этого проекта себе и освежить. Сама логика и прочее не трогалось, только обновление import-ов и мелкие правки под текущие реалии.

Для запуска вне библиотеки, нужно забрать себе в пакет эти файлы.

Там менять ничего не нужно, основные манипуляции были в MainDateRangePicker.kt и в MultiDatePickerColors.kt. На момент написания статьи рабочий код выглядит так:

Kotlin
package com.andtree.example.daterangepicker

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowLeft
import androidx.compose.material.icons.rounded.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import java.util.Calendar
import java.util.Date

@Composable
fun MainDateRangePicker(
    modifier: Modifier = Modifier,
    minDate: Date? = null,
    maxDate: Date? = null,
    startDate: MutableState<Date?> = remember { mutableStateOf(null) },
    endDate: MutableState<Date?> = remember { mutableStateOf(null) },
    colors: MultiDatePickerColors = MultiDatePickerColors.defaults(),
    cardRadius: Dp = mediumRadius,
) {
    val localDensity = LocalDensity.current

    val weekDays = listOf(Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY, Calendar.SATURDAY, Calendar.SUNDAY)
    val allYears = (1900..2100).toList()

    val calendar = remember { mutableStateOf(Calendar.getInstance()) }
    val currDate = remember { mutableStateOf(calendar.value.time) }
    val isSelectYear = remember { mutableStateOf(false) }
    val yearScrollState = rememberLazyListState()
    val pickerHeight = remember { mutableStateOf(0.dp) }
    var offsetX by remember { mutableFloatStateOf(0f) }
    var isSliding by remember { mutableStateOf(false) }

    LaunchedEffect(isSelectYear.value) {
        if (isSelectYear.value) {
            val yearIndex = allYears.indexOf(calendar.value.get(Calendar.YEAR)) - 3
            yearScrollState.scrollToItem(yearIndex)
        }
    }

    LaunchedEffect(isSliding) {
        if(!isSliding) {
            if(offsetX > 1) {
                // Remove a month
                currDate.value = calendar.value.apply { add(Calendar.MONTH, -1) }.time
            } else if(offsetX < -1) {
                // Add a month
                currDate.value = calendar.value.apply { add(Calendar.MONTH, 1) }.time
            }
        }
    }

    @Composable
    fun MonthPickerIcon(operation: Operation) {
        return Icon(
            when (operation) {
                Operation.PLUS -> Icons.Rounded.KeyboardArrowRight
                Operation.MINUS -> Icons.Rounded.KeyboardArrowLeft
            },
            contentDescription = when (operation) {
                Operation.PLUS -> "next"
                Operation.MINUS -> "previous"
            },
            tint = colors.iconColor,
            modifier = Modifier
                .clip(CircleShape)
                .clickable {
                    currDate.value = calendar.value.apply {
                        add(
                            Calendar.MONTH,
                            when (operation) {
                                Operation.PLUS -> 1
                                Operation.MINUS -> -1
                            }
                        )
                    }.time
                }
                .padding(xxsmallPadding)
        )
    }

    Column(
        modifier
            .fillMaxWidth()
            .background(color = colors.cardColor, RoundedCornerShape(cardRadius))
            .padding(innerPadding)
    ) {
        /**
         * HEADER
         */
        Row(
            Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(
                text = currDate.value.toMonthYear(),
                style = MaterialTheme.typography.bodyMedium.copy(color = colors.monthColor),
                modifier = Modifier
                    .clip(RoundedCornerShape(smallRadius))
                    .clickable { isSelectYear.value = true }
                    .padding(xxsmallPadding)
            )

            Row {
                MonthPickerIcon(Operation.MINUS)
                Spacer(Modifier.width(xxsmallPadding))
                MonthPickerIcon(Operation.PLUS)
            }
        }
        Spacer(Modifier.height(xxsmallPadding))

        AnimatedContent(
            isSelectYear.value,
            transitionSpec = {
                if (targetState) {
                    fadeIn() togetherWith fadeOut()
                } else {
                    fadeIn() togetherWith fadeOut()
                }
            }, label = ""
        ) { showYearSelector ->
            if (showYearSelector) {
                /**
                 * YEARS SELECTOR
                 */
                LazyColumn(
                    modifier = Modifier
                        .height(pickerHeight.value)
                        .fillMaxWidth(),
                    state = yearScrollState,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    items(allYears) { year ->
                        val isSelected = year == calendar.value.get(Calendar.YEAR)

                        Box(
                            modifier = Modifier
                                .height(pickerHeight.value / 7)
                                .clip(RoundedCornerShape(smallRadius))
                                .clickable {
                                    currDate.value = calendar.value.apply { set(Calendar.YEAR, year) }.time
                                    isSelectYear.value = false
                                },
                            contentAlignment = Alignment.Center
                        ) {
                            Text(
                                year.toString(),
                                style = if (isSelected) MaterialTheme.typography.headlineMedium.copy(color = colors.monthColor, fontWeight = FontWeight.Bold)
                                else MaterialTheme.typography.titleLarge.copy(color = colors.weekDayColor, fontWeight = FontWeight.Light),
                                modifier = Modifier.padding(horizontal = xsmallPadding)
                            )
                        }
                    }
                }
            } else {
                Column(
                    modifier = Modifier.onGloballyPositioned { pickerHeight.value = with(localDensity) { it.size.height.toDp() } }
                ) {
                    /**
                     * DAYS
                     */
                    Row {
                        weekDays.map {
                            Text(
                                text = Calendar.getInstance().apply { set(Calendar.DAY_OF_WEEK, it) }.time.toShortDay(),
                                style = MaterialTheme.typography.bodyMedium.copy(color = colors.weekDayColor),
                                textAlign = TextAlign.Center,
                                modifier = Modifier.weight(1f / 7f)
                            )
                        }
                    }
                    Spacer(Modifier.height(xxsmallPadding))

                    /**
                     * BODY
                     */
                    Column(
                        modifier = Modifier.pointerInput(Unit) {
                            detectDragGestures(
                                onDragStart = { isSliding = true },
                                onDragEnd = { isSliding = false },
                            ) { change, dragAmount ->
                                change.consume()
                                offsetX = dragAmount.x
                            }
                        },
                        verticalArrangement = Arrangement.spacedBy(xxsmallPadding),
                    ) {
                        val daysNumber: IntRange = (1..calendar.value.getActualMaximum(Calendar.DAY_OF_MONTH))
                        val days: List<Date> = daysNumber.map { calendar.value.apply { set(Calendar.DAY_OF_MONTH, it) }.time }
                        val daysItem: MutableList<Date?> = days.toMutableList()
                        // ADD EMPTY ITEMS TO THE BEGINNING OF THE LIST IF FIRST WEEK DAY OF MONTH DON'T START ON THE FIRST DAY OF THE WEEK
                        daysItem.first().let {
                            val dayOfWeek = if (it!!.day == 0) 7 else it.day
                            (1 until dayOfWeek).forEach { _ -> daysItem.add(0, null) }
                        }

                        val daysByWeek: List<MutableList<Date?>> = daysItem.chunked(7) { it.toMutableList() }
                        // ADD EMPTY ITEMS TO THE END OF THE LIST IF LAST WEEK DAY OF MONTH DON'T START ON THE FIRST DAY OF THE WEEK
                        daysByWeek.last().let { (1..7 - it.size).forEach { _ -> daysByWeek.last().add(null) } }

                        daysByWeek.map {
                            Row {
                                it.map { day ->
                                    val isSelected = day != null && (day == startDate.value || day == endDate.value)
                                    val isBetween = day != null
                                            && startDate.value != null
                                            && endDate.value != null
                                            && (day.after(startDate.value) && day.before(endDate.value))
                                    val isEnabled = day != null
                                            && (minDate == null || day.after(minDate) || day == minDate)
                                            && (maxDate == null || day.before(maxDate) || day == maxDate)

                                    val selectedBackgroundColor = animateColorAsState(targetValue = if (isSelected) colors.selectedIndicatorColor else Color.Transparent, label = "")
                                    val textColor = animateColorAsState(
                                        targetValue = if (isSelected) {
                                            colors.selectedDayNumberColor
                                        } else if (!isEnabled) {
                                            colors.disableDayColor
                                        } else {
                                            colors.dayNumberColor
                                        }, label = ""
                                    )

                                    Box(
                                        Modifier
                                            .weight(1f / 7f)
                                            .aspectRatio(1f)
                                            .background(
                                                if (isBetween || isSelected && endDate.value != null) colors.selectedDayBackgroundColor else Color.Transparent,
                                                if (isSelected) RoundedCornerShape(
                                                    topStartPercent = if (day == startDate.value) 100 else 0,
                                                    topEndPercent = if (day == endDate.value) 100 else 0,
                                                    bottomEndPercent = if (day == endDate.value) 100 else 0,
                                                    bottomStartPercent = if (day == startDate.value) 100 else 0,
                                                ) else RoundedCornerShape(0)
                                            )
                                            .clip(CircleShape)
                                            .clickable(enabled = isEnabled) {
                                                if (day != null) {
                                                    if (startDate.value == null) {
                                                        startDate.value = day
                                                    } else if (endDate.value == null) {
                                                        if (day.before(startDate.value)) startDate.value = day
                                                        else if (day.after(startDate.value)) endDate.value = day
                                                        else if (day == startDate.value) startDate.value = null
                                                    } else {
                                                        startDate.value = day
                                                        endDate.value = null
                                                    }
                                                }
                                            }
                                    ) {
                                        Box(
                                            Modifier
                                                .fillMaxSize()
                                                .background(selectedBackgroundColor.value, CircleShape),
                                            contentAlignment = Alignment.Center
                                        ) {
                                            var dayNumber: Int? = null
                                            if (day != null) {
                                                val calendarDay = Calendar.getInstance().apply { time = day }
                                                dayNumber = calendarDay.get(Calendar.DAY_OF_MONTH)
                                            }

                                            Text(
                                                text = dayNumber?.toString() ?: "",
                                                style = MaterialTheme.typography.bodyMedium.copy(color = textColor.value),
                                                textAlign = TextAlign.Center,
                                            )
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Поправим MultiDatePickerColors иначе переопределение цветов не будет работать при вызове Composable с MultiDatePicker.

Kotlin
data class MainDatePickerColors(
    val cardColor: Color,
    val monthColor: Color,
    val iconColor: Color,
    val weekDayColor: Color,
    val dayNumberColor: Color,
    val disableDayColor: Color,
    val selectedDayNumberColor: Color,
    val selectedIndicatorColor: Color,
    val selectedDayBackgroundColor: Color,
) {
    companion object {
        @Composable
        fun defaults(
            cardColor: Color = MaterialTheme.colorScheme.surface,
            monthColor: Color = MaterialTheme.colorScheme.primary,
            iconColor: Color = MaterialTheme.colorScheme.primary,
            weekDayColor: Color = MaterialTheme.colorScheme.onSurface,
            dayNumberColor: Color = MaterialTheme.colorScheme.primary,
            disableDayColor: Color = MaterialTheme.colorScheme.secondary,
            selectedDayNumberColor: Color = MaterialTheme.colorScheme.onPrimary,
            selectedIndicatorColor: Color = MaterialTheme.colorScheme.primary,
            selectedDayBackgroundColor: Color = MaterialTheme.colorScheme.onPrimary,
        ) = MainDatePickerColors(
            cardColor = cardColor,
            monthColor = monthColor,
            iconColor = iconColor,
            weekDayColor = weekDayColor,
            dayNumberColor = dayNumberColor,
            disableDayColor = disableDayColor,
            selectedDayNumberColor = selectedDayNumberColor,
            selectedIndicatorColor = selectedIndicatorColor,
            selectedDayBackgroundColor = selectedDayBackgroundColor,
        )
    }
}

Добавляем пока просто в тестовом виде.

Kotlin
val min = Calendar.getInstance()
min.add(Calendar.DAY_OF_MONTH, -10)
val max = Calendar.getInstance()
max.add(Calendar.DAY_OF_MONTH, 10)
val startDate: MutableState<Date?> = remember { mutableStateOf(null) }
val endDate: MutableState<Date?> = remember { mutableStateOf(null) }
MainDateRangePicker(
    minDate = min.time,
    maxDate = max.time,
    startDate = startDate,
    endDate = endDate,
    colors = MainDatePickerColors.defaults(
      cardColor = Color.Black
    )
)

В работе лагов замечено не было. Скроллинга тут нет, месяца переключаются просто через стрелки. Для меня не принципиально.

Официальная страница проекта.

Насколько статья полезна?

Нажмите на звезду, чтобы оценить!

Средняя оценка 0 / 5. Количество оценок: 0

Оценок пока нет. Поставьте оценку первым.

Оставить комментарий