Новый Bottom Sheet
Требует зависимость androidx.compose.material3:material3.
Создаёт Compose bottom sheet проекта. Замени {Feature} на назначение sheet, {feature} на имя в
lower camel case, а {package} на целевой пакет.
Фаза 1: создать папку фичи
Создай папку features/{feature}_sheet — постфикс _sheet обязателен, даже если фича сама по
себе не заканчивается на «шит».
Фаза 2: Intent и Model
Внутри features/{feature}_sheet создай папку intent. Папку model создавай только когда sheet
нужны отображаемые данные; если данных нет, не создавай ни папку, ни файл Model.
Файлы:
features/{feature}_sheet/{Feature}BottomSheet.ktfeatures/{feature}_sheet/intent/{Feature}Intent.kt- optional
features/{feature}_sheet/model/{Feature}Model.kt
MVI-типы sheet называются {Feature}Intent и {Feature}Model — без инфикса Sheet. Инфикс
Sheet остаётся только в имени пакета ({feature}_sheet), в composable ({Feature}BottomSheet) и
в идентификаторах на стороне вызывающего экрана (is{Feature}SheetVisible, On{Feature}SheetIntent,
{feature}SheetState). Собственный {Feature}ViewModel у sheet не создаётся — см. Фазу 3.
{Feature}Intent.kt
package {package}.features.{feature}_sheet.intent
import {package}.shared.mvi.Intent
sealed interface {Feature}Intent: Intent {
data object DismissClick: {Feature}Intent
}
Правила:
DismissClickприсутствует всегда и всегда идёт первым интентом вsealed interface.- Для остальных действий (клик по элементу, primary/secondary action и т. д.) добавляй свои
data object/data classзаписи;data objectвсегда идёт передdata class.
{Feature}Model.kt (только если есть данные)
package {package}.features.{feature}_sheet.model
import {package}.shared.mvi.Model
data class {Feature}Model(
val showPrimaryAction: Boolean = false
): Model
{Feature}BottomSheet.kt
package {package}
// Добавь все необходимые импорты
@Composable
fun {Feature}BottomSheet(
state: {Feature}Model,
dispatch: ({Feature}Intent) -> Unit
) {
SharedModalBottomSheet(
dispatch({Feature}Intent.DismissClick) }
) {
SharedLazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(
start = 16.dp,
top = 44.dp,
end = 16.dp,
bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
{Feature}Card(
state = {Feature}CardState(
model = state.item,
dispatch({Feature}Intent.ItemClick) }
)
)
}
if (state.showSecondaryAction) {
item {
{Feature}SecondaryButton(
dispatch({Feature}Intent.SecondaryActionClick) }
)
}
}
if (state.showPrimaryAction) {
item {
Button(
dispatch({Feature}Intent.PrimaryActionClick) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(8.dp)
) {
SharedFixedText(
text = stringResource(AppStrings.{Feature}PrimaryAction),
style = MaterialTheme.typography.medium16.copy(
textAlign = TextAlign.Center
)
)
}
}
}
}
}
}
@PreviewWrapper(ThemeWrapper::class)
@Preview
@Composable
private fun {Feature}BottomSheetPreview(
@PreviewParameter({Feature}ModelPreviewParameterProvider::class) state: {Feature}Model
) {
Box(
modifier = Modifier.fillMaxSize()
) {
{Feature}BottomSheet(
state = state,
dispatch = {}
)
}
}
private class {Feature}ModelPreviewParameterProvider: PreviewParameterProvider<{Feature}Model> {
override val values: Sequence<{Feature}Model>
get() {
val item = {Feature}ItemModel(
id = "sample-id",
title = "Sample item",
subtitle = "Sample details"
)
return sequenceOf(
{Feature}Model(
item = item,
showPrimaryAction = true,
showSecondaryAction = false
),
{Feature}Model(
item = item,
showPrimaryAction = false,
showSecondaryAction = true
)
)
}
}
Правила:
- Используй обёртку
SharedModalBottomSheet, а не сыройModalBottomSheet. - Не задавай
sheetStateвручную в фича-коде —SharedModalBottomSheetуже используетrememberModalBottomSheetState(skipPartiallyExpanded = true)по умолчанию. - Не задавай
containerColor,sheetGesturesEnabledилиdragHandle— уSharedModalBottomSheetуже есть нужные умолчания (dragHandleвсегда рисуетSharedDragHandle()). - Делай preview composable, который сам рендерит bottom sheet, а не отдельный приватный composable только с содержимым.
- Оборачивай preview bottom sheet в
Box(modifier = Modifier.fillMaxSize()); иначе preview может не отрендериться. - Используй анонимизированные тестовые данные для preview, такие как
sample-id,Sample itemиSample details. - Не добавляй пустые строки между соседними блоками
item {}внутриSharedLazyColumn. - Не добавляй
Spacerбез визуального назначения; используйSpacerтолько в конце списка, чтобы создать отступ под последним элементом.
Фаза 3: вызов sheet с экрана
Sheet не хранит собственный ViewModel — видимостью и данными управляет экран, который его открывает.
1. Флаг видимости в Model экрана
В Model вызывающего экрана добавь Boolean-поле is{Feature}SheetVisible — is, имя sheet
(с постфиксом Sheet), затем Visible:
data class {Screen}Model(
val is{Feature}SheetVisible: Boolean = false
): Model
2. Показ sheet
Там, где решаешь открыть sheet, диспатчи reduce, выставляющий флаг в true:
reduce { it.copy(is{Feature}SheetVisible = true) }
3. Обработка intent'ов sheet в ViewModel экрана
Добавь в {Screen}Intent экрана один case On{Feature}SheetIntent, оборачивающий весь
{Feature}Intent целиком — отдельный case на каждый intent sheet не создавай:
sealed interface {Screen}Intent: Intent {
data class On{Feature}SheetIntent(val intent: {Feature}Intent): {Screen}Intent
}
Разбирай intent.intent вложенным when внутри dispatch ViewModel экрана:
override fun dispatch(intent: {Screen}Intent) {
when (intent) {
is {Screen}Intent.On{Feature}SheetIntent -> {
when (intent.intent) {
is {Feature}Intent.DismissClick -> {
reduce { it.copy(is{Feature}SheetVisible = false) }
}
}
}
}
}
4. Отрисовка
Рендери sheet внутри первой, публичной функции экрана ({Screen}Screen, не
{Screen}ScreenContent), сразу после вызова {Screen}ScreenContent(...). В dispatch-лямбде sheet
просто пробрасывай весь intent в On{Feature}SheetIntent, не разбирая его case'ы в самом
composable:
@Composable
fun {Screen}Screen(
viewModel: {Screen}ViewModel = hiltViewModel()
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
{Screen}ScreenContent(
state = state,
dispatch = viewModel::dispatch
)
if (state.is{Feature}SheetVisible) {
{Feature}BottomSheet(
dispatch = { intent -> viewModel.dispatch({Screen}Intent.On{Feature}SheetIntent(intent)) }
)
}
}
Если у sheet есть Model
Когда sheet принимает state, не храни отдельную копию его Model в экране — вычисляй её свойством
get() из уже существующих данных экрана. Имя свойства оканчивается на State, а не на Model
(сам тип остаётся {Feature}Model):
data class {Screen}Model(
val {feature}Item: {Feature}Item = {Feature}Item.Empty,
val is{Feature}SheetVisible: Boolean = false
): Model {
val {feature}SheetState: {Feature}Model
get() = {Feature}Model(
item = {feature}Item
)
}
if (state.is{Feature}SheetVisible) {
{Feature}BottomSheet(
state = state.{feature}SheetState,
dispatch = { intent -> viewModel.dispatch({Screen}Intent.On{Feature}SheetIntent(intent)) }
)
}
Правила:
- Sheet не хранит собственный ViewModel; видимостью управляет
Boolean-полеis{Feature}SheetVisibleвModelвызывающего экрана. - Экран оборачивает весь
{Feature}Intentв один caseOn{Feature}SheetIntent(val intent: {Feature}Intent)своего{Screen}Intent; отдельных case'ов на каждый intent sheet не создавай — разбирай их вложеннымwhenвнутриdispatchViewModel. - Рендери sheet в первой, публичной функции экрана, сразу после вызова
{Screen}ScreenContent(...). - Если sheet нужна Model, вычисляй её свойством
get()вModelэкрана из уже существующих данных — не храни отдельную копию; имя свойства оканчивается наState, а не наModel.