diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt index a4ee2e12..a5986934 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.mm20.launcher2.ui.locals.LocalCardStyle @@ -14,11 +15,12 @@ fun LauncherCard( modifier: Modifier = Modifier, elevation: Dp = 2.dp, backgroundOpacity: Float = LocalCardStyle.current.opacity, + shape: Shape = MaterialTheme.shapes.medium, content: @Composable () -> Unit = {} ) { Surface( modifier = modifier, - shape = MaterialTheme.shapes.medium, + shape = shape, border = LocalCardStyle.current.borderWidth.takeIf { it > 0 } ?.let { BorderStroke(it.dp, MaterialTheme.colorScheme.surface) }, content = content, diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt index 6d125a6b..bfedb0ef 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt @@ -1,17 +1,17 @@ package de.mm20.launcher2.ui.launcher +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.FractionalThreshold import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Done @@ -61,13 +61,26 @@ fun PagerScaffold( val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false) val widgetsScrollState = rememberScrollState() - val searchScrollState = rememberScrollState() + val searchState = rememberLazyListState() val swipeableState = rememberSwipeableState(if (isSearchOpen) Page.Search else Page.Widgets) + val isSearchAtStart by remember { + derivedStateOf { + searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 + } + } + + val isSearchAtEnd by remember { + derivedStateOf { + val lastItem = searchState.layoutInfo.visibleItemsInfo.last() + lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding + } + } + val showStatusBarScrim by remember { derivedStateOf { if (isSearchOpen) { - searchScrollState.value < searchScrollState.maxValue + !isSearchAtEnd } else { widgetsScrollState.value > 0 } @@ -76,7 +89,7 @@ fun PagerScaffold( val showNavBarScrim by remember { derivedStateOf { if (isSearchOpen) { - searchScrollState.value > 0 + !isSearchAtStart } else { widgetsScrollState.value > 0 && widgetsScrollState.value < widgetsScrollState.maxValue } @@ -285,17 +298,21 @@ fun PagerScaffold( val webSearchPadding by animateDpAsState( if (websearches.isEmpty()) 0.dp else 48.dp ) + val windowInsets = WindowInsets.safeDrawing.asPaddingValues() SearchColumn( modifier = Modifier .requiredWidth(width) .fillMaxHeight() - .verticalScroll(searchScrollState, reverseScrolling = true) - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(horizontal = 8.dp) - .padding(top = 8.dp, bottom = 64.dp) - .padding(bottom = webSearchPadding), + .padding( + start = windowInsets.calculateStartPadding(LocalLayoutDirection.current), + end = windowInsets.calculateStartPadding(LocalLayoutDirection.current), + ), reverse = true, + state = searchState, + paddingValues = PaddingValues( + top = 4.dp + windowInsets.calculateTopPadding(), + bottom = 60.dp + webSearchPadding + windowInsets.calculateBottomPadding() + ) ) } } @@ -306,6 +323,7 @@ fun PagerScaffold( exit = slideOut { IntOffset(0, -it.height) } ) { CenterAlignedTopAppBar( + modifier = Modifier.systemBarsPadding(), title = { Text(stringResource(R.string.menu_edit_widgets)) }, @@ -322,7 +340,7 @@ fun PagerScaffold( when { swipeableState.direction != 0f -> SearchBarLevel.Raised !isSearchOpen && isWidgetsScrollZero -> SearchBarLevel.Resting - isSearchOpen && searchScrollState.value == 0 -> SearchBarLevel.Active + isSearchOpen && isSearchAtStart -> SearchBarLevel.Active else -> SearchBarLevel.Raised } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt index 0fdba6c2..9b2c2b9a 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt @@ -6,9 +6,9 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -25,8 +25,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity @@ -41,7 +41,6 @@ import de.mm20.launcher2.ui.launcher.search.SearchBarLevel import de.mm20.launcher2.ui.launcher.search.SearchColumn import de.mm20.launcher2.ui.launcher.search.SearchVM import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn -import de.mm20.launcher2.ui.modifier.verticalFadingEdges import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -60,20 +59,39 @@ fun PullDownScaffold( val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false) val widgetsScrollState = rememberScrollState() - val searchScrollState = rememberScrollState() + val searchState = rememberLazyListState() + + val isSearchAtStart by remember { + derivedStateOf { + searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 + } + } + + val isSearchAtEnd by remember { + derivedStateOf { + val lastItem = searchState.layoutInfo.visibleItemsInfo.last() + lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding + } + } val systemUiController = rememberSystemUiController() - val isWidgetsScrollZero by remember { + val isWidgetsAtStart by remember { derivedStateOf { widgetsScrollState.value == 0 } } + val isWidgetsAtEnd by remember { + derivedStateOf { + widgetsScrollState.value >= widgetsScrollState.maxValue + } + } + val showStatusBarScrim by remember { derivedStateOf { if (isSearchOpen) { - searchScrollState.value > 0 + !isSearchAtStart } else { widgetsScrollState.value > 0 } @@ -82,7 +100,7 @@ fun PullDownScaffold( val showNavBarScrim by remember { derivedStateOf { if (isSearchOpen) { - searchScrollState.value < searchScrollState.maxValue + !isSearchAtEnd } else { widgetsScrollState.value > 0 && widgetsScrollState.value < widgetsScrollState.maxValue } @@ -146,7 +164,7 @@ fun PullDownScaffold( val scope = rememberCoroutineScope() LaunchedEffect(isSearchOpen) { - if (isSearchOpen) searchScrollState.scrollTo(0) + if (isSearchOpen) searchState.scrollToItem(0) if (!isSearchOpen) searchVM.search("") searchBarOffset.animateTo(0f) } @@ -176,22 +194,25 @@ fun PullDownScaffold( object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (isWidgetEditMode) return Offset.Zero - val value = if (isSearchOpen) searchScrollState.value else widgetsScrollState.value - val newValue = value - available.y + val canPullDown = if (isSearchOpen) { + isSearchAtStart + } else { + isWidgetsAtStart + } + val canPullUp = isSearchOpen && isSearchAtEnd + val consumed = when { - (offsetY.value > 0 || source == NestedScrollSource.Drag && newValue < 0) -> { - val consumed = available.y - value - offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(0f, maxOffset) - consumed - } - isSearchOpen && (offsetY.value < 0 || source == NestedScrollSource.Drag && newValue > searchScrollState.maxValue) -> { - val consumed = available.y - (value - searchScrollState.maxValue) + canPullUp && available.y < 0 || offsetY.value < 0 -> { + val consumed = available.y offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(-maxOffset, 0f) consumed } - else -> { - 0f + canPullDown && available.y > 0 || offsetY.value > 0 -> { + val consumed = available.y + offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(0f, maxOffset) + consumed } + else -> 0f } searchBarOffset.value = @@ -246,11 +267,11 @@ fun PullDownScaffold( ) } ) { - val websearches by searchVM.websearchResults.observeAsState(emptyList()) val webSearchPadding by animateDpAsState( if (websearches.isEmpty()) 0.dp else 48.dp ) + val windowInsets = WindowInsets.safeDrawing.asPaddingValues() SearchColumn( modifier = Modifier .graphicsLayer { @@ -261,16 +282,20 @@ fun PullDownScaffold( } .fillMaxWidth() .requiredHeight(height) - .verticalScroll(searchScrollState) - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(8.dp) - .padding(top = 56.dp) - .padding(top = webSearchPadding) - .imePadding() + .padding( + start = windowInsets.calculateStartPadding(LocalLayoutDirection.current), + end = windowInsets.calculateStartPadding(LocalLayoutDirection.current), + ), + paddingValues = PaddingValues( + top = 60.dp + webSearchPadding + windowInsets.calculateTopPadding(), + bottom = 4.dp + windowInsets.calculateBottomPadding() + ), + state = searchState, + ) val editModePadding by animateDpAsState(if (isWidgetEditMode) 56.dp else 0.dp) val clockPadding by animateDpAsState( - if (isWidgetsScrollZero) insets.calculateBottomPadding() else 0.dp + if (isWidgetsAtStart) insets.calculateBottomPadding() else 0.dp ) val clockHeight by remember { derivedStateOf { @@ -328,9 +353,9 @@ fun PullDownScaffold( derivedStateOf { when { offsetY.value != 0f -> SearchBarLevel.Raised - isSearchOpen && searchScrollState.value == 0 -> SearchBarLevel.Active - isSearchOpen && searchScrollState.value > 0 -> SearchBarLevel.Raised - !isWidgetsScrollZero -> SearchBarLevel.Raised + isSearchOpen && isSearchAtStart -> SearchBarLevel.Active + isSearchOpen && !isSearchAtStart -> SearchBarLevel.Raised + !isWidgetsAtStart -> SearchBarLevel.Raised else -> SearchBarLevel.Resting } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index 3ee14497..11d097b1 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -1,41 +1,243 @@ package de.mm20.launcher2.ui.launcher.search -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier -import de.mm20.launcher2.ui.launcher.search.apps.AppResults -import de.mm20.launcher2.ui.launcher.search.appshortcuts.AppShortcutResults -import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorResults -import de.mm20.launcher2.ui.launcher.search.calendar.CalendarResults -import de.mm20.launcher2.ui.launcher.search.contacts.ContactResults -import de.mm20.launcher2.ui.launcher.search.favorites.FavoritesResults -import de.mm20.launcher2.ui.launcher.search.files.FileResults +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.search.data.Searchable +import de.mm20.launcher2.ui.component.LauncherCard +import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorItem +import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem +import de.mm20.launcher2.ui.launcher.search.common.list.ListItem import de.mm20.launcher2.ui.launcher.search.hidden.HiddenResults -import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterResults -import de.mm20.launcher2.ui.launcher.search.website.WebsiteResults -import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaResults -import de.mm20.launcher2.ui.layout.BottomReversed +import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterItem +import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem +import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItem +import de.mm20.launcher2.ui.locals.LocalGridColumns +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.math.ceil @Composable fun SearchColumn( modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(0.dp), + state: LazyListState = rememberLazyListState(), reverse: Boolean = false, ) { - Column( + + val columns = LocalGridColumns.current + + val viewModel: SearchVM = viewModel() + + val hideFavs by viewModel.hideFavorites.observeAsState(true) + val favorites by viewModel.favorites.observeAsState(emptyList()) + val apps by viewModel.appResults.observeAsState(emptyList()) + val appShortcuts by viewModel.appShortcutResults.observeAsState(emptyList()) + val contacts by viewModel.contactResults.observeAsState(emptyList()) + val files by viewModel.fileResults.observeAsState(emptyList()) + val events by viewModel.calendarResults.observeAsState(emptyList()) + val unitConverter by viewModel.unitConverterResult.observeAsState(null) + val calculator by viewModel.calculatorResult.observeAsState(null) + val wikipedia by viewModel.wikipediaResult.observeAsState(null) + val website by viewModel.websiteResult.observeAsState(null) + + + LazyColumn( + state = state, modifier = modifier, - verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top + contentPadding = paddingValues, + reverseLayout = reverse, ) { - FavoritesResults(reverse) - AppResults(reverse) - AppShortcutResults(reverse) - UnitConverterResults(reverse) - CalculatorResults(reverse) - CalendarResults(reverse) - ContactResults(reverse) - WikipediaResults(reverse) - WebsiteResults(reverse) - FileResults(reverse) - HiddenResults() + if (!hideFavs) { + GridResults(favorites.toImmutableList(), columns, reverse) + } + GridResults(apps.toImmutableList(), columns, reverse) + ListResults(appShortcuts.toImmutableList(), reverse) + val uc = unitConverter + if (uc != null) { + SingleResult { + UnitConverterItem(unitConverter = uc) + } + } + val calc = calculator + if (calc != null) { + SingleResult { + CalculatorItem(calculator = calc) + } + } + ListResults(events.toImmutableList(), reverse) + ListResults(contacts.toImmutableList(), reverse) + val wiki = wikipedia + if (wiki != null) { + SingleResult { + WikipediaItem(wikipedia = wiki) + } + } + val ws = website + if (ws != null) { + SingleResult { + WebsiteItem(website = ws) + } + } + ListResults(files.toImmutableList(), reverse) + item { + HiddenResults() + } + } +} + +fun LazyListScope.GridResults( + items: ImmutableList, + columns: Int, + reverse: Boolean, +) { + if (items.isEmpty()) return + val rows = ceil(items.size / columns.toFloat()).toInt() + items(rows) { + GridRow( + items = items.subList(it * columns, (it * columns + columns).coerceAtMost(items.size)), + columns = columns, + isFirst = if (reverse) it == rows - 1 else it == 0, + isLast = if (reverse) it == 0 else it == rows - 1 + ) + } +} + +@Composable +fun GridRow( + modifier: Modifier = Modifier, + items: ImmutableList, + columns: Int, + isFirst: Boolean, + isLast: Boolean, +) { + Box( + modifier = modifier + .clipToBounds() + ) { + LauncherCard( + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + top = if (isFirst) 4.dp else 0.dp, + bottom = if (isLast) 4.dp else 0.dp, + ), + shape = when { + isFirst && isLast -> MaterialTheme.shapes.medium + isFirst -> MaterialTheme.shapes.medium.copy( + bottomEnd = CornerSize(0), + bottomStart = CornerSize(0), + ) + isLast -> MaterialTheme.shapes.medium.copy( + topEnd = CornerSize(0), + topStart = CornerSize(0), + ) + else -> RectangleShape + } + ) { + Row { + for (item in items) { + GridItem( + modifier = Modifier + .weight(1f) + .padding(4.dp, 8.dp), + item = item, + showLabels = true + ) + } + for (i in 0 until columns - items.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +fun LazyListScope.ListResults( + items: ImmutableList, + reverse: Boolean, +) { + if (items.isEmpty()) return + items(items.size) { + ListRow( + item = items[it], + isFirst = if (reverse) it == items.size - 1 else it == 0, + isLast = if (reverse) it == 0 else it == items.size - 1 + ) + } +} + +@Composable +fun ListRow( + modifier: Modifier = Modifier, + item: Searchable, + isFirst: Boolean, + isLast: Boolean, +) { + Box( + modifier = modifier + .clipToBounds() + ) { + LauncherCard( + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + top = if (isFirst) 4.dp else 0.dp, + bottom = if (isLast) 4.dp else 0.dp, + ), + shape = when { + isFirst && isLast -> MaterialTheme.shapes.medium + isFirst -> MaterialTheme.shapes.medium.copy( + bottomEnd = CornerSize(0), + bottomStart = CornerSize(0), + ) + isLast -> MaterialTheme.shapes.medium.copy( + topEnd = CornerSize(0), + topStart = CornerSize(0), + ) + else -> RectangleShape + } + ) { + Box( + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + top = if (isFirst) 8.dp else 4.dp, + bottom = if (isLast) 8.dp else 4.dp, + ) + ) { + ListItem( + modifier = Modifier + .fillMaxWidth(), + item = item + ) + } + } + } +} + +fun LazyListScope.SingleResult(content: @Composable (() -> Unit)?) { + if (content == null) return + item { + LauncherCard( + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = 4.dp, + ) + ) { + content() + } } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt index fa8cf425..4b3ae73d 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt @@ -14,7 +14,7 @@ import de.mm20.launcher2.ui.launcher.search.SearchVM import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid @Composable -fun ColumnScope.AppResults(reverse: Boolean = false) { +fun AppResults(reverse: Boolean = false) { val viewModel: SearchVM = viewModel() val apps by viewModel.appResults.observeAsState(emptyList()) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt index dde3d48b..ab09257d 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt @@ -14,7 +14,7 @@ import de.mm20.launcher2.ui.launcher.search.SearchVM import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid @Composable -fun ColumnScope.FavoritesResults( +fun FavoritesResults( reverse: Boolean = false, ) { val viewModel: SearchVM = viewModel() diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/hidden/HiddenResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/hidden/HiddenResults.kt index 9a1a1e06..426a1ddf 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/hidden/HiddenResults.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/hidden/HiddenResults.kt @@ -19,7 +19,7 @@ import de.mm20.launcher2.ui.launcher.modals.HiddenItemsSheet import de.mm20.launcher2.ui.launcher.search.SearchVM @Composable -fun ColumnScope.HiddenResults() { +fun HiddenResults() { val viewModel: SearchVM = viewModel() val hiddenResults by viewModel.hiddenResults.observeAsState( emptyList() @@ -27,7 +27,7 @@ fun ColumnScope.HiddenResults() { var showHiddenItems by remember { mutableStateOf(false) } - AnimatedVisibility(visible = hiddenResults.isNotEmpty()) { + if(hiddenResults.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth(),