From 9106fc48b8790e0a9fbb7aa80a9027e5f1061d11 Mon Sep 17 00:00:00 2001 From: Nullptr <52071314+Dr-TSNG@users.noreply.github.com> Date: Fri, 6 May 2022 14:15:08 +0800 Subject: [PATCH] SearchBar & Animations --- manager/build.gradle.kts | 2 +- .../lsposed/lspatch/ui/component/SearchBar.kt | 125 ++++++++++++++ .../lsposed/lspatch/ui/page/SelectAppsPage.kt | 154 ++++++++++-------- .../ui/viewmodel/SelectAppViewModel.kt | 46 +++--- 4 files changed, 234 insertions(+), 93 deletions(-) create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 5151bd5..dd4221f 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -73,7 +73,7 @@ dependencies { compileOnly("dev.rikka.hidden:stub:2.3.1") implementation("dev.rikka.hidden:compat:2.3.1") implementation("androidx.core:core-ktx:1.7.0") - implementation("androidx.activity:activity-compose:1.6.0-alpha01") + implementation("androidx.activity:activity-compose:1.6.0-alpha03") implementation("androidx.compose.material:material-icons-extended:1.1.1") implementation("androidx.compose.material3:material3:1.0.0-alpha08") implementation("androidx.compose.runtime:runtime-livedata:1.1.1") diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt new file mode 100644 index 0000000..b70faf9 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt @@ -0,0 +1,125 @@ +package org.lsposed.lspatch.ui.component + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.lsposed.lspatch.TAG + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SearchAppBar( + title: @Composable () -> Unit, + searchText: String, + onSearchTextChange: (String) -> Unit, + onClearClick: () -> Unit, + onBackClick: () -> Unit, + onConfirm: (() -> Unit)? = null +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + var onSearch by remember { mutableStateOf(false) } + + LaunchedEffect(onSearch) { + if (onSearch) focusRequester.requestFocus() + } + + SmallTopAppBar( + title = { + Box { + AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterStart), + visible = !onSearch, + enter = fadeIn(), + exit = fadeOut(), + content = { title() } + ) + + AnimatedVisibility( + visible = onSearch, + enter = fadeIn(), + exit = fadeOut() + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) onSearch = true + Log.d(TAG, "onFocusChanged: $focusState") + }, + value = searchText, + onValueChange = onSearchTextChange, + trailingIcon = { + IconButton( + onClick = { + onClearClick() + onSearch = false + }, + content = { Icon(Icons.Filled.Close, null) } + ) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + onConfirm?.invoke() + }) + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = onBackClick, + content = { Icon(Icons.Outlined.ArrowBack, null) } + ) + }, + actions = { + AnimatedVisibility( + visible = !onSearch + ) { + IconButton( + onClick = { onSearch = true }, + content = { Icon(Icons.Filled.Search, null) } + ) + } + } + ) +} + +@Preview +@Composable +private fun SearchAppBarPreview() { + var searchText by remember { mutableStateOf("") } + SearchAppBar( + title = { Text("Search text") }, + searchText = searchText, + onSearchTextChange = { searchText = it }, + onClearClick = { searchText = "" }, + onBackClick = {} + ) +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt index b4914bd..44329df 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/SelectAppsPage.kt @@ -2,6 +2,9 @@ package org.lsposed.lspatch.ui.page import android.content.pm.ApplicationInfo import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -9,19 +12,19 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Done import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.toLowerCase import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavBackStackEntry import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import org.lsposed.lspatch.R import org.lsposed.lspatch.ui.component.AppItem +import org.lsposed.lspatch.ui.component.SearchAppBar import org.lsposed.lspatch.ui.util.LocalNavController import org.lsposed.lspatch.ui.util.observeState import org.lsposed.lspatch.ui.util.setState @@ -31,39 +34,64 @@ import org.lsposed.lspatch.ui.viewmodel.SelectAppsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectAppsPage(entry: NavBackStackEntry) { + val viewModel = viewModel() val navController = LocalNavController.current val multiSelect = entry.arguments?.get("multiSelect") as? Boolean ?: throw IllegalArgumentException("multiSelect is null") + var searchPackage by remember { mutableStateOf("") } + val filter: (AppInfo) -> Boolean = { + val packageLowerCase = searchPackage.toLowerCase(Locale.current) + val contains = it.label.toLowerCase(Locale.current).contains(packageLowerCase) || it.app.packageName.contains(packageLowerCase) + if (multiSelect) contains && it.app.metaData?.get("xposedminversion") != null + else contains && it.app.flags and ApplicationInfo.FLAG_SYSTEM == 0 + } + + LaunchedEffect(Unit) { + viewModel.filterAppList(false, filter) + } + BackHandler { navController.previousBackStackEntry!!.setState("isCancelled", true) navController.popBackStack() } Scaffold( - topBar = { TopBar() }, + topBar = { + SearchAppBar( + title = { Text(stringResource(R.string.page_select_apps)) }, + searchText = searchPackage, + onSearchTextChange = { + searchPackage = it + viewModel.filterAppList(false, filter) + }, + onClearClick = { + searchPackage = "" + viewModel.filterAppList(false, filter) + }, + onBackClick = { + navController.previousBackStackEntry!!.setState("isCancelled", true) + navController.popBackStack() + } + ) + }, floatingActionButton = { if (multiSelect) MultiSelectFab() } ) { innerPadding -> - if (multiSelect) { - MultiSelect( - modifier = Modifier.padding(innerPadding), - filter = { it.app.metaData?.get("xposedminversion") != null } - ) - } else { - SingleSelect(modifier = Modifier.padding(innerPadding)) + SwipeRefresh( + state = rememberSwipeRefreshState(viewModel.isRefreshing), + onRefresh = { viewModel.filterAppList(true, filter) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + if (multiSelect) MultiSelect() + else SingleSelect() } } } -@Composable -private fun TopBar() { - SmallTopAppBar( - title = { Text(stringResource(R.string.page_select_apps)) } - ) -} - @Composable private fun MultiSelectFab() { val navController = LocalNavController.current @@ -72,71 +100,55 @@ private fun MultiSelectFab() { } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun SingleSelect( - modifier: Modifier, - filter: (AppInfo) -> Boolean = { it.app.flags and ApplicationInfo.FLAG_SYSTEM == 0 } -) { - val context = LocalContext.current +private fun SingleSelect() { val navController = LocalNavController.current val viewModel = viewModel() - LaunchedEffect(viewModel) { - viewModel.filterAppList(context, false, filter) - } - SwipeRefresh( - state = rememberSwipeRefreshState(viewModel.isRefreshing), - onRefresh = { viewModel.filterAppList(context, true, filter) }, - modifier = modifier.fillMaxSize() - ) { - LazyColumn { - items(viewModel.filteredList) { - AppItem( - icon = viewModel.getIcon(it), - label = it.label, - packageName = it.app.packageName, - onClick = { - navController.previousBackStackEntry!!.setState("appInfo", it) - navController.popBackStack() - } - ) - } + LazyColumn { + items( + items = viewModel.filteredList, + key = { it.app.packageName } + ) { + AppItem( + modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), + icon = viewModel.getIcon(it), + label = it.label, + packageName = it.app.packageName, + onClick = { + navController.previousBackStackEntry!!.setState("appInfo", it) + navController.popBackStack() + } + ) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun MultiSelect( - modifier: Modifier, - filter: (AppInfo) -> Boolean = { true } -) { - val context = LocalContext.current +private fun MultiSelect() { val navController = LocalNavController.current val viewModel = viewModel() val selected by navController.previousBackStackEntry!!.observeState>("selected") - LaunchedEffect(viewModel) { - viewModel.filterAppList(context, false, filter) - } - - SwipeRefresh( - state = rememberSwipeRefreshState(viewModel.isRefreshing), - onRefresh = { viewModel.filterAppList(context, true, filter) }, - modifier = modifier.fillMaxSize() - ) { - LazyColumn { - items(viewModel.filteredList) { - val checked = selected!!.contains(it) - AppItem( - icon = viewModel.getIcon(it), - label = it.label, - packageName = it.app.packageName, - onClick = { - if (checked) selected!!.remove(it) else selected!!.add(it) - }, - checked = checked - ) - } + LazyColumn { + items( + items = viewModel.filteredList, + key = { it.app.packageName } + ) { + val checked = selected!!.contains(it) + AppItem( + modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), + icon = viewModel.getIcon(it), + label = it.label, + packageName = it.app.packageName, + onClick = { + if (checked) selected!!.remove(it) + else selected!!.add(it) + }, + checked = checked + ) } } } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt index 7e24869..aed9eb4 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt @@ -1,6 +1,5 @@ package org.lsposed.lspatch.ui.viewmodel -import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable @@ -15,7 +14,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize -import org.lsposed.lspatch.TAG +import org.lsposed.lspatch.lspApp +import java.text.Collator +import java.util.* + +private const val TAG = "SelectAppViewModel" @Parcelize class AppInfo(val app: ApplicationInfo, val label: String) : Parcelable @@ -35,29 +38,30 @@ class SelectAppsViewModel : ViewModel() { var filteredList by mutableStateOf(listOf()) private set - private suspend fun refreshAppList(context: Context) { - Log.d(TAG, "Start refresh apps") - isRefreshing = true - val pm = context.packageManager - val collection = mutableListOf() - withContext(Dispatchers.IO) { - pm.getInstalledApplications(PackageManager.GET_META_DATA).forEach { - val label = pm.getApplicationLabel(it) - appIcon[it.packageName] = pm.getApplicationIcon(it) - collection.add(AppInfo(it, label.toString())) - } - } - appList = collection - isRefreshing = false - Log.d(TAG, "Refreshed ${appList.size} apps") - } - - fun filterAppList(context: Context, refresh: Boolean, filter: (AppInfo) -> Boolean) { + fun filterAppList(refresh: Boolean, filter: (AppInfo) -> Boolean) { viewModelScope.launch { - if (appList.isEmpty() || refresh) refreshAppList(context) + if (appList.isEmpty() || refresh) refreshAppList() filteredList = appList.filter(filter) } } fun getIcon(appInfo: AppInfo) = appIcon[appInfo.app.packageName]!! + + private suspend fun refreshAppList() { + Log.d(TAG, "Start refresh apps") + isRefreshing = true + val collection = mutableListOf() + withContext(Dispatchers.IO) { + val pm = lspApp.packageManager + pm.getInstalledApplications(PackageManager.GET_META_DATA).forEach { + val label = pm.getApplicationLabel(it) + appIcon[it.packageName] = pm.getApplicationIcon(it) + collection.add(AppInfo(it, label.toString())) + } + collection.sortWith(compareBy(Collator.getInstance(Locale.getDefault())) { it.label }) + } + appList = collection + isRefreshing = false + Log.d(TAG, "Refreshed ${appList.size} apps") + } }