
* Show soft keyboard when the text field is composed (a redo) * Clear focus on text field when soft keyboard is hidden * Request focus on text field and show soft keyboard when clear button is clicked (cherry picked from commit 7a1b599462498f6be8faf08cbd2814a04863f3ef)
342 lines
12 KiB
Kotlin
342 lines
12 KiB
Kotlin
package eu.kanade.presentation.components
|
|
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.RowScope
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.text.BasicTextField
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.material.TextFieldDefaults
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.outlined.ArrowBack
|
|
import androidx.compose.material.icons.outlined.Close
|
|
import androidx.compose.material.icons.outlined.MoreVert
|
|
import androidx.compose.material.icons.outlined.Search
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TopAppBar
|
|
import androidx.compose.material3.TopAppBarDefaults
|
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
import androidx.compose.material3.surfaceColorAtElevation
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.key
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
import androidx.compose.ui.focus.focusRequester
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.graphics.vector.ImageVector
|
|
import androidx.compose.ui.platform.LocalFocusManager
|
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.text.input.VisualTransformation
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import eu.kanade.tachiyomi.R
|
|
import tachiyomi.presentation.core.util.clearFocusOnSoftKeyboardHide
|
|
import tachiyomi.presentation.core.util.runOnEnterKeyPressed
|
|
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
|
import tachiyomi.presentation.core.util.showSoftKeyboard
|
|
|
|
const val SEARCH_DEBOUNCE_MILLIS = 250L
|
|
|
|
@Composable
|
|
fun AppBar(
|
|
modifier: Modifier = Modifier,
|
|
// Text
|
|
title: String?,
|
|
subtitle: String? = null,
|
|
// Up button
|
|
navigateUp: (() -> Unit)? = null,
|
|
navigationIcon: ImageVector = Icons.Outlined.ArrowBack,
|
|
// Menu
|
|
actions: @Composable RowScope.() -> Unit = {},
|
|
// Action mode
|
|
actionModeCounter: Int = 0,
|
|
onCancelActionMode: () -> Unit = {},
|
|
actionModeActions: @Composable RowScope.() -> Unit = {},
|
|
|
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
|
) {
|
|
val isActionMode by remember(actionModeCounter) {
|
|
derivedStateOf { actionModeCounter > 0 }
|
|
}
|
|
|
|
AppBar(
|
|
modifier = modifier,
|
|
titleContent = {
|
|
if (isActionMode) {
|
|
AppBarTitle(actionModeCounter.toString())
|
|
} else {
|
|
AppBarTitle(title, subtitle)
|
|
}
|
|
},
|
|
navigateUp = navigateUp,
|
|
navigationIcon = navigationIcon,
|
|
actions = {
|
|
if (isActionMode) {
|
|
actionModeActions()
|
|
} else {
|
|
actions()
|
|
}
|
|
},
|
|
isActionMode = isActionMode,
|
|
onCancelActionMode = onCancelActionMode,
|
|
scrollBehavior = scrollBehavior,
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun AppBar(
|
|
modifier: Modifier = Modifier,
|
|
// Title
|
|
titleContent: @Composable () -> Unit,
|
|
// Up button
|
|
navigateUp: (() -> Unit)? = null,
|
|
navigationIcon: ImageVector = Icons.Outlined.ArrowBack,
|
|
// Menu
|
|
actions: @Composable RowScope.() -> Unit = {},
|
|
// Action mode
|
|
isActionMode: Boolean = false,
|
|
onCancelActionMode: () -> Unit = {},
|
|
|
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
|
) {
|
|
Column(
|
|
modifier = modifier,
|
|
) {
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
if (isActionMode) {
|
|
IconButton(onClick = onCancelActionMode) {
|
|
Icon(
|
|
imageVector = Icons.Outlined.Close,
|
|
contentDescription = stringResource(R.string.action_cancel),
|
|
)
|
|
}
|
|
} else {
|
|
navigateUp?.let {
|
|
IconButton(onClick = it) {
|
|
Icon(
|
|
imageVector = navigationIcon,
|
|
contentDescription = stringResource(R.string.abc_action_bar_up_description),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
title = titleContent,
|
|
actions = actions,
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
|
|
elevation = if (isActionMode) 3.dp else 0.dp,
|
|
),
|
|
),
|
|
scrollBehavior = scrollBehavior,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun AppBarTitle(
|
|
title: String?,
|
|
subtitle: String? = null,
|
|
) {
|
|
Column {
|
|
title?.let {
|
|
Text(
|
|
text = it,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
subtitle?.let {
|
|
Text(
|
|
text = it,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun AppBarActions(
|
|
actions: List<AppBar.AppBarAction>,
|
|
) {
|
|
var showMenu by remember { mutableStateOf(false) }
|
|
|
|
actions.filterIsInstance<AppBar.Action>().map {
|
|
IconButton(
|
|
onClick = it.onClick,
|
|
enabled = it.enabled,
|
|
) {
|
|
Icon(
|
|
imageVector = it.icon,
|
|
contentDescription = it.title,
|
|
)
|
|
}
|
|
}
|
|
|
|
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
|
|
if (overflowActions.isNotEmpty()) {
|
|
IconButton(onClick = { showMenu = !showMenu }) {
|
|
Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.abc_action_menu_overflow_description))
|
|
}
|
|
|
|
DropdownMenu(
|
|
expanded = showMenu,
|
|
onDismissRequest = { showMenu = false },
|
|
) {
|
|
overflowActions.map {
|
|
DropdownMenuItem(
|
|
onClick = {
|
|
it.onClick()
|
|
showMenu = false
|
|
},
|
|
text = { Text(it.title, fontWeight = FontWeight.Normal) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param searchEnabled Set to false if you don't want to show search action.
|
|
* @param searchQuery If null, use normal toolbar.
|
|
* @param placeholderText If null, [R.string.action_search_hint] is used.
|
|
*/
|
|
@Composable
|
|
fun SearchToolbar(
|
|
titleContent: @Composable () -> Unit = {},
|
|
navigateUp: (() -> Unit)? = null,
|
|
searchEnabled: Boolean = true,
|
|
searchQuery: String?,
|
|
onChangeSearchQuery: (String?) -> Unit,
|
|
placeholderText: String? = null,
|
|
onSearch: (String) -> Unit = {},
|
|
onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) },
|
|
actions: @Composable RowScope.() -> Unit = {},
|
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
initialShowKeyboard: Boolean = true,
|
|
) {
|
|
val focusRequester = remember { FocusRequester() }
|
|
|
|
AppBar(
|
|
titleContent = {
|
|
if (searchQuery == null) return@AppBar titleContent()
|
|
|
|
val keyboardController = LocalSoftwareKeyboardController.current
|
|
val focusManager = LocalFocusManager.current
|
|
|
|
val searchAndClearFocus: () -> Unit = f@{
|
|
if (searchQuery.isBlank()) return@f
|
|
onSearch(searchQuery)
|
|
focusManager.clearFocus()
|
|
keyboardController?.hide()
|
|
}
|
|
|
|
BasicTextField(
|
|
value = searchQuery,
|
|
onValueChange = onChangeSearchQuery,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.focusRequester(focusRequester)
|
|
.runOnEnterKeyPressed(action = searchAndClearFocus)
|
|
.showSoftKeyboard(initialShowKeyboard)
|
|
.clearFocusOnSoftKeyboardHide(),
|
|
textStyle = MaterialTheme.typography.titleMedium.copy(
|
|
color = MaterialTheme.colorScheme.onBackground,
|
|
fontWeight = FontWeight.Normal,
|
|
fontSize = 18.sp,
|
|
),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
|
keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
|
|
singleLine = true,
|
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
|
|
visualTransformation = visualTransformation,
|
|
interactionSource = interactionSource,
|
|
decorationBox = { innerTextField ->
|
|
TextFieldDefaults.TextFieldDecorationBox(
|
|
value = searchQuery,
|
|
innerTextField = innerTextField,
|
|
enabled = true,
|
|
singleLine = true,
|
|
visualTransformation = visualTransformation,
|
|
interactionSource = interactionSource,
|
|
placeholder = {
|
|
Text(
|
|
modifier = Modifier.secondaryItemAlpha(),
|
|
text = (placeholderText ?: stringResource(R.string.action_search_hint)),
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
style = MaterialTheme.typography.titleMedium.copy(
|
|
fontSize = 18.sp,
|
|
fontWeight = FontWeight.Normal,
|
|
),
|
|
)
|
|
},
|
|
)
|
|
},
|
|
)
|
|
},
|
|
navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch,
|
|
actions = {
|
|
key("search") {
|
|
val onClick = { onChangeSearchQuery("") }
|
|
|
|
if (!searchEnabled) {
|
|
// Don't show search action
|
|
} else if (searchQuery == null) {
|
|
IconButton(onClick) {
|
|
Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search))
|
|
}
|
|
} else if (searchQuery.isNotEmpty()) {
|
|
IconButton(
|
|
onClick = {
|
|
onClick()
|
|
focusRequester.requestFocus()
|
|
},
|
|
) {
|
|
Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.action_reset))
|
|
}
|
|
}
|
|
}
|
|
|
|
key("actions") { actions() }
|
|
},
|
|
isActionMode = false,
|
|
scrollBehavior = scrollBehavior,
|
|
)
|
|
}
|
|
|
|
sealed interface AppBar {
|
|
sealed interface AppBarAction
|
|
|
|
data class Action(
|
|
val title: String,
|
|
val icon: ImageVector,
|
|
val onClick: () -> Unit,
|
|
val enabled: Boolean = true,
|
|
) : AppBarAction
|
|
|
|
data class OverflowAction(
|
|
val title: String,
|
|
val onClick: () -> Unit,
|
|
) : AppBarAction
|
|
}
|