AppBar.kt 14 KB

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