Add SearchScaffold for SpaLib

This is used in App List to search the applications in list.

Bug: 235727273
Test: Unit test & Manual with Settings App
Change-Id: If75226f34b61c8c9fe311b38b135d2e91709c36c
This commit is contained in:
Chaohui Wang
2022-10-25 15:43:34 +08:00
parent e663986c39
commit deda3d4737
10 changed files with 508 additions and 68 deletions

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* 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 com.android.settingslib.spa.framework.compose
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
/**
* An action when run, hides the keyboard if it's open.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun hideKeyboardAction(): KeyboardActionScope.() -> Unit {
val keyboardController = LocalSoftwareKeyboardController.current
return { keyboardController?.hide() }
}
/**
* Creates a [LazyListState] that is remembered across compositions.
*
* And when user scrolling the lazy list, hides the keyboard if it's open.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun rememberLazyListStateAndHideKeyboardWhenStartScroll(): LazyListState {
val listState = rememberLazyListState()
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.distinctUntilChanged()
.filter { it }
.collect { keyboardController?.hide() }
}
return listState
}

View File

@@ -21,4 +21,5 @@ object SettingsOpacity {
const val Disabled = 0.38f
const val Divider = 0.2f
const val SurfaceTone = 0.14f
const val Hint = 0.9f
}

View File

@@ -16,9 +16,12 @@
package com.android.settingslib.spa.widget.scaffold
import androidx.appcompat.R
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.FindInPage
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
@@ -31,17 +34,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.android.settingslib.spa.framework.compose.LocalNavController
/** Action that navigates back to last page. */
@Composable
internal fun NavigateBack() {
val navController = LocalNavController.current
val contentDescription = stringResource(
id = androidx.appcompat.R.string.abc_action_bar_up_description,
)
val contentDescription = stringResource(R.string.abc_action_bar_up_description)
BackAction(contentDescription) {
navController.navigateBack()
}
}
/** Action that collapses the search bar. */
@Composable
internal fun CollapseAction(onClick: () -> Unit) {
val contentDescription = stringResource(R.string.abc_toolbar_collapse_description)
BackAction(contentDescription, onClick)
}
@Composable
private fun BackAction(contentDescription: String, onClick: () -> Unit) {
IconButton(onClick) {
@@ -52,6 +61,28 @@ private fun BackAction(contentDescription: String, onClick: () -> Unit) {
}
}
/** Action that expends the search bar. */
@Composable
internal fun SearchAction(onClick: () -> Unit) {
IconButton(onClick) {
Icon(
imageVector = Icons.Outlined.FindInPage,
contentDescription = stringResource(R.string.search_menu_title),
)
}
}
/** Action that clear the search query. */
@Composable
internal fun ClearAction(onClick: () -> Unit) {
IconButton(onClick) {
Icon(
imageVector = Icons.Outlined.Clear,
contentDescription = stringResource(R.string.abc_searchview_description_clear),
)
}
}
@Composable
fun MoreOptionsAction(
content: @Composable ColumnScope.(onDismissRequest: () -> Unit) -> Unit,
@@ -71,9 +102,7 @@ private fun MoreOptionsActionButton(onClick: () -> Unit) {
IconButton(onClick) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(
id = androidx.appcompat.R.string.abc_action_menu_overflow_description,
)
contentDescription = stringResource(R.string.abc_action_menu_overflow_description),
)
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.settingslib.spa.widget.scaffold
import androidx.activity.compose.BackHandler
import androidx.appcompat.R
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.settingslib.spa.framework.compose.hideKeyboardAction
import com.android.settingslib.spa.framework.theme.SettingsOpacity
import com.android.settingslib.spa.framework.theme.SettingsTheme
/**
* A [Scaffold] which content is can be full screen, and with a search feature built-in.
*/
@Composable
fun SearchScaffold(
title: String,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (searchQuery: State<String>) -> Unit,
) {
val viewModel: SearchScaffoldViewModel = viewModel()
Scaffold(
topBar = {
SearchableTopAppBar(
title = title,
actions = actions,
searchQuery = viewModel.searchQuery,
) { viewModel.searchQuery = it }
},
) { paddingValues ->
Box(
Modifier
.padding(paddingValues)
.fillMaxSize()
) {
val searchQuery = remember {
derivedStateOf { viewModel.searchQuery?.text ?: "" }
}
content(searchQuery)
}
}
}
internal class SearchScaffoldViewModel : ViewModel() {
var searchQuery: TextFieldValue? by mutableStateOf(null)
}
@Composable
private fun SearchableTopAppBar(
title: String,
actions: @Composable RowScope.() -> Unit,
searchQuery: TextFieldValue?,
onSearchQueryChange: (TextFieldValue?) -> Unit,
) {
if (searchQuery != null) {
SearchTopAppBar(
query = searchQuery,
onQueryChange = onSearchQueryChange,
onClose = { onSearchQueryChange(null) },
actions = actions,
)
} else {
SettingsTopAppBar(title) {
SearchAction { onSearchQueryChange(TextFieldValue()) }
actions()
}
}
}
@Composable
private fun SearchTopAppBar(
query: TextFieldValue,
onQueryChange: (TextFieldValue) -> Unit,
onClose: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
title = { SearchBox(query, onQueryChange) },
modifier = Modifier.statusBarsPadding(),
navigationIcon = { CollapseAction(onClose) },
actions = {
if (query.text.isNotEmpty()) {
ClearAction { onQueryChange(TextFieldValue()) }
}
actions()
},
colors = settingsTopAppBarColors(),
)
BackHandler { onClose() }
}
@Composable
private fun SearchBox(query: TextFieldValue, onQueryChange: (TextFieldValue) -> Unit) {
val focusRequester = remember { FocusRequester() }
val textStyle = MaterialTheme.typography.bodyLarge
TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = textStyle,
placeholder = {
Text(
text = stringResource(R.string.abc_search_hint),
modifier = Modifier.alpha(SettingsOpacity.Hint),
style = textStyle,
)
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = hideKeyboardAction()),
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
}
@Preview
@Composable
private fun SearchTopAppBarPreview() {
SettingsTheme {
SearchTopAppBar(query = TextFieldValue(), onQueryChange = {}, onClose = {}) {}
}
}
@Preview
@Composable
private fun SearchScaffoldPreview() {
SettingsTheme {
SearchScaffold(title = "App notifications") {}
}
}

View File

@@ -18,17 +18,10 @@ package com.android.settingslib.spa.widget.scaffold
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.android.settingslib.spa.framework.theme.SettingsDimension
import com.android.settingslib.spa.framework.theme.SettingsTheme
/**
@@ -42,32 +35,11 @@ fun SettingsScaffold(
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = title,
modifier = Modifier.padding(SettingsDimension.itemPaddingAround),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
navigationIcon = { NavigateBack() },
actions = actions,
colors = settingsTopAppBarColors(),
)
},
topBar = { SettingsTopAppBar(title, actions) },
content = content,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun settingsTopAppBarColors() = TopAppBarDefaults.smallTopAppBarColors(
containerColor = SettingsTheme.colorScheme.surfaceHeader,
scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader,
)
@Preview
@Composable
private fun SettingsScaffoldPreview() {

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* 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 com.android.settingslib.spa.widget.scaffold
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.android.settingslib.spa.framework.theme.SettingsDimension
import com.android.settingslib.spa.framework.theme.SettingsTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SettingsTopAppBar(
title: String,
actions: @Composable RowScope.() -> Unit,
) {
TopAppBar(
title = {
Text(
text = title,
modifier = Modifier.padding(SettingsDimension.itemPaddingAround),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
navigationIcon = { NavigateBack() },
actions = actions,
colors = settingsTopAppBarColors(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun settingsTopAppBarColors() = TopAppBarDefaults.smallTopAppBarColors(
containerColor = SettingsTheme.colorScheme.surfaceHeader,
scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader,
)

View File

@@ -14,10 +14,9 @@
* limitations under the License.
*/
package com.android.settingslib.spa.framework.compose.com.android.settingslib.spa.framework
package com.android.settingslib.spa.framework.compose
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.settingslib.spa.framework.compose.OverridableFlow
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* 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 com.android.settingslib.spa.widget.scaffold
import android.content.Context
import androidx.appcompat.R
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SearchScaffoldTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun initialState_titleIsDisplayed() {
composeTestRule.setContent {
SearchScaffold(title = TITLE) {}
}
composeTestRule.onNodeWithText(TITLE).assertIsDisplayed()
}
@Test
fun initialState_clearButtonNotExist() {
setContent()
onClearButton().assertDoesNotExist()
}
@Test
fun initialState_searchQueryIsEmpty() {
val searchQuery = setContent()
assertThat(searchQuery.value).isEqualTo("")
}
@Test
fun canEnterSearchMode() {
val searchQuery = setContent()
clickSearchButton()
composeTestRule.onNodeWithText(TITLE).assertDoesNotExist()
onSearchHint().assertIsDisplayed()
onClearButton().assertDoesNotExist()
assertThat(searchQuery.value).isEqualTo("")
}
@Test
fun canExitSearchMode() {
val searchQuery = setContent()
clickSearchButton()
composeTestRule.onNodeWithContentDescription(
context.getString(R.string.abc_toolbar_collapse_description)
).performClick()
composeTestRule.onNodeWithText(TITLE).assertIsDisplayed()
onSearchHint().assertDoesNotExist()
onClearButton().assertDoesNotExist()
assertThat(searchQuery.value).isEqualTo("")
}
@Test
fun canEnterSearchQuery() {
val searchQuery = setContent()
clickSearchButton()
onSearchHint().performTextInput(QUERY)
onClearButton().assertIsDisplayed()
assertThat(searchQuery.value).isEqualTo(QUERY)
}
@Test
fun canClearSearchQuery() {
val searchQuery = setContent()
clickSearchButton()
onSearchHint().performTextInput(QUERY)
onClearButton().performClick()
onClearButton().assertDoesNotExist()
assertThat(searchQuery.value).isEqualTo("")
}
private fun setContent(): State<String> {
lateinit var actualSearchQuery: State<String>
composeTestRule.setContent {
SearchScaffold(title = TITLE) { searchQuery ->
SideEffect {
actualSearchQuery = searchQuery
}
}
}
return actualSearchQuery
}
private fun clickSearchButton() {
composeTestRule.onNodeWithContentDescription(
context.getString(R.string.search_menu_title)
).performClick()
}
private fun onSearchHint() = composeTestRule.onNodeWithText(
context.getString(R.string.abc_search_hint)
)
private fun onClearButton() = composeTestRule.onNodeWithContentDescription(
context.getString(R.string.abc_searchview_description_clear)
)
private companion object {
const val TITLE = "title"
const val QUERY = "query"
}
}

View File

@@ -19,7 +19,6 @@ package com.android.settingslib.spaprivileged.template.app
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
@@ -29,6 +28,7 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.settingslib.spa.framework.compose.LogCompositions
import com.android.settingslib.spa.framework.compose.TimeMeasurer.Companion.rememberTimeMeasurer
import com.android.settingslib.spa.framework.compose.rememberLazyListStateAndHideKeyboardWhenStartScroll
import com.android.settingslib.spa.framework.compose.toState
import com.android.settingslib.spa.framework.theme.SettingsDimension
import com.android.settingslib.spa.widget.ui.PlaceholderTitle
@@ -76,7 +76,7 @@ private fun <T : AppRecord> AppListWidget(
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = rememberLazyListState(),
state = rememberLazyListStateAndHideKeyboardWhenStartScroll(),
contentPadding = PaddingValues(bottom = SettingsDimension.itemPaddingVertical),
) {
items(count = list.size, key = { option to list[it].record.app.packageName }) {

View File

@@ -16,10 +16,8 @@
package com.android.settingslib.spaprivileged.template.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -28,9 +26,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.android.settingslib.spa.framework.compose.stateOf
import com.android.settingslib.spa.widget.scaffold.MoreOptionsAction
import com.android.settingslib.spa.widget.scaffold.SettingsScaffold
import com.android.settingslib.spa.widget.scaffold.SearchScaffold
import com.android.settingslib.spa.widget.ui.Spinner
import com.android.settingslib.spaprivileged.R
import com.android.settingslib.spaprivileged.model.app.AppListConfig
@@ -50,35 +47,28 @@ fun <T : AppRecord> AppListPage(
appItem: @Composable (itemState: AppListItemModel<T>) -> Unit,
) {
val showSystem = rememberSaveable { mutableStateOf(false) }
// TODO: Use SearchScaffold here.
SettingsScaffold(
SearchScaffold(
title = title,
actions = {
ShowSystemAction(showSystem.value) { showSystem.value = it }
},
) { paddingValues ->
Box(
Modifier
.padding(paddingValues)
.fillMaxSize()
) {
WorkProfilePager(primaryUserOnly) { userInfo ->
Column(Modifier.fillMaxSize()) {
val options = remember { listModel.getSpinnerOptions() }
val selectedOption = rememberSaveable { mutableStateOf(0) }
Spinner(options, selectedOption.value) { selectedOption.value = it }
AppList(
appListConfig = AppListConfig(
userId = userInfo.id,
showInstantApps = showInstantApps,
),
listModel = listModel,
showSystem = showSystem,
option = selectedOption,
searchQuery = stateOf(""),
appItem = appItem,
)
}
) { searchQuery ->
WorkProfilePager(primaryUserOnly) { userInfo ->
Column(Modifier.fillMaxSize()) {
val options = remember { listModel.getSpinnerOptions() }
val selectedOption = rememberSaveable { mutableStateOf(0) }
Spinner(options, selectedOption.value) { selectedOption.value = it }
AppList(
appListConfig = AppListConfig(
userId = userInfo.id,
showInstantApps = showInstantApps,
),
listModel = listModel,
showSystem = showSystem,
option = selectedOption,
searchQuery = searchQuery,
appItem = appItem,
)
}
}
}