diff --git a/README.md b/README.md index e112b23..0a3a03c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,14 @@ # Lazy Sticky Headers -Kotlin Multiplatform library for adding sticky items to lazy lists. +Compose Multiplatform library for adding sticky headers to lazy lists. + +## Preview + +

+ + +

## Getting started diff --git a/asset/preview_calendar.gif b/asset/preview_calendar.gif new file mode 100644 index 0000000..5b0a4e2 Binary files /dev/null and b/asset/preview_calendar.gif differ diff --git a/asset/preview_contacts.gif b/asset/preview_contacts.gif new file mode 100644 index 0000000..7419e00 Binary files /dev/null and b/asset/preview_contacts.gif differ diff --git a/demo/composeApp/build.gradle.kts b/demo/composeApp/build.gradle.kts index 5211095..baa053b 100644 --- a/demo/composeApp/build.gradle.kts +++ b/demo/composeApp/build.gradle.kts @@ -106,6 +106,10 @@ android { getByName("release") { isMinifyEnabled = false } + create("composeRelease") { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = false + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/demo/composeApp/src/commonMain/composeResources/drawable/palette.xml b/demo/composeApp/src/commonMain/composeResources/drawable/palette.xml new file mode 100644 index 0000000..71325ab --- /dev/null +++ b/demo/composeApp/src/commonMain/composeResources/drawable/palette.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/App.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/App.kt index 35dfc65..58574cb 100644 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/App.kt +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/App.kt @@ -19,37 +19,24 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController -import me.gingerninja.lazy.sample.list.FINITE_ITEM_COUNT -import me.gingerninja.lazy.sample.list.calendarList -import me.gingerninja.lazy.sample.list.sampleList +import me.gingerninja.lazy.sample.grid.gridScreens +import me.gingerninja.lazy.sample.home.homeScreen +import me.gingerninja.lazy.sample.list.listScreens import me.gingerninja.lazy.sample.ui.theme.LazySampleTheme import org.jetbrains.compose.ui.tooling.preview.Preview @@ -76,56 +63,54 @@ fun App(onSettingsUpdate: (DemoSettings) -> Unit = {}) { Theme.DARK -> true } - LazySampleTheme( - darkTheme = darkTheme, - ) { - if (showSettings) { - SettingsDialog( - settings = settings, - onUpdate = { - settings = it - }, - onDismiss = { - showSettings = false + val layoutDirection = settings.layoutDirection ?: LocalLayoutDirection.current + + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + LazySampleTheme( + darkTheme = darkTheme, + ) { + if (showSettings) { + SettingsDialog( + settings = settings, + onUpdate = { + settings = it + }, + onDismiss = { + showSettings = false + }, + ) + } + + AppContent( + showSettings = { + showSettings = true }, ) } - - AppContent( - settings = settings, - showSettings = { - showSettings = true - }, - ) } } @Composable -private fun AppContent(settings: DemoSettings, showSettings: () -> Unit) { +private fun AppContent(showSettings: () -> Unit) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { AppNavHost( modifier = Modifier.fillMaxSize(), - settings = settings, showSettings = showSettings, ) } } @Composable -private fun AppNavHost( - settings: DemoSettings, - showSettings: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun AppNavHost(showSettings: () -> Unit, modifier: Modifier = Modifier) { val navController = rememberNavController() NavHost( modifier = modifier, navController = navController, - startDestination = Destination.Home.route, + startDestination = Home.route, enterTransition = { fadeIn() + slideInHorizontally { it / 2 } }, @@ -145,125 +130,20 @@ private fun AppNavHost( modifier = Modifier.fillMaxSize(), ) - sampleList( + listScreens( modifier = Modifier.fillMaxSize(), + onScreenClick = { navController.navigate(it.route) }, onBack = { navController.popBackStack() }, ) - calendarList( + gridScreens( modifier = Modifier.fillMaxSize(), + onScreenClick = { navController.navigate(it.route) }, onBack = { navController.popBackStack() }, ) } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SettingsDialog( - settings: DemoSettings, - onUpdate: (DemoSettings) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - ModalBottomSheet( - modifier = modifier, - onDismissRequest = onDismiss, - ) { - var themeSelectorOpen by remember { mutableStateOf(false) } - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - ListItem( - modifier = Modifier.clickable { - themeSelectorOpen = true - }, - headlineContent = { - Text("Theme") - }, - supportingContent = { - Box( - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.TopStart), - ) { - Text(settings.theme.text) - - DropdownMenu( - expanded = themeSelectorOpen, - onDismissRequest = { - themeSelectorOpen = false - }, - ) { - Theme.entries.forEach { - DropdownMenuItem( - text = { - Text(text = it.text) - }, - onClick = { - onUpdate(settings.copy(theme = it)) - themeSelectorOpen = false - }, - ) - } - } - } - }, - ) - ListItem( - modifier = Modifier.clickable { - onUpdate(settings.copy(isVertical = !settings.isVertical)) - }, - headlineContent = { - Text("Vertical layout") - }, - supportingContent = { - val countText = if (settings.isVertical) { - "column" - } else { - "row" - } - - Text("Showing items in a $countText") - }, - trailingContent = { - Switch( - checked = settings.isVertical, - onCheckedChange = { - onUpdate(settings.copy(isVertical = !settings.isVertical)) - }, - ) - }, - ) - - ListItem( - modifier = Modifier.clickable { - onUpdate(settings.copy(isInfinite = !settings.isInfinite)) - }, - headlineContent = { - Text("Infinite items") - }, - supportingContent = { - val countText = if (settings.isInfinite) { - "infinite" - } else { - FINITE_ITEM_COUNT.toString() - } - - Text("Displaying $countText items") - }, - trailingContent = { - Switch( - checked = settings.isInfinite, - onCheckedChange = { - onUpdate(settings.copy(isInfinite = !settings.isInfinite)) - }, - ) - }, - ) - } - } -} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/DemoSettings.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/DemoSettings.kt index cbe55ae..8f252ca 100644 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/DemoSettings.kt +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/DemoSettings.kt @@ -18,10 +18,12 @@ package me.gingerninja.lazy.sample import androidx.compose.runtime.Immutable import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.ui.unit.LayoutDirection @Immutable data class DemoSettings( val theme: Theme = Theme.AUTO, + val layoutDirection: LayoutDirection? = null, val isVertical: Boolean = true, val isInfinite: Boolean = true, ) { @@ -31,20 +33,27 @@ data class DemoSettings( */ val Saver = run { val themeKey = "theme" + val layoutDirKey = "layoutDir" val isVerticalKey = "isVertical" val isInfiniteKey = "isInfinite" mapSaver( save = { mapOf( - themeKey to it.theme.text, + themeKey to it.theme.ordinal, + layoutDirKey to it.layoutDirection?.ordinal, isVerticalKey to it.isVertical, isInfiniteKey to it.isInfinite, ) }, restore = { DemoSettings( - theme = it[themeKey] as Theme, + theme = (it[themeKey] as Int).let { ordinal -> + Theme.entries[ordinal] + }, + layoutDirection = (it[layoutDirKey] as? Int)?.let { ordinal -> + LayoutDirection.entries[ordinal] + }, isVertical = it[isVerticalKey] as Boolean, isInfinite = it[isInfiniteKey] as Boolean, ) diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/Destination.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/Destination.kt index 97c3ab2..78ccb25 100644 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/Destination.kt +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/Destination.kt @@ -15,37 +15,22 @@ */ package me.gingerninja.lazy.sample -sealed class Destination( +import me.gingerninja.lazy.sample.grid.GridDestinations +import me.gingerninja.lazy.sample.list.ListDestinations + +data class Destination( val route: String, val title: String, val description: String? = null, -) { - data object Home : Destination( - route = "home", - title = "Lazy Sticky Headers", - ) - - data object ListVertical : Destination( - route = "list/vertical", - title = "LazyColumn", - description = "Items are placed in a LazyColumn", - ) - - data object ListHorizontal : Destination( - route = "list/horizontal", - title = "LazyRow", - description = "Items are placed in a LazyRow", - ) + val enabled: Boolean = true, +) - data object ListCalendar : Destination( - route = "list/calendar", - title = "Calendar", - description = "Sample calendar schedule view", - ) -} +val Home = Destination( + route = "home", + title = "Lazy Sticky Headers", +) val topDestinations = listOf( - Destination.ListVertical, - Destination.ListHorizontal, - Destination.ListCalendar, + ListDestinations.root, + GridDestinations.root, ) diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/HomeScreen.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/HomeScreen.kt deleted file mode 100644 index a3c4929..0000000 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/HomeScreen.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2024 Gergely Kőrössy - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package me.gingerninja.lazy.sample - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import me.gingerninja.lazy.sample.ui.component.NavCard - -fun NavGraphBuilder.homeScreen( - onSettingsClick: () -> Unit, - onScreenClick: (destination: Destination) -> Unit, - modifier: Modifier = Modifier, -) { - composable(Destination.Home.route) { - HomeScreen( - onSettingsClick = onSettingsClick, - onScreenClick = onScreenClick, - modifier = modifier, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HomeScreen( - onSettingsClick: () -> Unit, - onScreenClick: (destination: Destination) -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - topBar = { - CenterAlignedTopAppBar( - title = { - Text(Destination.Home.title) - }, - actions = { - IconButton( - onClick = onSettingsClick, - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - ) - } - }, - ) - }, - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(it), - ) { - items(topDestinations) { destination -> - NavCard( - title = destination.title, - description = destination.description, - onClick = { - onScreenClick(destination) - }, - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = 20.dp, vertical = 10.dp), - ) - } - } - } -} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/SettingsDialog.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/SettingsDialog.kt new file mode 100644 index 0000000..5615023 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/SettingsDialog.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.LayoutDirection + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SettingsDialog( + settings: DemoSettings, + onUpdate: (DemoSettings) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + var themeSelectorOpen by remember { mutableStateOf(false) } + var layoutDirSelectorOpen by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + ListItem( + modifier = Modifier.clickable { + themeSelectorOpen = true + }, + headlineContent = { + Text("Theme") + }, + supportingContent = { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.TopStart), + ) { + Text(settings.theme.text) + + DropdownMenu( + expanded = themeSelectorOpen, + onDismissRequest = { + themeSelectorOpen = false + }, + ) { + Theme.entries.forEach { + DropdownMenuItem( + text = { + Text(text = it.text) + }, + onClick = { + onUpdate(settings.copy(theme = it)) + themeSelectorOpen = false + }, + ) + } + } + } + }, + ) + + ListItem( + modifier = Modifier.clickable { + layoutDirSelectorOpen = true + }, + headlineContent = { + Text("Layout direction") + }, + supportingContent = { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.TopStart), + ) { + Text( + text = when (settings.layoutDirection) { + LayoutDirection.Ltr -> "LTR" + LayoutDirection.Rtl -> "RTL" + null -> "System" + }, + ) + + DropdownMenu( + expanded = layoutDirSelectorOpen, + onDismissRequest = { + layoutDirSelectorOpen = false + }, + ) { + DropdownMenuItem( + text = { + Text(text = "System") + }, + onClick = { + onUpdate(settings.copy(layoutDirection = null)) + layoutDirSelectorOpen = false + }, + ) + DropdownMenuItem( + text = { + Text(text = "LTR") + }, + onClick = { + onUpdate(settings.copy(layoutDirection = LayoutDirection.Ltr)) + layoutDirSelectorOpen = false + }, + ) + DropdownMenuItem( + text = { + Text(text = "RTL") + }, + onClick = { + onUpdate(settings.copy(layoutDirection = LayoutDirection.Rtl)) + layoutDirSelectorOpen = false + }, + ) + } + } + }, + ) + } + } +} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/grid/GridScreen.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/grid/GridScreen.kt new file mode 100644 index 0000000..b6ebc7d --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/grid/GridScreen.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.grid + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import me.gingerninja.lazy.sample.Destination +import me.gingerninja.lazy.sample.ui.component.NavScreen + +fun NavGraphBuilder.gridScreens( + onBack: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + navigation( + startDestination = "grid/root", + route = GridDestinations.root.route, + ) { + composable("grid/root") { + GridScreen( + onBack = onBack, + onScreenClick = onScreenClick, + modifier = modifier, + ) + } + } +} + +object GridDestinations { + val root = Destination( + route = "grid", + title = "Grids", + description = "Coming soon...", + // description = "LazyVerticalGrid and LazyHorizontalGrid examples", + enabled = false, + ) +} + +private val destinations = listOf() + +@Composable +private fun GridScreen( + onBack: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + NavScreen( + title = GridDestinations.root.title, + subtitle = GridDestinations.root.description, + destinations = destinations, + onScreenClick = onScreenClick, + modifier = modifier, + onBack = onBack, + ) +} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/home/HomeScreen.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/home/HomeScreen.kt new file mode 100644 index 0000000..6fd417d --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/home/HomeScreen.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.home + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import me.gingerninja.lazy.composeapp.generated.resources.Res +import me.gingerninja.lazy.composeapp.generated.resources.palette +import me.gingerninja.lazy.sample.Destination +import me.gingerninja.lazy.sample.Home +import me.gingerninja.lazy.sample.topDestinations +import me.gingerninja.lazy.sample.ui.component.NavScreen +import org.jetbrains.compose.resources.vectorResource + +fun NavGraphBuilder.homeScreen( + onSettingsClick: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + composable(Home.route) { + HomeScreen( + onSettingsClick = onSettingsClick, + onScreenClick = onScreenClick, + modifier = modifier, + ) + } +} + +@Composable +private fun HomeScreen( + onSettingsClick: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + NavScreen( + title = Home.title, + destinations = topDestinations, + onScreenClick = onScreenClick, + modifier = modifier, + actions = { + IconButton( + onClick = onSettingsClick, + ) { + Icon( + imageVector = vectorResource(Res.drawable.palette), + contentDescription = "Settings", + ) + } + }, + ) +} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarList.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarList.kt index 22426c0..5b01446 100644 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarList.kt +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarList.kt @@ -28,6 +28,9 @@ 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.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -41,57 +44,47 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil import kotlinx.datetime.format import kotlinx.datetime.format.DayOfWeekNames import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.Padding import kotlinx.datetime.format.char import kotlinx.datetime.isoDayNumber -import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import me.gingerninja.lazy.StickyHeaders -import me.gingerninja.lazy.sample.DemoSettings -import me.gingerninja.lazy.sample.Destination import kotlin.math.absoluteValue -internal fun NavGraphBuilder.calendarList(onBack: () -> Unit, modifier: Modifier = Modifier) { - composable(Destination.ListCalendar.route) { - CalendarListScreen( - onBack = onBack, - settings = DemoSettings(), - modifier = modifier, - ) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CalendarListScreen( - onBack: () -> Unit, - settings: DemoSettings, - modifier: Modifier = Modifier, -) { +fun CalendarListScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { Scaffold( modifier = modifier, topBar = { TopAppBar( title = { Column { - Text(Destination.ListCalendar.title) + Text("Lazy Sticky Headers") + Text( + ListDestinations.Calendar.title, + style = MaterialTheme.typography.bodyMedium, + ) } }, navigationIcon = { @@ -104,24 +97,10 @@ private fun CalendarListScreen( ) } }, - - /*actions = { - IconButton( - onClick = { - showSettings() - } - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings" - ) - } - }*/ ) }, ) { CalendarList( - settings = settings, modifier = Modifier .fillMaxSize() .padding(it), @@ -130,35 +109,145 @@ private fun CalendarListScreen( } @Composable -private fun CalendarList(settings: DemoSettings, modifier: Modifier = Modifier) { - ScheduleView( - modifier = modifier, - isInfinite = settings.isInfinite, - ) -} - -@Composable -private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { - val startIndex = remember(isInfinite) { - if (isInfinite) Int.MAX_VALUE / 2 else 0 +private fun CalendarList(modifier: Modifier) { + val startIndex = remember { + Int.MAX_VALUE / 2 } val listState = rememberLazyListState( initialFirstVisibleItemIndex = startIndex, ) + val horizontalListState = rememberLazyListState( + initialFirstVisibleItemIndex = startIndex, + ) + val today = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date } - val startDate = remember(isInfinite, today) { - if (isInfinite) { - today - } else { - today.minus(today.dayOfMonth - 1, DateTimeUnit.DAY) + val startDate = remember(today) { + today + } + + LaunchedEffect(listState, horizontalListState) { + launch { + snapshotFlow { listState.firstVisibleItemIndex } + .collectLatest { + horizontalListState.animateScrollToItem( + scheduleIndexToMonthIndex(it, startIndex, startDate), + ) + } } } + Column( + modifier = modifier, + ) { + MonthView( + modifier = Modifier.padding(bottom = 12.dp), + listState = horizontalListState, + startIndex = startIndex, + startDate = startDate, + ) + + ScheduleView( + modifier = Modifier.weight(1f), + listState = listState, + startIndex = startIndex, + startDate = startDate, + today = today, + ) + } +} + +@Composable +private fun MonthView( + listState: LazyListState, + startIndex: Int, + startDate: LocalDate, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + StickyHeaders( + modifier = Modifier.fillMaxWidth(), + state = listState, + key = { item -> + val date = startDate.plus(item.index - startIndex, DateTimeUnit.DAY) + + LocalDate(date.year, date.month, 1) + }, + ) { + val formatter = LocalDate.Format { + monthName(MonthNames.ENGLISH_FULL) + chars(" ") + year() + } + + Text( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp), + text = it.key.format(formatter), + style = MaterialTheme.typography.labelSmall, + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + state = listState, + ) { + monthViewDayItems(startIndex, startDate) + } + } +} + +private fun LazyListScope.monthViewDayItems(startIndex: Int, startDate: LocalDate) { + items( + count = Int.MAX_VALUE, + key = { it }, + ) { + val date = startDate.plus(it - startIndex, DateTimeUnit.DAY) + + val formatter = LocalDate.Format { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + } + + val dateHeader = formatter.format(date).let { day -> + day.firstOrNull()?.toString() ?: day + } + + Column( + // modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .width(50.dp), + // .padding(horizontal = 10.dp, vertical = 10.dp) + // .fillParentMaxWidth(1 / 7f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(bottom = 10.dp), + text = dateHeader, + style = MaterialTheme.typography.labelSmall, + ) + + Text( + modifier = Modifier, + text = "${date.dayOfMonth}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ScheduleView( + listState: LazyListState, + startIndex: Int, + startDate: LocalDate, + today: LocalDate, + modifier: Modifier = Modifier, +) { fun getDataByIndex(index: Int) = calculateDataByIndex(index, startIndex, startDate) fun getItemTypeByIndex(index: Int): ScheduleItemType { @@ -171,14 +260,13 @@ private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { state = listState, ) { items( - count = if (isInfinite) Int.MAX_VALUE else FINITE_ITEM_COUNT, + count = Int.MAX_VALUE, key = { it }, contentType = ::getItemTypeByIndex, ) { val data = getDataByIndex(it) when (data.type) { - ScheduleItemType.MONTH -> TODO() ScheduleItemType.WEEK -> { ScheduleWeek( startDate = data.date, @@ -207,7 +295,7 @@ private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { .padding(start = 20.dp) .fillMaxHeight(), state = listState, - stickyKeyFactory = { item -> + key = { item -> val itemKey = getDataByIndex(item.index) val itemType = item.contentType as ScheduleItemType @@ -232,9 +320,9 @@ private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) } Text( - text = it.format(formatter), + text = it.key.format(formatter), style = MaterialTheme.typography.labelSmall, - color = if (today == it) { + color = if (today == it.key) { MaterialTheme.colorScheme.secondary } else { Color.Unspecified @@ -244,7 +332,7 @@ private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { modifier = Modifier .aspectRatio(1f) .run { - if (today == it) { + if (today == it.key) { background( color = MaterialTheme.colorScheme.secondary, shape = CircleShape, @@ -258,8 +346,8 @@ private fun ScheduleView(modifier: Modifier, isInfinite: Boolean) { Text( modifier = Modifier.padding(bottom = 2.dp), textAlign = TextAlign.Center, - text = "${it.dayOfMonth}", - color = if (today == it) { + text = "${it.key.dayOfMonth}", + color = if (today == it.key) { MaterialTheme.colorScheme.onSecondary } else { MaterialTheme.colorScheme.onSurface @@ -324,10 +412,11 @@ private fun ScheduleItem(title: String, subtitle: String, modifier: Modifier = M } } -private class CalculatedDate( - val date: LocalDate, - val type: ScheduleItemType, -) +private fun scheduleIndexToMonthIndex(index: Int, startIndex: Int, startDate: LocalDate): Int { + val original = calculateDataByIndex(index, startIndex, startDate) + + return startIndex + startDate.daysUntil(original.date) +} private fun calculateDataByIndex( index: Int, @@ -409,8 +498,12 @@ private object DateFormatters { } } +private class CalculatedDate( + val date: LocalDate, + val type: ScheduleItemType, +) + private enum class ScheduleItemType { - MONTH, WEEK, ITEM, } diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarRowList.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarRowList.kt new file mode 100644 index 0000000..09500a3 --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/CalendarRowList.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.DayOfWeekNames +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import me.gingerninja.lazy.StickyHeaders + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarRowListScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Column { + Text("Lazy Sticky Headers") + Text("Calendar row", style = MaterialTheme.typography.bodyMedium) + } + }, + navigationIcon = { + IconButton( + onClick = onBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { + CalendarRowList( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) + } +} + +@Composable +private fun CalendarRowList(modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + + val startDate = remember { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + + today.minus(today.dayOfMonth - 1, DateTimeUnit.DAY) + } + + val formatter = LocalDate.Format { + monthName(MonthNames.ENGLISH_FULL) + chars(" ") + year() + } + + Column( + modifier = modifier, + ) { + StickyHeaders( + modifier = Modifier + .fillMaxWidth(), + state = listState, + key = { item -> + val date = startDate.plus(item.index, DateTimeUnit.DAY) + + LocalDate(date.year, date.month, 1) + }, + ) { + Text( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp), + text = it.key.format(formatter), + style = MaterialTheme.typography.labelSmall, + ) + } + + LazyRow( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + state = listState, + ) { + horizontalListItems(startDate) + } + } +} + +private fun LazyListScope.horizontalListItems(startDate: LocalDate) { + items( + count = Int.MAX_VALUE, + key = { it }, + ) { + val date = startDate.plus(it, DateTimeUnit.DAY) + + val formatter = LocalDate.Format { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + } + + val dateHeader = formatter.format(date).let { day -> + day.firstOrNull()?.toString() ?: day + } + + Column( + modifier = Modifier.width(50.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(bottom = 10.dp), + text = dateHeader, + style = MaterialTheme.typography.labelSmall, + ) + + Text( + modifier = Modifier, + text = "${date.dayOfMonth}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ContactList.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ContactList.kt new file mode 100644 index 0000000..981e7ef --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ContactList.kt @@ -0,0 +1,348 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.gingerninja.lazy.StickyHeaders + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactListScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Column { + Text("Lazy Sticky Headers") + Text("Contacts", style = MaterialTheme.typography.bodyMedium) + } + }, + navigationIcon = { + IconButton( + onClick = onBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { + ContactList( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) + } +} + +@Composable +private fun ContactList(modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items( + items = names, + key = { it }, + ) { + Box( + modifier = Modifier + .padding( + start = 20.dp, + end = 20.dp, + top = 10.dp, + bottom = 10.dp, + ) + .padding(start = 50.dp) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(20.dp), + ) { + Text(text = it, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + + StickyHeaders( + modifier = Modifier + .padding(start = 10.dp) + .fillMaxHeight(), + state = listState, + key = { item -> + names[item.index].first() + }, + ) { + Box( + modifier = Modifier + .padding(vertical = 10.dp) + .width(50.dp) + // .padding(vertical = 20.dp) + // .border(1.dp, Color.Gray, MaterialTheme.shapes.medium) + .padding(horizontal = 10.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "${it.key}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + /*LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + state = listState + ) { + verticalListItems(startDate, settings) + }*/ + } +} + +private val names = listOf( + "Aaryan Blake", + "Abby Blackburn", + "Adil Stanley", + "Aimee Salazar", + "Aishah Escobar", + "Aiza Bonner", + "Alexa Mcmahon", + "Alison Franklin", + "Alissa Yates", + "Amanda Potts", + "Ameer Zimmerman", + "Andre Russell", + "Angel Mcdaniel", + "Antonia Frederick", + "Antony Villarreal", + "Ariana Joyce", + "Athena Fields", + "Austin Gutierrez", + "Autumn Mclaughlin", + "Ayla Mejia", + "Aysha Blevins", + "Bailey Park", + "Bartosz Lambert", + "Beatrix Huffman", + "Beth Meyers", + "Bethany Guerra", + "Betsy Jensen", + "Brooke Rivers", + "Brooklyn Gilbert", + "Bruce Downs", + "Callie Pierce", + "Candice Petty", + "Carl Stevenson", + "Carol Byrd", + "Caroline Whitney", + "Catherine Reyes", + "Chad Lozano", + "Christian Yoder", + "Clementine Mccann", + "Connor Clarke", + "Connor Estrada", + "Daisy Mayer", + "Dalton Shaw", + "Daniel Osborne", + "Daniela Graham", + "Daniela Mata", + "Darcey Gregory", + "Darren Nixon", + "Deborah Frye", + "Delores Casey", + "Demi-Leigh Vega", + "Eddie Baxter", + "Edmund Ware", + "Eesa Simpson", + "Elena Mcgowan", + "Eleni Howe", + "Eliot Cortez", + "Eliza Pugh", + "Elsa Gates", + "Elspeth Moss", + "Emre Parsons", + "Esha Acosta", + "Evie Porter", + "Fabian Wallace", + "Fahad Reese", + "Faiza Hubbard", + "Farhan Proctor", + "Farhan Rodgers", + "Fatimah Doherty", + "Felix Myers", + "Findlay Bowen", + "Fletcher Robles", + "Floyd Ortega", + "Franciszek Garza", + "Fred Ali", + "Freyja Holden", + "Garfield Torres", + "Gavin Jenkins", + "Gerald Crane", + "Haaris Austin", + "Habiba Lloyd", + "Hafsa Gilmore", + "Hari O'Reilly", + "Hasan Gonzalez", + "Hashim Bullock", + "Hayden Obrien", + "Hayley Olsen", + "Henrietta Hurst", + "Hollie Mercado", + "Hussain Blankenship", + "Ida Sandoval", + "India Mcdonald", + "Ines Rasmussen", + "Ishaan Perkins", + "Jackson Mccann", + "Jade Blackwell", + "Jan Hunter", + "Jan Walters", + "Jared Malone", + "Jaya Gallegos", + "Jennie Lang", + "Joan Price", + "Joanna Morrow", + "Johnathan Ferguson", + "Johnny Gallagher", + "Jonathan Knowles", + "Jude Cameron", + "Judy Espinoza", + "Karol Horne", + "Katie Nixon", + "Keiran Cordova", + "Kenneth Lamb", + "Keyaan Holt", + "Kieran Velez", + "Kimberly Hickman", + "Kirsten Connor", + "Kitty Everett", + "Kobi Finley", + "Kye Norton", + "Lena Nielsen", + "Leslie Stanley", + "Lorcan David", + "Lydia Lynch", + "Lyra David", + "Mahdi Lynch", + "Marco Sullivan", + "Maria Landry", + "Mariya Brady", + "Markus Herman", + "Markus Watts", + "Martin Macias", + "Mason Horne", + "Matilda Bowman", + "Maximilian Buchanan", + "May Mcintyre", + "Miah Winters", + "Micheal Stephens", + "Mohamad Mckay", + "Mohamed Le", + "Muhammed Escobar", + "Murray Ashley", + "Nadine Webster", + "Nate Combs", + "Nathanael Holder", + "Nelson Welch", + "Nieve Mahoney", + "Nikolas Contreras", + "Noel Strickland", + "Norman Cruz", + "Omari Richard", + "Oscar Koch", + "Patricia Simon", + "Peter Cain", + "Rachel Hendrix", + "Rafael Kemp", + "Rahim Walsh", + "Raihan Gray", + "Raymond Winters", + "Riley Beasley", + "Rosalie Mathis", + "Rosanna Barry", + "Russell Velazquez", + "Sadia Duffy", + "Sadie Beard", + "Safiya Gibbs", + "Safiyyah Franco", + "Saif Mayer", + "Samir Lawson", + "Samuel Crosby", + "Sana Clements", + "Scott Davenport", + "Serena Noble", + "Shreya Gill", + "Sonny Marshall", + "Sulaiman Bates", + "Sulayman Cain", + "Summer Hart", + "Tanya Sims", + "Tariq Luna", + "Terry Bullock", + "Tessa Diaz", + "Tim Butler", + "Tina Lozano", + "Tommy Wilcox", + "Tommy-Lee Carter", + "Tommy-Lee Hutchinson", + "Tomos Martin", + "Tony Hardin", + "Vanessa Snow", + "Veronica Hodges", + "Victor Kelley", + "Wiktor Holmes", + "Wilfred Terry", + "Willie Hoffman", + "Wojciech Ray", + "Yasir Lynn", + "Yousef Glover", + "Zane West", + "Zarah Stephenson", + "Zaynah Petty", +) diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ListScreen.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ListScreen.kt new file mode 100644 index 0000000..ea8a7be --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/ListScreen.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import me.gingerninja.lazy.sample.Destination +import me.gingerninja.lazy.sample.ui.component.NavScreen + +fun NavGraphBuilder.listScreens( + onBack: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + navigation( + startDestination = "list/root", + route = ListDestinations.root.route, + ) { + composable("list/root") { + ListScreen( + onBack = onBack, + onScreenClick = onScreenClick, + modifier = modifier, + ) + } + + composable(ListDestinations.Contact.route) { + ContactListScreen( + modifier = modifier, + onBack = onBack, + ) + } + + composable(ListDestinations.CalendarRow.route) { + CalendarRowListScreen( + modifier = modifier, + onBack = onBack, + ) + } + + composable(ListDestinations.Calendar.route) { + CalendarListScreen( + onBack = onBack, + modifier = modifier, + ) + } + } +} + +object ListDestinations { + val root = Destination( + route = "list", + title = "Lists", + description = "LazyColumn and LazyRow examples", + ) + internal val Contact = Destination( + route = "list/contact", + title = "Simple LazyColumn", + description = "A vertical list showcasing a simple contact list", + ) + + internal val CalendarRow = Destination( + route = "list/calendar-row", + title = "Simple LazyRow", + description = "A horizontal list showcasing a calendar row", + ) + + internal val Calendar = Destination( + route = "list/calendar", + title = "Complex calendar", + description = "Sample calendar schedule view", + ) +} + +private val destinations = listOf( + ListDestinations.Contact, + ListDestinations.CalendarRow, + ListDestinations.Calendar, +) + +@Composable +private fun ListScreen( + onBack: () -> Unit, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, +) { + NavScreen( + title = ListDestinations.root.title, + subtitle = ListDestinations.root.description, + destinations = destinations, + onScreenClick = onScreenClick, + modifier = modifier, + onBack = onBack, + ) +} diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/SampleList.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/SampleList.kt deleted file mode 100644 index 2c2ae29..0000000 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/list/SampleList.kt +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Copyright 2024 Gergely Kőrössy - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package me.gingerninja.lazy.sample.list - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -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.unit.dp -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import kotlinx.datetime.Clock -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.format -import kotlinx.datetime.format.DayOfWeekNames -import kotlinx.datetime.format.MonthNames -import kotlinx.datetime.isoDayNumber -import kotlinx.datetime.minus -import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import me.gingerninja.lazy.StickyHeaders -import me.gingerninja.lazy.sample.DemoSettings -import me.gingerninja.lazy.sample.Destination - -internal fun NavGraphBuilder.sampleList(onBack: () -> Unit, modifier: Modifier = Modifier) { - composable(Destination.ListVertical.route) { - SampleListScreen( - onBack = onBack, - settings = DemoSettings(isVertical = true), - title = Destination.ListVertical.title, - modifier = modifier, - ) - } - - composable(Destination.ListHorizontal.route) { - SampleListScreen( - onBack = onBack, - settings = DemoSettings(isVertical = false), - title = Destination.ListHorizontal.title, - modifier = modifier, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SampleListScreen( - onBack: () -> Unit, - settings: DemoSettings, - title: String, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Column { - Text(title) - } - }, - navigationIcon = { - IconButton( - onClick = onBack, - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = "Back", - ) - } - }, - - /*actions = { - IconButton( - onClick = { - showSettings() - } - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings" - ) - } - }*/ - ) - }, - ) { - SampleList( - settings = settings, - modifier = Modifier - .fillMaxSize() - .padding(it), - ) - } -} - -@Composable -private fun SampleList(settings: DemoSettings, modifier: Modifier = Modifier) { - val listState = rememberLazyListState() - - val startDate = remember { - val today = - Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - - /*val diff = today.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber - - today.minus(diff, DateTimeUnit.DAY)*/ - today.minus(today.dayOfMonth - 1, DateTimeUnit.DAY) - } - - val formatter = LocalDate.Format { - monthName(MonthNames.ENGLISH_ABBREVIATED) - } - - if (settings.isVertical) { - Box(modifier = modifier) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - ) { - verticalListItems(startDate, settings) - } - - StickyHeaders( - modifier = Modifier - .padding(start = 20.dp) - .fillMaxHeight(), - state = listState, - stickyKeyFactory = { item -> - val date = startDate.plus(item.index, DateTimeUnit.DAY).let { - it.minus( - value = it.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber, - unit = DateTimeUnit.DAY, - ) - } - - date - }, - ) { - Column( - modifier = Modifier - // .fillMaxWidth() - .width(50.dp) - .padding(vertical = 10.dp) - .clip(MaterialTheme.shapes.medium) - .border(1.dp, Color.Gray, MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 10.dp, vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - val endOfWeek = it.plus(6, DateTimeUnit.DAY) - - Text( - text = it.format(formatter), - style = MaterialTheme.typography.labelSmall, - ) - Text("${it.dayOfMonth}") - - Box( - Modifier - .padding(vertical = 4.dp) - .size(20.dp, 4.dp) - .background( - color = MaterialTheme.colorScheme.tertiary, - shape = CircleShape, - ), - ) - - if (endOfWeek.month != it.month) { - Text( - text = endOfWeek.format(formatter), - style = MaterialTheme.typography.labelSmall, - ) - } - Text("${endOfWeek.dayOfMonth}") - } - } - - /*LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - state = listState - ) { - verticalListItems(startDate, settings) - }*/ - } - } else { - Column( - modifier = modifier, - ) { - StickyHeaders( - modifier = Modifier - .fillMaxWidth(), - state = listState, - stickyKeyFactory = { item -> - val date = startDate.plus(item.index, DateTimeUnit.DAY) - - LocalDate(date.year, date.month, 1) - }, - ) { - val formatter = LocalDate.Format { - monthName(MonthNames.ENGLISH_FULL) - chars(" ") - year() - } - - Text( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp), - text = it.format(formatter), - style = MaterialTheme.typography.labelSmall, - ) - } - - LazyRow( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - state = listState, - ) { - horizontalListItems(startDate, settings) - } - } - } -} - -private fun LazyListScope.verticalListItems(startDate: LocalDate, settings: DemoSettings) { - items( - count = if (settings.isInfinite) Int.MAX_VALUE else FINITE_ITEM_COUNT, - key = { it }, - ) { - val date = startDate.plus(it, DateTimeUnit.DAY) - - if (date.dayOfMonth % 3 != 0) { - Card( - onClick = {}, - modifier = Modifier - .padding( - start = 20.dp, - end = 20.dp, - top = 10.dp, - bottom = 10.dp, - ) - .padding(start = 70.dp) - .fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(20.dp), - ) { - Text( - "Day card", - style = MaterialTheme.typography.titleMedium, - ) - Text( - "$date", - ) - } - } - } else { - Box( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.tertiaryContainer) - .padding(40.dp), - ) { - Text("Full-size item at $date") - } - } - } -} - -private fun LazyListScope.horizontalListItems(startDate: LocalDate, settings: DemoSettings) { - items( - count = if (settings.isInfinite) Int.MAX_VALUE else FINITE_ITEM_COUNT, - key = { it }, - ) { - val date = startDate.plus(it, DateTimeUnit.DAY) - - val formatter = LocalDate.Format { - dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) - } - - val dateHeader = formatter.format(date).let { day -> - day.firstOrNull()?.toString() ?: day - } - - Column( - // modifier = Modifier.fillMaxWidth(), - modifier = Modifier - .width(50.dp), - // .padding(horizontal = 10.dp, vertical = 10.dp) - // .fillParentMaxWidth(1 / 7f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.padding(bottom = 10.dp), - text = dateHeader, - style = MaterialTheme.typography.labelSmall, - ) - - Text( - modifier = Modifier, - text = "${date.dayOfMonth}", - style = MaterialTheme.typography.bodyMedium, - ) - } - } -} - -const val FINITE_ITEM_COUNT = 100 diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavCard.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavCard.kt index 190fd02..984f3e6 100644 --- a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavCard.kt +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavCard.kt @@ -32,9 +32,11 @@ fun NavCard( description: String?, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Card( onClick = onClick, + enabled = enabled, colors = CardDefaults.outlinedCardColors(), border = CardDefaults.outlinedCardBorder(), modifier = modifier, diff --git a/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavScreen.kt b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavScreen.kt new file mode 100644 index 0000000..6639bde --- /dev/null +++ b/demo/composeApp/src/commonMain/kotlin/me/gingerninja/lazy/sample/ui/component/NavScreen.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 Gergely Kőrössy + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.gingerninja.lazy.sample.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.gingerninja.lazy.sample.Destination + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NavScreen( + title: String, + destinations: List, + onScreenClick: (destination: Destination) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + actions: @Composable RowScope.() -> Unit = {}, + onBack: (() -> Unit)? = null, +) { + Scaffold( + modifier = modifier, + topBar = { + if (subtitle == null) { + CenterAlignedTopAppBar( + title = { + Text(title) + }, + actions = actions, + navigationIcon = { + onBack?.let { + IconButton( + onClick = it, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back", + ) + } + } + }, + ) + } else { + TopAppBar( + title = { + Column { + Text(title) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + navigationIcon = { + onBack?.let { + IconButton( + onClick = it, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back", + ) + } + } + }, + ) + } + }, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) { + items(destinations) { destination -> + NavCard( + title = destination.title, + description = destination.description, + enabled = destination.enabled, + onClick = { + onScreenClick(destination) + }, + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + ) + } + } + } +} diff --git a/sticky-headers/api/android/sticky-headers.api b/sticky-headers/api/android/sticky-headers.api index 278c3cb..9558e78 100644 --- a/sticky-headers/api/android/sticky-headers.api +++ b/sticky-headers/api/android/sticky-headers.api @@ -2,3 +2,19 @@ public final class me/gingerninja/lazy/StickyHeadersKt { public static final fun StickyHeaders (Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } +public final class me/gingerninja/lazy/StickyInterval { + public static final field $stable I + public fun (Ljava/lang/Object;II)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()I + public final fun component3 ()I + public final fun copy (Ljava/lang/Object;II)Lme/gingerninja/lazy/StickyInterval; + public static synthetic fun copy$default (Lme/gingerninja/lazy/StickyInterval;Ljava/lang/Object;IIILjava/lang/Object;)Lme/gingerninja/lazy/StickyInterval; + public fun equals (Ljava/lang/Object;)Z + public final fun getEndIndex ()I + public final fun getKey ()Ljava/lang/Object; + public final fun getStartIndex ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/sticky-headers/api/desktop/sticky-headers.api b/sticky-headers/api/desktop/sticky-headers.api index 278c3cb..9558e78 100644 --- a/sticky-headers/api/desktop/sticky-headers.api +++ b/sticky-headers/api/desktop/sticky-headers.api @@ -2,3 +2,19 @@ public final class me/gingerninja/lazy/StickyHeadersKt { public static final fun StickyHeaders (Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } +public final class me/gingerninja/lazy/StickyInterval { + public static final field $stable I + public fun (Ljava/lang/Object;II)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()I + public final fun component3 ()I + public final fun copy (Ljava/lang/Object;II)Lme/gingerninja/lazy/StickyInterval; + public static synthetic fun copy$default (Lme/gingerninja/lazy/StickyInterval;Ljava/lang/Object;IIILjava/lang/Object;)Lme/gingerninja/lazy/StickyInterval; + public fun equals (Ljava/lang/Object;)Z + public final fun getEndIndex ()I + public final fun getKey ()Ljava/lang/Object; + public final fun getStartIndex ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/sticky-headers/build.gradle.kts b/sticky-headers/build.gradle.kts index 96b4b64..ef6878d 100644 --- a/sticky-headers/build.gradle.kts +++ b/sticky-headers/build.gradle.kts @@ -13,7 +13,7 @@ plugins { alias(libs.plugins.nexusPlugin) } -version = "0.1.0-alpha01" +version = "0.1.0-alpha02" kotlin { jvm(name = "desktop") diff --git a/sticky-headers/src/commonMain/kotlin/me/gingerninja/lazy/StickyHeaders.kt b/sticky-headers/src/commonMain/kotlin/me/gingerninja/lazy/StickyHeaders.kt index 24c445c..bc90c3e 100644 --- a/sticky-headers/src/commonMain/kotlin/me/gingerninja/lazy/StickyHeaders.kt +++ b/sticky-headers/src/commonMain/kotlin/me/gingerninja/lazy/StickyHeaders.kt @@ -33,10 +33,22 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection +/** + * The sticky item interval container. This represents a single sticky item. + */ @Immutable -private data class StickyInterval( +data class StickyInterval( + /** + * Key of the interval that was returned by `key` in [StickyHeaders]. + */ val key: T, + /** + * Start index of the interval (inclusive). + */ val startIndex: Int, + /** + * End index of the interval (exclusive). + */ val endIndex: Int, ) @@ -49,27 +61,29 @@ private data class StickyInterval( * [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] or a * [LazyRow][androidx.compose.foundation.lazy.LazyRow] with [state]. * - * The items are grouped by the value returned by [stickyKeyFactory]. This grouping only occurs in + * The items are grouped by the value returned by [key]. This grouping only occurs in * a consecutive order, meaning that if the function returns the same value for two non-consecutive * items, two sticky headers will be created, thus this is generally discouraged. - * When the [stickyKeyFactory] returns `null`, it acts as a boundary for the sticky items before / + * When the [key] returns `null`, it acts as a boundary for the sticky items before / * after. * * @param state the [LazyListState] of the list - * @param stickyKeyFactory key factory function for the sticky items + * @param key key factory function for the sticky items * @param modifier [Modifier] applied to the container of the sticky items * @param content sticky item content */ @Composable fun StickyHeaders( state: LazyListState, - stickyKeyFactory: (item: LazyListItemInfo) -> T?, + key: (item: LazyListItemInfo) -> T?, + // contentType: (item: LazyListItemInfo) -> Any? = { null }, modifier: Modifier = Modifier, - content: @Composable (stickyKey: T) -> Unit, + // TODO use the StickyInterval instead of T? + content: @Composable (stickyKey: StickyInterval) -> Unit, ) { val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - val keyFactory = rememberUpdatedState(stickyKeyFactory) + val keyFactory = rememberUpdatedState(key) val orientation by remember(state) { derivedStateOf { @@ -94,21 +108,21 @@ fun StickyHeaders( buildList { items.forEach { item -> - val key = keyFactory.value(item) + val currentKey = keyFactory.value(item) if (!initKeySet) { initKeySet = true - lastKey = key + lastKey = currentKey } - if (lastKey != key) { + if (lastKey != currentKey) { lastKey?.also { add( StickyInterval(it, lastIndex, item.index), ) } - lastKey = key + lastKey = currentKey lastIndex = item.index } } @@ -126,8 +140,8 @@ fun StickyHeaders( Box( modifier = modifier.clipToBounds(), ) { - keys.forEach { (key, start, end) -> - key(key) { // TODO ReusableContentHost { }, see LazyLayoutItemContentFactory + keys.forEach { interval -> + key(interval.key) { // TODO ReusableContentHost { }, see LazyLayoutItemContentFactory Box( modifier = Modifier .run { @@ -140,10 +154,11 @@ fun StickyHeaders( } } .graphicsLayer { - val next = - state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == end } - val item = - state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == start } + val next = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == interval.endIndex } + + val item = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == interval.startIndex } val nextOffset = next?.offset ?: Int.MAX_VALUE @@ -173,7 +188,7 @@ fun StickyHeaders( } }, ) { - content(key) + content(interval) } } }