Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit deda3d47 authored by Chaohui Wang's avatar Chaohui Wang
Browse files

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
parent e663986c
Loading
Loading
Loading
Loading
+57 −0
Original line number Diff line number Diff line
/*
 * 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
}
+1 −0
Original line number Diff line number Diff line
@@ -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
}
+35 −6
Original line number Diff line number Diff line
@@ -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),
        )
    }
}
+189 −0
Original line number Diff line number Diff line
/*
 * 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") {}
    }
}
+1 −29
Original line number Diff line number Diff line
@@ -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() {
Loading