AppBar.kt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. package eu.kanade.presentation.components
  2. import androidx.compose.foundation.interaction.MutableInteractionSource
  3. import androidx.compose.foundation.layout.Column
  4. import androidx.compose.foundation.layout.RowScope
  5. import androidx.compose.foundation.layout.fillMaxWidth
  6. import androidx.compose.foundation.text.BasicTextField
  7. import androidx.compose.foundation.text.KeyboardActions
  8. import androidx.compose.foundation.text.KeyboardOptions
  9. import androidx.compose.material.TextFieldDefaults
  10. import androidx.compose.material.icons.Icons
  11. import androidx.compose.material.icons.outlined.ArrowBack
  12. import androidx.compose.material.icons.outlined.Close
  13. import androidx.compose.material.icons.outlined.MoreVert
  14. import androidx.compose.material.icons.outlined.Search
  15. import androidx.compose.material3.DropdownMenuItem
  16. import androidx.compose.material3.Icon
  17. import androidx.compose.material3.IconButton
  18. import androidx.compose.material3.MaterialTheme
  19. import androidx.compose.material3.Text
  20. import androidx.compose.material3.TopAppBar
  21. import androidx.compose.material3.TopAppBarDefaults
  22. import androidx.compose.material3.TopAppBarScrollBehavior
  23. import androidx.compose.material3.surfaceColorAtElevation
  24. import androidx.compose.runtime.Composable
  25. import androidx.compose.runtime.LaunchedEffect
  26. import androidx.compose.runtime.derivedStateOf
  27. import androidx.compose.runtime.getValue
  28. import androidx.compose.runtime.key
  29. import androidx.compose.runtime.mutableStateOf
  30. import androidx.compose.runtime.remember
  31. import androidx.compose.runtime.setValue
  32. import androidx.compose.ui.Modifier
  33. import androidx.compose.ui.focus.FocusRequester
  34. import androidx.compose.ui.focus.focusRequester
  35. import androidx.compose.ui.graphics.SolidColor
  36. import androidx.compose.ui.graphics.vector.ImageVector
  37. import androidx.compose.ui.platform.LocalFocusManager
  38. import androidx.compose.ui.platform.LocalSoftwareKeyboardController
  39. import androidx.compose.ui.res.stringResource
  40. import androidx.compose.ui.text.font.FontWeight
  41. import androidx.compose.ui.text.input.ImeAction
  42. import androidx.compose.ui.text.input.VisualTransformation
  43. import androidx.compose.ui.text.style.TextOverflow
  44. import androidx.compose.ui.unit.dp
  45. import androidx.compose.ui.unit.sp
  46. import eu.kanade.presentation.util.runOnEnterKeyPressed
  47. import eu.kanade.presentation.util.secondaryItemAlpha
  48. import eu.kanade.tachiyomi.R
  49. @Composable
  50. fun AppBar(
  51. modifier: Modifier = Modifier,
  52. // Text
  53. title: String?,
  54. subtitle: String? = null,
  55. // Up button
  56. navigateUp: (() -> Unit)? = null,
  57. navigationIcon: ImageVector = Icons.Outlined.ArrowBack,
  58. // Menu
  59. actions: @Composable RowScope.() -> Unit = {},
  60. // Action mode
  61. actionModeCounter: Int = 0,
  62. onCancelActionMode: () -> Unit = {},
  63. actionModeActions: @Composable RowScope.() -> Unit = {},
  64. scrollBehavior: TopAppBarScrollBehavior? = null,
  65. ) {
  66. val isActionMode by remember(actionModeCounter) {
  67. derivedStateOf { actionModeCounter > 0 }
  68. }
  69. AppBar(
  70. modifier = modifier,
  71. titleContent = {
  72. if (isActionMode) {
  73. AppBarTitle(actionModeCounter.toString())
  74. } else {
  75. AppBarTitle(title, subtitle)
  76. }
  77. },
  78. navigateUp = navigateUp,
  79. navigationIcon = navigationIcon,
  80. actions = {
  81. if (isActionMode) {
  82. actionModeActions()
  83. } else {
  84. actions()
  85. }
  86. },
  87. isActionMode = isActionMode,
  88. onCancelActionMode = onCancelActionMode,
  89. scrollBehavior = scrollBehavior,
  90. )
  91. }
  92. @Composable
  93. fun AppBar(
  94. modifier: Modifier = Modifier,
  95. // Title
  96. titleContent: @Composable () -> Unit,
  97. // Up button
  98. navigateUp: (() -> Unit)? = null,
  99. navigationIcon: ImageVector = Icons.Outlined.ArrowBack,
  100. // Menu
  101. actions: @Composable RowScope.() -> Unit = {},
  102. // Action mode
  103. isActionMode: Boolean = false,
  104. onCancelActionMode: () -> Unit = {},
  105. scrollBehavior: TopAppBarScrollBehavior? = null,
  106. ) {
  107. Column(
  108. modifier = modifier,
  109. ) {
  110. TopAppBar(
  111. navigationIcon = {
  112. if (isActionMode) {
  113. IconButton(onClick = onCancelActionMode) {
  114. Icon(
  115. imageVector = Icons.Outlined.Close,
  116. contentDescription = stringResource(R.string.action_cancel),
  117. )
  118. }
  119. } else {
  120. navigateUp?.let {
  121. IconButton(onClick = it) {
  122. Icon(
  123. imageVector = navigationIcon,
  124. contentDescription = stringResource(R.string.abc_action_bar_up_description),
  125. )
  126. }
  127. }
  128. }
  129. },
  130. title = titleContent,
  131. actions = actions,
  132. colors = TopAppBarDefaults.smallTopAppBarColors(
  133. containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
  134. elevation = if (isActionMode) 3.dp else 0.dp,
  135. ),
  136. ),
  137. scrollBehavior = scrollBehavior,
  138. )
  139. }
  140. }
  141. @Composable
  142. fun AppBarTitle(
  143. title: String?,
  144. subtitle: String? = null,
  145. ) {
  146. Column {
  147. title?.let {
  148. Text(
  149. text = it,
  150. maxLines = 1,
  151. overflow = TextOverflow.Ellipsis,
  152. )
  153. }
  154. subtitle?.let {
  155. Text(
  156. text = it,
  157. style = MaterialTheme.typography.bodyMedium,
  158. maxLines = 1,
  159. overflow = TextOverflow.Ellipsis,
  160. )
  161. }
  162. }
  163. }
  164. @Composable
  165. fun AppBarActions(
  166. actions: List<AppBar.AppBarAction>,
  167. ) {
  168. var showMenu by remember { mutableStateOf(false) }
  169. actions.filterIsInstance<AppBar.Action>().map {
  170. IconButton(
  171. onClick = it.onClick,
  172. enabled = it.enabled,
  173. ) {
  174. Icon(
  175. imageVector = it.icon,
  176. contentDescription = it.title,
  177. )
  178. }
  179. }
  180. val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
  181. if (overflowActions.isNotEmpty()) {
  182. IconButton(onClick = { showMenu = !showMenu }) {
  183. Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.abc_action_menu_overflow_description))
  184. }
  185. DropdownMenu(
  186. expanded = showMenu,
  187. onDismissRequest = { showMenu = false },
  188. ) {
  189. overflowActions.map {
  190. DropdownMenuItem(
  191. onClick = {
  192. it.onClick()
  193. showMenu = false
  194. },
  195. text = { Text(it.title, fontWeight = FontWeight.Normal) },
  196. )
  197. }
  198. }
  199. }
  200. }
  201. /**
  202. * @param searchEnabled Set to false if you don't want to show search action.
  203. * @param searchQuery If null, use normal toolbar.
  204. * @param placeholderText If null, [R.string.action_search_hint] is used.
  205. */
  206. @Composable
  207. fun SearchToolbar(
  208. titleContent: @Composable () -> Unit = {},
  209. navigateUp: (() -> Unit)? = null,
  210. searchEnabled: Boolean = true,
  211. searchQuery: String?,
  212. onChangeSearchQuery: (String?) -> Unit,
  213. placeholderText: String? = null,
  214. onSearch: (String) -> Unit = {},
  215. onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) },
  216. actions: @Composable RowScope.() -> Unit = {},
  217. scrollBehavior: TopAppBarScrollBehavior? = null,
  218. visualTransformation: VisualTransformation = VisualTransformation.None,
  219. interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
  220. ) {
  221. val focusRequester = remember { FocusRequester() }
  222. var searchClickCount by remember { mutableStateOf(0) }
  223. AppBar(
  224. titleContent = {
  225. if (searchQuery == null) return@AppBar titleContent()
  226. val keyboardController = LocalSoftwareKeyboardController.current
  227. val focusManager = LocalFocusManager.current
  228. val searchAndClearFocus: () -> Unit = f@{
  229. if (searchQuery.isBlank()) return@f
  230. onSearch(searchQuery)
  231. focusManager.clearFocus()
  232. keyboardController?.hide()
  233. }
  234. BasicTextField(
  235. value = searchQuery,
  236. onValueChange = onChangeSearchQuery,
  237. modifier = Modifier
  238. .fillMaxWidth()
  239. .focusRequester(focusRequester)
  240. .runOnEnterKeyPressed(action = searchAndClearFocus),
  241. textStyle = MaterialTheme.typography.titleMedium.copy(
  242. color = MaterialTheme.colorScheme.onBackground,
  243. fontWeight = FontWeight.Normal,
  244. fontSize = 18.sp,
  245. ),
  246. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
  247. keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
  248. singleLine = true,
  249. cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
  250. visualTransformation = visualTransformation,
  251. interactionSource = interactionSource,
  252. decorationBox = { innerTextField ->
  253. TextFieldDefaults.TextFieldDecorationBox(
  254. value = searchQuery,
  255. innerTextField = innerTextField,
  256. enabled = true,
  257. singleLine = true,
  258. visualTransformation = visualTransformation,
  259. interactionSource = interactionSource,
  260. placeholder = {
  261. (placeholderText ?: stringResource(R.string.action_search_hint)).let { placeholderText ->
  262. Text(
  263. modifier = Modifier.secondaryItemAlpha(),
  264. text = placeholderText,
  265. maxLines = 1,
  266. overflow = TextOverflow.Ellipsis,
  267. style = MaterialTheme.typography.titleMedium.copy(
  268. fontSize = 18.sp,
  269. fontWeight = FontWeight.Normal,
  270. ),
  271. )
  272. }
  273. },
  274. )
  275. },
  276. )
  277. },
  278. navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch,
  279. actions = {
  280. key("search") {
  281. val onClick = {
  282. searchClickCount++
  283. onChangeSearchQuery("")
  284. }
  285. if (!searchEnabled) {
  286. // Don't show search action
  287. } else if (searchQuery == null) {
  288. IconButton(onClick) {
  289. Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.action_search))
  290. }
  291. } else if (searchQuery.isNotEmpty()) {
  292. IconButton(onClick) {
  293. Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.action_reset))
  294. }
  295. }
  296. }
  297. key("actions") { actions() }
  298. },
  299. isActionMode = false,
  300. scrollBehavior = scrollBehavior,
  301. )
  302. LaunchedEffect(searchClickCount) {
  303. if (searchQuery == null) return@LaunchedEffect
  304. try {
  305. focusRequester.requestFocus()
  306. } catch (_: Throwable) {
  307. // TextField is gone
  308. }
  309. }
  310. }
  311. sealed interface AppBar {
  312. sealed interface AppBarAction
  313. data class Action(
  314. val title: String,
  315. val icon: ImageVector,
  316. val onClick: () -> Unit,
  317. val enabled: Boolean = true,
  318. ) : AppBarAction
  319. data class OverflowAction(
  320. val title: String,
  321. val onClick: () -> Unit,
  322. ) : AppBarAction
  323. }