Use lazy list for search results

This commit is contained in:
MM20 2022-08-22 20:55:49 +02:00
parent 74d02f9b4a
commit f3d8d95d78
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
7 changed files with 320 additions and 73 deletions

View File

@ -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,

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<Searchable>,
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<Searchable>,
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<Searchable>,
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()
}
}
}

View File

@ -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())

View File

@ -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()

View File

@ -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(),