AppBar.kt 15 KB

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