CommonMangaItem.kt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. package eu.kanade.presentation.components
  2. import androidx.compose.foundation.background
  3. import androidx.compose.foundation.combinedClickable
  4. import androidx.compose.foundation.layout.Box
  5. import androidx.compose.foundation.layout.BoxScope
  6. import androidx.compose.foundation.layout.Column
  7. import androidx.compose.foundation.layout.Row
  8. import androidx.compose.foundation.layout.RowScope
  9. import androidx.compose.foundation.layout.aspectRatio
  10. import androidx.compose.foundation.layout.fillMaxHeight
  11. import androidx.compose.foundation.layout.fillMaxWidth
  12. import androidx.compose.foundation.layout.height
  13. import androidx.compose.foundation.layout.padding
  14. import androidx.compose.foundation.layout.size
  15. import androidx.compose.foundation.shape.RoundedCornerShape
  16. import androidx.compose.material.icons.Icons
  17. import androidx.compose.material.icons.filled.PlayArrow
  18. import androidx.compose.material3.FilledIconButton
  19. import androidx.compose.material3.Icon
  20. import androidx.compose.material3.IconButtonDefaults
  21. import androidx.compose.material3.LocalContentColor
  22. import androidx.compose.material3.MaterialTheme
  23. import androidx.compose.material3.Text
  24. import androidx.compose.material3.contentColorFor
  25. import androidx.compose.runtime.Composable
  26. import androidx.compose.runtime.CompositionLocalProvider
  27. import androidx.compose.ui.Alignment
  28. import androidx.compose.ui.Modifier
  29. import androidx.compose.ui.draw.alpha
  30. import androidx.compose.ui.draw.clip
  31. import androidx.compose.ui.graphics.Brush
  32. import androidx.compose.ui.graphics.Color
  33. import androidx.compose.ui.graphics.Shadow
  34. import androidx.compose.ui.graphics.drawscope.ContentDrawScope
  35. import androidx.compose.ui.node.DrawModifierNode
  36. import androidx.compose.ui.node.modifierElementOf
  37. import androidx.compose.ui.text.TextStyle
  38. import androidx.compose.ui.text.style.TextOverflow
  39. import androidx.compose.ui.unit.dp
  40. import androidx.compose.ui.unit.sp
  41. import eu.kanade.presentation.util.selectedBackground
  42. object CommonMangaItemDefaults {
  43. val GridHorizontalSpacer = 4.dp
  44. val GridVerticalSpacer = 4.dp
  45. const val BrowseFavoriteCoverAlpha = 0.34f
  46. }
  47. private val ContinueReadingButtonSize = 32.dp
  48. private val ContinueReadingButtonGridPadding = 6.dp
  49. private val ContinueReadingButtonListSpacing = 8.dp
  50. private const val GridSelectedCoverAlpha = 0.76f
  51. /**
  52. * Layout of grid list item with title overlaying the cover.
  53. * Accepts null [title] for a cover-only view.
  54. */
  55. @Composable
  56. fun MangaCompactGridItem(
  57. isSelected: Boolean = false,
  58. title: String? = null,
  59. coverData: tachiyomi.domain.manga.model.MangaCover,
  60. coverAlpha: Float = 1f,
  61. coverBadgeStart: @Composable (RowScope.() -> Unit)? = null,
  62. coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null,
  63. onLongClick: () -> Unit,
  64. onClick: () -> Unit,
  65. onClickContinueReading: (() -> Unit)? = null,
  66. ) {
  67. GridItemSelectable(
  68. isSelected = isSelected,
  69. onClick = onClick,
  70. onLongClick = onLongClick,
  71. ) {
  72. MangaGridCover(
  73. cover = {
  74. MangaCover.Book(
  75. modifier = Modifier
  76. .fillMaxWidth()
  77. .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
  78. data = coverData,
  79. )
  80. },
  81. badgesStart = coverBadgeStart,
  82. badgesEnd = coverBadgeEnd,
  83. content = {
  84. if (title != null) {
  85. CoverTextOverlay(
  86. title = title,
  87. onClickContinueReading = onClickContinueReading,
  88. )
  89. } else if (onClickContinueReading != null) {
  90. ContinueReadingButton(
  91. modifier = Modifier
  92. .padding(ContinueReadingButtonGridPadding)
  93. .align(Alignment.BottomEnd),
  94. onClickContinueReading = onClickContinueReading,
  95. )
  96. }
  97. },
  98. )
  99. }
  100. }
  101. /**
  102. * Title overlay for [MangaCompactGridItem]
  103. */
  104. @Composable
  105. private fun BoxScope.CoverTextOverlay(
  106. title: String,
  107. onClickContinueReading: (() -> Unit)? = null,
  108. ) {
  109. Box(
  110. modifier = Modifier
  111. .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
  112. .background(
  113. Brush.verticalGradient(
  114. 0f to Color.Transparent,
  115. 1f to Color(0xAA000000),
  116. ),
  117. )
  118. .fillMaxHeight(0.33f)
  119. .fillMaxWidth()
  120. .align(Alignment.BottomCenter),
  121. )
  122. Row(
  123. modifier = Modifier.align(Alignment.BottomStart),
  124. verticalAlignment = Alignment.Bottom,
  125. ) {
  126. GridItemTitle(
  127. modifier = Modifier
  128. .weight(1f)
  129. .padding(8.dp),
  130. title = title,
  131. style = MaterialTheme.typography.titleSmall.copy(
  132. color = Color.White,
  133. shadow = Shadow(
  134. color = Color.Black,
  135. blurRadius = 4f,
  136. ),
  137. ),
  138. )
  139. if (onClickContinueReading != null) {
  140. ContinueReadingButton(
  141. modifier = Modifier.padding(
  142. end = ContinueReadingButtonGridPadding,
  143. bottom = ContinueReadingButtonGridPadding,
  144. ),
  145. onClickContinueReading = onClickContinueReading,
  146. )
  147. }
  148. }
  149. }
  150. /**
  151. * Layout of grid list item with title below the cover.
  152. */
  153. @Composable
  154. fun MangaComfortableGridItem(
  155. isSelected: Boolean = false,
  156. title: String,
  157. coverData: tachiyomi.domain.manga.model.MangaCover,
  158. coverAlpha: Float = 1f,
  159. coverBadgeStart: (@Composable RowScope.() -> Unit)? = null,
  160. coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null,
  161. onLongClick: () -> Unit,
  162. onClick: () -> Unit,
  163. onClickContinueReading: (() -> Unit)? = null,
  164. ) {
  165. GridItemSelectable(
  166. isSelected = isSelected,
  167. onClick = onClick,
  168. onLongClick = onLongClick,
  169. ) {
  170. Column {
  171. MangaGridCover(
  172. cover = {
  173. MangaCover.Book(
  174. modifier = Modifier
  175. .fillMaxWidth()
  176. .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
  177. data = coverData,
  178. )
  179. },
  180. badgesStart = coverBadgeStart,
  181. badgesEnd = coverBadgeEnd,
  182. content = {
  183. if (onClickContinueReading != null) {
  184. ContinueReadingButton(
  185. modifier = Modifier
  186. .padding(ContinueReadingButtonGridPadding)
  187. .align(Alignment.BottomEnd),
  188. onClickContinueReading = onClickContinueReading,
  189. )
  190. }
  191. },
  192. )
  193. GridItemTitle(
  194. modifier = Modifier.padding(4.dp),
  195. title = title,
  196. style = MaterialTheme.typography.titleSmall,
  197. )
  198. }
  199. }
  200. }
  201. /**
  202. * Common cover layout to add contents to be drawn on top of the cover.
  203. */
  204. @Composable
  205. private fun MangaGridCover(
  206. modifier: Modifier = Modifier,
  207. cover: @Composable BoxScope.() -> Unit = {},
  208. badgesStart: (@Composable RowScope.() -> Unit)? = null,
  209. badgesEnd: (@Composable RowScope.() -> Unit)? = null,
  210. content: @Composable (BoxScope.() -> Unit)? = null,
  211. ) {
  212. Box(
  213. modifier = modifier
  214. .fillMaxWidth()
  215. .aspectRatio(MangaCover.Book.ratio),
  216. ) {
  217. cover()
  218. content?.invoke(this)
  219. if (badgesStart != null) {
  220. BadgeGroup(
  221. modifier = Modifier
  222. .padding(4.dp)
  223. .align(Alignment.TopStart),
  224. content = badgesStart,
  225. )
  226. }
  227. if (badgesEnd != null) {
  228. BadgeGroup(
  229. modifier = Modifier
  230. .padding(4.dp)
  231. .align(Alignment.TopEnd),
  232. content = badgesEnd,
  233. )
  234. }
  235. }
  236. }
  237. @Composable
  238. private fun GridItemTitle(
  239. modifier: Modifier,
  240. title: String,
  241. style: TextStyle,
  242. ) {
  243. Text(
  244. modifier = modifier,
  245. text = title,
  246. fontSize = 12.sp,
  247. lineHeight = 18.sp,
  248. maxLines = 2,
  249. overflow = TextOverflow.Ellipsis,
  250. style = style,
  251. )
  252. }
  253. /**
  254. * Wrapper for grid items to handle selection state, click and long click.
  255. */
  256. @Composable
  257. private fun GridItemSelectable(
  258. modifier: Modifier = Modifier,
  259. isSelected: Boolean,
  260. onClick: () -> Unit,
  261. onLongClick: () -> Unit,
  262. content: @Composable () -> Unit,
  263. ) {
  264. Box(
  265. modifier = modifier
  266. .clip(MaterialTheme.shapes.small)
  267. .combinedClickable(
  268. onClick = onClick,
  269. onLongClick = onLongClick,
  270. )
  271. .selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
  272. .padding(4.dp),
  273. ) {
  274. val contentColor = if (isSelected) {
  275. MaterialTheme.colorScheme.onSecondary
  276. } else {
  277. LocalContentColor.current
  278. }
  279. CompositionLocalProvider(LocalContentColor provides contentColor) {
  280. content()
  281. }
  282. }
  283. }
  284. /**
  285. * @see GridItemSelectable
  286. */
  287. private fun Modifier.selectedOutline(
  288. isSelected: Boolean,
  289. color: Color,
  290. ): Modifier {
  291. class SelectedOutlineNode(var selected: Boolean, var color: Color) : DrawModifierNode, Modifier.Node() {
  292. override fun ContentDrawScope.draw() {
  293. if (selected) drawRect(color)
  294. drawContent()
  295. }
  296. }
  297. return this then modifierElementOf(
  298. key = isSelected.hashCode() + color.hashCode(),
  299. create = { SelectedOutlineNode(isSelected, color) },
  300. update = {
  301. it.selected = isSelected
  302. it.color = color
  303. },
  304. definitions = {
  305. name = "selectionOutline"
  306. properties["isSelected"] = isSelected
  307. properties["color"] = color
  308. },
  309. )
  310. }
  311. /**
  312. * Layout of list item.
  313. */
  314. @Composable
  315. fun MangaListItem(
  316. isSelected: Boolean = false,
  317. title: String,
  318. coverData: tachiyomi.domain.manga.model.MangaCover,
  319. coverAlpha: Float = 1f,
  320. badge: @Composable (RowScope.() -> Unit),
  321. onLongClick: () -> Unit,
  322. onClick: () -> Unit,
  323. onClickContinueReading: (() -> Unit)? = null,
  324. ) {
  325. Row(
  326. modifier = Modifier
  327. .selectedBackground(isSelected)
  328. .height(56.dp)
  329. .combinedClickable(
  330. onClick = onClick,
  331. onLongClick = onLongClick,
  332. )
  333. .padding(horizontal = 16.dp, vertical = 8.dp),
  334. verticalAlignment = Alignment.CenterVertically,
  335. ) {
  336. MangaCover.Square(
  337. modifier = Modifier
  338. .fillMaxHeight()
  339. .alpha(coverAlpha),
  340. data = coverData,
  341. )
  342. Text(
  343. text = title,
  344. modifier = Modifier
  345. .padding(horizontal = 16.dp)
  346. .weight(1f),
  347. maxLines = 2,
  348. overflow = TextOverflow.Ellipsis,
  349. style = MaterialTheme.typography.bodyMedium,
  350. )
  351. BadgeGroup(content = badge)
  352. if (onClickContinueReading != null) {
  353. ContinueReadingButton(
  354. modifier = Modifier.padding(start = ContinueReadingButtonListSpacing),
  355. onClickContinueReading = onClickContinueReading,
  356. )
  357. }
  358. }
  359. }
  360. @Composable
  361. private fun ContinueReadingButton(
  362. modifier: Modifier = Modifier,
  363. onClickContinueReading: () -> Unit,
  364. ) {
  365. Box(modifier = modifier) {
  366. FilledIconButton(
  367. onClick = onClickContinueReading,
  368. modifier = Modifier.size(ContinueReadingButtonSize),
  369. shape = MaterialTheme.shapes.small,
  370. colors = IconButtonDefaults.filledIconButtonColors(
  371. containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
  372. contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer),
  373. ),
  374. ) {
  375. Icon(
  376. imageVector = Icons.Filled.PlayArrow,
  377. contentDescription = "",
  378. modifier = Modifier.size(16.dp),
  379. )
  380. }
  381. }
  382. }